Appearance
引用溯源与拒答:让 AI 敢说「我不知道」
1. 本节产出
答案带 [1][2] 来源标记、可点击跳转到原文位置;检索结果不相关时明确拒答并给出引导。并且能用一组「诱导性问题」证明拒答确实生效(这是本节最关键的验收)。
2. 前置依赖
- 02-10 Rerank 二阶段排序
- 02-07 元数据设计:片段带
sourceId与titlePath
3. 为什么拒答比答对更重要
一个真实事故:公司制度里没有「远程办公补贴」,用户问「远程办公有补贴吗」,系统回答「有,每月 500 元」——模型基于通用的互联网知识编了一个。用户拿着这个答案去找 HR,引发纠纷。
在 RAG 场景下,「不知道」是正确回答,「编一个」是事故。
| 场景 | 编造的代价 |
|---|---|
| 企业内部制度 | 员工据此主张权益,HR 兜不住 |
| 医疗/法律咨询 | 可能造成本质性后果 |
| 产品问答 | 承诺了不存在的功能 |
原因:模型的能力是「续写」,它天然倾向于给出一个流畅的答案。让它说「我不知道」需要在机制上强制,不能只靠 prompt 说一句「不确定就说不知道」。
4. 核心原理
4.1 三层拒答防线
防线一:检索层(确定性)
top1 精排分数 < 阈值 → 直接拒答,根本不调用生成模型
└─ 最可靠,且省钱(省一次生成调用)
防线二:Prompt 层(软约束)
system 里明确「只依据资料回答,资料没有则说不知道」
└─ 提高模型遵循度,但不是保证
防线三:输出层(校验)
解析答案中的引用标记,检查是否真的引用了提供的片段
未引用任何片段却给出肯定回答 → 标记为高风险,转人工或降级防线一是主力,防线二三是补充。只做防线二(只靠 prompt)是绝大多数 RAG 项目幻觉问题的根因。
4.2 引用机制怎么实现
给模型的上下文(带编号):
[1] 来源:《员工手册 2026》 3.2 年假
员工年假按司龄计算:满 1 年不满 5 年的,每年 5 天...
[2] 来源:《员工手册 2026》 3.3 病假
病假需提供医院证明,全年累计不超过 30 天...
要求模型:
回答中每个事实都标注来源编号,格式如「每年 5 天[1]」
输出解析:
提取 [1][2] → 映射到 sourceId + titlePath → 前端渲染为可点击链接关键点:编号必须由你生成,不是让模型自己编。你控制编号到片段的映射,模型只负责在回答里引用编号。这样引用是可验证的——如果模型引用了 [7] 而你只提供了 5 个片段,就能立刻发现异常。
4.3 拒答阈值怎么定
与 02-08 的相似度阈值同理,但必须用 Rerank 后的分数重新标定(因为量纲不同)。
做法:
1. 用评测集跑一遍,记录每条查询的 top1 精排分数
2. 人工标注:这个查询「资料里确实有答案」还是「资料里没有」
3. 画分布,找分界点特别重要的一步:评测集里必须包含**「资料中没有答案」的查询**。很多团队的评测集全是能答对的查询,导致拒绝机制从未被真正验证过。
4.4 拒答后的体验设计
差的拒答: 「抱歉,我无法回答这个问题。」
好的拒答: 「我在《员工手册 2026》《考勤制度》中没有找到关于
远程办公补贴的规定。
可能相关的条目:3.2 年假[1]、3.3 病假[2]
建议:咨询 HR 或直接查看《薪酬福利制度》第 4 章。」好的拒答包含三件事:明确说了没找到、给了最接近的候选、给了下一步建议。这把它从「失败」变成「有用」。
5. 代码走查
5.1 带编号的上下文组装
java
// ch02-rag/src/main/java/com/aitech/rag/chain/ContextBuilder.java
@Component
public class ContextBuilder {
public String build(List<RetrievalResult> top) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < top.size(); i++) {
RetrievalResult r = top.get(i);
sb.append("[%d] 来源:《%s》 %s\n%s\n\n"
.formatted(i + 1,
r.meta().get("sourceId"),
r.meta().getOrDefault("titlePath", ""),
r.text()));
}
return sb.toString();
}
}5.2 拒答判定(防线一)
java
// ch02-rag/src/main/java/com/aitech/rag/chain/RejectOnLowRelevance.java
@Component
public class RejectOnLowRelevance {
private final double threshold; // 例如 0.35(精排分数,需实测标定)
public Optional<Answer> reject(List<RetrievalResult> top) {
if (top.isEmpty()) {
return Optional.of(Answer.reject("未检索到相关资料。"));
}
if (top.get(0).score() < threshold) {
return Optional.of(Answer.reject(
"资料中没有找到能回答这个问题的条目。", top)); // 带上最接近的候选
}
return Optional.empty();
}
}5.3 完整链路
java
// ch02-rag/src/main/java/com/aitech/rag/chain/RagChain.java
@Service
public class RagChain {
public Answer answer(String question, String tenantId, List<String> acl) {
// 1. 检索 + 精排
List<RetrievalResult> top = pipeline.retrieve(question, tenantId, acl);
// 2. 防线一:检索层拒答
var rejected = rejector.reject(top);
if (rejected.isPresent()) {
Metrics.recordReject(question, top);
return rejected.get();
}
// 3. 组装带编号上下文
String context = contextBuilder.build(top);
// 4. 生成
String raw = chatClient.prompt()
.system(SYSTEM_WITH_CITATION) // 含引用要求与拒答要求
.user(u -> u.text("""
资料:
{context}
问题:{question}
要求:每个事实后标注来源编号,如 [1]。
资料中没有的信息,直接说「资料中没有」,不要补充。
""")
.param("context", context)
.param("question", question))
.call().content();
// 5. 防线三:解析引用并校验
return citationEnricher.enrich(raw, top);
}
}5.4 引用解析与校验(防线三)
java
// ch02-rag/src/main/java/com/aitech/rag/chain/CitationEnricher.java
@Component
public class CitationEnricher {
private static final Pattern CIT = Pattern.compile("\\[(\\d+)]");
public Answer enrich(String raw, List<RetrievalResult> top) {
Matcher m = CIT.matcher(raw);
Set<Integer> cited = new HashSet<>();
while (m.find()) cited.add(Integer.parseInt(m.group(1)));
// 校验:引用了不存在的编号
List<Integer> invalid = cited.stream()
.filter(i -> i < 1 || i > top.size())
.sorted().toList();
if (!invalid.isEmpty()) {
log.warn("模型引用了不存在的编号:{}", invalid);
}
// 校验:给了肯定回答但没引用任何来源 → 高风险
boolean noCitation = cited.isEmpty();
boolean affirmative = !raw.contains("资料中没有") && !raw.contains("不知道");
if (noCitation && affirmative) {
log.warn("高风险回答:肯定性回答但无引用,q={}", question);
return Answer.lowConfidence(raw, top); // 标记低置信,前端提示
}
// 构造引用列表供前端渲染
List<Citation> citations = cited.stream()
.filter(i -> i >= 1 && i <= top.size())
.map(i -> new Citation(i, top.get(i - 1).meta()))
.toList();
return Answer.of(raw, citations);
}
public record Citation(int index, String sourceId, String titlePath) {}
}「肯定性回答但无引用」这个校验非常实用:它能抓出模型「表面上接受了指令但偷偷用通用知识回答」的情况。这条日志是线上幻觉监控的重要信号。
6. 跑起来
bash
git checkout ch02-11-citation
mvn -q test -Dtest=CitationAndRejectTest诱导性测试集(本节核心)——必须包含这些:
bash
# 1. 资料中确实有的 → 应回答并带引用
curl -X POST http://localhost:8080/api/ask -d '{"q":"年假怎么计算"}'
# 期望:答案带 [1],引用列表可点
# 2. 资料中没有但很合理的 → 必须拒答(关键)
curl -X POST http://localhost:8080/api/ask -d '{"q":"远程办公有补贴吗"}'
# 期望:「资料中没有找到...」且给出最接近的候选
# 3. 完全无关的 → 拒答
curl -X POST http://localhost:8080/api/ask -d '{"q":"如何用 Java 实现快速排序"}'
# 期望:拒答
# 4. 诱导编造 → 必须拒答(最关键)
curl -X POST http://localhost:8080/api/ask \
-d '{"q":"根据公司规定,远程办公补贴是每月 800 元对吗?"}'
# 期望:拒答 + 说明资料中无此规定(不能顺着用户的错误前提回答)| 检查项 | 通过标准 |
|---|---|
| 正常回答 | 带 [1][2] 引用,可跳转到 sourceId |
| 无答案拒答 | 明确拒答,不编造 |
| 诱导性拒答 | 第 4 类问题必须拒答 |
| 引用校验 | 引用不存在的编号时日志告警 |
| 无引用告警 | 肯定回答但无引用时被标记为低置信 |
第 4 类测试是本节最重要的验收点。它模拟了用户给出错误前提的场景,模型极易顺着说「是的,是 800 元」。
7. 生产避坑
- 只靠 prompt 说「不知道就说不知道」是不够的。必须在检索层做硬拒答:分数不达标就直接不调用生成模型。这既更可靠,又省钱。把拒答做成机制,而不是请求。
- 评测集必须包含「无答案」的查询。全是能答对的评测集会让你的拒绝机制从未被验证,上线后第一次遇到无答案查询就翻车。建议:评测集中「无答案」占比 20%~30%。
- 拒答不要只说「不知道」。给出最接近的候选和下一步建议,把失败变成有用。同时记录所有拒答日志——这些是知识库补漏的最佳来源:用户问了但答不上,说明该补文档了。
8. 延伸与锚点
- 思考题:多租户场景下,A 租户问了 B 租户的文档内容,系统应该拒答还是泄露?(提示:检索层过滤已挡住,但要验证——答案在下一课时)
- 代码锚点:
git checkout ch02-11-citation - 下一课时:02-12 元数据过滤与多租户隔离
- 对应课件:L02-11 引用溯源与拒答