引言 — 答案错了。您从哪里开始查
当 RAG 系统面对文档里写得清清楚楚的内容却答非所问时,大多数团队会去改提示,把“只能依据所提供的上下文作答”用更强硬的语气重写一遍。然后通常并不会变好。
这很正常。因为很多时候模型并没有违背指令,只是它收到的上下文里根本没有正确答案。RAG 是检索与生成串联而成的流水线,前段错了,后段就没有可下手的地方。
本文整理一套守顺序的调试流程:先切分原因,再从最常见的凶手查起,最后建立测量体系。不守顺序,就会花六个月只在改提示。
第 1 步 — 是检索失败,还是生成失败
第一件事是亲眼去看被检索出来的分块。跳过这一步的调试全是猜测。可令人意外的是,很多流水线根本不把检索结果记进日志。
有日志的话,下面这一个实验就能分开原因。
def diagnose(question, gold_passage, retriever, generate):
"""比较直接喂入正确分块与喂入检索结果这两种情况。"""
retrieved = retriever(question, k=5)
ctx_retrieved = "\n\n".join(c.text for c in retrieved)
ans_normal = generate(question, ctx_retrieved)
ans_oracle = generate(question, gold_passage) # 跳过检索
gold_in_ctx = gold_passage[:200] in ctx_retrieved
if not gold_in_ctx and is_correct(ans_oracle):
return "검색 실패" # 只要给答案就能答对 → 前段的问题
if gold_in_ctx and not is_correct(ans_normal):
return "생성 실패" # 给了答案还是答错 → 后段的问题
if not is_correct(ans_oracle):
return "생성 실패 (근본)" # 只给正确文档也解不出来
return "정상"
把这套诊断跑遍整个黄金集,就能得到两类失败的比例。以我的经验,早期流水线里检索失败要多得多,而其中大部分又来自分块。
被判定为生成失败的情况还能再分成三类:上下文里有答案却被忽略、改用先验知识作答;信息分散在多个分块里而无法综合;上下文彼此矛盾时选错了一边。三者的处方完全不同,不能笼统对待。
“不知道就说不知道”的局限
应对生成失败时最先尝试的,是阻止无依据作答的指令。
SYSTEM = """제공된 문맥만 근거로 답하십시오.
문맥에 답이 없으면 정확히 "문서에서 찾을 수 없습니다"라고만 출력하십시오.
각 주장 끝에 근거 청크 번호를 대괄호로 표기하십시오."""
这条指令是有效的。只是要知道两件事再用。
第一,强硬地要求弃权,会连带抬高本来能答的问题上的弃权比例。这是精确率与召回率的交换,而不是纯粹的改进。所以不要只报告“幻觉率下降了”,还要一起看“回答率下降了多少”。
第二,即便要求标注依据,模型写下的编号也不保证与真实依据一致。引用编号只是一种可验证的格式,验证要由您的代码来做。加上一步单独检查被引用的分块里是否真的包含该主张,它才算得上可信的信号。
分块占了原因的一半
如果判定是检索失败,请在换嵌入模型之前先看分块。多数情况下到这里就结束了。
典型的破坏是这样的:表格从中间被切开,表头和数据分到了不同分块;代码块在函数中间断掉;以“符合上述条件的情况”开头的段落与前一段被分离,无从知道它指的是什么;只有条款编号的那一行与正文各走各的。
定长切分对这些结构一无所知。所以第一项改进几乎总是尊重文档结构的切分。Markdown 用标题边界,HTML 用章节标签,PDF 用版面分析结果作为优先边界,只有在其内部长度溢出时才切。表格和代码块整块保留为一个分块,作为长度限制的例外处理。
大小和重叠是下一个问题。计算本身很简单。
import math
def chunk_stats(doc_tokens, size, overlap):
stride = size - overlap
n = 1 if doc_tokens <= size else math.ceil((doc_tokens - overlap) / stride)
stored = n * size
return n, stored / doc_tokens
for size, ov in [(256, 32), (512, 64), (1024, 128), (512, 256)]:
n, infl = chunk_stats(50_000, size, ov)
print(f"size={size:4} overlap={ov:3} → 청크 {n:3}개, 저장 토큰 {infl:.2f}배")
# size= 256 overlap= 32 → 청크 224개, 저장 토큰 1.15배
# size= 512 overlap= 64 → 청크 112개, 저장 토큰 1.15배
# size=1024 overlap=128 → 청크 56개, 저장 토큰 1.15배
# size= 512 overlap=256 → 청크 195개, 저장 토큰 2.00배
把重叠取成大小的一半,存储量就翻倍。向量存储成本本身不值一提:112 个分块、1536 维 fp32 嵌入也就 690KB 上下。真正的代价是候选列表被几乎相同的内容填满。取了前 5 个,其中 4 个是同一段落的重叠碎片,那有效上下文其实只有一个。多数情况下重叠取大小的 10~15% 就够了。
大小没有标准答案,但有方向。小分块检索精度高、上下文不足,大分块则相反。问题若是单一事实查询就切小,若要求流程或说明就切大。实在定不下来,就用小分块检索、再把相邻分块一并扩展后交出去,这是务实的折中。
嵌入相似度不是语义相似度
嵌入检索失败有几种典型模式。
查询里含有精确标识符的情况。错误码、产品编号、函数名、工号这类 token,在嵌入空间里几乎无法与形态相近的其他标识符区分开。向量是压缩语义的装置,而这类 token 的价值恰恰就在于那串字符本身。
否定和条件被反转的情况。“可以退款的条件”和“不能退款的条件”余弦相似度非常高。嵌入抓的是主题,不是逻辑。
查询与文档的表达形态不同的情况。短问句和长叙述段落本来分布就不一样。有些嵌入模型要求查询和文档分别加不同前缀,原因就在这里,漏掉那个前缀性能会明显下滑。
第一种和第三种,混入词法检索就能解决相当一部分。BM25 擅长精确字符串匹配,并对稀有 token 给出高权重。把两路结果合起来最稳妥的办法,是不做分数归一化、只用排名的倒数排名融合。
def rrf(rankings, k=60, top_n=10):
"""rankings: 各检索器的文档 ID 列表(按排名)。不需要对齐分数量纲。"""
scores = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)[:top_n]
hits = rrf([
bm25_search(query, k=50), # 词法
vector_search(query, k=50), # 语义
])
常数 60 是原论文提出的值,实务中直接沿用大体无碍。这种方式的优点是实际上只有一个值要调,缺点是丢掉了分数大小,无法反映某个检索器极度确信的情形。
我不会说混合检索永远获胜。如果领域里专有名词和标识符很少、句子以叙述为主,纯向量检索可能更好。不过同时跑两个检索器成本很低、实现只要半天,所以先试再测是合理的顺序。
重排序 — 什么时候值回票价
重排序是这样一个阶段:一次检索取出足量候选,再用更精细的模型把其中靠前的一部分重新排队。交叉编码器把查询和文档一起编码、直接观察二者的交互,因此比各自单独生成向量的方式更准确。
代价是计算量。向量检索只需把查询编码一次,而交叉编码器要按候选数量跑同样多次前向传播。
def rerank_budget(n_candidates, ms_per_pair, retrieval_ms):
total = retrieval_ms + n_candidates * ms_per_pair
return total, total / retrieval_ms
for n in (20, 50, 100):
total, ratio = rerank_budget(n, ms_per_pair=1.2, retrieval_ms=15)
print(f"후보 {n:3}개 → {total:5.0f}ms (검색만 대비 {ratio:.1f}배)")
# 후보 20개 → 39ms (검색만 대비 2.6배)
# 후보 50개 → 75ms (검색만 대비 5.0배)
# 후보 100개 → 135ms (검색만 대비 9.0배)
ms_per_pair 会因模型大小、分块长度、硬件以及是否批处理而变化很大。上面的 1.2 毫秒是为说明而放的值,请务必在自己的环境里实测后替换。
判断标准是这样的。重排序只有在一次检索确实把正确答案放进了候选、但排名偏低时才值回票价。也就是 recall@50 高而 recall@5 低的情形。反过来,如果 recall@50 本身就低,重排序什么也做不了,因为不存在的东西没法往上提。所以在考虑重排序之前要先测这两个数字,守住这个顺序就能避免白白增加延迟。
把它当作把全部上下文交给 LLM 之前的最后一道过滤器也是成立的。丢掉相关度低的分块,输入 token 会减少,模型被无关上下文带偏的概率也会降低。
给分块附上上下文
如果被嵌入的文本只有分块正文,那么这个分块是在不知道自己属于哪份文档哪个部分的状态下被检索的。“第 3 项的例外如下”这样一个段落,本身根本没有被检索到的可能。
解法很简单:在待嵌入的文本前面加上位置信息。
def contextualize(chunk, doc):
header = " > ".join(filter(None, [doc.title, chunk.section, chunk.subsection]))
return f"[{header}]\n{chunk.text}"
# 嵌入索引和 BM25 索引都以这段文本为对象构建。
# 但交给 LLM 的上下文里也要保留同样的头部,才能标注出处。
效果大有两个原因。检索方面,文档标题和章节名在每个分块里重复出现,主题信号得到加强。生成方面,模型能够区分不同文档,把“A 产品政策”和“B 产品政策”混在一起的错误随之减少。
再往前一步,还可以用 LLM 为每个分块预先生成一两句概括全文语境的话附上去。这是在索引时按文档付一次成本、换取检索质量的交易。不过这笔成本与文档数量成正比、是真金白银要花出去的,所以先试前面那种简单的加头部、不够时再考虑它,顺序上更合适。
元数据既是检索对象也是过滤器。把日期、版本、部门、文档类型存成字段,就能用过滤器切掉“检索到了去年的政策”这类失败。这种事靠调整排名做不好,只有过滤器才能做得确定。
黄金集与 recall@k — 没有测量就没有改进
到此为止的所有改动都必须可回退,而要能回退就需要数字。需要的是问题与正确依据分块 ID 的配对。
构建方式上,从真实用户提问日志里挑最好。没有日志就用 LLM 从文档生成问题,但一定要有人来验收。生成的问题往往会照抄原文表述,只用这类问题做出来的评测集会偏向对词法检索有利。请务必混入换了说法的问题、需要综合多个分块的问题,以及文档里没有答案的问题。最后一类是衡量弃权能力的唯一办法。
def recall_at_k(dataset, retriever, k):
"""正确分块进入前 k 名的问题所占比例"""
hit = 0
for q, gold_ids in dataset:
got = [c.id for c in retriever(q, k=k)]
if any(g in got for g in gold_ids):
hit += 1
return hit / len(dataset)
def mrr(dataset, retriever, k):
"""首个正确结果排名倒数的平均值。排名重要时与 recall 一起看。"""
total = 0.0
for q, gold_ids in dataset:
got = [c.id for c in retriever(q, k=k)]
for rank, cid in enumerate(got, start=1):
if cid in gold_ids:
total += 1.0 / rank
break
return total / len(dataset)
for k in (1, 5, 20, 50):
print(f"recall@{k:2} = {recall_at_k(golden, hybrid_retriever, k):.3f}")
把这两个数字按 k 列出来,诊断就变成机械操作了。recall@50 低是索引和分块的问题;recall@50 高而 recall@5 低是排名的问题;两者都高但答案还是错的,那就是生成的问题。
| 症状 | 疑似原因 | 确认方法 | 优先应对 |
|---|---|---|---|
| 答案里完全没有依据 | 检索失败 | 正确分块注入实验 | 分块、混合检索 |
| 正确分块不在前列 | 排名问题 | 比较 recall@50 与 recall@5 | 重排序 |
| 反复只引用同一份文档 | 重叠过多 | 前 k 个分块的重复率 | 缩小重叠、按文档做多样化 |
| 引用了旧版本内容 | 缺少过滤 | 被引分块的日期字段 | 元数据过滤 |
| 表格里的数字错了 | 表格被切分 | 直接查看分块原文 | 把表格整块作为一个分块 |
| 对能答的问题弃权 | 弃权指令过强 | 同时测量回答率与准确率 | 放宽指令、调整阈值 |
有一点要注意。如果这个评测集规模很小,那么从中得到的差异大多是噪声。不要因为在 30 道题上 recall 从 0.70 涨到 0.73 就做出上线决定。样本量与显著性的处理方法,整理在不靠感觉做 LLM 评测那一篇里。
结语 — 请先去看检索结果
RAG 调试有一半到“真的把检索出来的分块读一遍”就结束了。可这一步偏偏最常被省略,要么是流水线本就没写成会把上下文记进日志,要么是改提示看起来更快。
所以今天要做的两件事就够了:每次请求都把检索到的分块 ID 和原文记进日志;做一个 50 道题的黄金集,把 recall@5 和 recall@50 先测出来。没有这两样,之后的一切改进都是在看不见前方的状态下修东西;有了这两样,原因通常一小时之内就能收敛。
현재 단락 (1/113)
当 RAG 系统面对文档里写得清清楚楚的内容却答非所问时,大多数团队会去改提示,把“只能依据所提供的上下文作答”用更强硬的语气重写一遍。然后通常并不会变好。