一句话价值:上一篇说清了 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_text和embedding一一对应:向量是一堆 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 A | 15.3 | 0.88 |
| doc B | 8.7 | 0.71 |
| doc C | 4.2 | 0.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 A | 1 | 2 |
| doc B | 2 | 3 |
| doc C | 3 | 1 |
现在两列都是「名次」,可比了。融合公式:
score(d) = Σ_{每一路} 1 / (k + rank_i(d))
两个设计点:
- 用倒数而不是直接加名次:把「靠前」映射成「分数大」,方便降序排。
- 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/62 ≈ 0.03252
doc C = 1/(60+3) + 1/(60+1) = 1/63 + 1/61 ≈ 0.03226
doc B = 1/(60+2) + 1/(60+3) = 1/62 + 1/63 ≈ 0.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 补上。
本篇小结
- Milvus 管语义:向量检索是 bi-encoder,q 和 d 分开编码、无交互,只能粗排。
- 双写时
doc_text和embedding一一对应(原文 ↔ 向量),建/查索引的metric_type要一致。 - BM25 分(0
20+)和余弦分(01)量纲不可比,不能直接相加。 - RRF 只看排名不看分数:
Σ 1/(k+rank),k=60 平滑,零成本把两路合成一份。 - 代价是丢了「领先多少」的信息——所以它只是粗融合,精排交给下一篇。
系列预告
- 下一篇:rerank 精排——为什么融合完还要再「拧一遍」,cross-encoder 怎么靠 token 级交互精准重打分,以及完整三段式怎么落地到代码。