一个 RAG 问答答对了"差旅住宿标准 600 元/晚",并在末尾标注了来源文档。用户点开来源,发现那份文档里写的是"一线城市 550 元"——答案正确,来源错误。而这类"引用错配"比"没有引用"更危险,因为它看起来是可以核验的。
引用的价值不在"显得可信",而在让答案可以被核对。而可核对的前提是:引用的粒度足够细、来源与断言一一对应、且用户能验证。
1. 引用的三个粒度
| 粒度 | 可核对性 |
|---|---|
| 文档级(给出文档名) | 低(要自己找) |
| 段级(给出章节或片段) | 中 |
| 句级(给出原文那句话) | 高 |
第一项最常见也最没用:用户拿到一个 40 页的 PDF 名,等于没有引用。这个差别背后是一个工程现实:句级引用要求"检索片段"与"生成句子"能对齐,而多数系统在生成时已经把片段拼成一段长文本,对齐信息丢了。
而它在实现上最便宜,于是被大量系统采用。
第三项要求把"生成内容"与"原文片段"做对齐,实现成本高,但它是唯一能支撑"我核对过了"的粒度。
2. 引用错配的四种形态
| 形态 | 表现 |
|---|---|
| 张冠李戴 | 答案对、来源错 |
| 部分支持 | 来源只说了一半 |
| 语义漂移 | 原文"可能",答案"可以" |
| 引用失效 | 链接过期或文档已改版 |
第一项在检索到多份相似文档时最常见:两段都讲住宿标准,模型引用了其中一段,而数字取自另一段。第二项(部分支持)最隐蔽:来源确实讲了这个主题,但只说了其中一条,而答案把两条都写上了。用户核对时会觉得"来源大概支持它",除非他逐字比对。
第三项是"强度漂移":原文有条件的结论被写成无条件的,而引用看起来完全合理。
xt
// 错配的检测:把答案里的断言与引用片段做逐条比对
断言:一线城市住宿标准为 600 元
引用片段:一线城市住宿标准为 550 元(2023 版)
// 结论:数字不符 → 引用错配,应降级为"未确认"或拒答
3. 让引用可核对的四种做法
| 做法 | 说明 |
|---|---|
| 生成时逐句挂来源 | 模型输出结构化引用 |
| 生成后逐条校验 | 断言与片段比对 |
| 展示原文片段 | 不只给文件名 |
| 提供跳转定位 | 页码/锚点 |
第二项是必须的:只让模型"标注来源"不可靠,因为它可能标一个"看起来对"的文档。校验要独立于生成——把答案里的数字、日期、专有名词逐个在引用片段里查找。
第一项的实现方式是让模型输出结构化结果:每句一个 citations 数组,而不是末尾一个总引用。
// 逐句挂引用:每个断言都有自己的来源
{
"answer": [
{ "text": "一线城市住宿标准为 600 元/晚",
"citations": [{ "doc": "travel-2024", "chunk": "3.2", "quote": "一线城市:600 元/晚" }] },
{ "text": "二线城市为 450 元/晚",
"citations": [{ "doc": "travel-2024", "chunk": "3.3", "quote": "二线城市:450 元/晚" }] }
]
}
// 校验:每个 quote 必须能在对应 chunk 里精确找到
4. 校验的三道闸
| 闸 | 检查什么 |
|---|---|
| 存在性 | 引用的 chunk 真的存在 |
| 包含性 | quote 真的出现在 chunk 里 |
| 一致性 | 答案数字与 quote 里的一致 |
第一项防"模型编造文档名":chunk id 应当由系统分配并在检索结果里回传,模型只能引用它见过的 id。
第二项防"模型改写 quote":要求 quote 是原文的精确子串,一条 in 判断就能验证。
第三项是核心:"600 元"与 quote 里的"550 元"不一致时,必须拦下。这一条能抓住绝大多数错配。第三项的实现要注意"数字的单位与格式":600 与 600.00 与 6e2 是同一个数,而字符串比较会误判。所以比对数字前应当先归一化。
def verify(answer_items, chunks) -> list[str]:
errs = []
for item in answer_items:
for c in item["citations"]:
chunk = chunks.get(c["chunk"])
if chunk is None:
errs.append(f"chunk 不存在: {c['chunk']}"); continue
if c["quote"] not in chunk.text:
errs.append(f"quote 不在原文: {c['quote'][:20]}")
nums = extract_numbers(item["text"])
if any(n not in c["quote"] and n not in chunk.text for n in nums):
errs.append(f"数字无依据: {nums}")
return errs
引用还有一个"时间"维度需要固定:同一份文档会被更新,而历史答案引用的可能已是旧版。所以引用记录应当包含"文档版本或快照",否则半年后回看一条答案,无法判断"当时依据的原文是什么"。
// 引用记录里必须有的四个字段
citation:
doc_id: travel-2024
version: "2024-03-01" # 生效版本或快照日期
chunk_id: "3.2"
quote: "一线城市:600 元/晚" # 原文精确子串
# 缺 version → 无法判断时效;缺 quote → 无法核对
5. 引用展示的设计
| 做法 | 效果 |
|---|---|
| 折叠式原文 | 不打断阅读 |
| 高亮命中片段 | 快速核对 |
| 标注版本与日期 | 判断时效 |
| 标注置信度 | 提示不确定 |
第三项常被忽略:同一制度有多个版本时,"哪一版"决定答案对不对。第二项(高亮命中片段)看似是 UI 细节,实际影响很大:没有高亮时,用户要在一个 300 字的片段里自己找那句话,多数人不会找,于是"可核对"变成了"看起来可核对"。
所以引用里要带版本或生效日期,否则用户无法判断。
第四项(置信度)在内部工具里可以粗一些,但对外部用户应当分档而不是给数字——用户会问"0.68 是不是不太可信",而系统没有能力解释这个数。
第四项要谨慎:给出置信度数字容易被过度解读("0.72 是什么意思"),更稳的做法是分档("依据充分/依据有限/无依据")。
还有一个"多跳"场景的难点:答案依据的是两段拼起来的信息(A 说标准,B 说适用城市),而单条引用只能指向其中一段。此时应当输出"多条引用 + 它们各自支持哪一部分",而不是勉强挂一条。
// 多跳引用的写法
答案:一线城市(按 B 的城市分级)住宿标准为 600 元/晚(按 A 的标准表)
引用 1:travel-2024 §3.2 "一线城市:600 元/晚"
引用 2:travel-2024 §2.1 "一线城市指北京、上海、广州、深圳"
// 只挂引用 1 → 用户不知道"一线"的定义从哪来
6. 排错:五种"引用不可信"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 答案对来源错 | 多份相似文档 | 逐句挂引用 |
| 来源只有文件名 | 粒度太粗 | 展示片段 |
| 引用文档已改版 | 无版本信息 | 存快照 + 标日期 |
| 点击链接失效 | 文档被移动 | 存 chunk 快照 |
| 用户核对后仍困惑 | 片段未高亮 | 高亮命中 |
第四项有一个彻底解法:在答案生成时把引用的 chunk 原文一起存进会话,于是即使原文档被改,用户看到的仍然是"当时依据的原文"。
评估引用质量也要有指标,否则"看起来有引用"就够过关了。三个可算的数就够用:
// 三个可算的引用质量指标(每周统计)
1 引用覆盖率 = 有引用的断言数 / 断言总数 (目标 > 90%)
2 引用准确率 = 校验通过的引用数 / 引用总数 (目标 > 98%)
3 引用可用率 = 用户能点击并定位的比例 (目标 > 95%)
// 只测第 1 项会得到一个"很好"的数字,而答案仍不可核对
7. 边界:什么情况不必做细粒度引用
| 场景 | 为什么 | 做法 |
|---|---|---|
| 闲聊与头脑风暴 | 无事实约束 | 不引用 |
| 单一权威来源(如内部规范) | 几乎不会错 | 文档级引用够 |
| 已有人工复核流程 | 另有保障 | 粗引用即可 |
| 探索性问答(用户只是了解) | 不用于决策 | 粗引用 |
判断口径一句话:用户会不会拿这个答案去"做决定"或"引用给别人"。最后一条与"成本"有关的边界:句级引用的成本主要在"切分粒度"。片段太大(如整章),quote 容易找到但定位不准;片段太小(如一句),定位准但上下文不足。常见折中是"200–500 字 + 句级偏移"。
会,就必须做到句级并可核对;只是了解情况,文档级引用足够。
8. 自测
- 引用的三种粒度各有什么可核对性?为什么"文档级引用"在实现上最便宜却最没用?
- 引用错配的四种形态里,哪一种最常见?为什么它比"没有引用"更危险?
- 为什么"让模型标注来源"不可靠?校验为什么必须独立于生成?
- 三道闸分别防什么?"一致性"这一闸能抓住哪类问题?
- 引用里为什么要带版本或生效日期?"存 chunk 快照"解决了什么问题?
- 判断"是否需要细粒度引用"的那个问题是什么?