
Spring AI 接 RAG 最小 Demo:DeepSeek、Ollama、SimpleVectorStore 一次跑通
做公司知识库助手时,经常会遇到一个尴尬场景。
用户问:
试用期可以请几天年假?
模型回答:
一般公司试用期员工也可以享受年假,具体天数根据入职时间计算。
听起来像那么回事,但你们公司制度明明写着:
试用期员工不享受年假,只能申请事假或病假。
模型为什么答错?
不是它不会推理,而是它根本没看到你的内部文档。
这时很多人第一反应是:要不要微调?
微调当然是一条路,但如果只是让模型回答公司制度、接口文档、操作手册这类内容,更现实的做法通常是先上 RAG。
RAG 的思路很朴素:
调模型之前,先把相关资料查出来,再让模型基于资料回答。
这篇文章不讲大而全的 RAG 架构,我们只做一件事:
用 Spring AI 跑通一个最小可用的文档问答 Demo。
一、RAG 到底解决什么问题
RAG 全称是 Retrieval-Augmented Generation,也就是"检索增强生成"。
它不是训练,也不是让模型拥有长期记忆。
它做的是两步:
text
准备阶段:
文档 -> 切分 -> 向量化 -> 存入向量库
查询阶段:
用户问题 -> 检索相似文档 -> 补进 prompt -> 模型回答
所以 RAG 不是让模型"永久记住"你的文档。
模型参数没变,能力也没变。
变的是这次请求里,多了一批可参考的资料。
这也是它和微调、Memory 的区别:
text
微调:改模型参数,让模型更适应某类任务或表达方式
Memory:带上历史对话,让模型接上前文
RAG:检索外部文档,让模型基于资料回答
RAG 最适合解决这类问题:
- 模型不知道你的私有知识;
- 文档经常变,不适合频繁训练;
- 回答需要有依据,不能只靠模型猜。
二、Spring AI 里要认识的几个组件
接 RAG 之前,先把几个核心组件捋清楚。
不用背概念,知道它们各自干什么就够了。
1. Document
Document 是 Spring AI 里的文档对象。
它不是本地的 .txt 或 .pdf 文件,而是承载文本和元数据的 Java 对象:
java
Document doc = new Document(
"这是文档内容",
Map.of("source", "leave-policy.txt")
);
后面的读取、切分、向量化、检索,基本都围绕 Document 展开。
2. DocumentReader
DocumentReader 负责把外部文件读成 Document。
常见 Reader 有:
TextReader:读纯文本;JsonReader:读 JSON;PagePdfDocumentReader:读 PDF;TikaDocumentReader:借助 Apache Tika 读取多种格式。
读一个文本文件,大概是这样:
java
Resource resource = new ClassPathResource("docs/leave-policy.txt");
TextReader reader = new TextReader(resource);
List<Document> documents = reader.get();
TextReader 实现的是 get();DocumentReader 也提供了 read() 默认方法。这里用 get()。
3. TokenTextSplitter
TokenTextSplitter 用来把长文档切成小片段。
为什么要切?
因为检索时通常不是拿整份文件去匹配,而是拿一个个 chunk 去匹配。
如果一整份制度文档都塞进去,用户问"试用期年假",检索很难精确命中那一小段。
java
TokenTextSplitter splitter = new TokenTextSplitter();
List<Document> chunks = splitter.split(documents);
Spring AI 1.1.7 里,TokenTextSplitter 默认目标 chunk size 是 800 token。先用默认值跑通,后面再根据文档类型调整。
4. VectorStore
VectorStore 是向量库抽象。
它负责两件事:
- 把文档向量化后存起来;
- 根据问题检索相似文档。
Demo 阶段可以用 SimpleVectorStore,它是内存实现,适合本地验证。
生产环境再换成 PgVector、Milvus、Redis、Elasticsearch、Chroma 等持久化向量库。
java
vectorStore.add(chunks);
List<Document> results = vectorStore.similaritySearch(
SearchRequest.builder()
.query("试用期年假")
.topK(5)
.build()
);
5. QuestionAnswerAdvisor
QuestionAnswerAdvisor 是 Spring AI 提供的基础 RAG Advisor。
它会在调用模型前,根据用户问题去 VectorStore 检索相关文档,再把这些文档补进 prompt。
java
QuestionAnswerAdvisor advisor = QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder().topK(3).build())
.build();
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(advisor)
.build();
配置好以后,业务代码还是正常问:
java
String answer = chatClient.prompt()
.user("试用期可以请几天年假?")
.call()
.content();
表面看只是问了一个问题,背后 Advisor 已经帮你完成了检索和上下文注入。
三、最小 Demo:接入一份请假制度
目标很简单:
把一份公司请假制度文档接进来,让模型能基于这份文档回答问题。
假设你已经有一个能正常调用 ChatClient 的 Spring Boot 项目。
1. 准备依赖和配置
先说结论。
这个 Demo 需要三个东西:
text
ChatModel -> 负责最终回答
EmbeddingModel -> 负责把问题和文档转成向量
VectorStore -> 负责存储和检索向量
所以只有 DeepSeek 聊天模型还不够,还要有一个 embedding 模型。
这篇用下面这个组合:
text
DeepSeek -> ChatModel
Ollama -> EmbeddingModel
SimpleVectorStore -> VectorStore
先加依赖。这里默认项目已经引入 Spring AI BOM,所以不写版本号。
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-deepseek</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-ollama</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-vector-store</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>
再配 application.yml:
yaml
spring:
ai:
model:
chat: deepseek
embedding: ollama
deepseek:
api-key: ${DEEPSEEK_API_KEY}
chat:
options:
model: deepseek-v4-flash
ollama:
base-url: http://localhost:11434
embedding:
options:
model: bge-m3
本地还要准备 Ollama。
如果你已经装过 Ollama,可以跳过安装步骤。
如果没装,先去 Ollama 官网下载桌面版;macOS 或 Linux 也可以用官方脚本:
bash
curl -fsSL https://ollama.com/install.sh | sh
启动 Ollama 后,默认服务地址是:
text
http://localhost:11434
然后拉取 embedding 模型:
bash
ollama pull bge-m3
最后准备 DeepSeek 的 API Key:
bash
export DEEPSEEK_API_KEY=你的DeepSeek API Key
这几个配置要对应上:
text
spring.ai.model.chat=deepseek
-> 用 DeepSeek 提供 ChatModel
spring.ai.model.embedding=ollama
-> 用 Ollama 提供 EmbeddingModel
spring.ai.deepseek.chat.options.model=deepseek-v4-flash
-> 用 DeepSeek 的 v4 flash 模型回答
spring.ai.ollama.embedding.options.model=bge-m3
-> 用 bge-m3 做向量化
这里用 bge-m3,主要是因为它对中文和多语言文档更友好,拿来做公司知识库 Demo 更贴近真实场景。
spring.ai.ollama.base-url 默认就是 http://localhost:11434,这里显式写出来,是为了让读者知道 Spring AI 连接的是本机 Ollama 服务。
DeepSeek 官方当前推荐使用 deepseek-v4-flash 或 deepseek-v4-pro。旧的 deepseek-chat、deepseek-reasoner 仍然兼容,但官方已经标注后续会废弃。
另外,V4 模型默认是 thinking mode。官方文档说明,thinking mode 下 temperature、top_p 这类采样参数不会生效。所以这个最小 Demo 里先不配这些参数,避免看起来配了、实际没起作用。
如果你不想用 Ollama,也可以换成 OpenAI、Azure OpenAI、智谱等支持 embedding 的模型。
原则只有一个:
RAG 必须同时有 ChatModel 和 EmbeddingModel。
如果生产环境用 PgVector,再把内存版 SimpleVectorStore 换成 PgVector starter:
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
</dependency>
这篇先用 SimpleVectorStore,不用额外启动数据库,重点是把 RAG 链路跑通。
2. 准备测试文档
在 src/main/resources/docs/ 下创建 leave-policy.txt:
text
公司请假制度
1. 试用期员工不享受年假,只能申请事假或病假。
2. 正式员工入职满一年后,每年享有 5 天年假。
3. 病假需要提交医院开具的病假证明。
4. 请假超过 3 天,需要直属主管和部门负责人双审批。
5. 年假需要提前 5 个工作日提交申请。
这就是我们的私有知识。
但准确说,不是"教给模型",而是后面提问时让模型能检索到它。
3. 读取、切分、写入向量库
创建一个配置类:
java
@Configuration
public class RagConfig {
@Bean
public VectorStore vectorStore(EmbeddingModel embeddingModel) {
return SimpleVectorStore.builder(embeddingModel).build();
}
@Bean
public ApplicationRunner loadDocumentsToVectorStore(VectorStore vectorStore) {
return args -> {
List<Document> chunks = loadAndSplitDocuments();
vectorStore.add(chunks);
System.out.println("已加载 " + chunks.size() + " 个文档片段到向量库");
};
}
private List<Document> loadAndSplitDocuments() {
Resource resource = new ClassPathResource("docs/leave-policy.txt");
TextReader reader = new TextReader(resource);
List<Document> documents = reader.get();
TokenTextSplitter splitter = new TokenTextSplitter();
return splitter.split(documents);
}
}
这段代码做了三件事:
- 创建
SimpleVectorStore; - 应用启动时读取并切分文档;
- 把切分后的 chunk 写入向量库。
写入时,VectorStore 会通过 EmbeddingModel 把文本转成向量。
SimpleVectorStore 主要用于测试和 Demo。它有保存/加载能力,但默认就是内存向量库,生产环境不要把它当成正式方案。
4. 配置 QuestionAnswerAdvisor
再把 RAG 能力接到 ChatClient:
java
@Configuration
public class ChatClientConfig {
@Bean
public ChatClient chatClient(
ChatModel chatModel,
VectorStore vectorStore) {
QuestionAnswerAdvisor advisor = QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder()
.topK(3)
.build())
.build();
return ChatClient.builder(chatModel)
.defaultAdvisors(advisor)
.build();
}
}
topK(3) 表示每次最多取 3 个相似文档。
真实项目里不要一开始就调很大,先从 3 或 5 开始观察效果。
5. 写个接口测试
java
@RestController
public class KnowledgeController {
private final ChatClient chatClient;
public KnowledgeController(ChatClient chatClient) {
this.chatClient = chatClient;
}
@GetMapping("/ask")
public String ask(@RequestParam String question) {
return chatClient.prompt()
.user(question)
.call()
.content();
}
}
启动应用后访问:
text
http://localhost:8080/ask?question=试用期可以请几天年假?
理想情况下,模型会回答:
试用期员工不享受年假,只能申请事假或病假。
再问:
text
http://localhost:8080/ask?question=请假超过3天需要谁审批?
它应该能答出:
请假超过 3 天,需要直属主管和部门负责人双审批。
这两个答案都来自 leave-policy.txt。
如果去掉 QuestionAnswerAdvisor,模型大概率会回到通用回答,而不是你公司的具体规定。
四、一次 RAG 调用背后发生了什么
业务代码看起来只有一行:
java
chatClient.prompt()
.user("试用期可以请几天年假?")
.call()
.content();
但真正执行时,多了几步。
1. Advisor 拿到用户问题
QuestionAnswerAdvisor 会先拿到当前问题:
text
试用期可以请几天年假?
2. 去 VectorStore 检索
它会用这个问题构造 SearchRequest,然后去向量库找相似文档。
java
vectorStore.similaritySearch(
SearchRequest.builder()
.query("试用期可以请几天年假?")
.topK(3)
.build()
);
对 SimpleVectorStore 来说,它会调用 EmbeddingModel 把问题转成向量,再和库里的文档向量做相似度计算。
可能返回:
text
chunk 1:试用期员工不享受年假规则
chunk 2:正式员工年假规则
chunk 3:年假申请流程
3. 把文档补进 prompt
Advisor 会把检索到的文档内容拼成上下文,再增强这次用户问题。
大概意思是:
text
用户问题:
试用期可以请几天年假?
参考资料:
试用期员工不享受年假,只能申请事假或病假。
正式员工入职满一年后,每年享有 5 天年假。
年假需要提前 5 个工作日提交申请。
请基于参考资料回答。
真实默认模板可以自定义。Spring AI 1.1.7 里,如果你要自定义 QuestionAnswerAdvisor 的 prompt template,需要保留 query 和 question_answer_context 两个占位符。
4. 模型基于资料回答
模型看到资料后,就能回答:
试用期员工不享受年假,只能申请事假或病假。
它不是"学会"了制度。
只是这次请求里,它看到了制度。
五、生产环境先别急着堆功能
Demo 跑通后,很容易马上想加 rerank、query rewrite、hybrid search。
可以,但别一上来就堆。
RAG 的效果,先看基础链路稳不稳。
1. topK 不要开太大
topK 不是越大越好。
检索太多,上下文会变长,噪音也会变多。
模型看到一堆弱相关 chunk,反而容易答偏。
建议先从 topK=3 或 topK=5 开始。
2. chunk size 要跟文档类型走
TokenTextSplitter 默认目标 chunk size 是 800 token,但这不是最佳答案。
FAQ、短问答可以更小。
流程说明、接口文档可以稍大一点,避免把完整语义切断。
核心原则是:
一个 chunk 最好表达一个相对完整的意思。
3. SimpleVectorStore 不适合生产
SimpleVectorStore 适合 Demo、测试、样例。
生产环境建议换成持久化向量库,比如 PgVector、Milvus、Redis、Elasticsearch 或 Chroma。
怎么选?
先选团队最熟、最容易运维的。
不要为了"看起来高级"引入一套没人会维护的基础设施。
4. 一定要看检索日志
RAG 优化不能只靠感觉。
至少要记录:
- 用户问了什么;
- 检索到了哪些 chunk;
- 相似度大概是多少;
- chunk 是按什么顺序塞进 prompt 的;
- 最终回答有没有用上关键资料。
如果回答错了,你要先判断:
是没检索到?
还是检索到了但排序靠后?
还是文档本身写得不清楚?
没有日志,这些都只能猜。
5. 文档质量比算法更重要
不是所有公司文档都适合直接丢进 RAG。
优先接这些:
- 制度文档;
- FAQ;
- 接口文档;
- 操作手册;
- 故障处理手册。
这些内容结构清楚、信息密度高,检索效果通常更好。
会议纪要、聊天记录、口语化总结这类内容,先别急着放进去。
RAG 不能把烂文档变成好答案。
写在最后
Spring AI 接 RAG,基础链路可以记成这条线:
text
DocumentReader
-> TokenTextSplitter
-> VectorStore
-> QuestionAnswerAdvisor
-> ChatClient
跑通 Demo 不难。
真正难的是后面的优化:
- 文档要写清楚;
- chunk 要切得合理;
- topK 和相似度阈值要调;
- 检索日志要能看;
- 生产环境要换持久化向量库。
我的建议是:
text
先跑通最小 Demo
-> 接入一批真实文档
-> 看检索日志
-> 调 chunk 和 topK
-> 再考虑 rerank、query rewrite、hybrid search
RAG 不是堆功能。
它的核心就一句话:
找到正确资料,并让模型基于资料回答。
先把这条链路打稳,再谈复杂优化。
我是 Dilee,11 年 Java 老兵,专注 AI 落地应用。
完整系列也会同步整理在公众号「AI Agent 实战有术」,并且包含对应的示例代码。