Spring AI RAG 问答助手:回答带引用,AI 不再睁眼说瞎话
发布时间:2026/10/15 2:54:56 锦皓数字建站

Spring AI RAG 问答助手回答带引用AI 不再睁眼说瞎话作者鱼宵 Spring AI 实战精通营 · 第 8 篇上周帮朋友公司搭了个内部问答机器人把员工手册喂进去问年假怎么请它张口就来——我低头一对手册它说年假 10 天起手册里白纸黑字写的是入职满 1 年享 5 天。要命的是它语气特别自信跟真的一样。这就是大模型的老毛病记性不好但文笔太好了不知道的事它也敢一本正经地编行话叫幻觉。怎么治这节课我们不给它闭卷背书改成开卷考试——回答之前先从你的知识库里把相关资料捞出来压在问题下面让它照着答。代码全在仓库的lesson-08/目录里clone 下来照着命令跑一遍你会看到两件事问文档里有的回答和手册逐字对得上问文档里没有的它居然老老实实说不知道请联系人工。一、核心原理给记性不好的实习生开卷考试一句话RAG检索增强生成 回答前先检索你的知识库把命中的资料拼进提示词让模型照着资料答而不是凭记忆瞎编。1. RAG 五步流水线别被英文名唬住拆成五步就是步骤干什么类比开卷考试① 加载 Load把文件读成纯文本把《入职 FAQ》拿到考场② 切分 Split按块切成小片段把书按章节折好角③ 嵌入 Embed每块文本变成向量坐标给每章做张语义地图坐标④ 入库 Store向量存进向量库坐标索引好方便秒查⑤ 检索增强提问时捞最像的几块喂给模型考试先翻到相关那几页压在题下面答前四步启动时做一次把书备好第五步每次提问时实时发生。2. 为什么非得切分整块文档不行吗很多人第一反应我把整个 FAQ 文件扔进去不就行了不行。检索是按块找相似6 条 FAQ 存成一整块你问年假它就把 6 条全塞给模型——又贵还稀释注意力。切成小块后年假这个问题能精确命中年假那一块报销、社保那些不相干的就不带进来了。chunkSize每块多大就是 RAG 调参的第一课后面挑战题你亲手试。3. QuestionAnswerAdvisorController 零 RAG 代码的秘密Advisor顾问是夹在用户提问和发给模型之间的一道工序。QuestionAnswerAdvisor这道工序专门干一件事拿你的问题去向量库检索把命中的片段自动拼进提示词。类比机场安检裸 ChatClient 是直接登机挂上 Advisor 像过安检通道你的问题在交给模型前先被资料台塞了两张参考页进去。Controller 里那行代码和第 1 课长得一模一样但回答已经开卷了。4. 压幻觉两件套幻觉就是模型一本正经地编。本课两招一起上检索增强给它真资料让它有据可依系统提示词 低温yml 里temperature: 0.0不让它自由发挥提示词里写死资料里没有就说不知道。敢说不知道比乱答强一百倍——第四节你会亲眼看到它怎么拒答。二、动手十分钟跑起来环境JDK 17 Maven 3.9环境变量DEEPSEEK_API_KEY已配。本课端口8100。第 1 步检查环境。java-version# 期望 True[bool][Environment]::GetEnvironmentVariable(DEEPSEEK_API_KEY)# 期望 TrueGet-NetTCPConnection-LocalPort 8100-State Listen-ErrorAction SilentlyContinue# 无输出空闲第 2 步编译 启动。注意要在 lesson-08 目录下启动知识库文件是相对路径读的cd spring-ai-journey\lesson-08$env:JAVA_HOMEC:\Program Files\Java\jdk-17mvn clean install-DskipTests# 看到 BUILD SUCCESSmvn spring-boot:run# 看到 Tomcat started on port 8100 即成功第 3 步提问中文先 URL 编码。[Console]::OutputEncoding[System.Text.Encoding]::GetEncoding(936)$q[uri]::EscapeDataString(年假怎么请)Invoke-RestMethodhttp://localhost:8100/ask?q$q浏览器直接开http://localhost:8100/ask?q年假怎么请也行浏览器会自动编码。三、关键代码四个类看清 RAG 怎么挂上去真正要看的就四块yml模型本地嵌入配置、RagConfig挂顾问、FaqDataLoader启动建库、FaqAssistantController/ask 接口。第一段application.yml——聊天模型在线嵌入模型本地。server:port:8100# 本课端口lesson-08 8100spring:ai:openai:base-url:${LLM_BASE_URL:https://api.deepseek.com}# 默认 DeepSeek兼容 OpenAI 协议api-key:${DEEPSEEK_API_KEY}# Key 只从环境变量读文件里没有明文chat:options:model:${LLM_MODEL:deepseek-chat}max-tokens:400# 演示控成本temperature:0.0# 事实问答低温照本宣科防自由发挥压幻觉关键embedding:transformer:onnx:model-uri:file:./models/model.onnx# 本地 ONNX 嵌入模型不上传文本tokenizer:uri:file:./models/tokenizer.jsoncache:enabled:false# 模型在本地关远程缓存两个点值得盯temperature: 0.0是 RAG 事实问答的灵魂spring.ai.embedding.transformer.*是 1.0.9 的真实前缀写错不报错、只是不生效坑见第八节。第二段RagConfig.java——把问答顾问挂到 ChatClient 上。packagecom.springai.lesson08;importorg.springframework.ai.chat.client.ChatClient;importorg.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;importorg.springframework.ai.embedding.EmbeddingModel;importorg.springframework.ai.vectorstore.SimpleVectorStore;importorg.springframework.ai.vectorstore.VectorStore;importorg.springframework.context.annotation.Bean;importorg.springframework.context.annotation.Configuration;/** * RAG 装配本地嵌入 内存向量库 问答顾问拼成一条流水线。 */ConfigurationpublicclassRagConfig{// 向量库内存实现 本地 ONNX 嵌入生产换 Redis/PgVector 只改依赖业务代码不动BeanpublicVectorStorevectorStore(EmbeddingModelembeddingModel){returnSimpleVectorStore.builder(embeddingModel).build();}// 带 RAG 顾问的对话入口以后每次提问都自动检索→拼资料→交模型BeanpublicChatClientragChatClient(ChatClient.Builderbuilder,VectorStorevectorStore){// 问答顾问自动拿问题去这个向量库检索命中片段拼进提示词QuestionAnswerAdvisorqaAdvisorQuestionAnswerAdvisor.builder(vectorStore).build();// 系统提示词限定人设 防幻觉兜底规则StringsystemPrompt 你是星辰科技的入职知识助手。 规则 1. 只能依据参考资料回答不要编造制度、金额、电话、日期 2. 如果资料里没有答案必须回答 这个问题知识库中暂无相关信息建议您联系人工客服400-800-8888。 3. 回答简洁两三句话说清即可。 ;// defaultSystem 全局限定人设defaultAdvisors 全局挂顾问returnbuilder.defaultSystem(systemPrompt).defaultAdvisors(qaAdvisor).build();}}看出门道了吗Controller 一行 RAG 代码都不用写因为检索拼资料全被defaultAdvisors(qaAdvisor)这道工序承包了。第三段FaqDataLoader.java——启动时跑完 RAG 前四步。packagecom.springai.lesson08;importorg.springframework.ai.document.Document;importorg.springframework.ai.reader.tika.TikaDocumentReader;importorg.springframework.ai.transformer.splitter.TokenTextSplitter;importorg.springframework.ai.vectorstore.VectorStore;importorg.springframework.boot.ApplicationArguments;importorg.springframework.boot.ApplicationRunner;importorg.springframework.core.io.FileSystemResource;importorg.springframework.stereotype.Component;importjava.util.List;/** * 启动时自动把 docs/faq.md 走完加载→切分→嵌入→入库。 * ApplicationRunnerSpring Boot 起来后自动跑一次 run()适合初始化数据。 */ComponentpublicclassFaqDataLoaderimplementsApplicationRunner{privatefinalVectorStorevectorStore;publicFaqDataLoader(VectorStorevectorStore){this.vectorStorevectorStore;}Overridepublicvoidrun(ApplicationArgumentsargs){// ① 加载Tika 是万能扫描仪md/pdf/docx 都能抽成纯文本FileSystemResourcefaqFilenewFileSystemResource(docs/faq.md);if(!faqFile.exists()){thrownewIllegalStateException(找不到 docs/faq.md请在 lesson-08 根目录下启动);}TikaDocumentReaderreadernewTikaDocumentReader(faqFile);ListDocumentrawDocsreader.get();System.out.println( ① 加载完成读出 rawDocs.size() 个原始文档 );// ② 切分每块约 250 token丢掉小于 60 字符的碎块TokenTextSplittersplitterTokenTextSplitter.builder().withChunkSize(250)// 每块最大 token 数RAG 调参第一课.withMinChunkSizeChars(60)// 太碎的小尾巴丢弃.build();ListDocumentchunkssplitter.apply(rawDocs);System.out.println( ② 切分完成切成 chunks.size() 个知识块 );// ③ 嵌入 ④ 入库add() 内部自动调本地 ONNX 模型文本不出本机vectorStore.add(chunks);System.out.println( ③④ 嵌入入库完成共 chunks.size() 个向量块 );}}第四段FaqAssistantController.java——/ask 接口回答带引用。packagecom.springai.lesson08;importorg.springframework.ai.chat.client.ChatClient;importorg.springframework.ai.document.Document;importorg.springframework.ai.vectorstore.SearchRequest;importorg.springframework.ai.vectorstore.VectorStore;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.RequestParam;importorg.springframework.web.bind.annotation.RestController;importjava.util.ArrayList;importjava.util.LinkedHashMap;importjava.util.List;importjava.util.Map;/** * GET http://localhost:8100/ask?q年假怎么申请 * 返回 {question, answer, references}——references 就是引用出处。 */RestControllerpublicclassFaqAssistantController{privatefinalChatClientragChatClient;// RagConfig 里挂好顾问的那个 ClientprivatefinalVectorStorevectorStore;// 手动再查一次只为把引用展示给用户publicFaqAssistantController(ChatClientragChatClient,VectorStorevectorStore){this.ragChatClientragChatClient;this.vectorStorevectorStore;}GetMapping(/ask)publicMapString,Objectask(RequestParam(q)Stringq){// 这行背后已自动检索拼资料调模型看起来和第 1 课一模一样StringanswerragChatClient.prompt().user(q).call().content();// 故意再查一次向量库把命中的原文片段当引用返回——引用溯源citationListDocumenthitsvectorStore.similaritySearch(SearchRequest.builder().query(q).topK(2).build());ListMapString,StringreferencesnewArrayList();if(hits!null){for(Documentdoc:hits){MapString,StringrefnewLinkedHashMap();ref.put(content,doc.getText());// 命中的知识片段原文references.add(ref);}}MapString,ObjectresultnewLinkedHashMap();result.put(question,q);result.put(answer,answer);result.put(references,references);// 用户看得见依据不是模型瞎编returnresult;}}第五段Lesson08Application.java——标准启动类。packagecom.springai.lesson08;importorg.springframework.boot.SpringApplication;importorg.springframework.boot.autoconfigure.SpringBootApplication;/** * 启动后自动建知识库再访问 GET http://localhost:8100/ask?q... */SpringBootApplicationpublicclassLesson08Application{publicstaticvoidmain(String[]args){SpringApplication.run(Lesson08Application.class,args);}}注意一个细节整个工程没有一句手动检索向量库再拼字符串的胶水代码——检索全靠defaultAdvisors那道顾问工序业务代码只管.user(q).call()。这就是声明式 RAG 的爽点。四、实测输出问文档里有的 vs 文档外的判若两机以下是 2026-10-05 本机真实运行DeepSeek 实测HTTP 200。先看启动日志确认 RAG 前四步跑完Tomcat started on port 8100 (http) ① 加载完成Tika 读出 1 个原始文档 ② 切分完成切成 4 个知识块chunk ③④ 嵌入入库完成知识库已就绪共 4 个向量块 整份 faq.md 被读成 1 个文档切成 4 块全部本地嵌入后入内存库。测试一问文档里有的年假回答带依据。GET http://localhost:8100/ask?q年假和请假怎么申请 { question: 年假和请假怎么申请, answer: 年假方面入职满 1 年享 5 天每多 1 年增加 1 天上限 15 天。请假需通过 OA 系统提交申请年假需提前 3 个工作日病假凭医院证明可当天申请。, references: [ { content: 入职满 1 年享 5 天年假每多 1 年增加 1 天上限 15 天。请假通过 OA 系统提交申请年假需提前 3 个工作日…… } ] }看出门道了吗回答里的5 天/每年1/上限 15/提前 3 工作日和知识库第 2 条逐字对得上references 第一段正好从入职满 1 年享 5 天年假开头——切分生效检索精确命中没把 6 条全塞进去。测试二问文档外的年会看它怎么拒答。GET http://localhost:8100/ask?q公司年会什么时候开 { question: 公司年会什么时候开, answer: 这个问题知识库中暂无相关信息建议您联系人工客服400-800-8888。 }FAQ 里根本没有年会它没有编一个12 月开出来而是老老实实说不知道、还报了人工客服电话。这就是系统提示词 低温双保险压住幻觉的现场——生产级 RAG 最该验收的一条不是它答得多漂亮而是它敢说不知道。排查提示卡住 ONNX 下载是模型文件没就位见第八节回答里全是瞎编就查 yml 的temperature和系统提示词挂没挂PS 控制台中文乱码是显示问题服务端返回是 UTF-8。五、挑战题改个参数看看会怎样⭐玩切分大小把FaqDataLoader里的withChunkSize(250)改成800重启后再问年假怎么请对比 references 的命中精度和条数——体会块太大把全书塞给模型。答案在源码FaqDataLoader.java里。⭐⭐亲手放一次幻觉把RagConfig系统提示词里第 2 条兜底规则删掉再问年会什么时候开看它开始编什么——亲眼看一次幻觉长什么样你就懂这道闸多值钱。⭐⭐换一份自己的文档把docs/faq.md换成你自己的 md哪怕 3 条重启后提问观察 references 有没有跟着变——验证知识更新就是改文件重启。六、生产环境进阶三个加分项1. 本地嵌入 数据不出本机。文档切片的向量化全在本机 ONNX 算只有最终问题发给 DeepSeek。员工手册这种敏感资料不上传这是 RAG 选本地嵌入的合规理由。2. 换生产向量库只改依赖。本课是内存版SimpleVectorStore关机即丢。生产换 Redis / PgVector / Milvus只换 pom 依赖和配置VectorStore接口不变RagConfig这段业务代码一行不动——面向接口编程的胜利。3. 加相似度阈值兜底。similaritySearch返回的文档元数据里带距离分可以做个相似度低于 0.5 就直接走人工、不打扰模型的阈值省 token 又更稳挑战题作业 5 的方向。七、面试回答模板面试官RAG 完整流水线分几步为什么要先切分再检索整块文档直接入库不行吗一句话加载→切分→嵌入→入库→检索增强五步前四步建库时一次做完第五步每次提问实时发生。展开切分是因为检索按块找相似整块 6 条 FAQ 存一起问年假会把 6 条全塞给模型又贵又稀释注意力切成小块才能精确命中。指向本课第一节 / lesson-08 的 FaqDataLoader追问QuestionAnswerAdvisor 挂上之后Controller 那行.call()背后多发生了什么它自动拿问题去向量库检索相似片段拼进提示词再发给模型。所以业务代码和普通 ChatClient 一模一样却已经是开卷考试。展开这是 Spring AI 声明式 RAG 的核心检索逻辑全收进 Advisor业务零胶水代码。指向本课 RagConfig 第三节追问大模型为什么会幻觉RAG 怎么压下去幻觉是模型凭记忆一本正经地编。三件套检索给真资料 系统提示词写死不知道就说不知道 temperature 调低到 0。敢兜底比乱答值钱第四节测试二就是现场。指向本课第四节实测八、总结表坑现象解法ONNX 模型下载超时启动报 Failed to cache the resourcehf-mirror 下 model.onnxtokenizer.json 到 models/yml 指向 file:./models/嵌入属性前缀写错不报错但仍走远程下载1.0.9 用spring.ai.embedding.transformer.*chunk 切太大问什么都把全文塞给模型withChunkSize 调到 250小块检索才准PS 5.1 抓 JSON 中文乱码Invoke-RestMethod 拿到 mojibakeWebClient 拿字节流再 UTF8.GetStringJAVA_HOME 指向 JDK8mvn 跑在 Java 8构建前$env:JAVA_HOMEC:\Program Files\Java\jdk-17启动目录不对找不到 docs/faq.md必须在 lesson-08 根目录下启动九、关于这个系列本文是「Java 后端实战精通营」系列第 8 篇原则实战驱动、由浅到深、面试向每篇文章的结论都可以亲手验证。Spring AI 实战精通营10 课https://gitee.com/j67mk2/spring-ai-journey本文对应源码位置lesson-08/内含RagConfig挂问答顾问 FaqDataLoader启动建库 FaqAssistantController带引用的 /ask 接口系列文章一览按发布顺序篇主题1Spring AI 初体验配好 yml 就能聊ChatClient 四步链式调用2Spring AI 提示词模板{变量} 参数化 few-shot一条提示词反复用3Spring AI 结构化输出entity() 把模型回答解析成 JavaBean别再手撕 JSON4Spring AI 工具调用Tool 让大模型自己查订单查库存5Spring AI 流式输出Flux SSE 打字机回答不再干等三秒6Spring AI 多模态给大模型一双眼睛图片它也能看懂7Spring AI 向量检索本地 ONNX 嵌入文本秒变坐标知识库零成本起步8Spring AI RAG 问答助手回答带引用AI 不再睁眼说瞎话9Spring AI Advisor 编排记忆 工具 RAG 三合一一个接口全搞定10Spring AI 企业智能客服RAG 工具 记忆 流式 兜底十课收官下一篇预告《Spring AI Advisor 编排记忆 工具 RAG 三合一一个接口全搞定》——本课你只挂了一道资料安检下一课把它扩成一条完整通道记忆、查订单、知识库全部串进一个接口模型还记得你上句话叫什么。跑完有任何报错把终端输出发评论区一起排查。你问文档外的问题时它真的老实说不知道了吗标签建议SpringAI、RAG、大模型幻觉摘要建议≤256 字大模型最招人恨的就是一本正经地编。本文用 Spring AI 的 QuestionAnswerAdvisor 跑通一个带引用出处的知识库问答助手Tika 读 FAQ、切分、本地 ONNX 嵌入入库回答自动带命中片段文档外问题老实走兜底。逐行拆解四个类与实测输出附切分调参等 3 道挑战题和面试模板源码在 gitee lesson-08 可 clone 直接跑。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。