Learning AI Quality 返回 KuthorX Blog II博客首页

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

$$ \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, arXiv 2305.14627 ):

  • 引用召回(按陈述算):这条陈述至少带一个引用,并且被引文档拼起来能蕴含它,记 1,否则 0。
  • 引用精确率(按引用算):一个引用"无关"的条件是它单独撑不起这句话,并且去掉它,剩下的引用照样撑得起。陈述的引用召回为 1、且这个引用不是"无关"的,才记 1。

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

演示还用了一个答案完整度:参考答案的陈述有多少被回答覆盖。这是本章为了定位"没用上"而用的简化定义,思路接近 RAGAS FactualCorrectness 的 recall 模式。

六个预设回答按人工标注算出来的结果(citation_checker.py 的真实输出):

预设上下文召回忠实度完整度引用正确率主因
正确1.001.001.001.00全部通过
幻觉1.000.330.330.33用了但不忠实
引用错位1.001.001.000.00引用错位
遗漏关键信息1.001.000.331.00检索到但没用上
没检索到0.330.500.670.50没检索到
答非所问1.001.000.001.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 分钟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. 已知偏差。Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena(Zheng et al., NeurIPS 2023)列了 LLM 裁判的位置偏差、长度偏差和偏爱自己的输出。
  5. 成本。RAGAS 的忠实度每条样本至少两次 LLM 调用(拆陈述一次、逐条判定一次);答案相关性默认还要反推 3 个问题。

怎么校准裁判(人工标一批、算 Cohen’s κ、分别看漏判率和误杀率),第 6 周「Judge 与错误分析」专门讲。这里先记住:LLM 算出来的忠实度是一个带误差的测量值,不是真值。

动手实验:只用字符串匹配的引用核对器

代码在学习目录的 week04_上下文与记忆/code/citation_checker/,只用标准库:samples.json 是上面的样例和人工标注,citation_checker.py 是核对器和指标,test_citation_checker.py 是 13 个单元测试。回答的格式约定是:每句话末尾标 [n],需要原文支撑的关键内容放在「」里。核对器每句检查四件事:

  1. 有没有引用 [n],没有记 MISSING_CITATION;
  2. 引用编号在不在这次检索到的上下文里,不在记 BAD_ID;
  3. 「」里的引文是否逐字出现在被引文档(NFKC 归一化、去空白、忽略大小写),不在记 QUOTE_NOT_FOUND;
  4. 句子里的数字和英文词是否都出现在被引文档,缺了记 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}

这里的"人工"指被引文档能否单独支持这句话。和人工对照:

拦对放对误拦漏放无法核对
712111
  • 漏放:“密码错误时接口返回「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 句样例上,它漏放了一句、误拦了一句,还有一句无法核对。