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') # 期望 True
Get-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 SUCCESS
mvn 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 = 8100
spring:
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.json
cache:
enabled: false # 模型在本地,关远程缓存
两个点值得盯:temperature: 0.0 是 RAG 事实问答的灵魂;spring.ai.embedding.transformer.* 是 1.0.9 的真实前缀,写错不报错、只是不生效(坑见第八节)。
第二段:RagConfig.java——把问答顾问挂到 ChatClient 上。
package com.springai.lesson08;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* RAG 装配:本地嵌入 + 内存向量库 + 问答顾问,拼成一条流水线。
*/
@Configuration
public class RagConfig {
// 向量库:内存实现 + 本地 ONNX 嵌入(生产换 Redis/PgVector 只改依赖,业务代码不动)
@Bean
public VectorStore vectorStore(EmbeddingModel embeddingModel) {
return SimpleVectorStore.builder(embeddingModel).build();
}
// 带 RAG 顾问的对话入口:以后每次提问都自动"检索→拼资料→交模型"
@Bean
public ChatClient ragChatClient(ChatClient.Builder builder, VectorStore vectorStore) {
// 问答顾问:自动拿问题去这个向量库检索,命中片段拼进提示词
QuestionAnswerAdvisor qaAdvisor = QuestionAnswerAdvisor.builder(vectorStore).build();
// 系统提示词:限定人设 + 防幻觉兜底规则
String systemPrompt = """
你是"星辰科技"的入职知识助手。
规则:
1. 只能依据参考资料回答,不要编造制度、金额、电话、日期;
2. 如果资料里没有答案,必须回答:
"这个问题知识库中暂无相关信息,建议您联系人工客服(400-800-8888)。"
3. 回答简洁,两三句话说清即可。
""";
// defaultSystem 全局限定人设;defaultAdvisors 全局挂顾问
return builder.defaultSystem(systemPrompt)
.defaultAdvisors(qaAdvisor)
.build();
}
}
看出门道了吗?Controller 一行 RAG 代码都不用写,因为检索+拼资料全被 defaultAdvisors(qaAdvisor) 这道工序承包了。
第三段:FaqDataLoader.java——启动时跑完 RAG 前四步。
package com.springai.lesson08;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.io.FileSystemResource;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 启动时自动把 docs/faq.md 走完:加载→切分→嵌入→入库。
* ApplicationRunner:Spring Boot 起来后自动跑一次 run(),适合初始化数据。
*/
@Component
public class FaqDataLoader implements ApplicationRunner {
private final VectorStore vectorStore;
public FaqDataLoader(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
@Override
public void run(ApplicationArguments args) {
// ① 加载:Tika 是"万能扫描仪",md/pdf/docx 都能抽成纯文本
FileSystemResource faqFile = new FileSystemResource("docs/faq.md");
if (!faqFile.exists()) {
throw new IllegalStateException("找不到 docs/faq.md,请在 lesson-08 根目录下启动");
}
TikaDocumentReader reader = new TikaDocumentReader(faqFile);
List<Document> rawDocs = reader.get();
System.out.println("========== ① 加载完成:读出 " + rawDocs.size() + " 个原始文档 ==========");
// ② 切分:每块约 250 token,丢掉小于 60 字符的碎块
TokenTextSplitter splitter = 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 接口,回答带引用。
package com.springai.lesson08;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* GET http://localhost:8100/ask?q=年假怎么申请
* 返回 {question, answer, references}——references 就是"引用出处"。
*/
@RestController
public class FaqAssistantController {
private final ChatClient ragChatClient; // RagConfig 里挂好顾问的那个 Client
private final VectorStore vectorStore; // 手动再查一次,只为把引用展示给用户
public FaqAssistantController(ChatClient ragChatClient, VectorStore vectorStore) {
this.ragChatClient = ragChatClient;
this.vectorStore = vectorStore;
}
@GetMapping("/ask")
public Map<String, Object> ask(@RequestParam("q") String q) {
// 这行背后已自动"检索+拼资料+调模型",看起来和第 1 课一模一样
String answer = ragChatClient.prompt().user(q).call().content();
// 故意再查一次向量库,把命中的原文片段当"引用"返回——引用溯源(citation)
List<Document> hits = vectorStore.similaritySearch(
SearchRequest.builder().query(q).topK(2).build());
List<Map<String, String>> references = new ArrayList<>();
if (hits != null) {
for (Document doc : hits) {
Map<String, String> ref = new LinkedHashMap<>();
ref.put("content", doc.getText()); // 命中的知识片段原文
references.add(ref);
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("question", q);
result.put("answer", answer);
result.put("references", references); // 用户看得见依据,不是模型瞎编
return result;
}
}
第五段:Lesson08Application.java——标准启动类。
package com.springai.lesson08;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 启动后自动建知识库,再访问 GET http://localhost:8100/ask?q=...
*/
@SpringBootApplication
public class Lesson08Application {
public static void main(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 课):gitee.com/j67mk2/spri…
- 本文对应源码位置:
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 直接跑。