Skip to content

引用溯源与拒答:让 AI 敢说「我不知道」 ​

1. 本节产出 ​

答案带 [1][2] 来源标记、可点击跳转到原文位置;检索结果不相关时明确拒答并给出引导。并且能用一组「诱导性问题」证明拒答确实生效(这是本节最关键的验收)。

2. 前置依赖 ​

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
// src/main/java/com/example/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
// src/main/java/com/example/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
// src/main/java/com/example/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
// src/main/java/com/example/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. 生产避坑 ​

  1. 只靠 prompt 说「不知道就说不知道」是不够的。必须在检索层做硬拒答:分数不达标就直接不调用生成模型。这既更可靠,又省钱。把拒答做成机制,而不是请求。
  2. 评测集必须包含「无答案」的查询。全是能答对的评测集会让你的拒绝机制从未被验证,上线后第一次遇到无答案查询就翻车。建议:评测集中「无答案」占比 20%~30%。
  3. 拒答不要只说「不知道」。给出最接近的候选和下一步建议,把失败变成有用。同时记录所有拒答日志——这些是知识库补漏的最佳来源:用户问了但答不上,说明该补文档了。

8. 延伸与锚点 ​