检索不准?上混合检索和 RRF
约 2489 字大约 8 分钟
2026-09-22
在线侧第一场手术。上一篇跑基线时留下的那几个毛病——错误码搜不到、否定语义翻车、该中的排不进 top5——今天先治最普遍的一个:单路向量检索的盲区。本篇继续主战场在「会话洞察 → 检索评估」页——双通道结果合并后,这一页就是验收对比的地方。
向量检索到底瞎在哪
先把病理讲透,药方才显得不玄。
向量检索靠的是「语义指纹」,它的强项是「意思像就能找到」。但有三类东西,它的表现系统性地拉胯:
第一类:专有名词、错误码、型号。 「ERROR-4021 怎么处理」——4021 这个字符串在 Embedding 模型的世界里没有「意思」,它不是词,压进向量后跟一堆数字串挤在一起,检索基本靠缘分。但我博客里真有文章写了这个错误码的处理办法,就因为「字面精确的东西在语义空间里没有邻居」,它沉底了。
第二类:否定和对比。 「哪些场景不适合用缓存」,向量检索乐呵呵给你召回「缓存适用的场景配置大全」。因为「适合」和「不适合」在语义空间里几乎是邻居——差一个「不」字,1024 维的方向几乎不变。这个 bug 特别气人,因为答案明明就差一个字。
第三类:低频行话。 某些内部叫法、缩写,模型训练时压根没见过,压出来的向量就是个大概齐。
而另一边,有一门老手艺恰好这三类全不怕:关键词检索(学界叫 BM25)。它就是字面匹配——「4021」这四个字符原文里有没有、出现了几次。你写什么它搜什么,完全不动脑子,也就完全没有「语义理解」的坑。
当然它也有自己的瞎处:换个说法就抓瞎。「退款」搜不到写「退货撤单」的文章——这是向量检索的主场。
看明白了吧,这两位的盲区是互补的:
混合检索的本质
向量通道抓「意思像的」,关键词通道抓「长得像的」。两个瞎子互补,中文知识库这是标配,不是可选项。
BM25 三分钟入门
给不熟的朋友补个最小背景(熟的直接跳下节)。
BM25 是几十年搜索引擎沉淀出来的打分公式,理解成升级版 TF-IDF 就行:
- 一个词在当前文档出现越多分越高(但有饱和,出现第十次没比第九次多多少分)
- 词越稀有分越高(
ERROR-4021这种全库独一份的词,命中就是重锤) - 外加对长文档的惩罚
你不需要会推公式,需要记住的就一句:它是个纯字面的、可解释的打分。
落到 Postgres,就是 PG 自带的全文检索:tsvector 类型的列存分词结果,GIN 索引加速,ts_rank 打分。Java AI 没有现成的 BM25 封装,这部分自己写——SQL 本身不难,难点在下一个话题。
中文的大坑:分词
PG 的全文检索默认按空格分词,英文没问题,中文「防止重复下单」在它眼里是一个词,用户搜「重复下单」就匹配不上。两条路:装中文分词扩展(zhparser 或 pg_jieba,要在数据库服务器上编译安装);或者用偷懒的 bigram 方案——把文本切成两两一组的字(「重复」「复下」「下单」),检索时查询词同样切法。
我的选择:博客规模先用 bigram 起步。不需要动数据库环境,对「错误码、专名」这类字面查询够用——而这类查询本来就是 BM25 通道存在的意义。
两路结果怎么合:RRF 登场
好,向量通道捞回 top20,BM25 通道捞回 top20。问题来了:这 40 条怎么排成一个序?
天真的想法是分数归一化加权:把两边分数都缩放到 0-1,然后 0.6 × 向量分 + 0.4 × BM25 分。这条路看着科学,实际上很坑:两边的分数根本不在一个世界。余弦相似度挤在 0.5 到 0.85 之间波动,BM25 分数从 0 到几十狂野分布,而且随查询不同剧烈波动——今天这个查询 BM25 最高分 28,明天最高分 5,你的归一化天天失真。权重调到天荒地老也是时好时坏。
正解简单到让人想笑,叫 RRF(Reciprocal Rank Fusion,倒数排名融合):不看分数,只看名次。
某文档的 RRF 分 = 各通道 1/(k + 名次) 之和,k 取 60比如一篇文章的某段,在向量通道排第 2,BM25 通道排第 5:
1/(60+2) + 1/(60+5) = 0.0161 + 0.0154 = 0.0315而一个只被向量召回排第 1 的段落:1/(60+1) = 0.0164。两路都点头的段落,分数碾压单路冠军。
三个理解点,把 RRF 焊牢:
一,为什么用名次不用分数? 名次是两个通道之间唯一稳定的公共语言。你的分数是余弦、我的分数是 TF-IDF 变体,但「在我这边排第三」这个事实谁都能说、语义一致。
二,k=60 是干嘛的? 平滑。它压制头部名次的特权——没有 k,第 1 名(1/1=1)对第 10 名(1/10=0.1)是十倍碾压,排序变成「谁拿了单路第一谁称王」;k=60 一加,第 1 名(1/61)和第 10 名(1/70)差距被压平,排序更看「共识」而非「独苗」。60 是原论文经验值,业界用了很多年,一般不动它。
三,它暗含的哲学正中 RAG 下怀:「语义和字面都说相关」的段落,比「只有一边说特别相关」的段落更可能是真证据。这叫共识加权,和我们「找证据」的目标天然合拍。
代码层面 RRF 就是个循环加 Map 计数,二十行不到,纯内存计算,零额外成本。这可能是全系列投入产出比最高的一段代码。
改造后的检索服务
结构变成这样:
public List<ScoredChunk> retrieve(String query) {
var byVector = vectorChannel.search(query, 20); // PGVector, 宽阈值
var byKeyword = bm25Channel.search(query, 20); // tsvector + bigram
var merged = rrfFuse(byVector, byKeyword); // 排名融合 + 去重
return merged.stream().limit(20).toList();
}几个工程细节:
- 每条结果要带上「来自哪个通道、在该通道排第几」——RRF 要用
- 去重:同一段落被两路都召回,是好事(这正是共识信号),但融合后必须合并成一条,累加它的 RRF 分
- 向量通道阈值放宽(比如 0.5 甚至更低):既然有融合和后面的精排兜底,召回阶段宁滥勿缺
- 每个通道加个开关配置(
rag.hybrid.enabled之类):BM25 通道出问题时能一键退回纯向量,增强链不能变成故障链
检索架构总原则
宽召回 + 后置精筛,这个组合拳比「单通道高阈值」又准又稳。先混个脸熟,后面几篇反复出现。
拿基线档案验收
跑同样的 10 个评测问题,对比上一篇的基线档案。可以预期的变化(你的实际结果会有差异,但方向应该一致):
- 「
ERROR-4021是什么」——原来沉底的正确文章,靠 BM25 通道的精确命中冲进了 top3,RRF 又给它叠加了共识加成。盲区补上了 - 「哪些场景不适合缓存」——BM25 通道里「不适合」三个字字面命中,这个段落拿到了第二路投票,排名上移。部分缓解(彻底解决要靠第 10 篇的 Rerank,交叉编码器读得懂否定)
- 个别原来排第 4、5 的问题,因为无关段落在某一路偶然命中了字面词,排名反而掉了。记下来,这也是 Rerank 的活
注意最后一条:混合检索不是纯赚不赔,它是把「排序错误」从「漏召回」变成了「排序乱」——而排序乱是下游精排能治的病,漏召回谁也治不了。这笔账始终是划算的。
验收还是在「检索评估」页做,但这次看的东西多了:每条结果要标出来源通道(向量 / BM25 / 双通道命中)和该通道内的名次——RRF 的一切都建立在排名上,所以这一页的展示重点就是「谁在哪个通道排第几」。双通道命中的条目视觉上突出显示(比如加个标记),共识信号一眼可见。
📷 图片位(待补充)
应放什么图:「检索评估」页双通道版截图,输入带专有名词的问题(如错误码类),展示结果列表里每条的通道标签——一条「双通道命中」的排在前,单通道命中的在后,直观呈现 RRF 共识加权的效果。
今天的落地清单
到这里检索的「广度」问题解决了——该捞的都能捞回来。但顺序还乱着:top20 里真证据和路人甲挤在一起。下一篇请出漏斗末端的守门员 Rerank,让它把「20 条里挑 5 条真能用的」这活干了,顺便把「宁可承认不知道也不瞎编」的拒答闸门也装上。
下篇见。