第 21 章
第 4 周:检索对了,回答就对了吗?
RAG 端到端的忠实度与引用:Lewis et al. 2020 的原始 RAG 和今天常说的 RAG 有什么不同;失败拆成没检索到、检索到没用上、用歪了、答非所问、引用错位五层;上下文召回、忠实度、答案相关性、引用正确率按 RAGAS 0.4.3 和 ALCE 的定义;拆原子陈述的粒度问题和 LLM 裁判的坑;RAG 回归集与门禁。动手写一个只用字符串匹配的引用核对器,在 22 句样例上和人工标注对照。
测试 Agent 查到了正确的需求文档,回答就一定对吗?不一定。文档检索到了,模型还可能没用上、用歪了、答非所问,或者内容对了却标错出处。只看一个端到端的"答对率",这几种失败混在一起,修的时候不知道该改检索、改 prompt 还是改引用格式。这一章把 RAG 的失败拆成几层,每层配一个指标;核心做法是把回答拆成原子陈述,逐条判断"能不能被检索到的上下文支持"。最后用标准库写一个引用核对器,看看规则能做到哪一步、哪里必须交给模型。
讲解视频
互动演示
一个 BugHunt-Bench 场景的问题、三段检索到的上下文,和六个预设回答:正确、幻觉、引用错位、遗漏关键信息、没检索到、答非所问。回答已经拆成陈述,你逐条点"支持 / 不支持",下方实时算出你的忠实度;全部判完后揭晓参考判定、上下文召回、答案完整度、引用正确率,以及失败落在哪一层。最右一列是下文引用核对器的同款规则,可以看它在哪一句上和人工判断不一致。页面底部有自动判分的练习。
RAG 是什么:原始论文和今天的说法不一样
RAG 这个名字来自 Lewis 等人的论文 Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks(NeurIPS 2020, arXiv 2005.11401 )。论文里的 RAG 是一个要训练的模型,和今天工程上说的"先检索,再把结果塞进 prompt"不是一回事:
| Lewis et al. 2020 | 今天工程上常说的 RAG | |
|---|---|---|
| 检索器 | DPR(BERT 双塔)。微调时只更新查询编码器,文档编码器和索引固定 | BM25、嵌入或混合检索,常加重排;一般不和生成模型一起训练 |
| 生成器 | BART-large(约 4 亿参数的 seq2seq),和查询编码器一起端到端微调 | 通用大模型,多数通过 API 调用,参数不动 |
| 怎么用检索结果 | 检索到的文档当隐变量:每篇分别和输入拼接,生成器对每篇算概率,再按检索概率对 top-K 求和(边缘化)。RAG-Sequence 整个回答用同一篇,RAG-Token 每个 token 可以换一篇 | 把检索到的几段拼进同一个 prompt,模型一次读完,常要求标出引用 |
| 知识库 | 2018 年 12 月的维基百科,切成 100 词一段,共 2100 万段,FAISS 索引 | 产品文档、需求、Bug 库、代码等,按需切块 |
| 评测 | 开放域问答的 Exact Match、生成任务的 BLEU / ROUGE-L / Q-BLEU-1、FEVER 准确率、人工评测 | 检索指标 + 忠实度 + 答案正确性 + 引用质量,分层看 |
两者共同的想法是:知识放在模型外面的索引里(论文叫 non-parametric memory),换索引就能更新知识,不用重新训练。论文专门做了个实验:同一个模型换成 2016 年的维基百科索引,回答 2016 年的各国领导人,准确率 70%;用 2018 年的索引回答 2018 年的,68%。
面试被问到"原始论文的 RAG 是怎么训练的",要能说出:DPR + BART,检索文档当隐变量边缘化,查询编码器和生成器联合微调。今天常见的做法把它简化成"检索 + prompt 拼接",生成器换成了不训练的大模型。
端到端失败,拆成几层
场景沿用本周的 BugHunt-Bench:测试 Agent 在 Conduit 应用上发现"登录密码错误没有提示",去知识库查需求和历史 Bug,问:
登录时密码输错了,接口返回什么?前端应该怎么提示?
知识库里有三段相关文本(自制样例):登录需求 REQ-AUTH-03、历史 Bug BUG-103、注册需求 REQ-USER-02。注册需求也写着"返回 422",是一段很像但不对的干扰文档。参考答案拆成三条陈述:R1 返回 422;R2 errors 是 "email or password": ["is invalid"];R3 前端在登录表单上方用红色列表逐条显示。
回答错了,可能错在下面任何一层:
| 层 | 失败 | 症状 | 看哪个指标 |
|---|---|---|---|
| 检索 | 没检索到 | REQ-AUTH-03 不在上下文里;模型要么说不知道,要么凭记忆编 | 上下文召回;有标注时直接看 recall@k |
| 生成 | 检索到但没用上 | REQ-AUTH-03 在上下文里,回答只说了"返回 422" | 答案完整度 |
| 生成 | 用上了但歪曲 | “前端弹 toast,3 秒后消失”,上下文里没有这句 | 忠实度 |
| 生成 | 答非所问 | 回答的是"注册时用户名被占用",每句都有出处,但不是在回答登录 | 答案相关性;对照参考答案的完整度 |
| 引用 | 引用错位 | 内容对,标的出处撑不起这句话 | 引用正确率 |
层和层是串联的,前面坏了,后面的指标跟着变差,所以要分开量。“检索到了"和"用上了"确实是两回事:Lost in the Middle(Liu et al., TACL 2023)发现,相关信息放在长上下文中间时,模型用得最差。
四个指标怎么定义
下面的定义以 RAGAS 为准。RAGAS 来自 Es 等人的论文 Ragas: Automated Evaluation of Retrieval Augmented Generation(
arXiv 2309.15217
,EACL 2024 系统演示),也是一个开源库。写这一章时 PyPI 上的最新版是 0.4.3(2026-01-13),文档推荐用 ragas.metrics.collections 下的新接口,旧的 ragas.metrics 接口标为 legacy。
忠实度(Faithfulness):先让 LLM 把回答拆成陈述,再逐条判断能不能从检索到的上下文推出来。
$$ \text{Faithfulness} = \frac{\text{回答中能被检索上下文支持的陈述数}}{\text{回答中的陈述总数}} $$源码里有两个细节(
src/ragas/metrics/collections/faithfulness/metric.py
,tag v0.4.3):判定前把所有检索到的上下文拼成一段(第 124 行),不看每句话引的是哪篇;拆不出任何陈述时返回 NaN(第 119–121 行),既不是 0 也不是 1。但拒答不一定拆不出陈述:拆分 prompt(同目录 util.py 第 35 行)只要求把每句话拆成一条或多条陈述,没有专门处理拒答,LLM 可能把"没查到"拆成一条陈述、判为无支持,得到 0。这一点我没有实际调用 LLM 验证。
上下文召回(Context Recall):LLM 版本用参考答案代替"应该检索到的文档”,把参考答案拆成陈述,看有多少能在检索到的上下文里找到依据。
$$ \text{Context Recall} = \frac{\text{参考答案中能被检索上下文支持的陈述数}}{\text{参考答案中的陈述总数}} $$RAGAS 也有不用 LLM 的版本:有标注的相关文档时,直接比对内容或 id,算法就是普通的 recall。
答案相关性(Answer Relevancy):让 LLM 根据回答反推 \(N\) 个问题(默认 3 个,参数叫 strictness),算它们和原问题嵌入的余弦相似度,取平均。
它不看事实对不对,只看回答是不是在回应这个问题。源码里还有一条:反推出的问题如果全都被判为"含糊其辞"(noncommittal),分数直接乘 0(v0.4.3 的新接口和 legacy 接口都是"全部"才乘 0,只有部分含糊不扣)。文档也提醒,余弦相似度的范围是 −1 到 1,分数通常但不保证落在 0 到 1 之间。
引用正确率:RAGAS 的忠实度不检查引用。引用质量的经典定义来自 ALCE(Gao et al., EMNLP 2023, arXiv 2305.14627 ):
- 引用召回(按陈述算):这条陈述至少带一个引用,并且被引文档拼起来能蕴含它,记 1,否则 0。
- 引用精确率(按引用算):一个引用"无关"的条件是它单独撑不起这句话,并且去掉它,剩下的引用照样撑得起。陈述的引用召回为 1、且这个引用不是"无关"的,才记 1。
ALCE 用 NLI 模型 TRUE 判断"是否蕴含"。论文摘要里的结论:在 ELI5 数据集上,最好的模型也有一半的时间引用支撑不完整。演示里每条陈述只带一个引用,这时两个指标是同一个数,统一叫引用正确率。
演示还用了一个答案完整度:参考答案的陈述有多少被回答覆盖。这是本章为了定位"没用上"而用的简化定义,思路接近 RAGAS FactualCorrectness 的 recall 模式。
六个预设回答按人工标注算出来的结果(citation_checker.py 的真实输出):
| 预设 | 上下文召回 | 忠实度 | 完整度 | 引用正确率 | 主因 |
|---|---|---|---|---|---|
| 正确 | 1.00 | 1.00 | 1.00 | 1.00 | 全部通过 |
| 幻觉 | 1.00 | 0.33 | 0.33 | 0.33 | 用了但不忠实 |
| 引用错位 | 1.00 | 1.00 | 1.00 | 0.00 | 引用错位 |
| 遗漏关键信息 | 1.00 | 1.00 | 0.33 | 1.00 | 检索到但没用上 |
| 没检索到 | 0.33 | 0.50 | 0.67 | 0.50 | 没检索到 |
| 答非所问 | 1.00 | 1.00 | 0.00 | 1.00 | 答非所问 |
几个值得记住的组合:
- 引用错位:忠实度 1.0,引用正确率 0。每句话都能在上下文里找到依据,但标的出处全错。RAGAS 的忠实度把上下文拼在一起判,看不出这个问题。
- 遗漏:忠实度、引用都是满分。只有对照参考答案的完整度能发现。
- 答非所问:忠实度、引用也是满分。不用参考答案,答案相关性就能发现;有参考答案时,完整度 0.00 也会暴露。
- 没检索到:完整度 0.67 比忠实度 0.50 还高。有一条陈述上下文里没有,却碰巧是对的,来自模型自己的记忆。这次对了,下次不可控,主因仍然是检索。
主因按排查优先级取:没检索到 → 不忠实 → 答非所问 → 没用上 → 引用错位。后面的层常常是前面的连带后果,编造的陈述不可能引对,答非所问自然也没用上该用的上下文,所以引用层只统计"内容有支持、但引错了文档"的陈述。
拆成原子陈述,以及用 LLM 当裁判
为什么要拆:一段回答往往一半对一半错,给整段打"对 / 错"太粗。FActScore(Min et al., EMNLP 2023, arXiv 2305.14251 )把长文本拆成原子事实,算有多少被可靠知识源支持;RAGAS 的忠实度也是先拆陈述再逐条判。拆完之后分数可解释,哪一句没有依据,一眼就能看到。
但拆的粒度会改变分数。“幻觉"那个回答一个字不改:
| 拆法 | 陈述数 | 被支持 | 忠实度 |
|---|---|---|---|
| 按句拆:422 / 弹 toast 并 3 秒消失 / 输错 5 次锁 15 分钟 | 3 | 1 | 33.3% |
| 更细:后两句各拆开(弹 toast / 提示文字 / 3 秒消失 / 输错 5 次锁定 / 锁 15 分钟) | 6 | 1 | 16.7% |
| 不拆:整段当一条 | 1 | 0 | 0% |
所以跨版本比较忠实度时,拆分用的模型和 prompt 必须固定,否则分数变化可能只是拆法变了。
用 LLM 当裁判算忠实度,还要先回答这几个问题:
- 裁判准不准? RAGAS 论文在自建的 WikiEval 上测过:给两个回答(上下文相关性给的是两段上下文),看指标偏好的那个和人工是否一致。忠实度一致率 0.95,答案相关性 0.78,上下文相关性 0.70(论文用的是 gpt-3.5-turbo-16k)。这是成对比较的一致率,不是单条判定的准确率,换了领域、换了裁判模型都要重新测。
- 两种错误代价不同。把编造的陈述判成"有支持”(漏判)会放过幻觉;把正确的陈述判成"无支持"(误杀)会让门禁频繁报警。要分别统计,不能只看一致率。
- 裁判会漂。换了裁判模型版本、改了 prompt,同一批回答的分数会变。裁判的模型和 prompt 要和被测系统一样做版本管理。
- 已知偏差。Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena(Zheng et al., NeurIPS 2023)列了 LLM 裁判的位置偏差、长度偏差和偏爱自己的输出。
- 成本。RAGAS 的忠实度每条样本至少两次 LLM 调用(拆陈述一次、逐条判定一次);答案相关性默认还要反推 3 个问题。
怎么校准裁判(人工标一批、算 Cohen’s κ、分别看漏判率和误杀率),第 6 周「Judge 与错误分析」专门讲。这里先记住:LLM 算出来的忠实度是一个带误差的测量值,不是真值。
动手实验:只用字符串匹配的引用核对器
代码在学习目录的 week04_上下文与记忆/code/citation_checker/,只用标准库:samples.json 是上面的样例和人工标注,citation_checker.py 是核对器和指标,test_citation_checker.py 是 13 个单元测试。回答的格式约定是:每句话末尾标 [n],需要原文支撑的关键内容放在「」里。核对器每句检查四件事:
- 有没有引用
[n],没有记 MISSING_CITATION; - 引用编号在不在这次检索到的上下文里,不在记 BAD_ID;
- 「」里的引文是否逐字出现在被引文档(NFKC 归一化、去空白、忽略大小写),不在记 QUOTE_NOT_FOUND;
- 句子里的数字和英文词是否都出现在被引文档,缺了记 TOKEN_NOT_FOUND。
既没有引文也没有数字、英文词的句子,规则没法核对,记 UNVERIFIABLE。
import re
import unicodedata
CITE = re.compile(r"\[(\d+)\]")
QUOTE = re.compile(r"「([^」]+)」")
TOKEN = re.compile(r"[a-z]+|\d+")
def normalize(s):
return re.sub(r"\s+", "", unicodedata.normalize("NFKC", s).lower())
def tokens(s):
return set(TOKEN.findall(unicodedata.normalize("NFKC", s).lower()))
def check_sentence(sentence, docs, contexts):
"""返回 (verdict, detail)。verdict 为 PASS 或失败类型。"""
ids = CITE.findall(sentence)
if not ids:
return "MISSING_CITATION", "没有引用"
bad = [i for i in ids if i not in contexts]
if bad:
return "BAD_ID", f"引用了不在上下文里的 [{', '.join(bad)}]"
cited = " ".join(docs[i] for i in ids)
for q in QUOTE.findall(sentence):
if normalize(q) not in normalize(cited):
return "QUOTE_NOT_FOUND", f"引文「{q}」不在被引文档里"
body = CITE.sub("", sentence)
missing = sorted(tokens(body) - tokens(cited))
if missing:
return "TOKEN_NOT_FOUND", f"被引文档里没有:{', '.join(missing)}"
if not QUOTE.search(sentence) and not tokens(body):
return "UNVERIFIABLE", "没有引文、数字或英文词,规则无法核对"
return "PASS", ""
数字和英文词按整词比较,所以 “42” 不会因为文档里有 “422” 就算出现。在 6 个预设回答和 5 条边界样例(共 22 句)上跑 python3 citation_checker.py,逐句结果的节选:
[幻觉] QUOTE_NOT_FOUND 人工=错 前端弹出 toast 提示「用户名或密码错误」,3 秒后自动消失 [1]。
[幻觉] TOKEN_NOT_FOUND 人工=错 连续输错 5 次会锁定账号 15 分钟 [1]。
[引用错位] PASS 人工=错 密码错误时接口返回「422」[3]。 ← 和人工不一致
[引用错位] QUOTE_NOT_FOUND 人工=错 响应体的 errors 是「"email or password": ["is invalid"]」[2]。
[边界] TOKEN_NOT_FOUND 人工=对 服务端用 HTTP 422 拒绝这次登录 [1]。 ← 和人工不一致
[边界] UNVERIFIABLE 人工=对 错误信息会逐条显示在登录表单上方 [1]。
[边界] BAD_ID 人工=错 接口返回「422」[4]。
[边界] MISSING_CITATION 人工=错 前端没有渲染错误提示。
共 22 句:{'拦对': 7, '放对': 12, '误拦': 1, '漏放': 1, '无法核对': 1}
这里的"人工"指被引文档能否单独支持这句话。和人工对照:
| 拦对 | 放对 | 误拦 | 漏放 | 无法核对 |
|---|---|---|---|---|
| 7 | 12 | 1 | 1 | 1 |
- 漏放:“密码错误时接口返回「422」[3]"。文档 3 讲的是注册,但里面也有 422,字符串匹配就放过了。
- 误拦:“服务端用 HTTP 422 拒绝这次登录 [1]"。意思对,但文档里没有 “http” 这个词。
- 无法核对:“错误信息会逐条显示在登录表单上方 [1]"。没有引文、没有数字和英文词,规则无话可说。
python3 -m unittest -v test_citation_checker.py 的 13 个测试全部通过(Python 3.9 和 3.13 各跑了一遍)。
结论:规则核对器适合做格式和逐字引文的硬门禁。BAD_ID、MISSING_CITATION、QUOTE_NOT_FOUND 的含义是确定的:编号不存在就是不存在,引文没有逐字出现就是没出现。至于被引文档的意思能不能撑起这句话,要交给 NLI 模型或 LLM 裁判,而它们本身又要校准。
测开视角:RAG 的回归集和门禁
回归集里每条放这些:
| 字段 | 用途 |
|---|---|
| 问题 | 来自真实查询日志或测试 Agent 的历史提问,不要只写"标准问法” |
| 参考答案(拆好的陈述) | 算完整度和上下文召回;人工拆一次,长期复用 |
| 必须检索到的文档 id | 算 recall@k,不用 LLM,确定、便宜 |
| 类别标签 | 单文档 / 多文档综合 / 知识库里没有(应该拒答)/ 有相似干扰文档 / 新旧文档冲突 |
“知识库里没有"这一类一定要有:正确行为是说"没查到”。这时 RAGAS 忠实度可能是 NaN(拆不出陈述),也可能是 0(把"没查到"拆成一条陈述、判为无支持),两种都不能进平均值,要单独断言"回答是拒答”。“相似干扰文档"对应样例里的 REQ-USER-02,专门抓答非所问和引用错位。
门禁按确定性和成本分三档:
| 检查 | 性质 | 什么时候跑 | 门禁 |
|---|---|---|---|
| 引用格式:每句有引用、引用 id 存在、「」里的引文逐字出现 | 规则,确定 | 每次提交 | 硬门禁:BAD_ID、MISSING_CITATION、QUOTE_NOT_FOUND 为 0 |
| 检索 recall@k | 标注比对,确定 | 改切块、嵌入、索引时 | 不低于基线,按题配对比较 |
| 忠实度、完整度、引用正确率 | LLM 裁判,有噪声 | 每晚,或改 prompt、换模型时 | 看题目级分布(忠实度 < 1、< 0.5 的题目比例),并和基线做配对检验 |
别只看平均忠实度。情形 A:100 道题里 95 道忠实度 1.0、5 道是 0(整段编造);情形 B:100 道题每道都是 0.95(20 条陈述里有 1 条没依据)。两种情形平均都是 0.95,但分布完全不同:A 是 5 道题整段编造,B 是每道题都混了一句没依据的话,平均值区分不了。门禁要同时看题目级分布:“忠实度 < 1 的题目比例”(A 5%,B 100%)和"忠实度 < 0.5 的题目比例”(A 5%,B 0%),哪种更不能接受由业务决定。
样本量要够。100 道题里 3 道有不忠实陈述,观测比例 3%,Wilson 95% 区间是 1.0% ~ 8.5%。门禁要是写"不忠实率 ≤ 5%",这个结果证明不了达标;按门禁该用的单侧 95% 算(Wilson 上界 7.3%,精确法 7.6%),同样证明不了。比较两个版本时,同一批题配对,用「新 prompt 从 82% 涨到 86%,是真的变好了吗?」那一章的 McNemar 或配对 bootstrap,而不是比两个平均数。
常见错误说法
- “RAG 就是 Lewis 2020 提出的检索 + prompt 拼接”:原始 RAG 是联合微调的 DPR + BART,检索到的文档当隐变量边缘化。
- “检索 recall 高,回答就对”:检索到了还可能没用上、用歪、答非所问。
- “忠实度 1.0 就没有引用问题”:RAGAS 的忠实度把所有上下文拼在一起判,不检查每句引的是哪篇。
- “忠实度高就说明回答对”:忠实只是"有依据”,依据本身可能过期;遗漏关键信息、答非所问的回答,忠实度也可以是 1.0。
- “LLM 算出来的忠实度就是真值”:它依赖拆分粒度和裁判模型,要校准,要固定版本。
- “平均忠实度 0.95,说明问题分布均匀”:可能是 5 道题整段编造,也可能是每道题都有一句没依据,要看题目级分布。
- “引用核对用字符串匹配就够了”:规则能可靠地查格式和逐字引文,查不了语义;在 22 句样例上,它漏放了一句、误拦了一句,还有一句无法核对。