开发实践 / Spring AI 实战

Spring AI 2.0:让 RAG 回答带上资料来源

让 RAG 回答带上资料编号、文件名与页码,检查无效引用,同时保留对原文条件的核对。

大家好,我是一言,继续更新 Spring AI 2.0 系列。

前几篇解决了怎么找资料。到了实际使用,用户还会追问:这句话依据哪份文档?

微软的开源 RAG 示例把这种体验做得很直观:答案旁有引用编号,下方的 Citations 列出 PDF 文件和页码。看图中第一条回答,依据指向 Benefit_Options.pdf 第 3 页。

微软 Azure Samples 官方示例:答案中的编号对应下方 PDF 来源与页码;图下半部分为移动端和配置界面。© 2023 Azure Samples,MIT License
微软 Azure Samples 官方示例:答案中的编号对应下方 PDF 来源与页码;图下半部分为移动端和配置界面。© 2023 Azure Samples,MIT License

这篇把答案和来源一起返回:模型选择引用编号,Java 校验编号,再补上文件名、章节和原文。用户就能顺着引用核对回答。

我们先实现后端的来源回填,暂不做图中的 PDF 跳页和逐句引用。

先看实际返回什么#

问“电子发票的抬头写错了怎么办?”,本地模型返回:

json
{
  "answer": "电子发票的抬头写错了,应先申请红冲原发票,再按正确抬头重新开具。",
  "citationIds": ["S1"]
}

Java 根据 S1 找到本次检索的资料,补出:

json
{
  "id": "S1",
  "file": "invoice.md",
  "section": "修改抬头",
  "text": "电子发票抬头写错后,应先申请红冲原发票,再按正确抬头重新开具。"
}

文件名和原文取自后端保存的资料,模型只负责选编号。页面可以把来源做成展开项,查看对应章节和原文。

引用处理流程:检索结果编号,模型返回编号,Java 校验后补出真实来源;编号映射和校验为业务代码
引用处理流程:检索结果编号,模型返回编号,Java 校验后补出真实来源;编号映射和校验为业务代码

给本次检索结果编号#

准备四段演示资料,分别记录银行卡退款、银行卡注销、定制退货和发票抬头修改。每段保留来源标签:

java
Document.builder()
        .id("invoice-title")
        .text("电子发票抬头写错后,应先申请红冲原发票,再按正确抬头重新开具。")
        .metadata("source", "invoice.md")
        .metadata("section", "修改抬头")
        .build();

检索取 Top 2,再给它们分配当前请求的编号:

java
var sources = new LinkedHashMap<String, Document>();
for (int i = 0; i < retrieved.size(); i++) {
    sources.put("S" + (i + 1), retrieved.get(i));
}

发给模型的上下文按下面的格式组织:

text
[S1]
第一份检索资料的原文

[S2]
第二份检索资料的原文

S1 只在当前请求里有效。下一次提问的 S1 可能对应另一份资料,校验时要保留本次映射。

用 Java 对象接住引用#

复用前面讲过的 Structured Output:

Spring AI 官方 Structured Output 流程图:调用前提供格式说明,调用后把文本转换为结构化对象;Apache-2.0
Spring AI 官方 Structured Output 流程图:调用前提供格式说明,调用后把文本转换为结构化对象;Apache-2.0

图左侧对应 getFormat(),告诉模型按什么格式返回;右侧对应 convert(),把返回文本接成下面的 Draft 对象。

java
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 提供模型调用和对象转换。引用协议、编号映射和校验由我们实现,转换成功也不代表编号合法。

编号先校验,再返回#

校验的核心是:引用必须出现在本次候选中。

java
for (String id : draft.citationIds()) {
    Document doc = sources.get(id);
    if (doc == null) {
        throw new IllegalArgumentException("UNKNOWN_CITATION: " + id);
    }
    // 从 doc 取文件名、章节和原文,组装 sources 返回
}

完整实现还检查空答案、空引用,并去重。资料不足时模型可以返回空引用,这版示例会拦下该结果;产品中可另设“资料不足”状态。

我用固定输入验证了三个异常场景:

text
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,结果为:

text
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

其中四项检查引用解析与异常,另一项执行三道题的真实模型对照。引用让答案有据可查,无法自动保证每句话都正确。

公众号同名

微信搜索「雷一言说」

关注公众号,继续看我的文章。

手机阅读时,可以保存二维码后在微信中识别。

雷一言说微信公众号二维码
扫码关注公众号