为什么纯向量检索不够用: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@1 | 0.9231 | 0.9487 | +2.56pt |
| Recall@3 | 0.9744 | 0.9744 | 持平 |
| Recall@7 | 1.0000 | 1.0000 | 持平 |
| MRR | 0.9466 | 0.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 本身就可能带来提升。如果不设对照,你分不清涨的那部分是「重排序的功劳」还是「池子变宽的功劳」。
结果:
- 召回放宽无额外收益:hybrid → wide 完全一致,因为
hybrid_search内部本来就用dense_k=20 + sparse_k=20,取 21 和取 7 的前几条本就相同。 - rerank 零提升但有延迟代价:wide → rerank 全部指标
+0.0000,而延迟 P50 增加 405.2ms、P95 增加 625.3ms。
到这里最容易得出的结论是「精排没用,删掉」。但我先分清了这是 bug 还是评测设计问题:
| 检验项 | 数据 | 判定 |
|---|---|---|
| 精排是否真的改变顺序 | 132/273 个排名槽位被动过(48.4%) | 确实生效,不是静默降级 |
| 命中排名变化 | 改善 1 / 恶化 1 / 不变 37 | 净效果 0 |
| 精排后仍未排第 1 的题 | 仅 2 题 | — |
根因:37/39 题的正确答案本来就在第 1 位——精排根本没有施展空间。
所以结论应该表述为「当前评测集区分度不足」,而不是「精排无效」。最终处理是保留能力、默认关闭(RERANK_ENABLED=false),等有更有区分度的题集再评估。
这次消融最大的价值,其实不是「找到了一个提升点」,而是及时否决了一个负收益投入——如果没设 wide 对照档,很可能把延迟涨了 400ms 的东西当成「优化」上线了。
六、三条可复用的判断
- 分数不同量纲,就只融合排名。RRF 的价值在于它绕开了「权重怎么调」这个无底洞。
- 做消融要设对照档。想验证 A,就要有一个「只有 A 不同、其他都一样」的档位,否则提升归因不了。
- 指标持平不等于方案无效。先问「是方案没起作用,还是评测集测不出差别」——前者删代码,后者留着等更好的评测。