知识库明明有答案,为什么搜不到?我换掉 RRF 修好了混合检索

先说一个挺扫兴的场景。

你让 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 两次修正的原因。

相关推荐
聪明的笨猪猪2 小时前
实际工程问题:AI生成Redis分布式锁的原子性缺陷导致库存超卖
java·经验分享·面试·ai编程
孔令飞5 小时前
把 Agent 搬进飞书:飞书 Agent 机器人演示
云原生·go·ai编程
ZzT6 小时前
Anthropic 选模型指南拆解:先用 low 跑、失败的再用 high 重跑,通过率 97% 高于全程 high
ai编程·claude
西安小哥6 小时前
给 Agent 堆上下文,不如把缓存命中率抬上去
aigc·ai编程
LV技术派7 小时前
我的《成为 AI Native Builder》开源小册,全量开放了!
aigc·openai·ai编程
唐老板9 小时前
agent 撑爆 Git 之后,两家改的不是同一层
人工智能·ai编程
LucianaiB19 小时前
【元宝搜索开发者征文大赛】AI时代,学得慢就不用学?用元宝搜索打造一个教程保质期,确保每一个教程符合你的现状
前端·人工智能·ai编程
小虎AI生活20 小时前
REA(rea.tools) 接入 WorkBuddy 实操:Electron ASAR 免解压静态分析到零代码复刻
aigc·ai编程
Spidermanbaout21 小时前
Prompt-Engineering实战-2026年11月2日
人工智能·机器学习·ai·chatgpt·ai编程