什么是混合检索?为什么还需要关键词检索?
一句话回答
混合检索是让向量检索和关键词检索(通常是 BM25)两路并行召回,再把结果融合排序。向量检索懂语义,能把"怎么退钱"匹配到"退款流程",但对型号、错误码、人名这类需要精确匹配的词不敏感;BM25 按词匹配,正好补上这块短板。融合常用 RRF(倒数排名融合):只看文档在每一路结果中的排名,score = Σ 1/(k + rank),不用处理两路分数尺度不同的问题。
详细解析
向量检索的盲区
Embedding 把一段文本压缩成一个向量,保留的是整体语义,字面上的精确信息容易丢失:
- 编号和型号:查"E1024 报错怎么办",向量检索可能召回 E1023、E2048 的文档,它们在语义上都是"某个错误码"
- 专有名词和新词:内部系统名、项目代号、新产品名,Embedding 模型训练时没见过,表示不准
- 精确短语:法规条款号、配置项名称、函数名,用户要找的就是包含这个词的文档
这些恰好是关键词检索的强项。
BM25 的特点
BM25 按查询词在文档中的匹配情况打分,主要考虑三点:
- 词频:词在文档里出现得越多分越高,但收益递减(由参数 k1 控制)
- 逆文档频率(IDF):越稀有的词权重越高。"E1024"在整个库里只出现几次,命中它的文档会排得很靠前
- 长度归一化:避免长文档因为词多而占便宜(由参数 b 控制)。Elasticsearch 默认 k1 = 1.2、b = 0.75
它的短板是不懂同义词和换说法("退钱"和"退款");中文还依赖分词质量,分词器不认识的产品名会被切碎,匹配就不准。
两路结果怎么融合
两路的分数不能直接相加:余弦相似度的范围是 -1~1,BM25 分数没有固定上限,大小取决于查询词的数量和语料。常见的两种融合方法:
| 方法 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| RRF | score(d) = Σ 1/(k + rank),对文档在每一路中的排名求和,rank 从 1 开始,k 常取 60 | 只用排名,不受分数尺度影响,几乎不用调参 | 丢掉了分数差距的信息:领先很多和领先一点,得分一样 |
| 加权分数融合 | 先把每一路的分数归一化到 0~1(如 min-max),再按权重相加:α × 向量分 + (1 − α) × BM25 分 | 能利用分数差距,可以按业务调权重 | 对归一化方式敏感,权重要用评测集调,不同查询的分数分布差异也大 |
RRF 的计算示例(k = 60):
| 文档 | 向量检索排名 | BM25 排名 | RRF 分数 |
|---|---|---|---|
| A | 1 | 3 | 1/61 + 1/63 ≈ 0.0323 |
| C | 未召回 | 1 | 1/61 ≈ 0.0164 |
| B | 2 | 未召回 | 1/62 ≈ 0.0161 |
两路都靠前的文档得分最高;只在一路出现的文档,另一路的贡献是 0。
工程上要注意的
- 每一路多取一些(比如各 50 条),融合后再交给重排模型精排(见 重排序)
- 两路并行执行,总延迟取决于慢的那一路
- 不少搜索引擎和向量数据库已经内置了混合检索和 RRF,能用内置的就不必自己写
- 用评测集分别跑纯向量、纯 BM25 和混合检索,按问题类型分组对比 Recall@k,确认收益来自哪类问题
代码示例
interface Hit {
id: string
score: number
}
// RRF:每一路都已按相关性从高到低排好序,只用排名计算
function rrf(lists: Hit[][], k = 60): Hit[] {
const fused = new Map<string, number>()
for (const list of lists) {
list.forEach((hit, i) => {
fused.set(hit.id, (fused.get(hit.id) ?? 0) + 1 / (k + i + 1)) // 排名从 1 开始
})
}
return [...fused].map(([id, score]) => ({ id, score })).sort((a, b) => b.score - a.score)
}
// 加权融合:每一路先 min-max 归一化到 0~1;某一路没召回的文档,这一路按 0 分算
function weightedFusion(vector: Hit[], keyword: Hit[], alpha = 0.5): Hit[] {
const normalize = (hits: Hit[]) => {
const scores = hits.map((h) => h.score)
const min = Math.min(...scores)
const max = Math.max(...scores)
return new Map(hits.map((h) => [h.id, max === min ? 1 : (h.score - min) / (max - min)]))
}
const v = normalize(vector)
const kw = normalize(keyword)
return [...new Set([...v.keys(), ...kw.keys()])]
.map((id) => ({ id, score: alpha * (v.get(id) ?? 0) + (1 - alpha) * (kw.get(id) ?? 0) }))
.sort((a, b) => b.score - a.score)
}
// 两路并行召回,用 RRF 融合
async function hybridSearch(query: string, topN = 50): Promise<Hit[]> {
const [vectorHits, keywordHits] = await Promise.all([
vectorStore.search(query, topN), // 向量检索
keywordIndex.search(query, topN), // BM25 关键词检索
])
return rrf([vectorHits, keywordHits]).slice(0, topN)
}
面试官可能追问
RRF 里的 k 为什么常取 60?调大调小有什么影响?
60 来自提出 RRF 的论文:作者在预实验中定下 k = 60,发现它接近最优、而且取值对结果影响不大,之后被广泛沿用。k 越大,相邻名次之间的分差越小,排名靠后的文档也能贡献不少分数;k 越小,越偏向每一路的前几名。多数情况下不用调,真要调就在评测集上比较。
中文做 BM25 要注意什么?
关键在分词。用中文分词器(如 jieba、IK 这类),把产品名、型号、内部术语加进自定义词典,否则它们会被切碎;索引和查询要用同一套分词规则。也可以按字的 n-gram 切分,召回率高,但噪声也更多。
两路的比重怎么定?什么时候可以不用混合检索?
按问题类型看:用户多用自然语言描述、语料里编号和术语很少时,纯向量检索可能就够;代码搜索、商品型号、法规条款这类场景更依赖关键词。可以在评测集上按问题类型分组对比;也可以按查询的特征动态调整,比如查询里出现编号格式时,加大关键词检索的权重。
稀疏向量是什么?和 BM25 有什么关系?
稀疏向量的维度和词表一样大,绝大多数维度为 0,非零的维度对应文本里的词和它的权重。BM25 可以看作一种按规则计算权重的稀疏表示;SPLADE 这类模型学出来的稀疏向量,还能给原文没出现、但意思相关的词分配权重,相当于自动做了词扩展。很多向量数据库支持稀疏向量和稠密向量一起检索,用来实现混合检索。
易错点
- 直接把余弦相似度和 BM25 分数相加,BM25 的分数通常大得多,会主导结果
- RRF 的排名从 1 开始;同一文档被切成多块时,要先想清楚按块融合还是按文档融合
- 混合检索只是把两路候选合并,后面通常还要接重排
- 关键词检索的效果取决于分词,中文场景要检查专有名词有没有被切碎
AI 模拟面试官
用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。