Spring AI+向量数据库+RAG:Java知识库问答实践
做 Java 后端这么多年今年被问得最多的问题大概就是怎么把手头那堆文档变成一个能自然语言问答的 AI 服务搜索了一圈方案确实多但我最后的结论比较收敛Spring AI 向量数据库 RAG这三个词绑在一起几乎成了 Java 生态做知识库问答的标准开场。这篇文章用一个能直接跑通的入门案例把 RAG 到底在做什么、Spring AI 替我们干了哪些脏活累活、以及从文档到向量到问答这条链路上的每个环节怎么配、怎么写、怎么调一次讲清楚。如果你手里正好有一份产品说明书、内部运维手册或者客服语料想让它变成能聊天的知识库这篇就是给你写的。1. 为什么我不建议 Java 团队一上来就裸调大模型接口很多人第一次接触 AI 应用开发习惯直接从大模型 API 起步把问题拼进 prompt调用接口拿到回答就完事。这个做法 Demo 可以放到真实业务里很快会撞墙。1.1 直接调 API 的三个硬伤幻觉、知识过期、私有数据无关大模型的知识全部来自训练数据它的回答本质上是对训练语料的概率预测。第一个坑是幻觉模型没有见过你的售后条款但又不想承认自己不知道就会一本正经地编一个看起来很合理的退款流程甚至把别家产品的规则套到你头上。第二个坑是知识过期训练数据有截止时间上周刚更新的系统操作手册模型肯定不知道。第三个坑更致命私有数据根本不在模型参数里——你问它A 系统的接口超时时间是多少模型没学过 A 系统内部文档再大的参数规模也答不出来。我见过不少团队硬生生把几千页的用户手册全文塞进 prompt 里想靠堆字数解决。结果 prompt 超长被截断回答里混入大量前后矛盾的内容而且每次问答都重新传一遍全文成本和延迟都扛不住。这个方向从一开始就不对。1.2 带知识库的问答才是真实业务场景把 RAG 理解成给模型递资料RAGRetrieval-Augmented Generation检索增强生成解决的就是上面问题先根据用户问题从知识库里捞出一小段最相关的资料再把这段资料作为上下文交给大模型回答。大模型不需要记住你的文档它只需要临时翻书。用一个类比裸调大模型相当于把一个记忆力超强但完全没经验的应届生直接扔到客服岗位上。他态度很好但遇到没学过的问题只能现场编。RAG 做的事情是给他配了个图书管理员用户提问时管理员先把相关的几页规章制度翻出来递到他手上他照着念、归纳、再回答。检索质量决定了递过来的资料准不准模型能力决定了念完资料后总结得好不好。这就是为什么很多人说 RAG 的瓶颈通常在检索不在模型。2. RAG 整条链路拆开看一次问答背后发生了什么入门阶段如果直接抄代码很容易被各种概念绕晕。我建议先把整条链路分成离线加工和在线问答两段来理解后面写代码就顺了。2.1 离线流程清洗、切块、向量化、入库知识库里的文档先要被加工成机器可检索的形式。第一步是文档读取把 PDF、Word、Markdown 变成纯文本。第二步是切块一份几十页的手册不能整篇丢进去要按一定大小切成若干段落每个段落有独立编号和元数据。第三步是向量化把每个段落交给 embedding 模型转成一个固定维度的浮点数组也就是向量。这个向量的含义是这段文字的语义坐标语义相近的文本向量在空间中离得近。最后把向量连同原文段落一起存进向量数据库。2.2 在线流程向量化问题、相似度检索、上下文拼装、模型生成用户提问时同样先把问题转成向量。接着去向量数据库里做相似度搜索找出和问题最接近的 TopK 个文档片段。这一步是语义搜索而不是数据库里的LIKE %关键词%用户问退款要等几天即使文档里写的是退费处理时效为三到五个工作日语义上也能匹配上。然后把这些片段拼成上下文连同用户问题一起交给大模型。大模型基于给定的资料片段作答并被告知没有资料就承认不知道而不是瞎编。2.3 向量数据库为什么不能换成关系型数据库有人问我直接把文档段落存在 MySQL查询时用WHERE content LIKE %退款%不就行了对明确的短关键词关系型数据库的全文索引确实能凑合用。但自然语言的表达方式太灵活同义词、指代词、跨行表述关键词匹配很容易漏或者被噪声干扰。向量检索把文本变成数学向量计算的是余弦相似度之类的高维距离语义相关性的判断能力比字符串匹配高一个量级。向量数据库真正核心的是高效近似最近邻索引常见的有 HNSW分层小世界图、IVF倒排文件等。几万个片段全量线性扫描也能出结果但文档量大到百万级之后没有索引根本扛不住。入门阶段数据量小内存版向量库就能满足要求但架构上一定要留出无损切换到生产级向量库的空间。3. 技术选型Spring AI 替我们省掉了哪些重复劳动Java 生态做 AI 应用一个绕不开的问题是各家模型厂商的 SDK 风格迥异今天接这家明天换那家迟早被接口差异拖死。Spring AI 的价值恰恰是统一抽象。3.1 Spring AI 在 RAG 链路里扮演的角色Spring AI 把 RAG 涉及的能力都抽象成了接口ChatClient统一聊天调用EmbeddingModel统一文本向量化VectorStore统一向量存取DocumentReader统一文档解析DocumentWriter和TextSplitter统一加工流程。项目里写业务代码时只面向这些接口底层接的是哪家模型服务配置里换一行就行。它内置的QuestionAnswerAdvisor更实用。普通方式做 RAG你得手写检索向量库、拼上下文、塞 prompt这一套。用 Advisor 的话一个组件就把检索和增强的流程串起来了相当于 Spring 官方把 RAG 最佳实践封装好了直接调用即可。对 Java 团队来说这意味着不用自己维护一套胶水代码也不用到 Python 生态里另起炉灶。3.2 向量数据库怎么选先内存后生产向量数据库的选择我建议按阶段来不要一上来就上重武器。下面这几类方案可以对照看方案部署成本适合阶段需要关注的点SimpleVectorStore内存版零部署入门 Demo、本地联调重启丢数据数据量大了内存吃紧pgvectorPostgreSQL 插件低一个容器中小规模生产环境复用现有 PG 运维要建 HNSW 索引Milvus / 其他专用向量库中高独立集群大数据量、高并发检索性能强但运维复杂度明显上升Elasticsearch 向量检索中已经有 ES 的团队能同时做全文检索和向量检索但 mapping 配置要注意我实际开发时第一阶段直接用 SimpleVectorStore 跑通全流程确认检索效果后再换 pgvector。这样 Demo 阶段不需要安装额外服务聚焦在 RAG 本身上线阶段换依赖和配置业务代码几乎不碰。后面的代码示例也按这个方式来组织。4. 环境准备和最小工程先把骨架搭起来跑这个案例需要 JDK 17 以上、一个 Spring Boot 3.x 工程以及一个大模型接口。最省事的方案是本地装一个兼容 OpenAI 接口的运行时比如 Ollama拉一个支持中文问答的模型就能开跑不依赖公网服务也行。4.1 依赖清单和版本坑Spring AI 1.0.0 是个重要分水岭之前 API 变动比较频繁网上很多教程看版本都能看得出上古时期。建议直接用 1.0.0 起步。pom.xml 里最关键的是引入 Spring AI BOM统一管理版本号避免各个组件版本不一致dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- OpenAI 兼容的 ChatModel / EmbeddingModel -- dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency !-- PDF 文档解析如果只用文本/ Markdown 可以去掉 -- dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-pdf-document-reader/artifactId /dependency !-- 生产环境切到 pgvector 时再引入 -- !-- dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-vector-store-pgvector/artifactId /dependency -- /dependencies注意一个坑Spring AI 1.0 要求 Spring Boot 3.4 及以上如果项目还在用 Boot 3.2启动时大概率会遇到NoSuchMethodError或者自动配置不生效。版本对齐是第一优先事项。4.2 配置文件与模型接入application.yml 的核心配置如下spring: application: name: rag-demo ai: openai: base-url: ${AI_BASE_URL:http://localhost:11434/v1} api-key: ${AI_API_KEY:ollama} chat: options: model: ${AI_MODEL:qwen2.5:7b}这里的base-url指向任意兼容 OpenAI Chat Completions 和 Embeddings 接口的服务。本地用 Ollama 时模型对话和 embedding 都可以走同一个地址。如果你已经买了某家云厂商的 API把base-url和api-key替换过去即可。我测试时用的是本地方案省去了密钥管理和网络波动的麻烦。4.3 准备测试文档在src/main/resources/docs/下建一个user-manual.md内容不要太多但要有可检索的信息。我造了一份某客服系统用户手册的虚构文档包含下面几条典型条目# 客服系统用户手册 ## 退款规则 - 普通用户提交退款申请后系统会在 3 个工作日内完成审核。 - 退款审核通过后资金将在 5 个工作日内原路退回。 - 退款失败时请保留支付截图、订单编号和失败提示页面截图。 ## 技术支持联系方式 - 工作时间周一至周五 9:00-18:00。 - 提交工单时请提供账号 ID、问题发生时间、操作录屏、浏览器控制台报错截图。 ## 系统维护窗口 - 每周日凌晨 2:00-4:00 为系统维护窗口期间部分服务不可用。 - 计划维护前 24 小时系统会向注册邮箱发送维护通知。就这几行就够了。文档内容越贴近真实业务后面问答测试的效果越明显。5. 核心代码逐段拆解从入库到问答这一节是重点。我先把完整流程拆开讲最后给一个能直接跑通的 Service。5.1 文档读取与切块先定义向量存储的 Bean。入门阶段用内存版Configuration public class RagConfig { Bean public VectorStore vectorStore(EmbeddingModel embeddingModel) { // SimpleVectorStore 使用 JVector 实现适合本地 Demo重启后数据清空 return new SimpleVectorStore(embeddingModel); } }接着写一个初始化组件把docs/user-manual.md读出来、切块、向量化并入库。Markdown 文本的读取可以直接用 Spring 的Resource工具不依赖 Reader 组件Component public class DocumentIngestor { private final VectorStore vectorStore; private final EmbeddingModel embeddingModel; public DocumentIngestor(VectorStore vectorStore, EmbeddingModel embeddingModel) { this.vectorStore vectorStore; this.embeddingModel embeddingModel; } PostConstruct public void load() { var resource new ClassPathResource(docs/user-manual.md); try { String content resource.getContentAsString(StandardCharsets.UTF_8); var doc new Document(resource.getFilename(), content); // 切块默认按 800 token 一块、200 token 重叠 var splitter new TokenTextSplitter(); ListDocument chunks splitter.split(doc); vectorStore.add(chunks); System.out.println(已入库文档块数 chunks.size()); } catch (IOException e) { throw new RuntimeException(读取文档失败, e); } } }为什么要切块而不是整篇入库因为切块直接影响检索精度。块太大单块里包含的无关内容太多向量中心被稀释召回不精准块太小语义信息不完整又容易漏关键信息。TokenTextSplitter默认按 800 token 分块、200 token 重叠重叠是为了避免关键句子恰好被拦腰切断导致两边都搜不到。入门阶段用默认值后面再根据文档特征调整。5.2 向量化入库vectorStore.add(chunks)这一行底层做了三件事调用EmbeddingModel把每个 chunk 转成向量、把向量和原文 chunk 一起存储、生成可检索的索引。由于我们在RagConfig里已经注入了SimpleVectorStore这里的VectorStore会自动拿到正确的 bean不需要再手动实例化。如果你的文档是 PDF可以把ClassPathResource换成 Spring AI 的 PDF Readervar reader new PagePdfDocumentReader(classpath:docs/product-guide.pdf); ListDocument docs reader.get();但要注意扫描版 PDF 本质是图片需要先走 OCR 转成文本PagePdfDocumentReader只对带文本层的 PDF 有效。这个坑后面避坑清单里还会细说。5.3 语义检索阈值和 TopK 怎么调检索是 RAG 的核心环节。下面这段代码展示最基础的相似度查询String question 退款失败了怎么办; ListDocument hits vectorStore.similaritySearch( SearchRequest.query(question) .withTopK(3) .withSimilarityThreshold(0.3) );TopK决定返回几块相似文本SimilarityThreshold是相似度下限。这两个参数是 RAG 调优最先要动的东西。我个人经验是刚开始不要设太高阈值先用 0.3 甚至 0.0 跑一遍观察检索回来的文本块和相似度分数再慢慢收紧。很多人一上来withSimilarityThreshold(0.7)结果召回为空就以为没救了其实是阈值设死了。相似度分数和具体 embedding 模型强相关不同模型的分数分布差别很大。5.4 手工组装 Prompt 的 RAG Service检索到相关文档后最直观的做法是手工把上下文拼进 prompt让模型基于资料回答Service public class RagService { private final VectorStore vectorStore; private final ChatClient chatClient; public RagService(VectorStore vectorStore, ChatClient.Builder chatClientBuilder) { this.vectorStore vectorStore; this.chatClient chatClientBuilder.build(); } public String ask(String question) { ListDocument docs vectorStore.similaritySearch( SearchRequest.query(question).withTopK(3).withSimilarityThreshold(0.3) ); String context docs.stream() .map(Document::getContent) .collect(Collectors.joining(\n\n---\n\n)); String prompt 你是一个企业知识库助手请严格基于下面的资料回答用户问题。 如果资料里没有答案请直接回答“根据现有资料无法回答”不要编造。 资料 %s 问题 %s .formatted(context, question); return chatClient.prompt().user(prompt).call().content(); } }这里要注意 prompt 的约束描述。严格基于资料回答这句话非常关键没有这句模型还是会忍不住自由发挥。把不相关的 context 拼进去反而可能误导模型不如让它直接承认不知道。5.5 更省事的做法QuestionAnswerAdvisor手工拼 prompt 的好处是逻辑透明但 Spring AI 其实提供了封装好的组件。用QuestionAnswerAdvisor之后代码可以瘦身Service public class RagService { private final ChatClient chatClient; public RagService(VectorStore vectorStore, ChatClient.Builder chatClientBuilder) { this.chatClient chatClientBuilder .defaultAdvisors(new QuestionAnswerAdvisor(vectorStore)) .build(); } public String ask(String question) { return chatClient.prompt() .user(question) .call() .content(); } }QuestionAnswerAdvisor会在请求前自动做相似度检索把结果注入上下文再交给模型生成。它内置的 prompt 模板比我们手写的精简版更完善。但正因为封装好了里面的阈值、TopK 默认值需要你主动控制。更精细的需求可以通过传入SearchRequest来覆盖new QuestionAnswerAdvisor(vectorStore, SearchRequest.builder().withTopK(3).withSimilarityThreshold(0.3).build())5.6 测试入口一个简单的 Controller用 Spring Web 暴露一个接口方便调试RestController public class RagController { private final RagService ragService; public RagController(RagService ragService) { this.ragService ragService; } GetMapping(/ask) public String ask(RequestParam String question) { return ragService.ask(question); } }启动项目后直接访问http://localhost:8080/ask?question退款审核要多久就能看效果。6. 实测对比同一个问题有 RAG 和没 RAG 差别有多大为了让效果直观我准备了三组问题分别测试裸调模型和RAG 增强的回答。6.1 三组问答表现对比问题裸调大模型回答RAG 增强回答退款审核要多久通常需要 7 个工作日编造普通用户提交退款申请后系统会在 3 个工作日内完成审核维护窗口是什么时候抱歉我不了解您系统的维护安排每周日凌晨 2:00-4:00提前 24 小时发送通知提交工单需要什么信息建议联系客服咨询空泛需提供账号 ID、问题发生时间、操作录屏、控制台报错截图差距非常明显。裸调模型没有见过知识库内容只能给出通用甚至错误的答案RAG 给出的回答几乎可以直接作为客服话术。这就是检索增强存在的意义。6.2 召回不出来怎么办调试思路如果发现问题明明在文档里RAG 却答不出来先按下面的顺序排查打印召回结果确认 TopK 命中的文本块到底是不是相关内容。看similarityScore如果分数普遍很低说明 embeddings 没有很好地区分词义考虑换对中文更友好的 embedding 模型。检查文档切块是否合理。比如维护窗口信息容易被其他内容包围导致表达不完整。适当增大 TopK 或降低阈值观察是否改善。最终才考虑调整 prompt把请基于资料回答改得更严格甚至要求注明你只能回答资料里明确写到的内容。这个调试顺序我几乎每次都会走一遍。很多人一遇到答错就怀疑模型太弱其实九成问题出在召回不是生成。7. 从 Demo 到可用服务中文切块、增量更新、多轮对话这些坎案例跑通之后离能上线的知识库问答服务还差好几步。这里挑几个我实际项目中反复遇到的坎来说。7.1 中文切块别用固定字节数硬切TokenTextSplitter默认按 token 切英文语境下表现不错但中文偶尔会把一个完整句子拦腰切断影响语义。比如退款审核通过后资金将在 5 个工作日内原路退回如果从资金将在后面切断两块的语义都不完整检索时容易失焦。一个简单的改进是先按中文标点句号、问号、感叹号、分号切句再把若干句子聚合到一个 chunk 内最后设置少量重叠。这样既保住语义完整性又避免跨句噪声。过程可以用自定义TextSplitter实现核心思路就是句子是自然边界不要硬按字符数切。7.2 增量更新与删除文档变了向量库怎么同步知识库不会一成不变产品更新、错漏修正都会让旧文档失效。入库时给每个 Document 打上docId元数据更新时先按 ID 删除旧块再重新写入vectorStore.delete(filterExpression(docId manual-v1));具体过滤表达式写法取决于底层向量库但思路一致版本化管理文档块每次更新生成新的 docId。否则同一个文档反复入库库里都是新旧混杂的重复块检索结果会出现大量相似噪声。7.3 多轮对话与缓存让 RAG 真正可用目前的示例是无状态单轮问答真实产品通常需要多轮能力。Spring AI 提供了ChatMemory和MessageChatMemoryAdvisor把历史对话作为上下文传给模型还支持自动裁剪历史长度。RAG 场景里要注意历史对话不该参与检索的向量化否则上一轮的上下文会污染当前问题的检索结果。Spring AI 的 Advisor 机制可以把历史记忆和检索增强分开处理用起来就是两个 Advisor 叠加。缓存方面至少做两层一是 embedding 结果缓存同一段文本不必每次重新向量化二是高频问题的完整回答缓存比如维护窗口是几点答案恒定没必要每次检索、每次调模型。很多团队只想着换更好的模型却没意识到缓存能把成本和响应延迟降一半以上。7.4 性能与成本embedding 调用量和响应延迟在线链路里有两处耗时向量检索和模型生成。向量检索在百万级向量、HNSW 索引下通常是几十毫秒级模型生成反而是大头因为要流式输出。生产部署时建议开启流式输出首字延迟会明显改善。embedding 调用量的监控也别忽略。离线全量入库会消耗大量 embedding 额度增量更新、文档版本迭代都会持续消耗。设计时要考虑批量调用、失败重试、限流甚至可以先把文档内容做哈希内容没变就不重复向量化。8. 我踩过的几个离谱问题避坑清单最后必须把实践中的坑亮出来。说实话这些坑比教程正文更有价值。8.1 版本错位启动直接 NoSuchMethodErrorSpring AI 1.0 之前 API 变动频繁如果你从网上抄了一段旧代码再配上新版本依赖随时会报方法不存在。我第一次跑通案例时就因为教程里用的是 0.8.x 的 API而依赖坐标写的是 1.0.0启动时报错报得人一头雾水。解决办法只有一条统一走 BOM 管理版本代码 API 以当前实际依赖版本为准别混搭。8.2 PDF 明明有内容入库后却查不出来有一个测试文档是扫描版 PDFPagePdfDocumentReader读出来全是空字符串入库后检索结果自然为空。这类文档必须先做 OCR。另外PDF 页眉页脚、页码这些噪声文字会被一起向量化干扰检索。建议入库前做清洗去页眉页脚、去水印、统一换行符。文本清洗的质量直接决定向量质量这一步省不得。8.3 相似度分数永远不会下降召回全空我在 pgvector 场景里遇到过查询范围索引建好了阈值怎么调都召回不到数据。排查后发现问题出在相似度 vs 距离的语义差异上。有的实现返回的是余弦相似度越大越相似有的返回的是余弦距离越小越相似Spring AI 的适配层虽然做了转换但不同数据库的历史配置不一样阈值方向很容易写反。调试时先不做任何筛选直接打印 TopK 和相似度分数看数值分布再定阈值。8.4 向量维度不匹配索引直接建不出来切到生产级向量库后最常见的问题是 embedding 模型输出的维度和向量库表格定义的维度不一致。比如 OpenAI 的text-embedding-3-large输出 3072 维而 pgvector 建表时写的是 1536 维入库直接报错。解决方法是提前查清楚 embedding 模型输出维度建表时保持一致并且尽量不要轻易更换 embedding 模型因为换模型意味着存量向量全部需要重新生成。跑通这个入门案例之后我的体会是RAG 的瓶颈通常不在模型也不在框架而在文档质量和检索质量。花时间把切块、阈值、TopK 这些环节调好比不断换更大的模型有意义得多。至少在上手阶段Spring AI 加向量库加 RAG 这个组合是 Java 生态里试错成本最低的一条路了。