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. QuestionAnswerAdvisor:Controller 零 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_HOME="C:\Program Files\Java\jdk-17"mvn 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-RestMethod"http://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){// 问答顾问:自动拿问题去这个向量库检索,命中片段拼进提示词QuestionAnswerAdvisorqaAdvisor=QuestionAnswerAdvisor.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 走完:加载→切分→嵌入→入库。 * ApplicationRunner:Spring Boot 起来后自动跑一次 run(),适合初始化数据。 */@ComponentpublicclassFaqDataLoaderimplementsApplicationRunner{privatefinalVectorStorevectorStore;publicFaqDataLoader(VectorStorevectorStore){this.vectorStore=vectorStore;}@Overridepublicvoidrun(ApplicationArgumentsargs){// ① 加载:Tika 是"万能扫描仪",md/pdf/docx 都能抽成纯文本FileSystemResourcefaqFile=newFileSystemResource("docs/faq.md");if(!faqFile.exists()){thrownewIllegalStateException("找不到 docs/faq.md,请在 lesson-08 根目录下启动");}TikaDocumentReaderreader=newTikaDocumentReader(faqFile);List<Document>rawDocs=reader.get();System.out.println("========== ① 加载完成:读出 "+rawDocs.size()+" 个原始文档 ==========");// ② 切分:每块约 250 token,丢掉小于 60 字符的碎块TokenTextSplittersplitter=TokenTextSplitter.builder().withChunkSize(250)// 每块最大 token 数(RAG 调参第一课).withMinChunkSizeChars(60)// 太碎的小尾巴丢弃.build();List<Document>chunks=splitter.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.ragChatClient=ragChatClient;this.vectorStore=vectorStore;}@GetMapping("/ask")publicMap<String,Object>ask(@RequestParam("q")Stringq){// 这行背后已自动"检索+拼资料+调模型",看起来和第 1 课一模一样Stringanswer=ragChatClient.prompt().user(q).call().content();// 故意再查一次向量库,把命中的原文片段当"引用"返回——引用溯源(citation)List<Document>hits=vectorStore.similaritySearch(SearchRequest.builder().query(q).topK(2).build());List<Map<String,String>>references=newArrayList<>();if(hits!=null){for(Documentdoc:hits){Map<String,String>ref=newLinkedHashMap<>();ref.put("content",doc.getText());// 命中的知识片段原文references.add(ref);}}Map<String,Object>result=newLinkedHashMap<>();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 resource | hf-mirror 下 model.onnx+tokenizer.json 到 models/,yml 指向 file:./models/ |
| 嵌入属性前缀写错 | 不报错但仍走远程下载 | 1.0.9 用spring.ai.embedding.transformer.* |
| chunk 切太大 | 问什么都把全文塞给模型 | withChunkSize 调到 250,小块检索才准 |
| PS 5.1 抓 JSON 中文乱码 | Invoke-RestMethod 拿到 mojibake | WebClient 拿字节流再 UTF8.GetString |
| JAVA_HOME 指向 JDK8 | mvn 跑在 Java 8 | 构建前$env:JAVA_HOME="C:\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 接口)
系列文章一览(按发布顺序):
| 篇 | 主题 |
|---|---|
| 1 | Spring AI 初体验:配好 yml 就能聊,ChatClient 四步链式调用 |
| 2 | Spring AI 提示词模板:{变量} 参数化 + few-shot,一条提示词反复用 |
| 3 | Spring AI 结构化输出:entity() 把模型回答解析成 JavaBean,别再手撕 JSON |
| 4 | Spring AI 工具调用:@Tool 让大模型自己查订单查库存 |
| 5 | Spring AI 流式输出:Flux + SSE 打字机,回答不再干等三秒 |
| 6 | Spring AI 多模态:给大模型一双眼睛,图片它也能看懂 |
| 7 | Spring AI 向量检索:本地 ONNX 嵌入,文本秒变坐标,知识库零成本起步 |
| 8 | Spring AI RAG 问答助手:回答带引用,AI 不再睁眼说瞎话 |
| 9 | Spring AI Advisor 编排:记忆 + 工具 + RAG 三合一,一个接口全搞定 |
| 10 | Spring AI 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,十课收官 |
下一篇预告:《Spring AI Advisor 编排:记忆 + 工具 + RAG 三合一,一个接口全搞定》——本课你只挂了一道"资料安检",下一课把它扩成一条完整通道:记忆、查订单、知识库全部串进一个接口,模型还记得你上句话叫什么。
跑完有任何报错,把终端输出发评论区,一起排查。你问文档外的问题时,它真的老实说不知道了吗?
标签建议:SpringAI、RAG、大模型幻觉
摘要建议(≤256 字):大模型最招人恨的就是"一本正经地编"。本文用 Spring AI 的 QuestionAnswerAdvisor 跑通一个带引用出处的知识库问答助手:Tika 读 FAQ、切分、本地 ONNX 嵌入入库,回答自动带命中片段,文档外问题老实走兜底。逐行拆解四个类与实测输出,附切分调参等 3 道挑战题和面试模板,源码在 gitee lesson-08 可 clone 直接跑。