一个反直觉的排查经验:RAG 系统答非所问,八成问题不在生成层,而在检索层。 用户看到的答案是错的,团队第一反应往往是「换个更强的大模型」,但真正该先问的是——那份正确的文档,到底有没有被召回进上下文里。
如果检索根本没捞到对的文档,再强的模型也只能在错误的材料上编一个通顺的答案。这时候调 prompt、换模型全是无用功。
所以 RAG 质量不能当成一个整体去打分,必须分层测、先定位是哪一层塌了。
一、业务场景:企业知识库客服机器人,问 A 答 B
先把场景钉死。
我们接的是一个企业内部知识库客服机器人:底下挂着一堆制度文档、报销流程、IT 工单手册,员工问一句,系统先从知识库里检索若干段落塞进上下文,再让大模型基于这些段落作答。
问题很典型:员工问「年假怎么折算」,机器人答的是「病假需要哪些证明材料」。 答非所问,但答案本身读起来通顺、格式漂亮,甚至带了引用编号。
这时候最容易犯的错,是直接怀疑生成模型「幻觉了」,然后开始折腾 prompt、加约束、换模型。但在动手之前,必须先回答两个完全不同的问题:
- 是检索层没召回对文档?——「年假折算」那段制度压根没进上下文,模型手里根本没有正确材料。
- 还是检索对了、生成层瞎编?——正确段落明明在上下文里,模型却没用,自己脑补了一套。
这两种病因,药方完全相反。前者要改切分、改 embedding、改召回策略;后者才轮到调生成、加忠实度约束。不分层,就分不清该治哪一头。
二、四层评测:先分层,再定位
RAG 的质量评测,从检索到生成,可以拆成四层。每一层测的东西不同、塌了说明的病因也不同:
| 层级 | 测什么 | 怎么算 | 塌了说明什么 | 常用工具口径 |
|---|---|---|---|---|
| ① 检索命中 | 正确文档有没有被召回进 top-k | recall@k、MRR、命中率 | 检索层塌了:对的文档根本没进上下文,改切分/embedding/召回 | 信息检索通用指标;RAGAS 的 context recall |
| ② 上下文相关性 | 召回的段落跟问题相不相关 | 相关段落数 / 召回段落数 | 召回一堆噪声:命中了但也塞进大量无关段落,挤占上下文 | RAGAS context relevance |
| ③ 答案忠实度 | 答案每句话有没有检索到的出处支撑 | 被上下文支撑的陈述数 / 总陈述数 | 生成层瞎编:材料对了却脑补,出现幻觉 | RAGAS faithfulness |
| ④ 答案相关性 | 答案有没有正面回答用户的问题 | 答案与问题的语义相关度 | 答非所问:材料对、也没编,但跑题了 | RAGAS answer relevance |

这四层是有先后顺序的漏斗:上一层塌了,下一层测了也没意义。检索没召回对文档(①塌),你再去测答案忠实度(③),只会得出「答案没有出处支撑」这个废话结论——因为压根就没有出处可用。
正确的排查姿势是从上往下逐层测,在第一个塌掉的层停下来,那就是病根所在。
三、金标准问答对:评测的地基
四层评测全都依赖同一样东西——金标准(ground truth):一批「问题 → 应该命中哪些文档 → 参考答案应该包含哪些要点」的人工标注样本。
没有金标准,所有指标都是空中楼阁。构造原则三条:
- 量不用大,先跑起来。 三五十条覆盖高频问题的样本,就能把四层指标算出来,比空谈强一百倍。
- 标注要标到「文档 ID」粒度。 每个问题标清楚「正确答案应该来自哪几篇/哪几段文档」,这是算 recall@k 的前提。
- 样本要覆盖两类失败。 既要有「知识库里有答案」的题(测能不能召回),也要有「知识库里根本没答案」的题(测系统会不会硬编)。第二类题最容易被忽略,却最能暴露幻觉——一个健康的 RAG 系统在知识库里查无此答时,应该老实说「没有找到相关规定」,而不是拿一段沾边的材料硬凑出一个像模像样的答案。
还有一个务实建议:金标准不是一次性工程,要跟着线上 badcase 长。 每次客服机器人被投诉答错,就把那条问答补进金标准集,标清楚正确文档该是哪几篇。几个月下来,这份集合会变成你手里最值钱的资产——它记录的是一批「真实用户会怎么问、系统又是怎么答错的」样本,比任何公开榜单都贴合你自己的业务。
下面这份脚本,从构造金标准开始,一路算到 faithfulness,只依赖 Python 标准库,复制即可运行。
四、算 recall@k 与 MRR:先看检索层塌没塌
第一步永远是测检索层。这段代码构造一小份金标准,模拟检索器返回的排序结果,算出 recall@k 和 MRR:
# rag_eval.py —— 只依赖标准库,python rag_eval.py 直接跑
# 1) 金标准:每个问题 -> 应该命中的文档 ID 集合
GOLD = {
"年假怎么折算": {
"doc_annual_leave"},
"病假需要哪些证明": {
"doc_sick_leave", "doc_medical_cert"},
"报销打车费上限": {
"doc_expense_taxi"},
}
# 2) 模拟检索器:每个问题 -> 按相关度排序召回的文档 ID 列表(top-k)
RETRIEVED = {
"年假怎么折算": ["doc_sick_leave", "doc_annual_leave", "doc_holiday"],
"病假需要哪些证明": ["doc_sick_leave", "doc_medical_cert"],
"报销打车费上限": ["doc_expense_general", "doc_expense_hotel"],
}
def recall_at_k(gold: set, retrieved: list, k: int) -> float:
"""top-k 里命中的金标准文档数 / 金标准文档总数"""
hit = gold & set(retrieved[:k])
return len(hit) / len(gold) if gold else 0.0
def reciprocal_rank(gold: set, retrieved: list) -> float:
"""首个相关结果排名的倒数;没命中记 0"""
for rank, doc in enumerate(retrieved, start=1):
if doc in gold:
return 1.0 / rank
return 0.0
def evaluate_retrieval(k: int = 3):
recalls, rrs = [], []
for q, gold in GOLD.items():
ret = RETRIEVED.get(q, [])
r = recall_at_k(gold, ret, k)
rr = reciprocal_rank(gold, ret)
recalls.append(r)
rrs.append(rr)
flag = "OK" if r > 0 else "检索层塌陷"
print(f"[{flag}] {q} recall@{k}={r:.2f} RR={rr:.2f}")
n = len(GOLD)
print("-" * 40)
print(f"平均 recall@{k} = {sum(recalls)/n:.2f}")
print(f"MRR = {sum(rrs)/n:.2f}")
if __name__ == "__main__":
evaluate_retrieval(k=3)
跑完看两件事:
- 平均 recall@k 偏低:说明大量正确文档根本没进 top-k,检索层就是病根。上面「年假怎么折算」虽然 recall 不为 0,但正确文档排在第 2 位,MRR 会被拉低——这意味着上下文里第一位塞的是病假文档,模型很可能被带偏。
- 「报销打车费上限」直接标了「检索层塌陷」:recall 为 0,正确文档压根没召回。这种题无论生成层多强都答不对,别去动模型,回头改检索。
只有当 recall@k 和 MRR 都健康时,才有必要往下测生成层。 这一步能帮你省下大量「白换模型」的成本。
五、faithfulness:答案有没有出处支撑
检索层过了,才轮到测生成层最关键的指标——忠实度:答案里的每一句陈述,能不能在召回的上下文里找到支撑。
先用一个零成本的规则版做粗筛(判断答案的关键信息词是否落在上下文里),它抓不出所有幻觉,但能秒级定位「答案里出现了上下文根本没有的实体」:
def faithfulness_rule(answer: str, context: str, stopwords=None) -> float:
"""规则版忠实度:答案关键信息词被上下文覆盖的比例(粗筛,非严格判定)"""
stopwords = stopwords or {
"的", "了", "是", "在", "和", "与", "可以", "需要"}
# 极简切分:实际项目请用分词器(如 jieba)替换这里
tokens = [t for t in answer.replace(",", " ").replace("。", " ").split()
if t and t not in stopwords]
if not tokens:
return 1.0
supported = sum(1 for t in tokens if t in context)
return supported / len(tokens)
# 演示:上下文只讲病假,答案却在讲年假折算 —— 规则版会立刻暴露低覆盖
ctx = "病假需要提供二级以上医院开具的诊断证明与病假条。"
ans_good = "病假 需要 医院 诊断证明 病假条"
ans_bad = "年假 折算 按 入职 月份 比例 计算"
print("忠实答案覆盖:", faithfulness_rule(ans_good, ctx)) # 高
print("跑题答案覆盖:", faithfulness_rule(ans_bad, ctx)) # 低,暴露无出处陈述
规则版够快够便宜,适合放进 CI 做门禁粗筛。但它只能看字面覆盖,判断不了「语义上是否被支撑」。真正严格的 faithfulness 判定,通常交给一个裁判模型:把「问题 + 上下文 + 答案」丢给它,让它把答案拆成一条条陈述,逐条判断能否被上下文推出。RAGAS 这类开源评测库封装的就是这套逻辑,可以直接拿来当工具口径,不必自己从零写裁判 prompt。
但这里有一个必须讲明的坑:用模型判 faithfulness,裁判模型自己也会误判。 它会漏判隐含的幻觉,也会把合理改写误报成「无支撑」。所以在拿裁判模型的分数当结论之前,先用一批人工标注好的小样本校准它——算一下裁判判定和人工判定的一致率,一致率不达标,这个裁判就还不能上岗。否则你只是用一把没校准的尺子,去量另一把尺子。
六、上下文相关性与答案相关性:补齐另外两层
剩下两层测的是「质量」而非「有没有」:
- 上下文相关性测召回的段落里有多少是噪声。命中了正确文档,但如果 top-k 里塞进七八段无关内容,既挤占上下文预算,又容易把模型带偏。算法简单:相关段落数 / 召回段落数。
- 答案相关性测答案有没有正面回应用户的问题。它和忠实度是两件事——一个答案可以句句有出处(忠实度高),却完全答非所问(相关性低)。用户问折算规则,你答了一堆请假流程,每句都在文档里,但没解决问题。
四层各管一段,缺一不可。只看忠实度会漏掉跑题,只看答案相关性会漏掉幻觉,只看 recall 会漏掉「召回对了却瞎编」。
这也解释了为什么很多团队「上了评测却还是治不好 RAG」——他们往往只挑一个顺手的指标盯着看,比如只算答案相关性,觉得分数还行就收工。可答案相关性高不代表没幻觉:一个编得很圆、又恰好切题的答案,相关性可以很高,忠实度却是零。四层要一起看,缺哪层都会留下盲区。
七、分层定位:把力气用在塌掉的那一层
回到开头那个「问年假、答病假」的例子。分层测一遍,路径就清晰了:
先看①——recall@k 显示正确文档 doc_annual_leave 排在第 2 位,命中了但没排在首位;MRR 偏低。再看②——top-k 里病假文档排在年假文档前面,上下文相关性被噪声拉低。到这一步,病因已经定位:是检索排序的问题,不是生成的问题。 该做的是调整召回排序、优化 chunk 切分,而不是换模型。
如果①②都健康、正确答案稳稳排在上下文首位,用户却仍看到编造内容,那才轮到③忠实度塌了——这时候加生成约束、上裁判模型门禁才有的放矢。
RAG 评测最贵的不是指标本身,而是「不分层就开始优化」。 分层测的价值,是让你在动手改之前就知道该改哪一层——把有限的力气,精准砸在那个真正塌掉的地方。
如果你手上有个答非所问的 RAG 系统,留言区说说你测下来是塌在检索层还是生成层。