图 1:双路召回各管一段盲区,RRF 负责把两路捏成一份名单。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料整理,与任何公司的当前招聘无关。RAG 系统的第一道质量关口是召回——检索不到对的材料,后面生成再强也是白搭。而「为什么向量检索之外还要混一路关键词检索」正是考察召回理解的经典题。
向量检索的强项和它的三个盲区。
向量检索把查询和文档都编码成向量,按语义距离捞相近的块。它最大的优点是抗改写:「怎么退款」和「退货流程是什么」字面完全不同,向量空间里却是近邻,同义、口语化、跨语言表达都能命中。但语义相近不等于该命中,纯向量检索有三类典型盲区:
- 型号与编号:查「XYZ-3000 的驱动」,语义上相近的文档一大把,但用户要的就是这个词,向量分数再高也替代不了字面命中;
- 专有名词与罕见术语:训练语料里出现得少的词,嵌入容易被稀释成「普通词」,区分度不足;
- 长尾关键词组合:多个条件同时约束时,向量只能给出整体语义的折中,精确匹配某个冷门条件的那篇可能被挤出前列。
关键词检索:BM25 补的正是这一块。
关键词路线的代表是 BM25,思路与向量正相反:词必须命中,再按统计特征打分。它的公式可以拆成三个直觉:词频饱和——一个词出现 10 次不比出现 3 次重要十倍(k1 控制饱和曲线);逆文档频率——越稀有的词越值钱;长度归一——长文档天然词多,要按篇幅打折(b 控制归一强度)。Elasticsearch 至今默认用 BM25 作为相似度算法(默认 k1=1.2、b=0.75),经过几十年检验,在「精确词命中」场景上依然很难被向量替代。
融合的难点:两路分数根本不可比。
最直接的想法是各取 top 然后按分数加权合并,但这条路有个暗坑:向量相似度和 BM25 分数的量纲、分布完全不同,直接相加等于拿厘米和公斤做加法,归一化参数还得随着数据分布反复调。
工程上更常用的是 RRF(Reciprocal Rank Fusion,倒数名次融合):干脆扔掉原始分数,只看名次。每个文档的融合分数是它在各路人马里名次的倒数之和:
score(d) = Σ 1 / (k + rank)
rank 从 1 开始,k 是平滑常数,通常取 60。这个公式 2009 年 SIGIR 上的一篇论文提出后,因为「无需调参、无需分数归一、效果稳定超过单路」成了混合检索的标配——名次本身就是统一量纲,两路原始分数完全不用见面。
用代码把 RRF 算一遍。
假设向量路召回前三为 d1、d2、d3,关键词路前三为 d2、d5、d1,用 Node.js 22.15.0 实测(node rrf.js):
function rrf(rankings, k = 60) {
const score = new Map();
for (const list of rankings)
list.forEach((doc, i) =>
score.set(doc, (score.get(doc) || 0) + 1 / (k + i + 1)));
return [...score.entries()].sort((a, b) => b[1] - a[1]);
}
const vector = ['d1', 'd2', 'd3'];
const keyword = ['d2', 'd5', 'd1'];
for (const [doc, s] of rrf([vector, keyword]))
console.log(doc, s.toFixed(6));
实测输出:
d2 0.032522
d1 0.032266
d5 0.016129
d3 0.015873
d2 在两路都靠前,总分反超向量路的第一名 d1;只在单路出现的 d5、d3 排在其后。这正是混合检索想要的行为:两路交叉验证过的文档稳定上浮,单路独有的候选也不会被另一路的高分挤掉。
图 2:左边列盲区,右边算 RRF,两路名次一融合,d2 浮出水面。
落地时再补两个工程细节。
一是每路各取多大的窗口:窗口太小,融合没有原料,一般每路各取几十条候选再融合;二是融合之后通常还要接一道精排(Rerank),用交叉编码器对候选逐条重打分,RRF 负责「召回更全」,精排负责「排得更准」,两者分工不同。
面试答题建议按「盲区 → 补短 → 融合」三步走:先讲纯向量检索在型号、专名、长尾词上的失分案例,再讲 BM25 的词命中与统计打分如何互补,最后落在 RRF 的名次融合公式和 k=60 的约定上,补一句精排收尾。能主动指出「两路原始分数不可比,所以用名次而不是分数融合」,就把这道题从背诵题答成了理解题。你在项目里用的是哪路融合策略?有没有遇到过向量分数和关键词分数打架的情况?欢迎聊聊。