Spring AI 接 RAG 最小 Demo:DeepSeek、Ollama、SimpleVectorStore 一次跑通

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-flashdeepseek-v4-pro。旧的 deepseek-chatdeepseek-reasoner 仍然兼容,但官方已经标注后续会废弃。

另外,V4 模型默认是 thinking mode。官方文档说明,thinking mode 下 temperaturetop_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,需要保留 queryquestion_answer_context 两个占位符。

4. 模型基于资料回答

模型看到资料后,就能回答:

试用期员工不享受年假,只能申请事假或病假。

它不是"学会"了制度。

只是这次请求里,它看到了制度。


五、生产环境先别急着堆功能

Demo 跑通后,很容易马上想加 rerank、query rewrite、hybrid search。

可以,但别一上来就堆。

RAG 的效果,先看基础链路稳不稳。

1. topK 不要开太大

topK 不是越大越好。

检索太多,上下文会变长,噪音也会变多。

模型看到一堆弱相关 chunk,反而容易答偏。

建议先从 topK=3topK=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 实战有术」,并且包含对应的示例代码。

相关推荐
ClouGence3 小时前
Oracle CDC 架构优化:从主库直连到 DataGuard 备库同步
数据库·后端·oracle
Gopher_HBo3 小时前
Go语言学习笔记(十二)Tcp实现Rpc
后端
糖拌西瓜皮4 小时前
Java开发者视角:深入理解Node.js异步编程模型
java·后端·node.js
雪隐4 小时前
个人电脑玩AI-04让5060 Ti给你打工——本地claude code编程助理
人工智能·后端
AskHarries4 小时前
Browser Tool:网页打开、点击、输入、截图和验证
后端
程序员cxuan4 小时前
分享一下我最近常用的 10 个 Codex 小技巧。
人工智能·后端·程序员
一线大码4 小时前
Smart-Doc 的简单使用
java·后端·restful
喵个咪4 小时前
技术复盘:基于 go-wind-cms 的官网+商城双业务渐进拆分实战
后端·架构·go
ZengLiangYi4 小时前
批量导入 1000 条对话的性能优化实战
javascript·后端·架构