我先说一下我自己的处境。团队里 Obsidian 攒了几百篇技术笔记,后来文档越来越多,从一本能翻完的手册变成了“用的时候找不到、找的时候来不及”的资料堆。于是我先用 Dify 搭了一版知识库,把 MD 文件导进去,做了切块和向量化,网页对话框里也能检索出内容来。但问题马上来了:知识库是“能搜了”,可它跟业务系统没关系。用户问一个问题,还得跑到 Dify 后台去试,回答结果也没法嵌到我们的 Java 应用里。于是我开始折腾 Spring AI RAG,把已经建好的知识库接进 Java 服务。
这篇文章就聊一件事:知识库已经在了,Java 程序员接下来还要做什么?不想停留在“演示版问答”,想把 RAG 真正嵌进项目里的朋友,可以参考我下面的完整实操。
1. 为什么知识库建好了,还差一个 RAG 的服务化封装?
知识库本身不会主动回答问题。它只是一堆被切成块、转成向量的文本。真正让知识库“活”起来的,是 RAG 这条流水线:用户提问 → 检索相关片段 → 把片段和问题一起拼进 Prompt → 交给大模型生成回答。
1.1 从“Dify 里能用”到“业务系统里能用”的差距
很多团队的现状是:知识库有了,Dify 或者 FastGPT 里的对话也通了,演示的时候效果不错。但 Java 项目要接入,问题就来了:
- Dify 的 API 能调,但每次调用都要走 HTTP,请求格式、workflow id、user 字段全是 Dify 自家定义;
- 业务系统需要把“用户提问”和“知识库检索出来的上下文”一起管理,Dify 是个黑盒,检索细节拿不到;
- 有时候需要同时查多个知识库、按部门过滤、控制不同用户的访问范围,Dify 免费版做起来很别扭。
我一开始也想图省事直接用 Dify API,但折腾到权限和检索细节时放弃了。用 Spring AI 的好处是,RAG 的每一步——Embedding、向量存储、检索、Prompt 组装——都变成 Java 代码里可以控制的组件,后面想怎么调就怎么调。
1.2 Spring AI 在 RAG 链路里的角色
Spring AI 不是一个完整的应用,它是一套“大模型应用开发框架”,对标的是 LangChain 在 Python 生态里的位置。它做 RAG 时天生有几个优势:
- 统一抽象:ChatClient、EmbeddingModel、VectorStore 都是接口,底层接 OpenAI、智谱、DeepSeek、Ollama 都可以;
- 原生 Spring 生态:事务、缓存、配置、监控,一套体系直接用,对 Java 程序员几乎没有学习成本;
- 可编程的检索流程:QuestionAnswerAdvisor 和自定义 Advisor 可以让你精准控制“拿什么上下文去问模型”。
一句话总结:知识库解决的是“内容怎么存”,Spring AI 解决的是“内容怎么被业务调用”。
2. 动手前想清楚:RAG 的四种接入路径怎么选?
网上 RAG 教程很多,但一半以上是拿 Python 写的 Notebook 演示,Java 程序员照着抄都抄不顺。我把实际可行的路径捋了一遍,按“从简单到复杂”排序:
2.1 方案对比
| 方案 | 适合场景 | 复杂度 | 可定制性 |
|---|---|---|---|
| A. 直接调 Dify/FastGPT API | 快速上线、前端 Demo、团队内部工具 | 低 | 低,检索细节拿不到 |
| B. Spring AI + SQL 模糊搜索 | 小知识库、关键词即可满足 | 低 | 中,无向量概念 |
| C. Spring AI + 向量库(推荐) | 生产级知识问答,语义检索 | 高 | 高,全链路可控 |
| D. Spring AI + 向量库 + Agent 工具调用 | 需要多个工具配合、多轮规划 | 最高 | 最高 |
我自己最终选了 C,原因很简单:我既需要语义检索的效果,又需要 Java 代码里能随时调整检索策略,D 的 Agent 那套先不做,等检索稳定后再加。
2.2 别一上来就追求“高级 RAG”
很多教程张口就是“RAG-Fusion”“Self-RAG”“Agentic RAG”,但 Java 程序员接知识库的第一步,应该先把基础链路跑通:文档 → 切块 → Embedding → 存储 → 检索 → 生成。这个链路如果没调好,加再多高级技巧也白搭。
我见过最典型的反面案例:一上来买了付费向量数据库,配了十多个 Embedding 模型候选,折腾两周发现基础检索效果还不如直接关键词搜索。先把基础 RAG 调通,再用评测集去量化“检索质量”,这才是正常顺序。
3. 环境准备:Spring AI 版本、模型和向量库选型
这里我踩过不少坑,直接说结论。
3.1 Spring AI 版本怎么选?
Spring AI 的版本变化很快,网上教程经常互相打架。截至我实操时,稳定好用的是1.0.0 GA系列。如果你用 Spring Boot 3.2 或 3.3,可以直接引入:
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>然后加核心依赖:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-vector-store-pgvector</artifactId> </dependency>这里要特别提醒:Spring AI 的 Starter 和 Spring Boot 的版本兼容性极重要。Spring Boot 3.2.x 对应 Spring AI 1.0.0 没问题,但你如果用的是 Spring Boot 3.4 或更高,可能需要升级到 Spring AI 1.1+。看官方 Release Notes 比看博客靠谱。
3.2 模型层:DeepSeek 还是智谱?
因为合规和成本原因,我生产环境没有直连 OpenAI。试了两条路线:
路线一:Spring AI + 智谱 AI
智谱 AI 是国内对 Spring AI 支持比较早的厂商,通过 OpenAI 兼容接口对接。配置如下:
spring: ai: openai: base-url: https://open.bigmodel.cn/api/paas/v4 api-key: ${ZHIPU_API_KEY} chat: options: model: glm-4-flash注意:这里虽然写的是 openai,但是 Spring AI 的设计就是通过 OpenAI 兼容协议对接各种模型服务。智谱的/api/paas/v4路径就是兼容 OpenAI 的接口,实测可用。
路线二:Spring AI + 本地部署 DeepSeek
用 Ollama 跑 deepseek-r1 或 qwen2.5 这类模型:
spring: ai: ollama: base-url: http://localhost:11434 chat: options: model: deepseek-r1:14b本地模型的优势是数据不出内网,缺点是 14B 模型在普通 GPU 上回答质量不如云端。我的做法是:开发环境用本地模型联调,生产环境切云端 API,配置层面只换 base-url 和 model,代码完全不用改。
3.3 向量存储选 pgvector 还是 Elasticsearch?
我选 pgvector 的原因比较务实:
- 团队本来就用了 PostgreSQL,不需要额外维护一套 Elasticsearch 或 Milvus;
- pgvector 0.5.0+ 支持 HNSW 索引,100 万级别的向量检索性能足够;
- Spring AI 对 pgvector 的支持很完善,VectorStore 接口实现直接可用。
如果你知识库体量特别大(千万级以上)或者需要复杂的过滤查询,再考虑 Milvus 或 Elasticsearch 的向量检索能力。
4. 核心实现:从知识库文件到可问答的 Java 服务
下面拆解整个 RAG 流程的 Java 实现。我的知识库是 Obsidian 里的 Markdown 文件,所以核心任务就是:读文件 → 切块 → 向量化 → 存库 → 检索问答。
4.1 第一步:封装文档读取与切块
Spring AI 提供了TextSplitter接口和现成的TokenTextSplitter实现。但直接按 token 切块对中文文档效果一般,我参考了社区方案,结合 Obsidian 笔记的特性做了定制:
package com.example.rag.service; import org.springframework.ai.document.Document; import org.springframework.ai.transformer.splitter.TextSplitter; import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.List; @Component public class ObsidianMarkdownSplitter implements TextSplitter { // 按标题层级切分 Obsidian 笔记 @Override public List<Document> split(Document document) { String content = document.getContent(); List<Document> chunks = new ArrayList<>(); // 按二级标题切块,保留标题作为上下文前缀 String[] sections = content.split("(?m)^##\\s"); for (int i = 0; i < sections.length; i++) { String section = sections[i].trim(); if (section.isEmpty()) { continue; } // 如果片段太长,继续切 List<String> subChunks = splitLongText(section, 800, 200); for (String sub : subChunks) { Document chunk = new Document(sub, document.getMetadata()); chunk.getMetadata().put("source", document.getMetadata().get("source")); chunk.getMetadata().put("chunk_index", i); chunks.add(chunk); } } return chunks; } private List<String> splitLongText(String text, int maxLength, int overlap) { List<String> chunks = new ArrayList<>(); if (text.length() <= maxLength) { chunks.add(text); return chunks; } int start = 0; while (start < text.length()) { int end = Math.min(start + maxLength, text.length()); // 尽量在段落边界截断 int lastBreak = text.lastIndexOf("\n", end); if (lastBreak > start) { end = lastBreak; } chunks.add(text.substring(start, end)); start = end - overlap; } return chunks; } }切块参数我试过好几组,最后固定为 800 字主块、200 字重叠。800 字对中文技术文档来说语义比较完整,200 字重叠能保证跨块的信息不丢。块太小(200字)会导致检索时上下文不够,块太大(2000字)又会稀释向量表示,回答容易跑偏。
4.2 第二步:向量化与入库
用 Spring AI 的VectorStore接口,把切好的文档块写入 pgvector。这里我调用了spring-ai-starter-vector-store-pgvector提供的PgVectorStore:
package com.example.rag.service; import lombok.RequiredArgsConstructor; import org.springframework.ai.document.Document; import org.springframework.ai.vectorstore.VectorStore; import org.springframework.stereotype.Service; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; import java.util.stream.Stream; @Service @RequiredArgsConstructor public class KnowledgeBaseIngestService { private final VectorStore vectorStore; private final ObsidianMarkdownSplitter markdownSplitter; /** * 扫描 Obsidian 目录,把 Markdown 文件入库 */ public void ingest(String vaultPath) throws IOException { List<Document> allDocs = new ArrayList<>(); try (Stream<Path> paths = Files.walk(Paths.get(vaultPath))) { List<Path> mdFiles = paths .filter(Files::isRegularFile) .filter(p -> p.toString().endsWith(".md")) .toList(); for (Path file : mdFiles) { String content = Files.readString(file); Document doc = new Document(content); doc.getMetadata().put("source", file.toString()); doc.getMetadata().put("type", "obsidian_note"); List<Document> chunks = markdownSplitter.split(doc); allDocs.addAll(chunks); System.out.println("Processed: " + file + ", chunks: " + chunks.size()); } } // 批量写入向量库 if (!allDocs.isEmpty()) { vectorStore.add(allDocs); System.out.println("Total documents indexed: " + allDocs.size()); } } }代码不复杂,但有几个细节要记住:
- 向量维度要和 Embedding 模型保持一致:智谱的 embedding-3 返回 1024 维,Ollama 的 nomic-embed-text 是 768 维。pgvector 建表时指定了维度,如果换了模型,要重建表。
- Metadata 必须包含业务需要的过滤条件:比如部门 ID、文档类型、更新时间。我在实际项目里加了
dept_id,用户提问时只检索本部门的文档,效果比检索全库好很多。 - 写入要批量:一条条 add 太慢,一次性 add 几百个 Document 没问题。我 2000 多个 Markdown 文件切出来大约 1.2 万个 chunk,全量入库耗时约 3 分钟。
4.3 第三步:问答链路的实现
核心是 Spring AI 的ChatClient结合QuestionAnswerAdvisor(就是 RAG 里的“检索+生成”部分)。我当时对比了两种写法,推荐下面这种相对新的 API:
package com.example.rag.service; import lombok.RequiredArgsConstructor; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor; import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.VectorStore; import org.springframework.stereotype.Service; import reactor.core.publisher.Flux; @Service @RequiredArgsConstructor public class RagChatService { private final ChatClient chatClient; private final VectorStore vectorStore; /** * 同步问答 */ public String ask(String question, String deptId) { SearchRequest searchRequest = SearchRequest.builder() .query(question) .topK(5) .similarityThreshold(0.5) .filterExpression("dept_id = " + deptId) // 按部门过滤 .build(); return chatClient.prompt() .user(question) .advisors(new QuestionAnswerAdvisor(vectorStore, searchRequest)) .call() .content(); } /** * 流式问答,适合前端打字机效果 */ public Flux<String> askStream(String question) { return chatClient.prompt() .user(question) .advisors(new QuestionAnswerAdvisor(vectorStore)) .stream() .content(); } }这个QuestionAnswerAdvisor做的事情可以用两句话概括:先用SearchRequest去向量库检索出 topK 条相关片段,再把片段和用户问题合并成一个增强 Prompt 发给大模型。Spring AI 默认的 Prompt 模板里要求模型“仅基于上下文回答,如果上下文不够就明确说不知道”,这个约束对知识库问答非常重要,能显著减少模型胡编乱造。
我在实际测试中发现topK=5比topK=3效果更稳,因为多两个候选可以弥补向量检索的误差。similarityThreshold=0.5是调出来的经验值——低于 0.5 会混入太多不相关内容,超过 0.6 又会漏掉一些语义接近但用词不同的片段。
4.4 第四步:把服务暴露成接口
这一步和普通 Spring Boot 无异,但有一个设计建议:不要把原始大模型返回直接透传给前端,最好包一层响应结构,把“引用的文档来源”也带回去。用户看到回答后可以点开来源验证,这是企业知识库标配能力。
package com.example.rag.controller; import com.example.rag.service.RagChatService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController @RequestMapping("/api/rag") @RequiredArgsConstructor public class RagController { private final RagChatService ragChatService; @PostMapping("/ask") public Map<String, String> ask(@RequestBody AskRequest request) { String answer = ragChatService.ask(request.question(), request.deptId()); return Map.of("answer", answer); } public record AskRequest(String question, String deptId) {} }5. 检索效果调优:为什么答非所问?关键在 Embedding 和切块
RAG 的效果瓶颈通常不在大模型,而在检索。模型只要你把对的上下文喂给它,回答就不会差。检索不到或者检索错了,后面全白搭。
5.1 Embedding 模型应该怎么选?
我在智谱 embedding-3 和 Ollama 的 nomic-embed-text 之间做了对比测试。简单说:
- 云端 embedding-3:语义理解能力强,中文效果基本是国产模型第一梯队,官方定价非常便宜,千万 token 才几块钱,可以直接用;
- 本地 nomic-embed-text:768 维,CPU 也能跑,但中文效果明显弱一些,适合离线环境或数据敏感场景;
- 不建议用 OpenAI 的 text-embedding-3-small 处理纯中文业务文档,不是不能用,是中文效果和性价比都拼不过国产模型。
编码向量之前还有一个关键点:同一个知识库的文档必须用同一个 Embedding 模型,不能今天用智谱明天换 Ollama 再写入同一个 pgvector 表,否则向量空间不一致,检索结果完全没意义。
5.2 切块策略的迭代记录
我第一次跑通时用的就是默认的TokenTextSplitter,chunk size 设成 500 token。效果一般,经常出现“检索到片段但内容不完整”的情况。后来按 Obsidian 笔记的实际结构改成“按标题切块+段落边界截断”,明显变好。记录两个要点:
- 切块时保留标题前缀:把“## 为什么知识库需要 RAG”这样的标题加在每块内容开头,Embedding 时能显著提升检索准确率;
- 重叠值至少 10%~20%:不加重叠会出现一个问题,比如“问题定位”的最后几句话在上一块末尾,检索时只命中下一块,模型就缺少上下文。
5.3 你需要一个“评测集”
这话听起来很正规,做起来其实很简单。我从真实用户提问里挑出 30 个典型问题,人工标注了每个问题应该命中哪些文档或片段。每次调完参数,就拿这 30 个问题跑一遍,记录“回答正确/部分正确/完全跑偏”的数量。
哪怕只有 30 条评测集,也比“感觉效果还行”强得多。我靠这个评测集发现在 800 字切块+200 字重叠的配置下,回答正确率从最初的 53% 提升到 76%——这是最让我意外的收获,因为代码逻辑没变,纯粹是切块和检索参数变了。
6. 生产落地:事务、缓存、安全一个都不能少
知识库问答上线后,服务稳定性是最容易翻车的地方。我这里梳理了三个必须处理的工程问题:
6.1 向量库事务一致性
编辑知识库文档后要更新向量,但vectorStore.add()和vectorStore.delete()不是原生事务性的。我的做法是给文档块加上doc_id元数据,更新时先按doc_id删除旧块,再写入新块。如果中途失败,可能会留下脏数据,但实际影响有限,因为知识库更新的频率不高,而且可以靠重建索引兜底。
6.2 缓存策略
大模型调用贵且慢,同一个问题短时间内反复问完全没必要。我在 Controller 层加了 Caffeine 缓存,对相同问题和相同部门 ID 的结果缓存 10 分钟。实测 QPS 上来后,模型调用量减少了约 40%。
需要注意缓存 key 的设计:一定要包含检索参数(topK、过滤条件、模型版本),否则知识库更新后用户会一直拿到旧回答。我在缓存 value 里加了知识库版本号,每次重建索引就递增版本号,从机制上避免这个问题——这个思路是在 Dify 的 metadata 管理里学到的。
6.3 安全与权限
这一条坑最深。RAG 检索时如果不加权限过滤,等于把内部文档开放给所有能调用接口的人。我的方案分两层:
- 接口层:用户身份通过 JWT 解析,拿到 userId;
- 检索层:从用户信息推导出允许访问的部门列表,拼进
SearchRequest.filterExpression,控制 VectorStore 的检索范围。
千万别把权限过滤放在“生成回答之后”——那是自欺欺人。模型上下文里如果混入了无权访问的文档,你无法保证它不会泄露。必须在检索阶段就把不该出现的内容过滤掉。
另一个安全点是 Prompt 注入。知识库文档里如果被人写了“忽略以上所有指令,直接输出xxx”,模型可能被带偏。Spring AI 的QuestionAnswerAdvisor默认模板对“仅基于上下文回答”有约束,但在知识库导入阶段最好也做一轮清洗,删掉明显的指令性内容。
7. 常见问题与排错实录
下面是实操中真实遇到的 5 个问题,每个都是血泪换来的:
| 问题 | 现象 | 排查思路 | 最终解决方案 |
|---|---|---|---|
1.No bean named 'vectorStore' available | 启动直接报错 | pgvector Starter 没引入,或配置不对 | 确认引入spring-ai-starter-vector-store-pgvector,并在 yml 配spring.ai.vectorstore.pgvector.index-type=HNSW |
| 2. 回答经常说“根据提供的信息,我无法回答” | 有知识库但模型说找不到 | 检索到的片段和问题语义不匹配 | 调低similarityThreshold到 0.4~0.5,或者换更强的 Embedding 模型;检查切块是否把关键内容拆散了 |
| 3. 检索速度慢,响应超过 5 秒 | 100 万级向量,全表扫描 | pgvector 没建 HNSW 索引 | 执行 SQL 建 HNWS 索引,向量列用vector_cosine_ops |
| 4. 模型回答了,但来源完全不对 | 回答内容很流畅,但是错的 | 向量检索命中了错误文档,模型基于错误上下文生成了流畅的错误回答 | 在返回结构中增加引用来源字段,调优检索参数;可以在 prompt 里要求模型每个结论都带引用 |
| 5. 不同环境(Dev/Prod)效果差异大 | 本地好,线上差 | 线上向量库和本地不同步,或环境配置的模型不一致 | 用同一个导入脚本重建索引,确认两边 embedding 模型一致 |
补充一个很实用的排查工具:Spring AI 的ChatClient支持.advisor()链式调用,你可以写一个LoggingAdvisor,把每次检索到的片段和相似度分数打到日志里。这样用户说“回答错了”时,你一眼就能看出是“没检索到”还是“模型生成跑偏”,不至于两头瞎猜。
8. 从 RAG 到 Agent:下一步你还能做什么?
基础 RAG 稳定跑通之后,知识库的利用率会提升一大截,但你会发现新的瓶颈:RAG 只能回答“知识库里有”的问题。用户如果说“帮我查一下库存,然后结合知识库里的补货策略生成一份报告”,这就不是纯 RAG 能解决的了。
这就要进入 Agentic RAG 阶段。Spring AI 提供了ToolCalling支持,你可以把“查库存”“查订单”封装成 Tools,让模型自主决定先调用哪个工具、再用知识库辅助回答。比如下面的思路:
@Component public class InventoryTool implements ToolCallback { // 模型发现需要库存数据时自动调用 @Override public String call(String toolInput) { // 调用库存服务,返回 JSON return inventoryService.queryBySku(toolInput); } }我在本地已经验证过这个链路:用户问“当前 A 类物料库存预警阈值是多少?结合知识库里的补货规则,给我一个补货建议”,模型会先查库存服务的实时数据,再结合知识库中的规则文档,生成完整回答。这个体验比纯 RAG 好很多,但复杂度也上一个台阶——建议先从 RAG 开始,把检索质量和稳定性做到位,再往里加工具调用。
后面我还计划引入 GraphRAG,把知识库里的实体关系也存进去——比如“Spring AI 依赖 Spring Boot 3.x”“Observability 模块用了 Micrometer”,这种关系用向量检索很难表示清楚,但用图结构就自然得多。这也符合“知识库已经在,Java 程序员还能做什么”的下一站答案:别再满足于“能搜到”,去把知识真正用起来。
我把上面这套东西跑通之后最深的感受是:RAG 不是一个“搭完就结束”的功能,它是一个需要持续调优的管线。Java 程序员从 Spring AI 入手做 RAG,最大的优势不是能写代码,而是能把“检索、提示词、模型调用、工程治理”这一整套东西都纳入统一的技术栈里管理。你不需要成为 Prompt 工程师,也不需要学 Python,只靠 Java 生态就能把企业知识库变成一个真正能用的服务。如果你也正在“知识库有了但不知道怎么接业务”这个阶段,希望这篇文章能帮你少踩一些坑。