检索对了,回答就对了吗?
不一定。检索到了正确的文档,模型还可能没用上、用歪了、答非所问,或者引错了出处。RAG 的评测要把这几层拆开,一层一层量。
核心做法:把回答拆成一条条原子陈述,逐条问"这句话能不能被检索到的上下文支持"。支持的比例就是忠实度。再对照参考答案、对照被引文档,失败落在哪一层就清楚了。
1RAG 是什么:原始论文和今天的说法不一样
RAG 这个名字来自 Lewis 等人 2020 年的论文 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%。
2端到端失败,拆成几层
BugHunt-Bench 场景:测试 Agent 在 Conduit 应用上发现"登录密码错误没有提示",它去知识库里查需求和历史 Bug,问:"登录时密码输错了,接口返回什么?前端应该怎么提示?"参考答案有三条陈述:R1 返回 422;R2 errors 是 "email or password": ["is invalid"];R3 前端在登录表单上方用红色列表逐条显示。
回答错了,可能错在下面任何一层。层和层之间是串联的,前面坏了,后面的指标会跟着变差,所以要分开量:
| 层 | 失败 | 症状 | 看哪个指标 |
|---|---|---|---|
| 检索 | 没检索到 | 需求文档 REQ-AUTH-03 根本不在上下文里;模型要么说不知道,要么凭记忆编 | 上下文召回(参考陈述有多少能在上下文里找到依据);有标注时直接看 recall@k |
| 生成 | 检索到但没用上 | REQ-AUTH-03 在上下文里,回答只说了"返回 422" | 答案完整度(参考陈述有多少被回答覆盖;本章自定义的简化指标) |
| 用上了但歪曲 | "前端弹 toast,3 秒后消失"——上下文里没有这句 | 忠实度(回答的陈述有多少能被上下文支持) | |
| 答非所问 | 回答的是"注册时用户名被占用",每句都有出处,但不是在回答登录 | 答案相关性;对照参考答案的完整度 | |
| 引用 | 引用错位 | 内容是对的,但标的出处撑不起这句话 | 引用正确率(被引文档能否单独支持这句话) |
3四个指标怎么定义
下面的定义以 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):
- 引用召回(按陈述算):这条陈述至少带一个引用,并且被引文档拼起来能蕴含这条陈述,记 1,否则 0。
- 引用精确率(按引用算):一个引用"无关"的条件是它单独撑不起这句话,并且去掉它,剩下的引用照样撑得起。陈述的引用召回为 1、且这个引用不是"无关"的,才记 1。
ALCE 用 NLI 模型(TRUE)判断"是否蕴含"。论文摘要里的结论:在 ELI5 数据集上,最好的模型也有一半的时间引用支撑不完整。下面的演示里每条陈述只带一个引用,这时两个指标是同一个数,统一叫引用正确率。
演示里还用了一个答案完整度:参考答案的陈述有多少被回答覆盖。这是本章为了定位"没用上"而用的简化定义,不是 RAGAS 的指标,思路接近 RAGAS FactualCorrectness 的 recall 模式。代码里另有一个"切题率"(回答的陈述中在回答这个问题的比例),也是本章的简化指标,用人工标注算,不是 RAGAS 的答案相关性。
4拆成原子陈述,以及用 LLM 当裁判的问题
为什么要拆:一段回答往往一半对一半错,给整段打"对 / 错"太粗。FActScore(Min et al., EMNLP 2023)把长文本拆成原子事实,算有多少被可靠知识源支持;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 要和被测系统一样做版本管理。
- 已知偏差:MT-Bench 那篇论文(Zheng et al., NeurIPS 2023)列了 LLM 裁判的位置偏差、长度偏差、偏爱自己的输出。
- 成本:RAGAS 的忠实度每条样本至少两次 LLM 调用(拆陈述一次、逐条判定一次);答案相关性默认还要生成 3 个问题。
5测开视角:RAG 的回归集和门禁
回归集里每条放什么
| 字段 | 用途 |
|---|---|
| 问题 | 来自真实查询日志或测试 Agent 的历史提问,不要只写"标准问法" |
| 参考答案(拆好的陈述) | 算完整度、上下文召回;人工拆一次,长期复用 |
| 必须检索到的文档 id | 算 recall@k,不用 LLM,确定、便宜 |
| 类别标签 | 单文档 / 多文档综合 / 知识库里没有(应该拒答)/ 有相似干扰文档 / 新旧文档冲突 |
"知识库里没有"这一类特别要有:正确行为是说"没查到"。这时 RAGAS 忠实度可能是 NaN(拆不出陈述),也可能是 0(把"没查到"拆成一条陈述、判为无支持),两种都不能进平均值,要单独断言"回答是拒答"。"相似干扰文档"对应演示里的 REQ-USER-02(注册也返回 422),专门抓答非所问和引用错位。
门禁分三档
| 检查 | 性质 | 什么时候跑 | 门禁 |
|---|---|---|---|
| 引用格式:每句有引用、引用 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%),同样证明不了。比较两个版本时,同一批题配对,用第 1 周讲过的 McNemar 或配对 bootstrap,而不是比两个平均数。
▶互动演示:逐条判定忠实度,找出失败落在哪一层
数据是自制的 BugHunt-Bench 样例。选一个预设回答,逐条判断"这句话能不能被检索到的上下文支持"(不管引的是哪篇),下方实时算你的忠实度;全部判完后揭晓参考判定和诊断。规则核对器那一列是下面"动手实验"里那段 Python 的同款逻辑,只做字符串匹配。
✎练习
"\n".join(retrieved_contexts),定义上就不检查引用。这不是裁判能力的问题(C),而且引用错对用户是实打实的问题(B 不对)。要单独算引用正确率。6面试要点与代码
一句话讲清楚
RAG 端到端的失败要拆层看:没检索到看上下文召回,检索到没用上看答案完整度(本章自定义),用歪了看忠实度,答非所问看答案相关性,引错出处看引用正确率。忠实度的做法是把回答拆成原子陈述,逐条判断能否被上下文支持,算支持的比例;它通常由 LLM 来判,所以裁判要先拿人工标注校准,拆分粒度也要固定。
追问准备
- 忠实度高就说明回答对吗?不。忠实只是"有依据",依据本身可能是过期文档;而且没覆盖关键信息、答非所问的回答,忠实度也可以是 1.0。
- 没有参考答案能评吗?忠实度和答案相关性不需要参考答案(RAGAS 论文主打的就是 reference-free);上下文召回和完整度需要。回归集最好有参考答案,线上监控可以只看前两个。
- 拒答怎么算?拆不出陈述时 RAGAS 忠实度返回 NaN;但拆分 prompt 没有专门处理拒答,也可能拆出一条"没查到"、判为无支持,得到 0。回归集里"知识库没有"的题,不进平均值,单独断言"回答是拒答"。
- 引用核对能不能不用 LLM?格式和引文是否逐字出现,用规则就够,确定、免费、可以做硬门禁;"被引文档是否支持这句话的意思"要语义判断,规则会漏放也会误拦(见下面的实验)。
常见错误说法
❌ "检索 recall 高,回答就对":检索到了还可能没用上、用歪、答非所问。
❌ "忠实度 1.0 就没有引用问题":RAGAS 的忠实度把上下文拼在一起判,不检查引用。
❌ "LLM 算出来的忠实度就是真值":它依赖拆分粒度和裁判,要校准、要固定版本。
❌ "平均忠实度 0.95,说明问题分布均匀":可能是 5 道题整段编造,也可能是每道题都有一句没依据,要看题目级分布。
动手实验:一个只用字符串匹配的引用核对器
代码在 week04_上下文与记忆/code/citation_checker/,只用标准库。每句话检查四件事:有没有引用 [n];引用的编号在不在这次的上下文里;「」里的引文是否逐字出现在被引文档(NFKC 归一化、去空白、忽略大小写);句子里的数字和英文词是否都出现在被引文档。核心函数:
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):
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", ""
在 6 个预设回答和 5 条边界样例(共 22 句)上跑,和人工标注(被引文档能否单独支持这句话)对比:
| 拦对 | 放对 | 误拦 | 漏放 | 无法核对 |
|---|---|---|---|---|
| 7 | 12 | 1 | 1 | 1 |
- 漏放:"密码错误时接口返回「422」[3]"。文档 3 讲的是注册,但里面也有 422,字符串匹配就放过了。
- 误拦:"服务端用 HTTP 422 拒绝这次登录 [1]"。意思对,但文档里没有 "http" 这个词。
- 无法核对:"错误信息会逐条显示在登录表单上方 [1]"。没有引文、没有数字和英文词,规则无话可说。
结论:规则核对器适合做格式和逐字引文的硬门禁(BAD_ID、MISSING_CITATION、QUOTE_NOT_FOUND 的含义是确定的:编号不存在就是不存在,引文没有逐字出现就是没出现),语义层面的"引得对不对"要交给 NLI 模型或 LLM 裁判,而它们本身又要校准。仓库里附了 13 个单元测试(python3 -m unittest -v test_citation_checker.py)。
公式显示依赖 KaTeX(CDN),断网时公式会显示成原始 LaTeX 源码,交互部分不受影响。