语义侧怎么召回,两路不可比的分数怎么合成一份——Hybrid RAG 系列之二

0 阅读5分钟

一句话价值:上一篇说清了 ES 那半条腿(关键词),这一篇补上 Milvus 那半条腿(语义),再解决一个真正棘手的工程问题——ES 的 BM25 分和 Milvus 的余弦分量纲完全不同,根本不能相加。RRF(倒数排名融合)用「只看排名、不看分数」这个巧妙动作,零成本地把两路结果合成一份,是混合检索里性价比最高的一环。


Milvus 那半条腿:向量检索怎么做

和 ES 的「查表」不同,Milvus 干的是「比」。它的流程是:

query ──embedding──▶ 向量 q
doc   ──embedding──▶ 向量 d(已提前算好,存进集合)
相似度 = 距离(q, d)   // L2 / COSINE / IP

建集合时,向量字段要单独声明维度,并给向量字段单独建索引加速(这一步和 ES 建 mapping 是两回事——Milvus 里「建集合」和「建索引」是分开的两步):

// 建集合:定义 schema(业务字段 + 主键 + 向量)
await milvusClient.createCollection({
  collection_name: collectionName,
  fields: [
    { name: 'id', data_type: DataType.VarChar, max_length: 100 },
    { name: 'note_body', data_type: DataType.VarChar, max_length: 4096 },
    // ...其他业务字段
    { name: 'langchain_primaryid', data_type: DataType.Int64,
      is_primary_key: true, autoID: true },          // 主键
    { name: 'doc_text', data_type: DataType.VarChar,
      max_length: 10000 },                            // 向量化的原文
    { name: 'embedding', data_type: DataType.FloatVector, dim } // 向量
  ]
});

// 建索引:只给向量字段建,HNSW 加速近似最近邻
await milvusClient.createIndex({
  collection_name: collectionName,
  field_name: 'embedding',
  index_type: IndexType.HNSW,
  metric_type: MetricType.L2,
  params: { M: 8, efConstruction: 64 }
});

注意两个容易踩的坑:

  • doc_textembedding 一一对应:向量是一堆 float,人读不懂,检索命中后要把「原文」取出来喂 LLM,所以必须单独存一份向量化的原文。
  • 建索引和查索引的 metric_type 要一致:这里建索引用 L2(欧氏距离,越小越相似),将来检索也得用 L2 去查,否则结果错乱。

向量检索的天花板:bi-encoder 没有交互

但向量检索有个结构性缺陷——它是 bi-encoder(双塔)

query  ──Encoder──▶ q    ← 各自编码,编码时互相没见过对方
doc    ──Encoder──▶ d    ← 各自编码,编码时互相没见过对方
相关度 = cos(q, d)

q 和 d 是分别编码的,两个向量算余弦,只能捕捉「整体语义靠不靠近」,捕捉不到「query 里的某个词和 doc 里的某个词之间的精确对应」。问「乔峰的父亲」,doc 里有「养父」「生父」,双塔只判断「大致相关」,分不清「父亲」到底指哪个。

所以向量检索永远是「粗排」——快,但不够准。这也埋下了下一篇 rerank 的伏笔。

真问题来了:两路分数根本不能相加

现在 ES 和 Milvus 各召回了一批结果,带着各自的分数:

文档ES 的 BM25 分Milvus 的余弦相似度
doc A15.30.88
doc B8.70.71
doc C4.20.93

最朴素的想法是 BM25 + 余弦,但马上撞墙:

  • BM25 是「越大越相关」,量纲是 0~20+ 的无界数;
  • 余弦相似度也是「越大越相关」,但量纲是 0~1。

doc A 的 15.3 + 0.88 和 doc C 的 4.2 + 0.93 没法比——BM25 那几分的差距会彻底淹没余弦那零点几分的差距,等于「谁的 BM25 高谁赢」,Milvus 那一路白召回了。

RRF:只看排名,不看分数

RRF(Reciprocal Rank Fusion)的解法简单到令人意外:既然分数不可比,那就把分数丢掉,只看排名

  • 分数是「绝对量」,量纲各不相同、不可比;
  • 排名是「相对量」,不管原始分数是 15.3 还是 0.93,排第 1 就是 1——「第 1 名」在所有路里都是同一个概念。

把分数表换成排名表:

文档ES 里的排名Milvus 里的排名
doc A12
doc B23
doc C31

现在两列都是「名次」,可比了。融合公式:

score(d) = Σ_{每一路}  1 / (k + rank_i(d))

两个设计点:

  1. 用倒数而不是直接加名次:把「靠前」映射成「分数大」,方便降序排。
  2. k 是平滑常数(常取 60):不加 k,第 1 名 = 1/1、第 2 名 = 1/2,第一名独大、压死其他路;加 k=60 后第 1 名 = 1/61、第 2 名 = 1/62,几乎一样,更「民主」,让「多路都排中游」的文档有机会靠数量优势赢过「单路第一」。

完整算一遍(k=60):

doc A = 1/(60+1) + 1/(60+2) = 1/61 + 1/620.03252
doc C = 1/(60+3) + 1/(60+1) = 1/63 + 1/610.03226
doc B = 1/(60+2) + 1/(60+3) = 1/62 + 1/630.03200

最终排序:A > C > B。注意 A 和 C:A 是「ES 眼里的第一」,C 是「Milvus 眼里的第一」,两个都是强候选,RRF 给了它们几乎相同的分数——符合直觉,而直接加分数是做不到这一点的。

代码就十几行:

function rrfFusion(listA, listB, k = 60) {
  const scoreMap = new Map();
  for (const list of [listA, listB]) {
    list.forEach((item, rank) => {
      const s = scoreMap.get(item.id) ?? 0;
      scoreMap.set(item.id, s + 1 / (k + rank + 1));
    });
  }
  return [...scoreMap.entries()]
    .sort((a, b) => b[1] - a[1])
    .map(([id, score]) => ({ id, score }));
}

RRF 的代价:它只信顺序,不信差距

RRF 这个「只看排名」既是它能跨量纲的原因,也是它的天花板:

  • 丢了「领先多少」的信息:doc A 的 BM25 是 15.3、doc B 是 8.7,A 遥遥领先;但换成排名后只剩「A 在 B 前面」,领先幅度没了。
  • 这就是为什么 RRF 只是「粗融合」,精度还得靠下一篇的 rerank 补上。

本篇小结

  1. Milvus 管语义:向量检索是 bi-encoder,q 和 d 分开编码、无交互,只能粗排。
  2. 双写时 doc_textembedding 一一对应(原文 ↔ 向量),建/查索引的 metric_type 要一致。
  3. BM25 分(020+)和余弦分(01)量纲不可比,不能直接相加。
  4. RRF 只看排名不看分数:Σ 1/(k+rank),k=60 平滑,零成本把两路合成一份。
  5. 代价是丢了「领先多少」的信息——所以它只是粗融合,精排交给下一篇。

系列预告

  • 下一篇:rerank 精排——为什么融合完还要再「拧一遍」,cross-encoder 怎么靠 token 级交互精准重打分,以及完整三段式怎么落地到代码。