Java开发者AI转型第二十五课!Spring AI 个人知识库实战(四)——RAG来源追溯落地,拒绝AI幻觉
大家好我是直奔標杆专注Java开发者AI转型干货分享从零基础到实战落地和大家一起稳步进阶今天带来《Spring AI 零基础到实战》系列的第二十五课也是个人知识库实战的第四篇——RAG的来源追溯帮大家解决AI回答“无凭无据”的核心痛点回顾上一节课第二十四课我们已经基于Spring AI内置组件解耦了RAG检索链路还通过SSE与响应式编程实现了多轮流式对话接口相信很多小伙伴已经把基础功能跑通了。但做过企业级产品的朋友都知道demo能跑通不代表能落地其中一个关键问题就是AI的回答没有“依据”。举个很实际的例子当用户在知识库中询问“年假与调休的合并规则”大模型能输出一套完整的解答但用户凭什么相信这是公司规定的原话而不是大模型基于概率“瞎编”的幻觉这也是企业级RAG与个人demo的核心区别之一——来源可追溯Citations。所谓来源追溯就是在AI回答的末尾添加类似[1]、[2]的上标点击就能跳转到对应的原始文档让AI的每一句话都有迹可循、有据可查。本节课我们就摒弃纯文本提取流深入ChatResponse底层数据结构从SSE数据流中剥离RAG命中文档的元数据真正实现“字字有出处句句有回音”一起把知识库做得更专业、更靠谱本节学习目标建议收藏对照实操底层透视吃透ChatResponse的数据结构搞懂RAG检索到的文档是如何被Spring AI框架挂载的打破“黑盒”认知末端帧劫持放弃便捷但不灵活的字符串流封装掌握FluxChatResponse对象流的高阶转换技巧掌控数据流主动权架构契约在SSE协议生命周期的末端EOF优雅追加JSON格式元数据制定前后端联调标准筑牢溯源防线。核心原理大模型响应元数据Metadata的作用很多小伙伴可能会疑惑RAG检索到的文档用完之后就丢了吗其实不然——当QuestionAnswerAdvisor从向量库中检索出高相关度的文档切片比如4块并喂给大模型时会将这些文档包含我们入库时添加的source_filename等标签打包成“装箱单”附着在大模型的最终响应体中。简单来说这些元数据就是AI回答的“身份证”里面包含了回答所参考的原始文档名称、页码等关键信息我们要做的就是把这些信息提取出来以规范的格式返回给前端。实操核心重构ChatServiceImpl提取引文信息上一节课我们为了快速跑通流式响应使用了chatClient.prompt()....stream().content()这个语法糖虽然简单但代价很大——它会底层提取字符串直接丢弃包含参考文献、Token消耗等关键信息的ChatResponse对象。要实现来源追溯必须舍弃这个便捷的语法糖改用.chatResponse()获取原始的FluxChatResponse流手动拦截、拆解数据流。这里给大家明确我们的设计思路前后端配合更顺畅流的前段只推送大模型生成的正文比如data: 调休相关规定为...保持纯净不影响前端打字机效果流的末尾EOF当大模型推理结束后追加一段用特殊标记包裹的JSON数据格式示例[CITATIONS_START]{sources:[手册.pdf]}[CITATIONS_END]前端检测到标记后即可解析并渲染为引用卡片。下面直接上核心代码ChatServiceImpl.java重构关键步骤都加了注释大家可以直接复制实操遇到问题欢迎在评论区交流public class ChatServiceImpl implements ChatService { /** 1. 制定前后端溯源契约约定特殊标记避免解析冲突 */ private static final String CITATIONS_START [CITATIONS_START]; private static final String CITATIONS_END [CITATIONS_END]; public FluxString streamChatWithCitations(String chatId, String message) { // 核心修改获取原始ChatResponse流并添加缓存避免重复调用大模型 FluxChatResponse responseFlux this.chatClient.prompt() // 此处省略上节课的prompt构建逻辑保持不变 // 切换为chatResponse()获取完整响应对象而非仅字符串 .chatResponse() .cache(); // 2. 提取大模型生成的正文流用于前端打字机展示 FluxString textFlux responseFlux.map(chatClientResponse - { return chatClientResponse.chatResponse().getResult().getOutput().getText(); }); // 3. 提取元数据在流末尾追加溯源信息 FluxString citationsFlux responseFlux.last() .mapNotNull(this::extractCitationsFromResponse) // 提取溯源信息 .filter(c - !c.isEmpty()) // 过滤空数据避免无效推送 .flux(); // 合并正文流和溯源流先后推送给前端 return Flux.concat(textFlux, citationsFlux); } /** * 核心工具方法从ChatResponse元数据中提取RAG命中文档的来源信息 * 关键说明QuestionAnswerAdvisor会在流结束时将检索到的Document列表挂载到response的Metadata中 * 挂载的key固定为QuestionAnswerAdvisor.RETRIEVED_DOCUMENTS */ private String extractCitationsFromResponse(ChatClientResponse chatClientResponse) { // 1. 从响应上下文获取RAG检索到的文档列表 Object documentsObj chatClientResponse.context() .get(QuestionAnswerAdvisor.RETRIEVED_DOCUMENTS); // 此处省略非空判断实际项目中需添加避免空指针 ListObject docs (ListObject) documentsObj; // 2. 按文件名聚合页码保证页码有序、去重用TreeSet实现 MapString, TreeSetObjectgt; filePageMap new LinkedHashMap(); for (Object obj : docs) { if (obj instanceof Document doc) { // 获取文档文件名入库时注入的source_filename标签 String filename (String) doc.getMetadata().get(source_filename); if (filename null) continue; // 跳过无文件名的文档 // 不存在则创建新的TreeSet保证页码有序 filePageMap.computeIfAbsent(filename, k - new TreeSet()); // 获取页码仅PDF文件有由PagePdfDocumentReader注入 Object pageObj doc.getMetadata().get(page_number); if (pageObj ! null) { filePageMap.get(filename).add(pageObj); } } } // 3. 构造结构化溯源数据方便前端解析渲染 ListMapString, Object sources new ArrayList(); for (Map.EntryString, TreeSetObject entry : filePageMap.entrySet()) { MapString, Object item new LinkedHashMap(); item.put(file, entry.getKey()); // 文档文件名 item.put(pages, new ArrayList(entry.getValue())); // 页码列表无页码则为空 sources.add(item); } // 4. 转换为JSON格式并用约定标记包裹返回给前端 try { String jsonStr objectMapper.writeValueAsString(Map.of(sources, sources)); return CITATIONS_START jsonStr CITATIONS_END; } catch (JsonProcessingException e) { log.error(溯源信息JSON序列化失败, e); return ; } } }底层细节剖析避坑关键必看很多小伙伴实操时会遇到“提取不到元数据”的问题核心原因是没搞懂Spring AI的底层流转逻辑这里给大家拆解清楚避免踩坑Spring AI框架的设计非常克制在SSE流式响应过程中前面推送的数百个数据包只包含大模型生成的零散文本元数据是不完整的。只有当框架检测到FinishReason即大模型宣告推理完毕时才会将完整的请求生命周期报告包含Token消耗、RAG检索结果等统一塞进Metadata中相当于“最后补送的装箱单”。我们代码中使用的responseFlux.last()就是专门获取这个“末端帧”从而提取到完整的溯源信息——这就是“末端帧劫持”的核心逻辑。大家可以查看org.springframework.ai.chat.client.advisor.api.BaseAdvisor的源码更直观理解这个过程关键片段如下default FluxChatClientResponse adviseStream() { return chatClientResponseFlux.map((response) - { // 检测大模型推理是否完成完成则调用after方法追加扩展内容即Metadata if (AdvisorUtils.onFinishReason().test(response)) { response this.after(response, streamAdvisorChain); } return response; }).onErrorResume((error) - Flux.error(new IllegalStateException(Stream processing failed, error))); }实操验证启动服务测试溯源效果好在我们上一节课构建的ChatController具备良好的解耦性这一步无需修改HTTP通信层逻辑直接启动Spring Boot服务用浏览器访问测试地址即可测试地址http://localhost:8080/api/chat/stream?chatId春风不晚messagejava开发手册modeldeepseek【预期效果】浏览器中会先看到AI的打字机流式输出在数据流的最后会追加我们封装的溯源信息格式如下data: 按照提供的文档信息 ... data:[CITATIONS_START]{sources:[{file:阿里巴巴Java开发手册(终极版).pdf,pages:[1,7]}]}[CITATIONS_END]前端开发小伙伴只需添加一行正则匹配解析[CITATIONS_START]和[CITATIONS_END]之间的JSON数据就能在对话下方渲染出类似“[引用来源阿里巴巴Java开发手册(终极版).pdf]”的标签点击即可跳转原始文档——这样一来AI回答的可解释性和商业公信力直接拉满重点避坑.cache()的作用防止重复计费这里有一个非常关键的细节也是很多小伙伴容易忽略的点——我们在获取responseFlux时添加了.cache()操作符这可不是多余的而是能帮大家省成本、提速度的关键先给大家讲清楚原理Flux是“冷流”每订阅一次就会重新执行整个链路。我们的代码中需要两次消费这个流第一次消费将流转换成SSE格式推送给前端实现打字机效果第二次消费等流结束后提取元数据追加溯源标记。如果不加.cache()两次消费会触发两次大模型调用不仅响应变慢还会产生双倍费用真实项目中一定要注意避免浪费加了.cache()第一次调用大模型获取的流数据会被缓存第二次消费直接从内存中读取不会重复调用大模型既省成本又提效。延伸提示在Spring AI结合WebFlux的开发中只要涉及“流式响应给用户”“后台处理完整内容”比如存库、敏感词审核就必须加.cache()这是实战中总结的高频避坑点本节课总结一起复盘加深记忆本节课我们跳出了“傻瓜式语法糖”的舒适区完成了一次底层架构的深度实操1. 舍弃.stream().content()通过.chatResponse()获取原始FluxChatResponse流掌控流式响应的微观生命周期2. 利用responseFlux.last()拦截SSE末端帧提取RAG命中文档的元数据实现来源追溯3. 制定前后端溯源契约通过特殊标记包裹JSON数据实现无缝联调4. 掌握.cache()的核心用法避免重复调用大模型降低成本、提升响应速度。其实做AI知识库“靠谱”比“花哨”更重要来源追溯就是让知识库靠谱的核心环节——打破AI幻觉让每一句回答都有凭有据这才是企业级产品该有的样子。下期预告提前剧透敬请期待【第二十六课Spring AI 个人知识库实战五——增强联网搜索能力】到目前为止我们的本地知识库RAG主线已经全部通关但它依然是一座“数据孤岛”如果用户问“今天的北京天气”本地Redis向量库中没有相关数据AI只能回复“不知道”。一个智能的知识库绝不能被禁锢在本地下一节课我们将给ChatClient注入最后的灵魂——大模型函数调用Function Calling让AI在本地找不到答案时自动唤醒外部工具比如联网搜索引擎、实时天气API拥有主动探索真实世界的能力跟着直奔標杆一步一个脚印把Spring AI实战落地咱们下节课见往期内容回顾连贯学习不迷路Java开发者AI转型第二十二课Spring AI 个人知识库实战一——架构搭建与核心契约落地Java开发者AI转型第二十三课Spring AI个人知识库实战二异步ETL流水线搭建与避坑指南Java开发者AI转型第二十四课Spring AI 个人知识库实战三——记忆交互SSE流式响应落地我是直奔標杆专注Java开发者AI转型干货分享每一节课都贴合实战、拒绝空谈。大家在实操过程中遇到任何问题欢迎在评论区留言交流一起学习、一起进步早日实现AI转型目标