检索对了,回答就对了吗?

不一定。检索到了正确的文档,模型还可能没用上、用歪了、答非所问,或者引错了出处。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%。

面试时说"RAG 就是检索加生成"没错,但如果被追问"原始论文的 RAG 是怎么训练的",要能说出:DPR + BART,检索文档当隐变量边缘化,查询编码器和生成器联合微调。今天的做法是把这套简化成了"检索 + prompt 拼接",生成器换成了不训练的大模型。

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 秒后消失"——上下文里没有这句忠实度(回答的陈述有多少能被上下文支持)
答非所问回答的是"注册时用户名被占用",每句都有出处,但不是在回答登录答案相关性;对照参考答案的完整度
引用引用错位内容是对的,但标的出处撑不起这句话引用正确率(被引文档能否单独支持这句话)
只看一个端到端的"答对率",这五种失败混在一起,修的时候不知道该改检索、改 prompt 还是改引用格式。"Lost in the Middle"(Liu et al., TACL 2023)还发现,相关信息放在长上下文的中间时,模型用得最差。所以"检索到了"和"用上了"确实是两回事,要分开量。

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),算它们和原问题嵌入的余弦相似度,取平均:

$$\text{Answer Relevancy} = \frac{1}{N}\sum_{i=1}^{N}\cos(E_{g_i}, E_o)$$

它不看事实对不对,只看回答是不是在回应这个问题。源码里还有一条:如果反推出的问题全都被判为"含糊其辞"(noncommittal),分数直接乘 0(v0.4.3 的新接口和 legacy 接口都是"全部"才乘 0,只有部分含糊不扣)。文档也提醒,余弦相似度的范围是 −1 到 1,分数通常但不保证落在 0 到 1 之间。

引用正确率

RAGAS 的忠实度不检查引用。引用质量的经典定义来自 ALCE(Gao et al., EMNLP 2023):

ALCE 用 NLI 模型(TRUE)判断"是否蕴含"。论文摘要里的结论:在 ELI5 数据集上,最好的模型也有一半的时间引用支撑不完整。下面的演示里每条陈述只带一个引用,这时两个指标是同一个数,统一叫引用正确率。

一个反直觉的组合:忠实度 1.0、引用正确率 0。回答的每句话都能在上下文里找到依据,但标的出处全错了。因为 RAGAS 的忠实度把上下文拼在一起判,它看不出这个问题。用户点开引用核对时,看到的是一篇讲注册的文档,信任就没了。

演示里还用了一个答案完整度:参考答案的陈述有多少被回答覆盖。这是本章为了定位"没用上"而用的简化定义,不是 RAGAS 的指标,思路接近 RAGAS FactualCorrectness 的 recall 模式。代码里另有一个"切题率"(回答的陈述中在回答这个问题的比例),也是本章的简化指标,用人工标注算,不是 RAGAS 的答案相关性。

4拆成原子陈述,以及用 LLM 当裁判的问题

为什么要拆:一段回答往往一半对一半错,给整段打"对 / 错"太粗。FActScore(Min et al., EMNLP 2023)把长文本拆成原子事实,算有多少被可靠知识源支持;RAGAS 的忠实度也是先拆陈述再逐条判。拆完之后分数可解释:哪一句没有依据,一眼就能看到。

但拆的粒度会改变分数。"幻觉"那个回答:

拆法陈述数被支持忠实度
按句拆:422 / 弹 toast 并 3 秒消失 / 输错 5 次锁 15 分钟3133.3%
更细:后两句各拆开(弹 toast / 提示文字 / 3 秒消失 / 输错 5 次锁定 / 锁 15 分钟)6116.7%
不拆:整段当一条100%

同一个回答,三个分数。所以跨版本比较忠实度时,拆分用的模型和 prompt 必须固定,否则分数变化可能只是拆法变了。

用 LLM 当裁判,要先回答这几个问题

  1. 裁判本身准不准?RAGAS 论文在自建的 WikiEval 上测过:给两个回答(上下文相关性给的是两段上下文),看指标偏好的那个和人工是否一致。忠实度一致率 0.95,答案相关性 0.78,上下文相关性 0.70(论文用的是 gpt-3.5-turbo-16k)。这是成对比较的一致率,不是单条判定的准确率,换了领域、换了裁判模型都要重新测。
  2. 两种错误代价不同:把编造的陈述判成"有支持"(漏判)会放过幻觉;把正确的陈述判成"无支持"(误杀)会让门禁频繁报警。要分别统计,不能只看一致率。
  3. 裁判会漂:换了裁判模型版本、改了 prompt,同一批回答的分数会变。裁判的模型和 prompt 要和被测系统一样做版本管理。
  4. 已知偏差:MT-Bench 那篇论文(Zheng et al., NeurIPS 2023)列了 LLM 裁判的位置偏差、长度偏差、偏爱自己的输出。
  5. 成本:RAGAS 的忠实度每条样本至少两次 LLM 调用(拆陈述一次、逐条判定一次);答案相关性默认还要生成 3 个问题。
怎么校准裁判(人工标一批、算 Cohen's κ、分别看漏判率和误杀率),第 6 周「Judge 与错误分析」专门讲。这里先记住:LLM 算出来的忠实度是一个带误差的测量值,不是真值。

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 的同款逻辑,只做字符串匹配。

预设回答
问题
检索到的上下文(虚线 = 这次没检索到)
你算的忠实度
参考忠实度
你和参考判定一致
上下文召回
答案完整度
引用正确率
失败落在哪一层(红框 = 主因;后面的层常常是主因的连带后果)

✎练习

题 1(算):一个回答拆成 6 条陈述。4 条能被上下文支持,1 条和上下文矛盾,1 条上下文里完全没提到。按 RAGAS 的定义,忠实度是多少?
%
4 / 6 ≈ 66.7%。"矛盾"和"没提到"在忠实度里都算不被支持,分母是全部陈述。
题 2(拆分粒度):"幻觉"回答按句拆成 3 条时,只有 1 条被支持,忠实度 33.3%。把后两条各拆细,总共变成 6 条,被支持的还是那 1 条。忠实度变成多少?
%
1 / 6 ≈ 16.7%。回答一个字没变,分数降了一半。跨版本比较忠实度时,拆分用的模型和 prompt 要固定。
题 3(定位):某道题上下文召回 0.33、忠实度 0.5,但回答和参考答案对照,有一条"没依据"的陈述其实是对的。最该先修哪里?
上下文召回 0.33 说明问题出在检索层。那条"没依据但正确"的陈述来自模型自己的记忆,这次碰巧对了,但不可控。只改 prompt(A)会让模型改成说"不知道",检索不修,答案还是不完整。
题 4(指标盲区):"引用错位"那个回答,RAGAS 忠实度是 1.0。为什么?
源码里判定前先 "\n".join(retrieved_contexts),定义上就不检查引用。这不是裁判能力的问题(C),而且引用错对用户是实打实的问题(B 不对)。要单独算引用正确率。
题 5(测开视角):给 RAG 设回归门禁,哪种组合最合理?
A 的问题:平均值会掩盖少数整段编造的题;LLM 裁判有噪声、有成本,每次提交都跑不划算,未校准的裁判做硬门禁会误报。C 样本太少:10 道全对,失败率的单侧 95% 上界约 26%(精确法 1 − 0.051/10;3/n 速算给 30%)。B 按确定性和成本分档。

6面试要点与代码

一句话讲清楚

RAG 端到端的失败要拆层看:没检索到看上下文召回,检索到没用上看答案完整度(本章自定义),用歪了看忠实度,答非所问看答案相关性,引错出处看引用正确率。忠实度的做法是把回答拆成原子陈述,逐条判断能否被上下文支持,算支持的比例;它通常由 LLM 来判,所以裁判要先拿人工标注校准,拆分粒度也要固定。

追问准备

  1. 忠实度高就说明回答对吗?不。忠实只是"有依据",依据本身可能是过期文档;而且没覆盖关键信息、答非所问的回答,忠实度也可以是 1.0。
  2. 没有参考答案能评吗?忠实度和答案相关性不需要参考答案(RAGAS 论文主打的就是 reference-free);上下文召回和完整度需要。回归集最好有参考答案,线上监控可以只看前两个。
  3. 拒答怎么算?拆不出陈述时 RAGAS 忠实度返回 NaN;但拆分 prompt 没有专门处理拒答,也可能拆出一条"没查到"、判为无支持,得到 0。回归集里"知识库没有"的题,不进平均值,单独断言"回答是拒答"。
  4. 引用核对能不能不用 LLM?格式和引文是否逐字出现,用规则就够,确定、免费、可以做硬门禁;"被引文档是否支持这句话的意思"要语义判断,规则会漏放也会误拦(见下面的实验)。

常见错误说法

❌ "RAG 就是 Lewis 2020 提出的检索 + prompt 拼接":原始 RAG 是联合微调的 DPR + BART,检索文档当隐变量边缘化。
❌ "检索 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 句)上跑,和人工标注(被引文档能否单独支持这句话)对比:

拦对放对误拦漏放无法核对
712111

结论:规则核对器适合做格式和逐字引文的硬门禁(BAD_ID、MISSING_CITATION、QUOTE_NOT_FOUND 的含义是确定的:编号不存在就是不存在,引文没有逐字出现就是没出现),语义层面的"引得对不对"要交给 NLI 模型或 LLM 裁判,而它们本身又要校准。仓库里附了 13 个单元测试(python3 -m unittest -v test_citation_checker.py)。

公式显示依赖 KaTeX(CDN),断网时公式会显示成原始 LaTeX 源码,交互部分不受影响。