RAG 召回不准,先别急着换模型:用 RRF 把 BM25 和向量召回接起来

RAG 召回不准,先别急着换模型:用 RRF 把 BM25 和向量召回接起来

做 RAG 检索时,我碰到过一类比较典型的召回问题:

  • 问"怎么理解事务传播行为",答得还行;
  • 问"@Transactional 什么情况下会失效",答非所问。

同一个知识库、同一个模型、同一套 prompt,差别只在提问方式 。前者是自然语言,后者更像"代码 + 术语"。一开始很容易把原因归到模型上,但把召回结果单独拿出来看,往往能发现问题其实出在召回:真正相关的内容没有排到前面。

这里主要记录一下我对这两路召回做融合时的思路,以及几个实际实现时需要注意的地方。


一、先看看两路召回各自擅长什么

常见的 RAG 检索会把文档切成子块,再进行向量化;在此基础上增加一条关键词召回链路,就可以形成混合召回。向量召回的原理是语义相近则向量相近,它的弱点也很明确:

查询类型 向量召回表现 原因
"怎么理解事务传播行为" ✅ 好 语义完整,和正文表述贴近
"@Transactional 什么情况下会失效" ❌ 差 关键词是代码符号和专有术语,这类查询更依赖精确的字面匹配,而向量召回主要提供的是语义相似信号
"上次那个报错怎么解决" ❌ 差 信息量太低,语义漂移

传统 BM25 更擅长处理关键词、术语和代码符号,但对同义改写、自然语言表达差异的处理能力有限。用户不会用你文章里的词提问。

两路的优势刚好互补,所以才有了混合召回。


二、为什么没有直接做加权

最直觉的方案就是加权:final = 0.7 × 向量分 + 0.3 × BM25分。但这里有一个比较麻烦的问题:

这两个分数量纲完全不可比。

  • 向量侧是余弦相似度,具体分布取决于 embedding 模型和语料;
  • BM25 侧是 ES 的 _score,无上界,随查询词频、文档长度、IDF 浮动,同一个查询不同文档能差两个数量级。

直接线性加权,问题在于两路分数没有统一的可比尺度。举个极端一点的例子:某篇文档的向量相似度是 0.82,而 BM25 得分是 2100,按 0.7/0.3 加权:

0.7 × 0.82 + 0.3 × 2100 = 0.574 + 630 = 630.574

权重虽然写的是 0.7 : 0.3,但实际结果几乎完全由 BM25 分数主导。这个例子想说明的不是 BM25 一定会比向量分数大很多,而是两路分数本身没有稳定的可比尺度。

那先归一化再加权呢?也不算理想。BM25 本身是长尾分布,按每次查询的候选集做归一化后,不同查询之间的刻度还可能发生变化。

所以问题不只是"要不要归一化",而是不同召回链路的分数本身就很难放到同一个尺度上比较。最后我选择直接看名次。


三、RRF:换一种方式融合

RRF(Reciprocal Rank Fusion,倒数排名融合)不再比较两路的原始分数,而是直接使用各自的排名:排在第几名,就按对应的排名计算贡献。

scss 复制代码
score(d) = Σ_r  1 / (k + rank_r(d))
  • rank_r(d) 是文档 d 在第 r 路召回里的名次(从 1 开始);
  • k 是平滑常数,取 60(原论文默认值);
  • 一个文档出现在多路里,分数相加。

这样做的好处是,两路只需要比较排名,不需要比较原始分数。不管 BM25 那边打出来是 3.7 分还是 3700 分,"排在第一名"这件事的语义是统一的。超参只有一个 k,不需要任何训练。

k 越大,名次之间的差距越平缓。k=60 是原论文里的默认取值,我这里直接沿用。它主要控制排名分差的衰减速度,是否需要调整,还是应该结合自己的评测集验证。

拿 3 篇文档算一遍会更直观。某次查询,向量路排序 A > B > C,BM25 路 B > A > D,k 取 60:

文档 向量路 BM25 路 融合分
A 第 1 → 1/61 ≈ 0.0164 第 2 → 1/62 ≈ 0.0161 0.0325
B 第 2 → 1/62 ≈ 0.0161 第 1 → 1/61 ≈ 0.0164 0.0325
C 第 3 → 1/63 ≈ 0.0159 没上榜 0.0159
D 没上榜 第 3 → 1/63 ≈ 0.0159 0.0159

两路都进前二的 A、B 并列第一,而只在单路上榜的 C、D 明显落后。这里可以直观看到:RRF 只看排名,不再关心两路原始分数到底是多少。A、B 出现同分也很常见,所以实现时需要额外的 tie-breaker(同分时的排序裁决规则),比如分数相同时按文档 id 升序,让同一个查询每次跑出同样的顺序。

去掉排序和其他边角处理,核心代码其实不多:

java 复制代码
Map<Long, Double> scores = new HashMap<>();                  // 文档id → 融合分
for (List<Long> route : List.of(vectorRoute, bm25Route)) {   // 两路列表凑成一组,先向量路后 BM25 路
    int rank = 1;                                            // 名次从 1 开始数
    for (Long id : route) {                                  // 这一路按相关度降序传入,顺序即名次
        if (id == null) { rank++; continue; }                // 当前实现里让异常数据占一个名次
        scores.merge(id, 1.0 / (k + rank++), Double::sum);   // 核心:给 id 加上 1/(k+名次) 这么多分
    }
}
// 循环结束:按融合分从高到低输出,同分按 id 升序(见下文 tie-breaker)

这段代码就是按每一路已有的顺序取 rank,计算 1 / (k + rank),再把同一个文档在不同路得到的分数累加起来。

某一路没有结果时,直接跳过即可;如果以后增加第三路召回,也不需要改 RRF 的核心公式。

实现时还有两个容易被忽略的细节:

  1. 同分按 id 升序。两路融合后出现等分并不罕见,如果没有确定性的 tie-breaker,结果顺序可能不稳定。
  2. 异常数据要提前处理 。例如 null 最好在进入融合前清理;如果保留在列表里,则要明确它是否占用 rank,避免影响后续排名。

如果所使用的搜索引擎或检索框架已经提供 RRF,可以直接使用原生能力;如果不同召回路由分布在不同组件中,也可以在应用层完成融合。这里的核心并不依赖具体的存储或搜索产品。


四、接入时还有两个地方需要处理

4.1 某一路失败时怎么降级

两路都可能失败。BM25 路通常涉及网络调用,向量召回前也可能有 embedding 调用。比较简单的做法是:某一路失败时返回空列表,再交给 RRF 处理。这样融合时只会使用剩下的召回结果,降级逻辑也比较容易维护。

另外可以加一个开关,在需要时直接切回单路;同时记录各路召回数量和最终融合结果,排查问题会方便一些。如果用于真实服务,外部依赖还需要考虑超时和熔断,避免持续故障拖住请求线程。

4.2 召回用子块,上下文可以取整篇

另一个问题是检索粒度。通常不建议直接把整篇长文作为一个向量,而是先切成段落级子块。整篇文本过长时,单个向量容易把不同主题的信息混在一起,长文本还可能受到 embedding 输入长度限制。

因此可以把两个粒度拆开:子块负责被召回,整篇或更完整的上下文负责被阅读。融合排序时,可以用一篇文档中最相关的子块代表整篇,再根据文档 id 取回完整上下文。这样既保留了段落级匹配的精度,也避免最终生成阶段只看到零散片段。

具体的子块大小、重叠长度、单篇上限和最终上下文长度,都应该结合语料特点、召回效果和生成成本来调整。这里没有一个适用于所有项目的固定数字。


五、几个实现上的取舍

  • MAX(相似度) 聚合会丢掉"多块命中"的加强信号。如果一篇文章有 5 个段落都命中,理论上应该比"只有 1 段命中"更相关,但取最大值后这两者没有区别。换来的是聚合逻辑简单、成本可控;要做得更好,可以把子块分做二次聚合(求和或加权),代价是多一层聚合逻辑要维护。
  • BM25 是否需要叠加时间因素,要看业务目标。单纯为了提高"新内容"的权重,不一定应该直接混进召回分数。
  • 如果候选数量本身就很少,也可以考虑跳过 LLM 精排,避免为了有限的候选集增加一次额外调用。具体阈值还是应该结合实际评测结果决定。

六、总结

混合召回真正需要解决的,不是简单把两个分数加起来,而是怎么把两套不同的相关性信号放进同一个排序里。

BM25 更擅长字面匹配,向量更擅长语义匹配。RRF 把两路统一到排名上,不再要求它们的原始分数具有相同尺度。

至于 k、chunk 大小、候选数量这些参数,还是要结合自己的数据做评测。RRF 解决的是融合方式,最终效果还得看实际评测结果。

相关推荐
打工仔折腾 AI1 小时前
把模型切换交给平台:用蓝耘智能路由搭建商品评论分析工具
java·服务器·前端·后端·python·性能优化·ai agent 实战
后端LV1 小时前
Caffeine 源码详解:为什么它是最快的 Java 本地缓存——一次压测引发的源码考古
java·后端
dadaobusi1 小时前
Trace-driven 建模(基于轨迹/踪迹的建模)
java·后端·spring
摇滚侠1 小时前
《Spring Boot 3:高级与架构设计》第 3 章 Bean 的全生命周期原理 Bean 的全生命周期概览 阅读笔记 9
spring boot·笔记·后端
墨家句子2 小时前
AnythingLLM 搭本地知识库:文档问答不准怎么调
linux·后端
知守观2 小时前
OBS 同名文件互相覆盖,客户拿错了别人的报告:一次文件串号的排查与重构
java·后端
特立独行的猫A2 小时前
Godot 游戏编辑器移植鸿蒙 PC:难度与可行性分析
后端·harmonyos
章鱼哥19712 小时前
DeepSeek Harness 插件开发新手教程
后端·deepseek
特立独行的猫A2 小时前
C++ 异步编程:std::future 与 std::promise 详解(含 RPC 客户端实现实践)
c++·后端