Spring AI 这套框架我前后用了有大半年,踩了不少坑,也积累了不少一手经验。今天想聊的,是很多团队从 demo 走到生产时都会卡住的话题:RAG(Retrieval-Augmented Generation,检索增强生成)。标题里的“本地知识库检索落地”不是一句口号,而是实打实要解决几个问题:怎么把散落的文档变成可检索的向量,怎么在 Spring AI 里把知识召回和模型生成串成一条可靠链路,以及当检索效果不理想时,问题到底出在哪个环节。这篇文章我就按自己做项目的顺序,从原理、数据准备、代码实现到调优排查,把这一整套流程拆开讲清楚。
上手之前先说明适合谁看。如果你已经用 Spring Boot 写过简单的 AI 功能,理解 Controller、Service 这套东西,但还没碰过向量数据库和 Embedding,本文非常适合你。如果你之前用 Python 生态做过 RAG,现在想迁移到 Java 技术栈,也能从中找到对应的组件和设计思路。
1. RAG 的核心链路:为什么要把知识外置而不是硬塞给模型
1.1 大模型的天生局限:参数不是硬盘
抛开各种花哨的包装,大模型本质上是一个从海量文本里学出概率分布的预测器。它确实记住了很多公开知识,但它的记忆有几个硬伤:训练数据有截止时间,最新资料它没见过;私有化的内部文档、项目规范、产品手册,训练阶段根本没有机会接触;哪怕见过,也无法保证具体到某一段话、某一个数字时不张冠李戴。这就是圈内常说的“模型幻觉”的根源之一。你费劲调 Prompt 告诉它“不知道就说不知道”,它依然会在不确定时一本正经地编。
解决办法不是去改模型参数,而是改变信息输入方式。RAG 的思路很朴素:既然模型记不住,那我们就在每次回答前,先把和问题最相关的资料片段查出来,拼进 Prompt,再让模型基于这段资料作答。类比一下就是:原来你让一个天才闭卷考试,现在你允许它先翻书,但只给它看最相关的三页纸。它不是记住了所有的内容,而是在答题那一刻掌握了正确的上下文。
1.2 一次完整检索的四个必经环节
RAG 的标准流程可以分成四步:入库、召回、重排、生成。入库也叫离线阶段,是把本地文档切分成片段,逐段用 Embedding 模型转成向量,写入向量数据库;召回是在线阶段的第一步,用户输入问题后,同样转成向量,去向量库里做相似度搜索,找出最接近的一批片段;重排是可选但往往很关键的步骤,对召回结果重新排序,把真正有用的段落顶到最前面;生成阶段则把排序好的候选片段塞进模板,和用户问题一起交给大模型,由模型生成最终答复。
这四步里,入库决定效果上限,召回和重排决定效果下限,生成只负责把已有的好东西表达出来。很多人觉得自己 RAG 效果差是因为模型不行,其实多数时候问题出在前三步:文档切得太碎,语义断了;向量模型不合适,召回了一堆无关内容;没有重排,最相关的片段被埋在第三四位。把这些环节逐一打磨好,模型的压力就会小很多。
1.3 为什么在 Java 生态里选 Spring AI
团队如果已经有了 Java 后端,引入 Spring AI 最大的好处不是省代码量,而是把“AI 调用”从失控的散点变成了可控的框架能力。它提供了几个非常实用的抽象:EmbeddingModel 接口统一了向量化调用,不管是接 OpenAI、Ollama 还是其他本地模型,上层代码不用改;VectorStore 接口统一了向量库操作,切换 PGVector、Chroma、Milvus 等实现时只改配置;还有 Advisors 机制,可以在请求进入模型前统一注入检索逻辑。这意味着 RAG 不只是写死的一个 Demo,而是能塞进 Spring Boot 生命周期里的标准组件。
同步的好处是,事务管理、连接池、监控埋点这些 Java 生态原有的能力可以无缝衔接上去。你不需要像在 Python 脚本里那样四处拼 HTTP 请求、自己管理向量库连接,Spring AI 帮你把链路封装好了,但又不至于黑盒到无法干预。接下来我从数据准备开始,把每个环节的实操讲透。
2. 本地知识库建设:切分、向量化与入库的工程细节
2.1 数据准备的第一步:清点文档,规划目录结构
很多项目一开始就急着写代码,结果测试时才发现文档质量一塌糊涂。我建议先花半天时间把素材理清楚。要清点三件事:格式有哪些,是一堆 Markdown、Word、PDF 还是纯文本;内容是否杂糅,一个文件里既有产品说明又有报销制度,必须拆分;有没有大量低价值内容,比如表格模板、代码示例、重复声明,这些会在检索时产生噪音。
目录结构上,我习惯按业务域建目录,而不是按文件类型建。假设项目里有人事和报销两块知识,就应该有hr/和finance/两个目录,而不是pdf/和md/。这背后的原因很实际:向量检索召回的是语义片段,如果同一主题的文档分散在不同目录,元数据上就很难标记归属,后续想按业务领域过滤时会非常痛苦。每个文件在入库时还要带上一份标注了来源路径、标题、更新时间的元数据,这是后续排查“答案是从哪来的”的唯一线索。
2.2 文档切分策略:窗口、重叠与语义完整性
文档切分是整个 RAG 管线里最容易被低估的环节。切太粗,比如整篇文章存成一条向量,召回时相关片段会被淹没在无关内容里,模型读到的上下文不够精确;切太细,比如一句话一段,语义又不完整,模型看不到前因后果。我的经验是,通用场景下优先用“固定窗口 + 重叠”的切法:设 chunk_size 为 500 到 800 个字符,chunk_overlap 为 100 到 150 个字符。重叠是为了避免某个关键信息恰好在两个片段交界处被割裂,导致两边都不完整。
但纯按字符切有个问题:它不知道句子的边界,容易把一句话拦腰斩断。进阶一点的方案是用递归字符切分,按章节标题、段落、句子逐级尝试,优先在语义完整的位置断开。如果你手里是结构化很强的 Markdown 或 HTML,还可以根据标题层级先定位大段,再在大段内部切分。Spring AI 社区和 LangChain 生态里都有类似的 TextSplitter 组件,不过我更推荐自己封装一个:根据文档类型封装不同的拆分逻辑,在切分时把原始文件的路径和章节号也带上。这样出了效果问题时,你能顺着元数据一路找回源头。
2.3 Embedding 模型选型:精度和成本的平衡点
Embedding 模型的任务是把一段文本变成一个高维向量,让语义相近的文本在向量空间里挨得近。选模型时绕不开一个三角:精度、速度、成本。如果你用的是云端 API,OpenAI 的 text-embedding-3-small 是个不错的起点,语义表现稳定,但每次调用出网延迟不稳定,且数据出了内网,对敏感项目不友好。如果数据必须留在本地,常见做法是接 Ollama 拉一个开源的 embedding 模型,比如 nomic-embed-text 或 bge-m3 系列。
我实测下来,本地小模型的向量质量相比云端大模型还是有一点差距,但这个差距在“领域文档足够规范、切分合理”的前提下并不致命。真正致命的是没做归一化、没统一模型版本。同一个文本入库和查询时如果用了不同模型,向量空间根本对不上,检索结果一塌糊涂。所以两个铁律:入库和查询永远用同一个 EmbeddingModel;模型版本升级后,必须全量重新向量化,不能新旧向量混在一个库里。
2.4 向量化与批量写入:细节决定入库成功率
Spring AI 中把文本转成向量非常直白,调用 embeddingModel.embed 就能得到 List 或向量数组。但在实操里,我会强烈建议批量处理,而不是在循环里一条一条调用,尤其当你用的是远程模型时,单条调用的网络往返成本会直接拖垮入库速度。Spring AI 的 EmbeddingModel 提供了 embed(List ) 接口,一次传一批文本进去,内部会走批量请求。用之前务必确认你的部署环境对单次请求的 token 数有没有限制,超限会被拒绝。
讲到批量入库,还要注意高频调用带来的限流问题。本地 Ollama 模型一般不太限流,但云 API 有每分钟请求数限制。遇到 429 时不要直接在循环里 sleep 硬等,要做一个简单的指数退避重试,比如第一次等 1 秒,第二次等 2 秒,最多等 8 秒。写完入库后,最好再从库里随机抽几条,人工确认内容片段完整、元数据正确、数量符合预期,这一步能省下后面调试时的很多困惑。
3. Spring AI 的 RAG 落地:组件选型与核心代码实现
3.1 工程依赖与基础配置:让框架先跑起来
工程基础配置上,我先给出一个最小可跑通的方案以验证链路。假设你的向量数据库用的是 PGVector,项目里引入 spring-ai-pgvector-store 和 spring-ai-openai(或 spring-ai-ollama)依赖。版本建议用当前较新的稳定版,Spring AI 的版本迭代比较快,API 在 0.8.x 和 1.0 之间有差异,代码示例请以自己项目里的实际版本为准。
配置文件里至少要指定三块内容:Embedding 模型的基础地址与 key,如果走 OpenAPI 格式就配置 base-url 和 api-key;向量库的连接信息,包括数据库 URL、用户名、密码;向量表名与维度,维度必须和 Embedding 模型的输出维度一致。比如 nomic-embed-text 输出 768 维,bge-m3 输出 1024 维,写错维度启动时不会立刻报错,但查询时结果会很诡异。稳妥的做法是让框架自动建表,并检查日志里是否创建成功。
3.2 向量存储接入:以 PGVector 为例解析底层逻辑
PGVector 是 PostgreSQL 的扩展,最大优势是不用额外部署一套专用数据库服务,直接复用在业务库上。在 Spring AI 里接入 PGVector 时,要配置的是 VectorStore 的实现类。底层原理是:每个片段在库中存成一行,包含 id、content、metadata 和 embedding 字段;写入时调用阿里的 pgvector 的 INSERT 语句,查询时使用类似 ORDER BY embedding <=> ? LIMIT topK 的余弦距离或欧几里得距离检索。
使用关系数据库当向量库有个天然好处:查询时可以混用 SQL 条件,比如按照 metadata 中的部门字段过滤,只检索某部门的文档。这个能力在专业场景里非常实用。比如公司有十多个部门的知识库,不带过滤条件地全局检索,会召回一堆别的部门的内容,加了过滤后命中率立刻提升。缺点是纯向量查询性能不如专用向量库,不过数据量在十万级以下完全够用。
3.3 检索链路编码:改写、TopK 与评分逻辑
代码层面,一个典型的检索 Service 会做三件事:接收用户问题,生成向量;调用向量库的相似度搜索;把结果转换成上下文片段。在 Spring AI 里,VectorStore 提供了 similaritySearch 方法,可以传查询文本、TopK 和过滤条件,返回一组 Document 对象。TopK 我建议初始设为 4 到 6,不要一上来就调 20,因为候选越多,模型读的上下文越长,费用越高、响应越慢,而且噪音也越大。
有些场景还需要做“查询改写”。用户提问常常是很短的话,比如“报销要几张发票”,直接拿这句去向量库搜索,语义信息量太少。一个简单的做法是先用大模型把问题扩展成两个子问题,比如“报销的发票数量要求”“差旅报销的发票类型”,再分别去检索,最后合并去重。这个步骤能明显提升召回率,代价是多一次模型调用,是否值得就看业务场景对延迟的容忍度了。
3.4 Prompt 模板与生成:把检索结果有效喂给模型
检索到内容之后,不能直接把一堆文本原样丢给模型,必须放进设计过的 Prompt 模板里。我的模板一般包含三个区块:角色与任务说明,声明“你是一个客服助手,请严格依据【资料内容】回答,不要使用资料以外的知识”;资料内容区,把命中的片段按顺序放进去,每条前面标注来源编号和出处;用户问题区,放原始问题,并加上一句“如果资料中没有答案,请直接回答‘知识库中没有相关信息’”。
这里有一个容易被忽略的细节:片段顺序影响生成质量。如果你的重排策略没有生效,片段可能按相似度降序排列,但真正有用的信息排在后面。模型往往是“看前重后轻”,关键信息排在后面容易被漏掉。所以我会在生成前强制按“相关性优先,且显式标注来源”的方式组装片段,如果候选里有多个来源,按来源分组,让模型能看出哪段内容出自哪份文档。回答时要求它先说明“根据《XX文档》”,这既方便用户溯源,也在一定程度上抑制幻觉。
4. 检索质量调试:从“能搜到”到“搜得准”的进阶优化
4.1 阈值与 TopK 的配合:既不能漏也不能杂
调检索时最容易犯的错误是只看 TopK 数量,不看分数。向量相似度返回的分数在不同 Embedding 模型下分布差异很大,有的模型相似度 0.7 已经很相关,有的模型 0.85 才算及格。所以我建议这么做:先跑一批问题,把命中文档的相似度分数记录下来,画个粗略分布图,再定阈值。低于阈值的候选不要进 Prompt,宁可少给也不要给错的。
阈值和 TopK 需要联动调整。TopK 太小,阈值再低也捞不出足够材料;TopK 太大且阈值太低,大量低质量片段混入。我常用的调参顺序是:先固定 TopK=5,用 20 个问题测试,看有没有漏召回;如果没有,再逐步下调阈值,直到开始出现噪音为止,最终参数取“不漏 + 噪音最少”的交点。这个方法论比凭感觉调准得多。
4.2 多路召回与重排序:用低成本实现接近商用的效果
单路向量召回在专业领域常遇到一个问题:语义相似不等于答案正确。比如用户问“请假流程”,文档里出现很多“请假审批制度”“休假管理办法”,向量距离都很近,但具体答案藏在某一篇里。这时候最优做法是多路召回:一路走向量相似度,另一路走关键词匹配或 BM25 全文检索,两路结果合并后再用 Reranker 模型统一排序。
重排序的原理不复杂,它不依赖向量距离,而是把 Query 和每个候选片段拼在一起,用一个交叉编码器去计算相关性,得出一个更可靠的相关性分数。在 Java 技术栈里,可以通过调用本地重排模型或者远程 API 来实现。经过这一层,排名第一的候选往往才是真正应该被模型读取的段落。代价是多一段网络调用延迟,但在答案质量要求高的场景里,这个延迟值得付。
4.3 可观测性:没有观测,就没有调优
RAG 链路比较长,从问题进来、向量化、查库、重排、Prompt 组装到模型输出,任何一环出错都可能表现为“最终回答不准”。但如果你没有观测,就不知道是哪一环出了问题。我强烈建议在链路的每个关键节点打日志或做埋点,至少记录这些信息:原始问题;改写后的问题(如果有改写);召回的 TopK 条内容及各自相似度分数;重排后的最终排序与截断阈值;组装进 Prompt 的片段数量与总字数;模型输出的回答。
把这些日志按时间对齐,能很快定位问题。比如检索出来的片段看着都对,但回答还是不对,那问题出在 Prompt 组装或模型指令上;如果检索出来的片段本身就文不对题,那问题在切分、Embedding 或检索参数上。在实际项目里,我就是靠这组日志定位出了八成以上的 RAG 效果问题,比瞎调参数有效得多。
5. 常见问题排查实录:从零散报错到稳定上线
5.1 向量查询返回空结果或者结果顺序不对
这种情况大概率是维度或距离计算方式不匹配。比如入库用了 1024 维的模型,查询时框架配置里却写的 768 维,数据库层面会报错或者查出空结果;另一种情况是同一个向量库里混入了不同模型的向量,查询时距离计算完全错乱。我的排查顺序是:先看建表语句中的向量维度;再确认查询和入库是否走同一个 EmbeddingModel;最后看返回结果的前几条相似度分数是否呈现出合理的梯度,而不是全都一样。
如果排查完模型和维度都没问题,还要检查是否在查询时加了过滤条件,比如 metadata 里的部门字段写错了值,导致过滤后没有任何记录。我曾在排查一个“查不到数据”的问题时折腾了两小时,最后发现是 YAML 里部门编号写错了,案例虽然简单,却时刻提醒我先查元数据条件。
5.2 回答内容失真:模型悄悄用了“知识库之外”的信息
这是最令人头疼的问题之一。明明检索到了正确资料,回答却还是凭想象发挥。关键原因一般是 Prompt 指令不够强硬。用词必须是“必须严格基于资料回答,禁止使用个人常识”,而不是温和的“可以参考以下资料”。如果吃了强指令仍然不好,检查是否在 Prompt 里放入了过多的低相关性片段,那等于变相给了模型发挥空间。
另一种失真来自切分不完整。假设某条知识分散在前后的不同片段中,模型只读到了后半段,就会按照自己的理解脑补出前半段。解决办法是适当增加 chunk_overlap,并检查关键主题的片段是否被完整切开。如果文档本身有明确的章节结构,优先在章节边界切,切完再看是否需要合并小片段。
5.3 性能瓶颈:同步调用阻塞与超时
RAG 在线链路至少涉及两次模型调用(向量化和生成),如果还做了查询改写和重排,那就是四次。Java 默认的同步 Servlet 模型下,每个请求占用的线程会一直阻塞等待外部接口返回,并发稍高就会把线程池打满。我遇到过测试环境并发 20 就持续超时的情况,原因就在这里。
解决问题的思路有两个方向。一是把外部调用改成响应式或异步,WebFlux 或 CompletableFuture 包装,但复杂度会上升;另一个更务实的做法是给每个外部调用设置合理超时,以及用连接池管理 HTTP 客户端。向量化调用超时设定为 10 秒、生成调用超时设定为 60 秒,低于默认值。另外把耗时较长的重排环节做成并行调用,也可以显著缩短总响应时间。先保证系统不崩,再谈优化性能。
5.4 数据更新后检索结果没有变化:缓存与索引的坑
做本地知识库难免要频繁更新文档。常见问题是:删除了旧文档,新增了新文档,但查询结果还是旧内容。这里要检查两件事:是否有缓存层没失效;向量库中旧向量是否真正被删除。Spring AI 的 VectorStore 一般提供 delete 方法,需要按文档 ID 或元数据条件删除,但如果你在切分时没有为每个片段生成稳定 ID,更新后旧向量就会残留。
解决方式是建立一个“文档级 ID 到片段级 ID”的映射,更新程序先按文档 ID 删除全部旧片段,再重新切分和入库。整个过程可以做成一个定时任务,增量扫描文档目录,文件内容变化时自动触发重建。实际操作时我还会在重建前先查一遍总数,重建后对比数量,确保没有漏删或重复插入。
写在最后:从一个失败 Demo 到稳定链路的个人体会
坦白讲,我第一次把 Spring AI 的 RAG Demo 搭好的时候,信心满满,结果一问业务问题就露馅,回答的准确率连一半都不到。当时的第一反应是换更大更强的模型,后来才发现真正的瓶颈全在检索侧:切得太碎、阈值太松、没有重排、也没有倒排索引兜底。把这些基建补上之后,即使还是同一个本地小模型,效果都有了肉眼可见的飞跃。这也验证了一个观点:RAG 的质量不是靠某一个环节的“豪华堆料”,而是靠每一环都做到合格。
如果你正在做类似的项目,我的建议是先把链路基础打稳,再考虑优化。先跑通切分、入库、检索、生成闭环,把可观测性日志加上,再去调 Embedding 模型、阈值和重排策略。Spring AI 的价值在于它能让你快速构建出标准化的 RAG 管道,同时保留充分的扩展位,把薄弱环节替换成自己更合适的实现。我后续准备把多路召回和增量更新的部分继续做深,到时候再整理一篇实操记录分享出来。