什么是混合检索?为什么还需要关键词检索?

进阶高频原理约 8 分钟读完

一句话回答

混合检索是让向量检索和关键词检索(通常是 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,确认收益来自哪类问题

代码示例

TypeScript
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 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。