先说一个挺扫兴的场景。
你让 AI 记住一句话:「用户最喜欢的音乐是 Poker Face。」过了几天,你问它:「用户最喜欢什么音乐?」它却答不上来。
你去知识库里一看,这句话明明还在。更奇怪的是,单独按「意思相近」来搜,它能排在前面;把另一种搜索方式加进来,它反而从结果里消失了。
我遇到的就是这个问题。一开始我怀疑模型不行,后来才发现:资料找到了,却在合并搜索结果时被淘汰了。
最后我没有换更大的模型,而是改了一个只有几行的排序公式。
一、先不谈技术,知识库到底在干什么
即使没接触过 kb,也可以把它理解成一个放在自己电脑里的资料抽屉。
你把笔记、文档、网页和表格放进去,以后提问时,它先把相关资料找出来。至于要不要再让大模型根据资料写一段回答,是下一步的事。
这类「先查资料,再回答」的做法,通常叫 RAG。名字听起来唬人,核心其实和开卷考试差不多:与其让 AI 凭印象猜,不如先把课本翻到对应的位置。
这篇文章讲的是「翻课本」这一步,不讲大模型怎么写作文。哪怕完全不配大模型,检索流程也会运行。
问题是,资料在库里,不等于它一定能被翻到。
二、为什么要用两种方式一起找
假设库里有三类内容:
- 中文笔记:「用户喜欢猫咪」;
- 中英混合笔记:「用户最喜欢的音乐是 Poker Face」;
- 英文笔记:「The hybrid retriever fuses BGE-M3 vector search with BM25 keyword ranking.」
现在用中文问:「用户最喜欢什么音乐?」或者「混合检索是怎么融合两路结果的?」
一种办法是按字面找。问题里出现「音乐」,含有「音乐」的笔记就得分;出现「Poker Face」,含有这个歌名的笔记就得分。
另一种办法是按意思找。即使问题和资料用了不同语言、不同说法,只要意思接近,也有机会被找回来。
kb 同时用了这两条路:
| 检索方式 | 通俗理解 | 擅长什么 | 容易漏掉什么 |
|---|---|---|---|
| 向量检索 | 按意思找 | 换个说法、跨语言、近义表达 | 对精确型号、符号、名字,有时不够稳定 |
| BM25 | 按关键词找 | 人名、型号、错误码、专业词、原文短句 | 字面不重叠时,可能完全找不到 |
这里的「向量」,可以暂时理解成把文字变成一串数字,意思越接近,数字通常也越接近。负责做这件事的是嵌入模型,不是聊天的大模型。
两路一起用,就叫混合检索:一路负责「意思差不多」,一路负责「词确实对上了」。
text
同一个问题
│
┌─────────┴─────────┐
▼ ▼
向量检索:按意思找 BM25:按字面找
│ │
└─────────┬─────────┘
▼
合并两份结果
▼
返回最前面几条
听起来很稳妥:两路互相补漏,总比一路强吧?
我漏算了一件事------两份结果怎么合并,也是一道会出错的题。
三、RRF 是什么:把两个榜单合成一个榜单
两路检索都会返回一个有序列表,但它们的原始分数不能直接比。
向量检索可能给出 0.86,BM25 可能给出 8.31。不能因为 8.31 比 0.86 大,就说 BM25 的结果一定更好,就像不能把摄氏度和公里放在一起比大小。
RRF 的思路很省事:先别管原始分数,只看每条资料在各自榜单里排第几。
它的全称是 Reciprocal Rank Fusion,公式是:
text
score(d) = Σ 1 / (k + rank_i(d))
不用被符号吓到,它只说了三件事:
rank是名次,第 1 名就代 1,第 10 名就代 10;k是一个平滑参数,我的代码里取 60;- 一条资料出现在几路榜单里,就把几路的得分加起来。
所以,双路第 1 名的分数是:
text
1 / (60 + 1) + 1 / (60 + 1) = 0.0328
RRF 流行是有理由的:不用研究两路分数的量纲,不怕某一路突然冒出一个特别大的分,实现也只有几行。做第一版混合检索时,选它很自然。
四、问题出在哪:一路第一,输给了两路都一般
把几种情况代进公式,问题就露出来了:
| 一条资料在两路榜单里的表现 | RRF 分数 |
|---|---|
| 两路都是第 1 | 1/61 + 1/61 = 0.0328 |
| 向量第 1,BM25 没命中 | 1/61 = 0.0164 |
| 两路都是第 10 | 1/70 + 1/70 = 0.0286 |
| 两路都是第 15 | 1/75 + 1/75 = 0.0267 |
这里有两件事让我意外。
第一,双路第 1 和双路第 15,只差约 0.006。名次差了这么多,最后分数却挤在一起。
第二,也是这次 bug 的关键:向量第 1、BM25 缺席,只有 0.0164;两路都排第 10,反而有 0.0286。
换句话说,一条资料即使被「按意思找」排在最前面,只要「按关键词找」没命中,就可能输给一堆两路都表现平平的资料。
「缺席」不是资料被删了,也不是它没入库,只是这一路没有给它有效分数。跨语言资料特别容易遇到这种情况:中文问题与纯英文笔记没有词面重叠,BM25 自然可能交白卷。
最终返回结果时还有一个 top_k 限制。它表示「只留前几条」,例如 top_k=5 就是只给用户看前 5 条。那条向量第 1 的英文笔记一旦被挤到第 6,在用户眼里就等于「知识库根本没有它」。
所以,换嵌入模型只能改善左边那张榜单;如果右边的合并规则把它压下去,换再好的模型也救不了最终结果。
五、第一次修正:把两路分数换到同一把尺上
我先把 RRF 从默认路径上撤下来,改成「归一化后加权」。
「归一化」这个词很学术,做的事却很朴素:每一路分别把本路候选分数换算到 0~1,再合并。
用的是 min-max 归一化:
text
norm_i(d) = (score_i(d) - min_i) / (max_i - min_i)
举个只有三个候选的例子。如果一路原始分是 100、50、0,换算后就是:
| 原始分 | 换算后 |
|---|---|
| 100 | 1.0 |
| 50 | 0.5 |
| 0 | 0.0 |
这样做有两个好处:
- 向量路的 0.86 和 BM25 路的 8.31,不再直接硬比;
- 同一路内部的高低差被保留下来,不会像 RRF 那样把第 1 和第 15 压进很窄的分数区间。
这里有个容易误读的地方:归一化后的 1.0,不代表「这条资料百分百相关」,只代表它在当前这批候选里排最高。
如果检索回来的全是不相关资料,最高分那个也可能只是「矮子里拔将军」。所以后面还需要一道相关性下限,不能只看归一化分数。
六、第二次修正:缺席一路,不要再白挨一刀
只换归一化还不够。
我第一次合并时,仍然习惯性地除以总路数。两路检索,就把每条资料的分数都除以 2:
text
最终分 = 两路归一化分之和 / 2
这又制造了同一个问题。
为了把规则说清楚,下面构造一组简化数字------它们是在解释公式,不是实测 benchmark:
| 资料 | 向量路归一化分 | BM25 路归一化分 | 除以总路数 2 |
|---|---|---|---|
| 英文笔记 | 1.0 | 缺席,按 0 处理 | 0.5 |
| 中文笔记 A | 0.9 | 0.8 | 0.85 |
| 中文笔记 B | 0.7 | 0.6 | 0.65 |
英文笔记明明在向量路拿到了 1.0,只是因为 BM25 没命中,就被砍成 0.5,又输给了中文笔记 A。
但「BM25 没命中」在这里并不代表内容不相关,只代表中文问题和英文笔记没有足够的字面重叠。让它为此少拿一半分,等于重复惩罚跨语言资料。
后来代码改成:
text
最终分 = 实际获得的归一化分之和 / 这条资料实际出现的路数
也就是:
- 只在向量路出现,就只看向量路,1.0 仍然是 1.0;
- 两路都出现,就把两路分数平均,和原来的双路均权规则一致;
- 不因为某一路天然不适用,就强行把另一条路的分数砍半。
真正关键的不是「加权」两个字,而是这个分母:缺席到底该不该计入平均分?
在我的跨语言场景里,不应该。
七、这个改法不是白捡的,还要两道护栏
新规则会偏爱「单路很强」的资料。
一条只在向量路拿 1.0 的资料,会排在「两路分别是 0.9」的资料前面。跨语言检索里,这正是我想要的结果;但如果那条单路资料只是碰巧被召回,它也会因此获得机会。
所以代码里还有两道限制。
第一道是相关性下限。向量路原始余弦分低于 0.30 的候选,会在融合前被过滤;BM25 路完全 0 分的记录也会被去掉。否则一批无关候选经过归一化,照样可能冒出一个虚假的 1.0。
第二道是候选数量 。最终只要前 5 条,不代表每一路也只取 5 条。代码每路先取 max(30, 3*top_k) 个候选:至少要 30 个,或者是最终返回数量的 3 倍,取两者中较大的那个。
这样做是因为,正确资料如果早在单路候选阶段就被截掉,后面的融合公式再漂亮也没用。
这几处配合起来,才是完整修复,不是单独把 RRF 换成另一个公式就万事大吉。
八、怎么确认它真的修好了
改完后,我用同一批中文问题人工检查过结果,英文笔记重新回到了前列,中文结果没有明显异常。
后来我又把这类场景写进回归测试,位置是 tests/test_n32_hybrid_fusion_recall.py:
- 双路第 1 的记录,归一化融合分应为 1.0;第 16 名应为 0.0,验证区分度没有被压平;
- 只在一路出现的记录,不再被除以总路数稀释;
- 用中文「用户最喜欢的音乐」检索,含
Poker Face的记忆在top_k=3和top_k=5时都必须命中; - 用中文询问混合检索原理,纯英文的
BGE-M3笔记也必须进入结果。
这些测试的作用是:以后再有人改融合公式,同类问题会先被测试拦住。
我没有跑大规模 benchmark,所以不会在这篇文章里宣称「召回率提升了多少」。固定问题集、人工检查和回归测试能证明这个具体 bug 被覆盖,但不能代替全面的效果评测。这个边界还是得说清楚。
九、RRF 本身没错,我也没有把它删了
现在回头看,我不认为 RRF 是「错误算法」。
如果你的几路检索都有机会命中同一份资料,而且你只关心名次、不想处理分数尺度,RRF 依然简单、稳定、省心。冷启动时,它仍然是一个合理选择。
真正需要警惕的是:某一路会不会对某类资料系统性交白卷。
跨语言资料、代码符号、专有名词,都可能出现这种事。这时「没有命中」不一定代表质量差,也可能只是这条检索路不适合当前问题。
代码里,rrf_fuse 仍然保留着,用于既有测试和明细对比;默认的 hybrid 路径已经改走 weighted_fuse。所以这次改动准确地说,是「替换默认融合方式」,不是把 RRF 从项目里彻底消灭。
十、这件事给我的提醒
对用户来说,问题只有一句:「明明存过,为什么找不到?」
对开发者来说,中间却有很多站:文字怎么切分、嵌入模型怎么理解、每一路找回了什么、候选池留了多少、几张榜单怎么合并、最后又截掉了什么。
任何一站出问题,用户看到的都是同一个「找不到」。
我这次走的弯路,是盯着模型看了很久,却没有先把融合前后的名单打印出来。真正定位问题时,需要的不是更复杂的理论,只是把三个列表摆在一起:
text
向量检索结果:它排第几?
BM25 检索结果:它有没有出现?
融合后的结果:它为什么被挤掉?
如果你没做过检索系统,也可以记住一句话:搜索不是「某一路找到了」就结束了,怎样合并不同来源的结果,同样决定答案能不能被看见。
如果你也在做混合检索,欢迎留个言:你用的是 RRF,还是按分数加权?有没有遇到过某一路对某类资料经常缺席的情况?一句「用的哪种 + 什么场景」就行,我很想看看别人的实际经验。
项目地址:github.com/fish827-08/...
融合代码在 kb/retriever.py,默认函数是 weighted_fuse,注释里记录了 N32、N33 两次修正的原因。