RAG 的三个失效环节:检索、重排、生成各自会怎么坏
前言
RAG 这套架构现在几乎是知识问答的默认方案。但它的调试体验很糟糕:最终输出不好,你不知道是哪一段坏的。
检索没找到、找到了但排错了、排对了但模型没用好——这三种情况从用户视角看完全一样,都是一句错误的回答。而它们的修法截然不同:第一种要改切分和索引,第二种要换重排,第三种要改提示结构和引用约束。用错了手段,你会陷入"换了个更强的模型,效果没变"的循环。
这篇文章试图把这三段拆开讲清楚,重点是每段的失效长什么样、怎么和另外两段区分开。
一、检索阶段:不是"没找到",是"找错了"
检索失效是最容易被误判的。因为我们习惯看"返回了什么",而不看"该返回什么"。
1.1 query 和文档不在同一个语义空间
这是最常见也最隐蔽的问题。
用户问的是问题,库里存的是陈述。这两者的 embedding 分布其实不一样。
举个典型例子。用户问:"为什么我的定时任务没有按时触发?"
库里有一段文档写的是:"调度器在检测到队列积压超过阈值时,会延后新任务的入队时间。"
这两句话在语义上是相关的,但向量相似度可能很低——因为前者是疑问语气、聚焦"失败现象",后者是陈述语气、聚焦"机制描述"。bi-encoder 把两句话各自压成一个向量再算余弦,中间丢失的信息就包括"提问 vs 陈述"这个语用差异。
常见的缓解手段有几种,代价不同:
| 手段 | 做法 | 代价 |
|---|---|---|
| 多查询扩展 | 让模型把一个问题改写成 3-5 个不同表述,分别检索后合并 | 检索次数翻倍,延迟上升 |
| HyDE | 让模型先"编"一个假答案,用假答案去检索 | 编错了会把检索带偏 |
| 关键词混合 | 向量检索 + BM25 稀疏检索,结果融合 | 需要维护两套索引 |
| 文档侧改写 | 离线给每段文档生成"它可能回答哪些问题" | 一次性成本,索引体积变大 |
我倾向最后一种。因为它把成本放在离线阶段,在线延迟不变,而且对"提问 vs 陈述"这个差异是直接对症的。
1.2 chunk 切分破坏了语义边界
固定窗口切分(比如每 512 token 一刀)有个根本问题:它假设语义边界和 token 边界对齐,而这两者基本不相关。
被切断的典型形态:
- 一个概念的定义在上块结尾、展开在下块开头
- 表格的表头在上块、数据行在下块
- 代码的函数签名在上块、实现在下块
后果是检索命中了一块"半截话",重排模型看到它也没法给出高分,最后生成阶段拿到一段没有主语的片段。
改善方向是按结构切而不是按长度切:Markdown 按标题层级切、代码按函数/类切、HTML 按块级元素切。做完结构切分之后,再对超长的块做二次切分,切的时候带上父级标题作为上下文前缀。
一个实用的技巧是重叠窗口 + 上下文注入:
块内容:<h2>调度器</h2> 在检测到队列积压超过阈值时,会延后新任务的入队时间。
把标题塞进块内容里,这一块的检索命中率会明显好于孤立的正文。因为标题提供了"这段在讲什么"的弱信号,而这个信号恰好是 query 里最常出现的词。
1.3 embedding 模型的域偏移
通用 embedding 模型是在开放语料上训的。如果你的库是内部技术文档、法律条文、医疗记录这类术语密度极高的文本,通用模型会把大量专有名词映射到相近的区域,导致"不同概念看起来很像"。
判断方法比较直接:找一批你确信不相关的文档对,算它们的相似度。如果两个八竿子打不着的东西余弦相似度到了 0.8 以上,说明这个模型的表示在你的域上塌缩了。
这时候要么换领域模型,要么做微调。微调 embedding 的成本比想象中低——用对比学习,正样本是同段落的不同表述,负样本用 in-batch 负例,几千对就能有可感知的提升。
二、重排阶段:分数不可比
重排(rerank)常被当成"锦上添花",实际上它解决的是检索阶段的一个硬伤:bi-encoder 无法建模 query 和 doc 之间的交互。
2.1 两种架构的目标差异
| bi-encoder(检索) | cross-encoder(重排) | |
|---|---|---|
| 输入 | query 和 doc 分别编码 | query + doc 拼接后一起编码 |
| 交互 | 无,只在最后算余弦/点积 | 有,注意力可以跨两者 |
| 复杂度 | doc 向量可离线算好,在线 O(1) 查表 | 每个候选都要跑一次前向,O(N) |
| 精度 | 较低 | 明显更高 |
所以标准做法是:用 bi-encoder 从百万级里粗捞出 top-100,再用 cross-encoder 精排到 top-5。这是精度和延迟的折中。
2.2 失效点一:截断
cross-encoder 有最大输入长度。当 query + doc 超过这个长度,通常的做法是从尾部截断。
问题在于,关键信息可能在中后段。一段 1000 字的文档,真正的答案在第 800 字,截断到 512 就正好切掉了。
这个失效很隐蔽,因为重排不会报错,只是给了一个偏低的分。表现出来就是"这段明明是对的,但没进 top-k"。
对策:重排前先确认文档长度分布。如果长尾很长,要么换支持更长输入的 reranker,要么在切分阶段就把块控制在重排窗口内(这也是为什么 chunk 大小不应该大于 reranker 的最大长度)。
2.3 失效点二:分数不可比
reranker 输出的是 logits 或归一化后的相关度。不同批次、不同 query 之间的分数不可直接比较。
有人会设一个全局阈值——比如"分数低于 0.3 就丢掉"。这个做法在跨 query 场景下是错的,因为分数分布随 query 变化。同一条文档,对 query A 得 0.2,对 query B 得 0.8,不代表它对 B 更相关,可能只是 B 的候选集合整体分数偏高。
正确的做法是用同一 query 内部的相对排序,或者用 top-1 与 top-k 的分数差来判断置信度:
gap = score[0] - score[k-1]
gap 大说明第一名明显突出,可以只取一条;gap 小说明候选之间难以区分,应该多取几条让生成阶段自己判断。
三、生成阶段:上下文给了,模型没用
这一段最反直觉。你把正确的文档放在上下文里,模型仍然可能答错。
3.1 位置偏差
关于长上下文利用有一个被反复验证的现象:模型对上下文开头和结尾的内容利用得更好,中间的内容容易被忽略。这个现象常被描述为 U 型曲线。
对你的 RAG 的直接含义是:如果 top-1 的文档被放在长上下文的中间位置,它可能还不如放在最后一条有用。
实践上的处理很简单:把最相关的放最前面,次相关的放最后面,中间的放不重要的。或者干脆控制上下文总量——5 条精排后的文档,好过 20 条粗排的。
3.2 冲突信息
检索回来的文档之间可能互相矛盾:旧版本文档说 A,新版本文档说 B。
模型面对冲突时不会主动报错,它倾向于综合两者给出一个模糊的答案,而这个答案通常两边都不对。
对策是在提示里显式要求处理冲突:
如果检索到的资料之间存在矛盾,不要自行调和。 请指出矛盾点,并优先采信标注了更新时间的较新资料。
同时给每段文档带上元信息(来源、更新时间),让模型有依据判断。
3.3 无引用约束
如果提示里只说"根据以下资料回答",模型在资料不足时会用参数里的知识补全,而补全的内容和资料混在一起,用户无法分辨。
加上引用要求之后,行为会明显改变:
回答中的每个事实性陈述后面,用 [1] [2] 标注来源编号。 如果资料无法支撑某个结论,直接说"资料中未提及",不要推测。
这个约束的价值不只是让答案可溯源,更重要的是它给了模型一个"承认不知道"的出口。没有这个出口,模型的默认行为是硬答。
四、分阶段诊断
要定位问题在哪一段,就得分开评估,而不是只看端到端效果。
4.1 三个指标
| 阶段 | 指标 | 怎么算 |
|---|---|---|
| 检索 | Recall@k | 标注的正确答案是否出现在 top-k 里 |
| 重排 | nDCG@k / MRR | 正确答案排在第几位 |
| 生成 | 忠实度(faithfulness) | 答案中的陈述是否都能被上下文支撑 |
关键点:Recall@k 是上限。如果正确答案不在检索结果里,后面两段做得再好也是零。所以诊断顺序应该是先看召回,再看排序,最后看生成。
很多人直接跳到"换个更大的模型",但如果 Recall@20 只有 0.6,换模型顶多把剩下 40% 的失败率里的排序问题修一修,天花板还在。
4.2 定位表
| 症状 | 可能原因 | 优先检查 |
|---|---|---|
| 答案完全跑题 | 检索召回失败 | Recall@k、embedding 相似度分布 |
| 答案沾边但不准 | 重排把对的排下去了 | top-1 与人工标注对比 |
| 答案对一半 | 上下文太长或位置不好 | 上下文条数与顺序 |
| 答案编造 | 缺引用约束 | 提示里的引用要求 |
| 同一问题两次答案不同 | 温度过高 / 无确定性约束 | temperature、结构化输出 |
做了什么
- 把 RAG 拆成检索、重排、生成三段,分别讲清失效机理
- 指出 query 与文档的"提问 / 陈述"语用差异是召回失败的常见根因,对比了四种缓解手段的代价
- 说明固定窗口切分破坏语义边界,给出按结构切 + 标题前置的做法
- 讲清 bi-encoder 与 cross-encoder 的架构差异,以及重排阶段的截断与分数不可比问题
- 指出生成阶段的位置偏差、冲突信息、无引用约束三个失效点
- 给出三阶段的独立指标与定位表,明确 Recall@k 是整体上限
一些体会
"换个更强的模型"是最贵的无效动作。 模型能力提升的是"给定上下文后的推理质量",它修不了"上下文里压根没有正确答案"。在动手换模型之前,先量一下召回率——这一步能省掉大量试错。
分数不要跨 query 比较。 这是个很容易犯的错,因为代码写起来很自然:if score < 0.3: drop()。但 reranker 的分数是相对的,全局阈值在不同 query 上的行为完全不一致。
长上下文不是免费的午餐。 能塞 128K 不代表该塞 128K。上下文越长,中间内容被利用的概率越低,成本还更高。精选 5 条经常好过全量 20 条。
给模型留"不知道"的出口。 不要求引用、不允许说"资料未提及",模型就只能硬答。这个约束看起来是格式要求,实际是幻觉控制的主要手段之一。
代码
分阶段评估的骨架(伪代码,指标实现用标准定义):
def evaluate_pipeline(queries, gold, retriever, reranker, generator, k_ret=20, k_rer=5):
"""
queries: 问题列表
gold: {query: [正确文档 id 列表]}
"""
recall_hits = 0
mrr_sum = 0.0
faithful = 0
for q in queries:
# ---- 阶段一:检索 ----
cand = retriever.search(q, top_k=k_ret) # -> [(doc_id, score)]
gold_ids = set(gold[q])
hit = [c for c in cand if c[0] in gold_ids]
recall_hits += 1 if hit else 0
# ---- 阶段二:重排 ----
reranked = reranker.rerank(q, [doc for doc, _ in cand])[:k_rer]
for rank, doc_id in enumerate(reranked, start=1):
if doc_id in gold_ids:
mrr_sum += 1.0 / rank
break
# ---- 阶段三:生成 ----
answer = generator.answer(q, [doc_text[d] for d in reranked])
if all_claims_supported(answer, [doc_text[d] for d in reranked]):
faithful += 1
n = len(queries)
return {
f"Recall@{k_ret}": recall_hits / n, # 上限指标
"MRR": mrr_sum / n, # 排序质量
"Faithfulness": faithful / n, # 生成是否被上下文支撑
}
all_claims_supported 需要模型辅助判断,常见做法是把答案拆成原子陈述,逐条检查是否有出处。这一步本身也是个 LLM 调用,成本不低,建议只在评估集上跑。
上下文组装的顺序控制:
def build_context(docs_with_scores):
"""最相关的放最前,次相关的放最后,避免关键信息落在长上下文中段"""
if len(docs_with_scores) <= 2:
return docs_with_scores
head = docs_with_scores[0]
tail = docs_with_scores[1]
middle = docs_with_scores[2:]
return [head] + middle + [tail]
这段看着像小技巧,但它解决的是"检索和重排都对了、答案还是错"这类最难查的问题。
- 点赞
- 收藏
- 关注作者
评论(0)