为什么纯向量检索不够用:BM25 + RRF 混合检索实践

做高考志愿问答时,最先撞上的不是「模型会不会胡说」,而是「检索凭什么能把正确的那段话捞出来」。一开始我用的就是最朴素的向量检索,能跑,但一遇到高校名、专业名、批次名这类必须精确命中的词就开始漂。这篇记录我怎么把它换成混合检索,以及换完到底涨了多少。

一、纯向量检索的两个短板

向量检索(稠密检索)本质上是把文本映射到语义空间,然后比距离。它的强项是「换个说法也能找到」,但代价是对专名和精确术语不敏感。

举例:用户问「浙江 600 分能上什么」,语料里有一段讲「温州医科大学 2024 年在浙江的投档最低位次」。向量模型会认为这两句语义相近,把这段排上来——这是对的。但当语料里同时还有 30 所学校的类似段落时,向量打分很难把它们排出一个符合预期的顺序,因为它不真的理解「温州医科大学」这六个字必须整体精确匹配。

反过来,纯 BM25(稀疏检索、关键词检索)的短板正好相反:对口语化改写无能为力。用户问「学护理有前途吗」,语料里写的是「护理专业就业方向」,两边词面几乎不重叠,BM25 直接匹配不到。

核心矛盾:向量擅长语义改写但专名会漂移;BM25 擅长精确命中但扛不住口语改写。任何一路单独用,都有一类问题注定解不了。

二、为什么不能简单相加:RRF 的引入

第一反应是「那我把两路分数加权平均不就行了」。这里有个坑:两路分数根本不是一个量纲。

向量那路给的是余弦相似度,通常落在 0~1 之间、小数点后三四位;BM25 那路给的是 tsvector 的打分,量级完全看词频和文档长度,可能是 0.03,也可能是 12.7。把 0.87 和 12.7 直接加权相加,权重调得再细也是拍脑袋。

正确做法是只融合排名,不融合分数——这就是 RRF(Reciprocal Rank Fusion,倒数排名融合):

score(d) = Σ  1 / (k + rank_i(d))
          i∈{dense, sparse}

每一个文档在两路里各有一个排名,把它换算成 1/(k+排名) 再叠加。k 是平滑常数(常用 60),作用是削弱头部排名的绝对优势。这样两路分数不需要可比,只需要各自的排序是对的。

三、中文特有问题:PG 原生不切中文词

我用的是 PostgreSQL 单库(结构化表 + 向量 + 全文检索同一个库)。但 PostgreSQL 原生的全文检索 tsvector 是按空格切词设计的,面对中文整句会把它当成一个大 token,等于没切。

所以入库和查询前都得自己接一层 jieba 预分词,并且要加载领域词典,否则「温州医科大学」会被切成「温州」「医科」「大学」,检索时反而引入噪声。我这边加载的词典规模是 1690 个高校名 + 1360 个专业名——这两个数字不是拍出来的,是从结构化表里的院校、专业字段去重统计出来的。

环节做法
入库jieba + 领域词典预分词 → 写入 tsvector 字段 → 建 GIN 索引
查询同样 jieba 预分词 → 构造 to_tsquery → 与向量检索并行
融合两路各取 Top-K,RRF 按排名融合
回取命中子块后按父块 ID 取回完整段落(父子块检索)

四、实测:39 题消融

光说「加了 BM25 更好」没有说服力,得做消融。我用了 39 道检索评测题,两档对比:naive 是纯稠密单路取 Top-7,hybrid 是 RRF 融合后取 Top-7。

指标naive(稠密单路)hybrid(RRF 融合)变化
Recall@10.92310.9487+2.56pt
Recall@30.97440.9744持平
Recall@71.00001.0000持平
MRR0.94660.9679+2.13pt

关键信息不在「涨了」,而在涨在哪里:Recall@3 和 Recall@7 两档完全一样,说明这两档早就饱和了(1.0000 也没法再涨)。提升全部集中在 Top-1。

翻译成人话:BM25 的作用不是「多捞回几个相关块」,而是把本来就在候选池里的正确块,顶到第一位。而 Top-1 恰恰是「给不给用户推荐」最敏感的位置——用户看到的第一条就是错的和第三条才是对的,体验完全不同。

纯向量对专名和精确术语会漂移,纯 BM25 对口语改写匹配不到,而且两路分数不同量纲不能直接相加。所以我加了 BM25 走 RRF 融合,Recall@1 从 0.9231 提到 0.9487。中文这块 PG 原生不切词,接了 jieba 预分词加领域词典。

—— 面试时我会这么讲

五、顺带说说 Rerank:一次「零提升」的消融

混合检索之后,很自然会想再加一层精排(Rerank)。我做了四档消融,而不是直接上线:

naive   稠密单路 K=7             最朴素基线
hybrid  RRF 融合 K=7             当前生产基线
wide    RRF 召回 21 → 取前 7     ★ 对照档:池子与 rerank 相同,但不精排
rerank  RRF 召回 21 → 精排取 7   要验证的档位

为什么必须加 wide 这个对照档:精排的常见用法是「召回放宽 → 精排收窄」,但候选池从 7 扩到 21 本身就可能带来提升。如果不设对照,你分不清涨的那部分是「重排序的功劳」还是「池子变宽的功劳」。

结果:

到这里最容易得出的结论是「精排没用,删掉」。但我先分清了这是 bug 还是评测设计问题:

检验项数据判定
精排是否真的改变顺序132/273 个排名槽位被动过(48.4%)确实生效,不是静默降级
命中排名变化改善 1 / 恶化 1 / 不变 37净效果 0
精排后仍未排第 1 的题仅 2 题—

根因:37/39 题的正确答案本来就在第 1 位——精排根本没有施展空间。

所以结论应该表述为「当前评测集区分度不足」,而不是「精排无效」。最终处理是保留能力、默认关闭(RERANK_ENABLED=false),等有更有区分度的题集再评估。

这次消融最大的价值,其实不是「找到了一个提升点」,而是及时否决了一个负收益投入——如果没设 wide 对照档,很可能把延迟涨了 400ms 的东西当成「优化」上线了。

六、三条可复用的判断

  1. 分数不同量纲,就只融合排名。RRF 的价值在于它绕开了「权重怎么调」这个无底洞。
  2. 做消融要设对照档。想验证 A,就要有一个「只有 A 不同、其他都一样」的档位,否则提升归因不了。
  3. 指标持平不等于方案无效。先问「是方案没起作用,还是评测集测不出差别」——前者删代码,后者留着等更好的评测。
← 返回笔记列表