AI 与大模型

怎么保证 RAG 的召回率

召回是 RAG 效果的天花板——没被捞回来的内容,后面再怎么重排、再怎么换大模型都救不回来。这篇拆开五种互补的召回手段,讲清「宁多勿漏」这个取舍的代价,以及为什么多路分数不能直接相加。

2026-04-16 约 13 分钟 5254 字 RAG 向量检索 BM25 重排

排查 RAG 效果时,有一类判断特别容易出错:看到重排后的相似度分数很高,就认为检索环节没有问题。

但重排分数高只说明「留下来的这几条很相关」,它完全没有回答另一个问题——该被留下来的那些,有没有被捞进候选集。

这两件事是分开的。重排只能对已经进入候选集的片段做排序,它没有能力把没召回到的内容变出来。所以:

召回是天花板。候选集里没有的东西,后面所有环节都无能为力。

这也决定了两阶段的职责划分:召回阶段优先保证「不漏」,精度交给重排和 LLM 去筛。

一、先把目标定清楚

召回阶段 精排阶段
目标 优先 Recall(相关的尽量不漏) 优先 Precision(无关的尽量不送)
手段 多路检索、查询扩展、放宽 TopK Reranker、过滤、截断
候选量级 几十到上百条 5~8 条
用的是什么模型 双塔(查询和文档无交互) 交叉编码器(有交互)
失败代价 信息缺失,答案答不全或硬编 噪声干扰,答案被带偏

两边的失败代价并不对称。召不回来,模型要么答不全,要么直接用自己脑子里的知识编——这是幻觉最主要的来源之一;而召回来一堆无关的,还有重排和 Prompt 约束兜底。所以工程上的默认取向是:

工程取舍:召回阶段「宁多勿漏」,精度交给重排和 LLM 去筛。

下面五种手段是组合使用的,不是选一个。

用户 query
   ↓
① Chunking 优化 ──────→ 让每个块本身具备「可被召回」的条件
   ↓
② 查询增强 ───────────→ 把一个问题扩成多个检索入口
   ↓
③ 多路检索融合 ───────→ 向量 + BM25,互补盲区
   ↓
④ 元数据预过滤 ───────→ 先缩小范围,再谈排序
   ↓
⑤ 重排分层 ───────────→ 放宽 TopK 进,精排少量出
   ↓
上下文

二、手段一:Chunking 优化

这是所有手段里性价比最高的一条,因为它改的是「检索单元的形态」,而不是检索算法。

具体做四件事:

  1. 优先语义切块,而不是固定 token 硬切。 固定长度切分会把一句话从中间切断,导致检索命中了块、但块里没有答案。
  2. 结构化文档按章节边界切,并设置合理 overlap。 技术文档、政策条款的天然边界就是标题和「条」,顺着它切最省事也最准。
  3. chunk 附带元数据(章节、文档类型、生效时间、权限),这是后面做过滤和路由的前提。
  4. 把标题路径拼进 chunk 文本本身。 例如「X30 洗地机 > 产品参数 > 吸力」。否则块被剥离上下文后,嵌入模型看不到主题词,召回会明显变差。

一句话:切块决定了「这块内容有没有机会被召回」。切碎了,后面做什么都补不回来。

三、手段二:查询增强(Query Expansion)

用户的提问方式和知识库的表述方式往往不一致。查询增强的思路是:不指望一个问题能命中,而是把它扩成多个检索入口。

方式 做法 解决的问题
改写 口语化 → 规范化表述 「这玩意儿咋关」→「如何关闭」
同义 / 平行问题 生成等价问法的多个版本 「噪音小」vs「静音」
子问题拆分 复杂问题拆成多个子查询 「对比 A 和 B 的合规要求」
指代消解 结合历史补全省略成分 多轮里的「它多少钱」
术语归一 简写 / 黑话 → 标准描述 内部代号 → 正式名称

多路查询的结果最后合并成一个候选集。这个做法在用户表达不规范的场景里收益最明显——同一个意图换一种说法,嵌入模型的表现可能天差地别。

多轮场景下必须先改写

对话式产品里有一类高频失败:用户第二句说「那它呢」「这个多少钱」,代词没有还原就丢进检索,结果召回一堆完全无关的内容。

所以多轮场景的第一步永远是:把「历史 + 当前句」改写成一句独立完整、可脱离上下文理解的查询,再做后续检索。这一步不做,后面所有优化都建在错的输入上。

四、手段三:多路检索融合(Hybrid 检索)

向量检索和关键词检索的强项是不重叠的,这是混合召回存在的全部理由。

向量语义检索 关键词检索(BM25)
匹配什么 语义相似 字面命中
强项 同义表达、模糊描述、自然语言 专有名词、型号、编号、术语
短板 对专有实体召回弱(型号「H20」可能匹配到别的型号) 对同义表达无能为力(「静音」匹配不到「噪音小」)
对未登录词 依赖子词切分,可能失真 精确匹配,不会失真
分数分布 余弦 0~1,集中在高位 无上界,长尾
  • 只用向量:问「X30 的吸力」可能召回一堆讲吸力的通用段落,却漏掉参数表里那一行
  • 只用 BM25:问「噪音小一点的」召不回写着「静音设计」的段落

所以两路都要,结果融合。它们的盲区正好互补,这是单路调参永远达不到的效果。

五、手段四:元数据预过滤(先路由再检索)

这一类手段经常被忽略,但它的效果往往比调排序更直接:

检索前先缩小范围,比事后重排更有效。

做法是两层:

① 路由:意图识别 → 决定检索哪个知识分区
   问「怎么退货」     → source_type = 售后政策
   问「X30 参数」     → source_type = 商品参数
   问「活动什么时候结束」→ source_type = 活动规则

② 过滤:在选定分区内,再做标量条件过滤
   生效时间 ≤ 当前时间
   文档状态 = 生效中
   权限标签 ⊆ 用户可见范围
   (必要时用向量库的 partition 做物理隔离)

为什么这比事后重排有效?因为它改变的是候选集的构成,而不是候选集的顺序。相似但不属于该业务范围的文档,在语义上确实相关,重排模型未必能压下去——但如果它压根没进候选集,就不存在这个问题。

三个实现要点:

  • source_type 这类路由字段要在切片的时候就打好,不能靠检索时猜
  • 时效性字段(生效时间、版本)必须存在元数据里,并且在检索时作为条件下推
  • 意图识别可以用小模型,也可以用关键词规则兜底——兜底这一层很重要,因为路由错了等于整个检索都跑偏

六、手段五:重排分层策略

这是把「宁多勿漏」落到具体参数上的一步:

粗排(召回)                精排(重排)
向量 topK = 50  ┐
                ├─→ RRF 融合 ─→ Reranker ─→ 取 Top 5~8 ─→ Prompt
BM25 topK = 50  ┘   ~100 条      逐条打分
    ↑                                              ↑
放宽,保证不漏                            收紧,保证上下文干净

先放宽 TopK,再用 Reranker 把相关性低的压下去。 这两个动作必须成对出现——只放宽不重排,等于把噪声直接塞给模型;只收紧不重排,等于把召回率交给了粗排。

为什么必须分两阶段,而不是「一开始就精准检索」?

双塔(向量粗排) 交叉编码器(重排)
结构 查询和文档分别编码成向量,算距离 查询和文档拼在一起过一遍模型
交互 无交互,只是向量距离 有完整的注意力交互
准确率 一般,区分度不够 明显更高
成本 可预先建索引,查询时极快 每个候选都要前向一次,无法预建
能处理的量级 百万级 几十条

交叉编码器准确率高但慢,所以只能作用在少量候选上;双塔快但区分度不够,所以必须放宽召回。这正是「召回宁可多、重排负责精准」这个两阶段范式的由来——不是习惯,是成本和精度的必然结果。

分数融合:为什么不能直接相加

两路结果合并时,一个很自然的想法是「加权求和」。但两路的分数量纲不同:

余弦相似度: 0.72, 0.68, 0.65 ...   ← 集中在高位,区分度小
BM25 分数 : 18.3, 9.7, 4.2, 0.0    ← 无上界,长尾分布,同一分值在不同查询下含义不同

直接相加会让 BM25 的分数主导排序(它的数值范围大得多),而且同一个 BM25 分数在不同查询里根本不是一回事——查一个常见词的 10 分和查一个生僻词的 10 分,含金量完全不同。

两种常见处理:

方式 做法 问题
归一化加权 各自 min-max 归一化后加权求和 对异常值敏感;权重还得调;分数分布随查询变化
RRF 只用排名,不用原始分数 丢弃了分数里的信息(但换来稳定性)

RRF 的公式很简单:

score(d) = Σ  1 / (k + rank_i(d))          k 通常取 60
          i∈各路结果

其中 rank_i(d) 是文档 d 在第 i 路结果里的名次。举例,k = 60 时:

文档 向量排名 BM25 排名 RRF 分数 结果
A 1 未命中 1/61 = 0.0164
B 3 2 1/63 + 1/62 = 0.0320 第 1
C 2 5 1/62 + 1/65 = 0.0315 第 2
D 未命中 1 1/61 = 0.0164

可以看到:两路都排得靠前的文档(B、C)会浮上来,只在单路表现好的(A、D)会被压下去。这正是想要的行为——融合的价值就是「交叉验证」,而不是「谁分数大听谁的」。

k 的作用是平滑:取值越小越信任头部排名,越大则让各名次之间的差距变小。k = 60 是文献里的经验值,实践中的调整方向是:k 调小,头部结果的话语权更强;k 调大,排序更平缓。

七、「宁多勿漏」的代价

前面的建议听起来都是「加」:加路数、加 TopK、加候选。但放宽召回不是免费的,它是有明确代价的工程取舍:

放宽召回得到什么 付出什么
相关片段更可能进候选集 检索和重排的耗时上升(重排是逐条打分)
减少「信息不足」型漏答 Reranker 计算成本随候选数线性增长
送入 Prompt 的上下文变长 → token 成本上涨、延迟变高
上下文太长时模型会忽略中间部分的信息,反而降低利用率

所以「宁多勿漏」准确的表述是:

在召回阶段宁多勿漏——但「多」只到重排的输入为止。进入 Prompt 的上下文必须重新收紧。

放宽的部分由重排消化,而不是直接倒给模型。这也是为什么只放宽不加重排是常见但错误的改法:它把召回阶段的宽松直接传导到了生成阶段。

八、怎么验证召回率

召回率是少数几个可以直接量化的环节,前提是有一批标注数据。

评测集要带「应命中标注」

标准的评测集是三元组:问题 + 标准答案 + 应命中的 chunk 列表。有了第三条,才能算出检索指标:

指标 含义 怎么算
Recall@K 应该召回的片段里,有多少进了前 K 名 命中数 / 应命中总数
Precision@K 前 K 名里有多少是相关的 相关数 / K
MRR 第一个正确结果排在第几位 第一个正确结果排名的倒数均值
Hit Rate 前 K 名里有没有至少一条正确 有命中的查询数 / 总查询数

K 要按阶段分别看:K=50 看粗排召回率,K=5~8 看最终送入上下文的效果。这两个数会差很多——粗排 Recall@50 很高但 Recall@8 很低,说明问题在重排;两个都低,说明问题在召回本身。

一张诊断表:召回率低的六个常见原因

现象 大概率原因 往哪调
该命中的块完全没进候选 只有单路检索 上 Hybrid,补 BM25
命中了但块里没有答案 切块问题(切口 / 粒度) 见上一节:切块是主因
参数类问题召不回 表格没被解析成文本 表格转自然语言再入库
代词类问题跑偏 没做多轮改写 先改写再检索
召回了不相关的同名字段 没做路由和过滤 先路由再检索
命中率不错但答案还是不全 TopK 太小 放宽粗排 TopK,加 Rerank

顺序很重要:先查数据层(文档入库了吗、解析对吗、是不是被切碎了),再查配置层(切片参数、TopK、过滤条件),最后才考虑换嵌入模型。换模型是最贵的一步,而且往往不是根因。

小结

  • 召回是天花板,候选集里没有的内容,重排和 LLM 都救不回来
  • 目标分层:召回优先 Recall,精度交给重排——这就是「宁多勿漏」的由来
  • 五种互补手段:Chunking 优化、查询增强、多路检索融合、元数据预过滤、重排分层
  • 多轮场景下必须先做查询改写,否则后面所有优化都建在错误的输入上
  • 向量抓语义、BM25 抓字面,两者的盲区正好互补;分数不能直接相加,用只依赖排名的 RRF
  • 检索前缩小范围比事后重排更有效——它改的是候选集构成,不只是顺序
  • 「宁多勿漏」只到重排的输入为止;送入 Prompt 的上下文必须重新收紧
  • 验证召回率要有**「问题 + 答案 + 应命中 chunk」**的三元组评测集,分 K=50 和 K=5~8 两段看
  • 排查顺序:先查数据,再调配置,最后才换模型

召回优化没有一步到位的银弹,它的本质是用多种互补的视角去覆盖单个视角的盲区。任何单路检索都有系统性短板,而补齐短板的方式,往往不是把这一路调得更强,而是再加一路。

留下点什么

评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。