大家好,我是一言,继续更新 Spring AI 2.0 系列。
前几篇解决了怎么找资料。到了实际使用,用户还会追问:这句话依据哪份文档?
微软的开源 RAG 示例把这种体验做得很直观:答案旁有引用编号,下方的 Citations 列出 PDF 文件和页码。看图中第一条回答,依据指向 Benefit_Options.pdf 第 3 页。

这篇把答案和来源一起返回:模型选择引用编号,Java 校验编号,再补上文件名、章节和原文。用户就能顺着引用核对回答。
我们先实现后端的来源回填,暂不做图中的 PDF 跳页和逐句引用。
先看实际返回什么#
问“电子发票的抬头写错了怎么办?”,本地模型返回:
{
"answer": "电子发票的抬头写错了,应先申请红冲原发票,再按正确抬头重新开具。",
"citationIds": ["S1"]
}Java 根据 S1 找到本次检索的资料,补出:
{
"id": "S1",
"file": "invoice.md",
"section": "修改抬头",
"text": "电子发票抬头写错后,应先申请红冲原发票,再按正确抬头重新开具。"
}文件名和原文取自后端保存的资料,模型只负责选编号。页面可以把来源做成展开项,查看对应章节和原文。

给本次检索结果编号#
准备四段演示资料,分别记录银行卡退款、银行卡注销、定制退货和发票抬头修改。每段保留来源标签:
Document.builder()
.id("invoice-title")
.text("电子发票抬头写错后,应先申请红冲原发票,再按正确抬头重新开具。")
.metadata("source", "invoice.md")
.metadata("section", "修改抬头")
.build();检索取 Top 2,再给它们分配当前请求的编号:
var sources = new LinkedHashMap<String, Document>();
for (int i = 0; i < retrieved.size(); i++) {
sources.put("S" + (i + 1), retrieved.get(i));
}发给模型的上下文按下面的格式组织:
[S1]
第一份检索资料的原文
[S2]
第二份检索资料的原文S1 只在当前请求里有效。下一次提问的 S1 可能对应另一份资料,校验时要保留本次映射。
用 Java 对象接住引用#
复用前面讲过的 Structured Output:

图左侧对应 getFormat(),告诉模型按什么格式返回;右侧对应 convert(),把返回文本接成下面的 Draft 对象。
record Draft(String answer, List<String> citationIds) {}
var converter = new BeanOutputConverter<>(Draft.class);
String raw = client.prompt()
.system(system)
.user("资料:\n" + context(sources)
+ "\n问题:" + question
+ "\n" + converter.getFormat())
.call()
.content();
Draft draft = converter.convert(raw);提示词要求:只引用直接支持回答的资料,citationIds 填 S1 这样的编号,不生成文件名或网址。
Spring AI 提供模型调用和对象转换。引用协议、编号映射和校验由我们实现,转换成功也不代表编号合法。
编号先校验,再返回#
校验的核心是:引用必须出现在本次候选中。
for (String id : draft.citationIds()) {
Document doc = sources.get(id);
if (doc == null) {
throw new IllegalArgumentException("UNKNOWN_CITATION: " + id);
}
// 从 doc 取文件名、章节和原文,组装 sources 返回
}完整实现还检查空答案、空引用,并去重。资料不足时模型可以返回空引用,这版示例会拦下该结果;产品中可另设“资料不足”状态。
我用固定输入验证了三个异常场景:
NEGATIVE invented=S99 rejected=true
NEGATIVE outsideRequest=invoice-title rejected=true
NEGATIVE missingCitation rejected=true即使 invoice-title 是知识库中已有的文档 ID,也不能绕过本次 S1/S2 映射直接引用。这三条是 Java 校验测试,没有让模型故意生成错误编号。
有引用,仍要看原文#
真实模型测试用了 Java 21、Spring AI 2.0.1,Qwen3-Embedding 0.6B 和 Qwen2.5 3B。向量在内存中按余弦相似度排序,三道题各调用一次回答模型。
银行卡注销这题,回答带出了人工处理要求,也返回了对应章节和原文:

| 问题 | 返回来源 | 原文核对 |
|---|---|---|
| 银行卡注销后退款多久到 | 银行卡注销章节 | 回答包含人工处理和到账限制 |
| 定制商品不想要了能否退 | 定制商品章节 | 引用有效,回答漏掉质量问题条件 |
| 发票抬头写错怎么办 | 修改抬头章节 | 回答与红冲重开规则一致 |
第二题尤其值得看:

S1 确实存在,校验会通过;原文中的“存在质量问题时”,却被回答省略了。编号校验负责拦住无效引用,原文核对才能发现这种条件遗漏。
本地执行 RUN_REAL_MODEL_TEST=true mvn test,结果为:
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS其中四项检查引用解析与异常,另一项执行三道题的真实模型对照。引用让答案有据可查,无法自动保证每句话都正确。
