基于Spring AI与向量数据库的RAG语义搜索实战指南 1. 为什么搜索要“扩展”从关键词匹配到语义检索1.1 传统搜索方案的瓶颈在哪先聊个场景。假设你维护着一个Spring Boot应用里面存了几千份产品手册、技术文档或者说白了就是各种Word和PDF。用户想找“怎么配置超时时间”系统按关键字“配置 超时”去数据库里LIKE一圈或者走ES倒排索引分词匹配结果经常是用户换个说法问“连接多久会断开”就什么都搜不出来了。这不是你的代码写得不好而是关键词检索本质上是在做字面匹配。用户大脑里的问题表达和文档里的字面表达天然存在一条语义鸿沟。以前我们靠人工堆同义词、堆规则去填这个坑后来靠ES的分词器、BM25打分去缓解但都不治本。真正改变检索形态的是向量数据库和RAG这套组合拳。RAGRetrieval-Augmented Generation检索增强生成。它的思路很直接先不让大模型凭空回答而是先从你的私域知识库里检索出与问题最相关的片段再把片段塞进Prompt里让模型基于这些材料作答。这套链路里向量数据库负责“找得到”大模型负责“说得好”两者结合才能把企业内部的文档搜索从“搜得到关键词”升级成“搜得准意图”。Spring AI在这个场景里的价值则是把Embedding模型、向量存储、大模型调用全部抽象成了Spring风格的接口。你不需要自己写HTTP调用去连向量库也不用关心Embedding API的参数细节更像是在写一套普通的Service。这篇文章是“搜索扩展”这个系列的“上”篇我重点讲向量数据库选型、Spring AI集成以及从文档入库到语义检索的完整链路。1.2 向量数据库和RAG分别解决了什么问题先打个比方。传统搜索就像你在一本纸质说明书里翻页找“超时”两个字找到了就高亮给你看向量检索则是你身边坐了一个非常熟悉这批资料的同事你用自然语言问他“连接断开前能撑多久”他不会去逐字比对而是先理解你的问题再去资料堆里挑出意思最接近的几段内容给你。这个“意思最接近”就是向量的核心逻辑。Embedding模型会把一段文字映射成一串浮点数比如1536维的向量。语义上相近的文本映射出来的向量在空间里的距离也更近。向量数据库干的事情就是快速计算“用户问题的向量”和“库里所有文档片段的向量”之间的距离把最接近的TopK个片段捞出来。说白了它就是为“海量向量最近邻搜索”这个场景专门优化的存储引擎。那RAG在上面又加了一层什么检索只是前半程后半程是生成。向量库捞出来的片段往往是碎片化的可能来自不同章节直接丢给用户看体验很差。RAG的做法是把这些片段拼装成上下文连同用户原始问题一起发给大模型让模型自己组织语言、提炼要点甚至标注信息来源。这样用户拿到的不再是“一堆命中片段”而是一段像模像样、有理有据的答案。1.3 Spring AI 在搜索扩展里的角色Spring AI是这个链条里的“胶水层”。在没有它之前你想在Spring Boot里接入向量检索大概需要手动写Qdrant的REST客户端、自己封装Embedding模型的远程调用、再想一套方案把向量库的结果转成业务对象。这些代码不难但很琐碎而且换一个向量库就得重写一遍。Spring AI把这层做了统一抽象。EmbeddingModel负责把文本变成向量VectorStore负责写入和相似度检索ChatClient负责与大模型对话整个链路暴露给你的是几个Interface和可配置的Starter。更关键的是它遵循Spring Boot的自动配置约定引入依赖、填好application.yml一个可用的向量检索组件就跑起来了。对于业务团队来说这意味着你不用专门养一个AI基础架构的人也能把RAG链路搭出来。我建议把Spring AI理解成当年Spring Boot对Servlet容器的封装——它没有发明新技术但把复杂技术接入标准化的Spring体系里让普通后端团队也能低门槛上手。这也是我为什么在搜索扩展这个方向上最终选择基于Spring AI来做而不是自己拼一套微服务。2. 方案选型向量数据库与Spring AI版本怎么配2.1 从“能用”到“够用”选库的5个维度向量数据库的市场现在很热闹Qdrant、Milvus、pgvector、Redis、Weaviate、Chroma各有各的拥趸。我挑几个主流的做下横向对比这些库我都实际跑过业务不是只看过文档。数据库部署复杂度查询性能生态成熟度适用场景Qdrant低单容器可跑高Rust实现Spring AI官方支持良好中小规模生产快速落地Milvus高依赖较多组件极高分布式丰富但偏重千万级向量以上、大规模生产pgvector低PostgreSQL插件中等走SQL和业务数据在一起已有PostgreSQL想少维护一套服务Redis低但需单独模块高内存型以缓存场景为主轻量级、临时性向量检索我实际的选型思路其实可以归纳成五个问题。第一数据量级是多少十万级和千万级完全不是同一个选法第二团队有没有能力运维多组件部署Milvus那套组件栈不是所有团队都扛得住第三是否要求强一致性搜索场景通常可以放宽第四Spring AI对它的支持成熟度怎么样官方Starter总比自己手写客户端稳第五是不是已经有了PostgreSQL或Redis能少维护一个中间件就直接少一个。按照这五个问题筛一遍如果你手里是一个典型的Spring Boot业务系统文档量在百万级以内我建议优先看Qdrant和pgvector。pgvector的好处是表结构、事务、权限都沿用PG适合“文档和检索结果需要强关联”的场景Qdrant则胜在查询性能和API清爽适合专门做知识库检索。我最后在这篇项目里用的是Qdrant原因很朴素Spring AI的QdrantVectorStore封装完整度最高而且在本地用Docker跑一个容器就能开始开发做实验阶段几乎没有心智负担。2.2 依赖引入与配置Spring AI在2025年发布了1.0.0 GAAPI已经稳定可以放心用到生产项目里。我这里的示例基于Spring AI 1.x和Spring Boot 3.x先引入两个核心依赖向量库的Starter和Embedding模型对应的Starter。以OpenAI兼容接口和Qdrant为例pom.xml里加这些依赖dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-vector-store-qdrant/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency如果你用的是Spring AI Alibaba的DashScope只需要把第二个依赖换成对应的Starter即可VectorStore提供的接口是一致的所以业务代码不需要大改。这也是我强调“抽象层”价值的地方——模型供应商和向量库都是可替换的。然后是application.yml的配置spring: ai: openai: api-key: ${OPENAI_API_KEY} base-url: ${OPENAI_BASE_URL} embedding: options: model: text-embedding-3-small vectorstore: qdrant: host: localhost port: 6333 collection-name: product_docs这里有两个容易踩的细节。第一base-url通常指向一个统一网关地址而不是直接暴露公网地址这个看团队基础设施但Spring AI给了这个配置项就别浪费。第二collection-name需要提前在Qdrant里创建好并且向量维度要和Embedding模型输出维度严格一致。text-embedding-3-small的输出是1536维创建collection时就得按1536建不然写入时会报维度不匹配。Qdrant本身我用Docker启动开发环境一条命令就搞定docker run -p 6333:6333 -p 6334:6334 qdrant/qdrant:latest然后用它的HTTP API把collection建好curl -X PUT http://localhost:6333/collections/product_docs \ -H Content-Type: application/json \ -d {vectors: {size: 1536, distance: Cosine}}distance选Cosine因为文本Embedding向量用余弦相似度衡量语义接近程度最合适。2.3 为上线预留的扩展点选型这件事不能只看当下能不能跑通还要想想三个月后你要加什么。我总结三个最值得提前设计的扩展点。第一个是Collection的隔离粒度。不要把所有文档都塞进同一个collection。我会按业务域划分比如product_docs、operation_manual、qa_records各一个collection。这样检索时天然做了数据隔离也方便后续按库做权限管理和数据过期清理。第二个是Metadata的设计。VectorStore存的核心是向量但每条向量旁边是允许挂业务元数据的。我会提前约定好documentType、owner、version、timestamp这些字段虽然当下用不到但一旦需要做检索后的过滤、排序、审计有metadata和没有metadata是两种工作量的差别。第三个是Embedding模型的可替换性。模型升级是必然的但模型换掉往往意味着向量维度变化而collection的维度是创建时定死的。我的做法是在配置层单独抽象一个EmbeddingModel实例不直接散落在业务代码里后续要换模型时只改配置和重新灌数据而不是到处改代码。3. 核心实现从文档入库到语义检索3.1 文档切分RAG效果的分水岭很多人第一次做RAG兴奋点全在向量库和大模型上结果跑出来效果一塌糊涂就觉得是模型不行。实际上我做过几个项目之后结论很明确切分策略对检索效果的影响往往比选哪个向量库、用哪个Embedding模型更大。为什么切分这么关键因为向量检索的粒度就是“一个被索引的文档片段”大模型回答问题时看到的也是这个片段。如果片段太大比如把整篇五千字的手册当一个片段那个1536维向量根本表达不了这么多内容的重点检索时召回结果的精度会惨不忍睹如果片段太小比如一句话一个片段语义信息又太单薄而且切割的位置很容易把完整语义拦腰截断。Spring AI内置了TikaDocumentReader负责把PDF、Word、Markdown等格式解析成文本然后通过TokenTextSplitter做切分。我的配置习惯是目标块大小500 token左右重叠80到100 token。重叠部分很重要它保证跨片段边界的语义能保留下来不会因为切分把“因为……所以……”这种因果关系统统切断。切分还有一个被忽略的细节尽量按文档结构先做段落拆分再按token控制大小。比如先按Markdown的标题层级拆成多个小节如果某一节还太长再在这个节内按段落切。这样检索出来的片段自带标题上下文大模型读起来“站位”更准确。Spring AI的Document API里可以自定义Metadata切分时顺手把所属章节标题写进metadata后面做引用溯源非常方便。3.2 向量化写入切分完成之后就是Embedding和入库。Spring AI对这一段的抽象很简洁核心就是VectorStore接口。把Document列表交给vectorStore.add()它会自动调用EmbeddingModel做向量化再批量写入向量库。我贴一段实际入库的业务代码注释里是把每一步的意图标出来了Service public class DocumentIngestionService { private final VectorStore vectorStore; public DocumentIngestionService(VectorStore vectorStore) { this.vectorStore vectorStore; } public void ingest(Path filePath, String category) { // 1. 用Tika解析各种格式的文件 TikaDocumentReader reader new TikaDocumentReader(filePath); ListDocument documents reader.get(); // 2. 按500 token切块保留100 token重叠 TextSplitter splitter new TokenTextSplitter(500, 100); ListDocument chunks splitter.apply(documents); // 3. 给每个chunk打上业务元数据方便后续过滤和溯源 chunks.forEach(doc - { doc.getMetadata().put(category, category); doc.getMetadata().put(sourceFile, filePath.getFileName().toString()); }); // 4. 批量Embedding并写入Qdrant vectorStore.add(chunks); } }这段代码看起来简单但我后面实际跑生产量时踩了几个坑。第一个是批量写入性能问题一次性加几千个块Qdrant那边的写入压力会瞬时拉高日志里容易报超时。我后来在循环里按每批200个chunk提交一条数据都不掉。第二个坑是重复写入。如果同一个文档被重新导入一遍向量库里会存在两份一模一样的向量检索召回时就会出现两条重复结果。解决方式是利用metadata里的文档指纹入库前先查一把存在就跳过。我在项目里用sourceFile加文件大小加修改时间拼一个docId写入前按docId过滤。第三个坑我单独拿出来说就是Embedding模型的调用并发。Spring AI的EmbeddingModel内部有简单的连接管理但面对几千个chunk的批量任务默认的调用方式还是偏串行。我会手动配一个线程池用parallelStream或者CompletableFuture分批并发调用整体入库时间能缩短一个量级。注意控制并发数太激进会被API限流我一般压到20到30的并发度。3.3 语义检索接口与RAG调用链入库搞定之后检索就是查询接口的事了。Spring AI里构造相似度检索核心是SearchRequest这个对象几个关键参数分别是query、topK、similarityThreshold和filterExpression。我直接贴一个实际在用的ControllerRestController RequestMapping(/api/search) public class SearchController { private final VectorStore vectorStore; private final ChatClient chatClient; public SearchController(VectorStore vectorStore, ChatClient chatClient) { this.vectorStore vectorStore; this.chatClient chatClient; } GetMapping(/semantic) public SearchResult semanticSearch(RequestParam String query) { // 1. 在向量库中做语义检索取最相似的4个片段 SearchRequest request SearchRequest.builder() .query(query) .topK(4) .similarityThreshold(0.5) .filterExpression(category manual) .build(); ListDocument docs vectorStore.similaritySearch(request); // 2. 把片段拼装成上下文 String context docs.stream() .map(doc - doc.getContent()) .collect(Collectors.joining(\n\n)); // 3. 让大模型基于检索结果做RAG回答 String answer chatClient.prompt() .user(u - u.text(请基于以下资料回答问题如果资料中没有相关信息请直接说不知道。\n\n资料\n{context}\n\n问题{question}) .param(context, context) .param(question, query)) .call() .content(); return new SearchResult(query, docs, answer); } }这一步有四个细节值得展开说。第一topK不要贪多。有段时间我为了“看起来全面”把topK设成10结果大模型回答时经常被不相关的片段带偏反而质量下降。后来调到4到6之间效果稳定很多。topK的本质是给大模型投喂的“候选材料”材料太多反而增加筛选负担。第二similarityThreshold的语义要理解清楚。它过滤的是“与问题的语义相似度低于0.5的结果”但这个阈值跟Embedding模型强相关不同模型算出来的相似度分布差别很大。我换过一个模型之后发现同样的阈值召回结果完全不一样所以每个模型上线前都要用一批真实query去调阈值不能直接沿用老配置。第三filterExpression的写法是Spring AI的表达式语法和Qdrant原生过滤条件不太一样。上面示例里category manual这种写法看着简单实际上我翻了不少文档才意识到它可以支持and/or嵌套。如果要写复杂条件比如(category manual || category faq) version 2.0记得用括号把优先级圈清楚。第四业务数据脱敏。RAG链路最终会把检索片段拼进Prompt发给大模型如果这些片段里有敏感信息等于公开给模型了。我这边在上文拼装前加了一道过滤把包含敏感明文标记的片段剔除掉从源头避免泄露。4. 常见问题与排查技巧实录4.1 六个我自己踩过的坑这里写的内容全是我在项目里实际遇到、实际排查过的问题比官方文档多一层“现场感”。第一个坑是集合维度不匹配。这是我做向量检索遇到的第一个报错Qdrant在写入向量时校验维度发现collection是1536维但Embedding模型输出的是1024维直接拒绝写入。原因是我中途换过一次Embedding模型旧集合适配老模型新向量写不进去。排查思路很直接看异常信息里报的期望维度是多少但根本解法是提前创建好维度固定的collection并且把Embedding模型的版本和collection绑定记录在配置文档里换模型时必须重建集合。第二个坑是中文文本的Embedding效果差。OpenAI的text-embedding-3-small跑英文效果不错但项目里大量中文文档相似度检索的召回结果经常让人哭笑不得。后来我把Embedding模型换成对中文支持更好的BGE-M3或者通过Ollama跑本地Embedding模型效果立刻好了不少。这里有个成本考量本地Embedding模型一毛钱不花但需要一台有GPU的机器或者能接受CPU推理的延迟具体看你的文档量和QPS要求。第三个坑是切分把代码块和表格切碎了。我们的技术文档里含有大量代码示例和表格TokenTextSplitter根本不关心Markdown语法直接从代码块中间一刀切下去导致检索到的片段是个残缺片段代码不完整表格也断了。我的处理方式是自定义Splitter先识别文档里的代码块和表格作为独占的“不可分割块”文本部分再走TokenTextSplitter。这样既不割裂代码结构又能控制文本块大小。第四个坑是metadata过滤条件写错导致结果为空。我一直用filterExpression过滤category某天突然发现查询结果为空排查了半天发现是metadata里存的category值带有前后空格而过滤条件里写的是不带空格的精确值自然匹配不上。这类字符串匹配问题在ES里常见向量库里也一样。建议入库前对metadata值统一做trim和规范化别把脏数据带进向量库。第五个坑是并发写入导致的连接池耗尽。批量入库任务一开几千个chunk同时并发调用Qdrant结果Qdrant客户端连接池被打满后半段的写入全部超时。后来我检查了Spring AI里Qdrant客户端的连接配置手动调大了连接池上限同时在业务侧加了一个简单的限流信号量把实际并发控制在合理范围。具体数字取决于你的Qdrant实例规格但原则是“宁可慢一点不要打爆”。第六个坑是相似度阈值设得过高。一开始我把similarityThreshold调到0.8想着精度高一点更好结果大量真实查询返回空结果。原因在于Embedding模型对query和文档的向量分布在0.6-0.75之间0.8的阈值基本把大多数相关结果都拦在门外了。调低到0.5之后召回率明显提升。这个教训是阈值一定要看实际的数据分布不能拍脑袋定。4.2 常见问题速查表我把项目组内部沉淀的排查记录整理成一份速查表方便大家直接对照。问题现象可能原因排查步骤解决方案写入时向量维度报错collection维度与Embedding模型输出不一致查看Qdrant集合定义与模型配置重建collection统一维度中文检索结果差Embedding模型对中文支持弱人工抽样检查召回结果的相关性换BGE-M3等中文优化模型检索结果重复同一文档重复导入按metadata中的docId做去重查询入库前检查docId特定过滤条件的查询返回为空metadata值不规范或类型不匹配检查metadata原始值是否有空格、类型是否一致入库前规范化metadata批量入库超时并发过高打满连接池观察客户端连接池监控调大连接池加限流部分真实问题召回不到内容相似度阈值设太高打印相似度分布做分析根据分布下调阈值切分导致内容不完整切分器不识别文档结构检查片段内容是否有断裂自定义结构化切分器除了表格里的这些我还有一个额外的排查习惯所有VectorStore的调用都打印一份耗时日志检索超过500毫秒就记作慢查询。消息量上来之后慢查询往往不是向量搜索本身慢而是Qdrant实例所在节点CPU打满、网络延迟升高这类问题不靠日志统计根本发现不了。5. 从检索到RAG落地路径与下一步5.1 先跑通最小闭环很多团队做RAG项目一开始就直接规划多知识库、多租户、RBAC权限、知识图谱结果做了两个月还没上线。我的建议非常直接先跑通最小闭环上线一个具体的业务场景再逐步把复杂度加回去。最小闭环我定义为三个接口一个是文档入库接口接收文件路径和分类一个是语义检索接口返回TopK相关片段一个是RAG问答接口基于检索片段生成答案。三个接口对应上面的代码例子一周内就能全部跑通。这个闭环跑通之后你可以拿真实用户的搜索日志去检验效果然后根据问题去迭代切分策略、调整阈值、优化Prompt。这个阶段最容易犯的错误是“追求RAG链路里的每个环节都做到90分”。实际上检索召回质量、片段拼装顺序、Prompt模板这些都是互相影响的独立优化某一环效果都不明显必须放到完整链路上做整体评估。先跑通闭环你才能有一个真正的评估基准。5.2 下一步评估体系与多路召回如果最小闭环跑稳了下一步我会建议做两件事。第一件事是建立检索质量评估体系。简单的方式是准备30到50条真实用户query人工标注每条query对应的正确文档片段然后跑一轮检索计算召回率和准确率。这个集合不用大但要有代表性。每次改动切分参数、Embedding模型、阈值都用同一套集合回测看指标是升是降。没有这套评估你对“哪个版本更好”的判断就全凭感觉。第二件事是设计多路召回。实际业务里纯向量检索不是万能的。产品型号这种精确匹配场景关键词检索反而更准刚入库的新文档如果还没完成Embedding向量检索也会漏掉。我的做法是在搜索接口里同时跑两路一路走向量库语义检索一路走ES关键词检索然后按业务规则做结果合并和重排。这套机制落地后搜索效果的整体稳定性比单一向量检索高很多。这也是“搜索扩展”这个系列的下一步方向。我在实际项目中的体会是RAG的落地难点从来不在“调通一个Demo”而在于你如何围绕自己的业务数据把检索质量做到可评估、可迭代、可信任。先把向量数据库和Spring AI这一层基础打牢再往上叠加策略路线会清晰很多。