1. 为什么你的模型需要一套“外挂记忆”很多人第一次接触 Spring AI 的 RAG脑子里冒出来的第一个疑问是大模型不是已经读过海量数据了吗为什么还要我给它喂私有知识这个问题不搞清楚后面写出来的代码大概率是“能跑但没用”。我拿一个真实场景来说明。假设你所在的公司有一套内部 ERP 系统里面有几千个产品型号、规格参数、适配关系。你直接问通用大模型“XX 型号的额定电流是多少”它要么编一个看起来很像但完全错误的数字要么干脆说“我无法获取该信息”。这不是模型笨而是它的训练数据里根本没有你公司的产品手册。模型的参数化知识是“冻结”的训练完成那一刻起它对外部世界的认知就定格了。RAG 要解决的就是这个“知识时效性”和“知识私有性”的问题。它的核心思路非常朴素不去改模型本身而是在模型回答问题之前先从你的私有知识库里检索出相关内容把这些内容作为上下文一起塞给模型让模型“看着材料答题”。这就像开卷考试和闭卷考试的区别——闭卷考的是记忆力开卷考的是检索和阅读理解能力。RAG 把模型从“背答案”变成了“查资料再回答”。Spring AI 在这个基础上做的事情是把检索、拼接上下文、调用模型这一整套流程抽象成了一套可配置的 API。你不需要自己去写向量化、相似度计算、Prompt 模板拼接这些底层逻辑框架已经帮你封装好了。但封装好不等于你不需要理解原理恰恰相反不理解原理的人用 Spring AI 做 RAG最常见的结局是Demo 跑通了一上真实数据就翻车。这篇文章面向的是已经会用 Spring Boot、对 Spring AI 有初步了解、但还没真正把 RAG 落地到生产项目的开发者。我会从架构拆解讲到代码实操再讲到那些文档里不会写的坑。关键词里的 EmbeddingModel、VectorStore、RAG 切块、多轮对话设计都会在对应的章节里展开。提示如果你还没接触过 Spring AI 的基础概念建议先跑通一个最简单的 ChatClient 调用再回来看这篇。RAG 是建立在基础对话能力之上的增强层跳过基础直接上 RAG 容易一头雾水。2. RAG 在 Spring AI 里的四层架构拆解2.1 从“文档”到“向量”的完整链路Spring AI 的 RAG 流程可以拆成两条独立的链路写入链路和查询链路。很多人只关注查询链路忽略了写入链路的质量结果就是检索出来的内容驴唇不对马嘴。写入链路的完整流程是这样的原始文档PDF、Word、数据库记录、网页经过DocumentReader读取为统一的 Document 对象然后由TextSplitter按照策略切分成若干 chunk每个 chunk 通过EmbeddingModel转换成高维向量最后存入VectorStore。这条链路是一次性的离线过程但它的质量直接决定了查询链路的上限。查询链路则是用户提问经过同一个 EmbeddingModel 转换为查询向量VectorStore 执行相似度搜索返回 Top-K 个最相关的 chunk这些 chunk 被拼接到 Prompt 模板中连同用户原始问题一起发送给 ChatModel最终生成回答。这里有一个容易被忽视的细节写入和查询必须使用同一个 EmbeddingModel。如果你写入时用的是某个 1024 维的模型查询时换成了另一个 768 维的模型向量空间完全对不上相似度计算的结果就是随机噪声。我在实际项目中见过有人因为切换模型忘了重建索引排查了大半天才发现问题。2.2 EmbeddingModel 选型不是越贵越好EmbeddingModel 的选择直接影响到检索质量和成本。目前主流的选择分两类一类是调用云端 API 的嵌入模型另一类是本地部署的开源嵌入模型。云端 API 的优势是效果好、免运维缺点是每次调用都有网络延迟和费用而且你的文档内容会发送到外部服务。本地部署的优势是数据不出内网、无调用费用、延迟可控缺点是需要自己维护模型服务效果可能略逊于顶级云端模型。在 Spring AI 体系下切换 EmbeddingModel 的代价很小因为框架做了统一抽象。你只需要在配置文件里换一个 starter 依赖和对应的配置项业务代码基本不用动。这让“先本地跑通、再按需切换”成为可能。选型时我建议关注三个指标向量维度、最大输入长度、中文支持程度。向量维度影响存储成本和检索精度维度越高精度通常越好但存储开销越大。最大输入长度决定了你的 chunk 能切多大超过限制的内容会被截断。中文支持程度则直接关系到中文文档的检索效果有些模型在英文上表现优异但中文语义捕捉能力一般。2.3 VectorStore 的选型逻辑与存储结构VectorStore 是 RAG 的“记忆仓库”。Spring AI 支持多种实现从最简单的内存版 SimpleVectorStore 到生产级的 PgVector、Milvus、Redis 等。选型的核心考量是数据量级、是否需要持久化、是否已有现成的基础设施。如果你只是做原型验证SimpleVectorStore 足够了它把向量存在内存里重启就丢。如果数据量在百万级以下且已经有 PostgreSQLPgVector 是最省事的选择不用额外引入中间件。如果数据量上亿或者对检索延迟有极高要求才需要考虑 Milvus 这类专用向量数据库。这里我要特别提醒一个坑VectorStore 里存的不仅仅是向量。每个 chunk 的原始文本、元数据来源文件、页码、章节标题等都会一起存储。元数据在后续的过滤检索中非常关键。比如你可以只检索某个产品线下的文档或者只检索最近三个月更新的内容。如果写入时没有保留元数据后面想做精细化检索就只能重建索引。2.4 ChatModel 在 RAG 中的角色定位很多人把 RAG 的注意力全放在检索上忽略了 ChatModel 这一环。实际上检索回来的内容怎么“喂”给模型同样决定了最终回答的质量。Spring AI 提供了QuestionAnswerAdvisor这个开箱即用的组件它自动完成了“检索 拼接 调用”的流程。但开箱即用意味着你放弃了精细控制。在真实项目中我通常会把检索和生成拆开自己控制 Prompt 模板。原因很简单默认模板在处理多轮对话、多文档冲突、需要引用来源等场景时往往不够用。ChatModel 在 RAG 中的角色是“阅读理解 组织回答”它不负责判断检索内容是否正确。如果检索回来的内容本身是错的或者不相关的模型要么被误导给出错误答案要么直接忽略检索内容凭自己的知识回答。所以检索质量是 RAG 的生命线生成环节只是锦上添花。3. 文档切块RAG 效果的分水岭3.1 为什么“按固定字数切”是最差的选择TextSplitter 是 RAG 里最不起眼但最致命的环节。我见过太多项目用最简单的TokenTextSplitter按固定 token 数切分结果检索出来的 chunk 要么断头断尾语义不完整要么包含大量无关内容。举个具体的例子。假设你的文档里有一段产品规格说明“型号 A100 的额定电压为 220V额定电流为 5A防护等级为 IP65。”如果你按每 20 个 token 切分很可能切成“型号 A100 的额定电压为 220V额定”和“电流为 5A防护等级为 IP65”两个 chunk。用户问“A100 的额定电流是多少”检索系统可能只召回了第二个 chunk模型看到“电流为 5A”但没有型号信息回答时就可能张冠李戴。固定字数切分的问题在于它完全无视文档的语义结构。好的切块策略应该尽量保证每个 chunk 是一个语义完整的单元比如一个段落、一个小节、一个完整的问答对。3.2 语义切块的三种落地策略在实际项目中我常用的切块策略有三种按复杂度递增第一种基于分隔符的递归切分。优先按段落分隔符双换行切如果某段太长再按单换行切还太长再按句号切。Spring AI 的TokenTextSplitter支持配置分隔符列表本质上就是这个思路。这种策略实现简单对结构清晰的文档效果不错。第二种基于文档结构的切分。如果原始文档是 Markdown 或 HTML可以按标题层级切分每个小节作为一个 chunk同时把标题路径作为元数据保留。这样检索出来的 chunk 自带上下文信息模型更容易理解。比如一个 chunk 的元数据里记录了“产品手册 第三章 电气参数”即使 chunk 正文里没写型号模型也能从元数据推断。第三种基于语义相似度的切分。先按句子切分然后计算相邻句子的向量相似度在相似度骤降的地方断开。这种策略能最大程度保证 chunk 内部的语义连贯性但计算成本较高适合对检索质量要求极高的场景。我的建议是先用第二种不够再上第三种。第一种只适合快速验证。3.3 chunk 大小与重叠窗口的参数计算chunk 大小设多少合适这个问题没有标准答案但有一个计算框架。chunk 大小的上限由 EmbeddingModel 的最大输入长度决定。假设你用的模型最大支持 512 个 token那 chunk 就不能超过这个数否则会被截断。但实际设置时建议留 20% 的余量因为还要考虑元数据和特殊标记的占用。chunk 大小的下限则由“最小语义单元”决定。如果一个完整的问答对需要 100 个 token 才能表达清楚那 chunk 就不应该小于 100。我的经验值是中文文档 300 到 500 字英文文档 200 到 400 词这是一个比较通用的起点。重叠窗口overlap的作用是防止关键信息刚好落在切分边界上被割裂。通常设置为 chunk 大小的 10% 到 20%。比如 chunk 是 400 字overlap 设 60 到 80 字。overlap 太大会导致存储冗余和检索重复太小则起不到保护作用。文档类型建议 chunk 大小建议 overlap切分策略产品规格表200-300 字40-60 字按表格行/段落技术文档400-600 字80-120 字按标题层级问答对一问一答为一块0按问答边界法律合同300-500 字100 字按条款3.4 元数据设计被大多数人忽略的检索加速器元数据是 RAG 里投入产出比最高的设计。写入时多存几个字段查询时就能做精细化过滤检索精度可能提升一个档次。我通常会在元数据里保留这几类信息来源标识文件名、URL、数据库表名、结构路径章节标题、页码、时间信息创建时间、更新时间、业务标签产品线、文档类型、权限等级。有了这些元数据你就可以实现“只在某个产品线的文档里检索”“只检索最近半年更新的内容”“过滤掉当前用户无权访问的文档”等需求。Spring AI 的SearchRequest支持传入过滤表达式底层 VectorStore 会把它翻译成对应的过滤条件。注意不同 VectorStore 对过滤表达式的支持程度不同。SimpleVectorStore 支持基本的等于和比较PgVector 支持更复杂的 JSON 字段查询。选型时要确认你的过滤需求是否被支持。4. 从零搭建一条可用的 RAG 链路4.1 依赖引入与版本对齐Spring AI 的版本迭代比较快不同版本之间的 API 有差异。在开始之前先确认你用的 Spring Boot 版本和 Spring AI 版本是匹配的。我写这篇文章时参考的是 Spring AI 1.0.x 系列它要求 Spring Boot 3.4 以上。Maven 依赖的核心是三个Spring AI 的 OpenAI starter或者你选用的模型 starter、VectorStore 的 starter、以及文档读取相关的依赖。如果你用的是国内的模型服务需要引入对应的 starter 并配置 base-url 和 api-key。dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-pgvector-store-spring-boot-starter/artifactId /dependency配置文件里需要设置模型服务的地址、密钥、嵌入模型名称、向量库连接信息。这里有一个容易踩的坑嵌入模型和对话模型是分开配置的。很多人只配了对话模型忘了配嵌入模型启动时直接报错。spring: ai: openai: api-key: ${API_KEY} base-url: ${BASE_URL} embedding: options: model: text-embedding-3-small chat: options: model: gpt-4o-mini4.2 文档读取与向量化的代码骨架写入链路的核心代码分三步读取、切分、存储。// 1. 读取文档 Resource resource new ClassPathResource(docs/product-manual.md); DocumentReader reader new MarkdownDocumentReader(resource); ListDocument documents reader.get(); // 2. 切分 TokenTextSplitter splitter new TokenTextSplitter(500, 100, 10, 5000, true); ListDocument chunks splitter.apply(documents); // 3. 写入向量库 vectorStore.add(chunks);这段代码看起来简单但每一步都有讲究。TokenTextSplitter的构造参数依次是默认 chunk 大小、最小 chunk 字符数、最小 chunk 长度、最大 chunk 数量、是否保留分隔符。参数顺序容易记混建议用命名参数或者封装成配置类。vectorStore.add()内部会自动调用 EmbeddingModel 把文本转成向量。如果文档量大建议分批写入避免一次性发送过多请求导致超时。我一般每批 50 到 100 个 chunk。4.3 检索与生成的两种集成方式Spring AI 提供了两种集成检索和生成的方式我分别说一下适用场景。方式一QuestionAnswerAdvisor。这是最省事的方式把它注册到 ChatClient 上后续所有对话都会自动走 RAG 流程。ChatClient chatClient ChatClient.builder(chatModel) .defaultAdvisors(new QuestionAnswerAdvisor(vectorStore)) .build(); String answer chatClient.prompt() .user(A100 的额定电流是多少) .call() .content();这种方式适合快速验证和简单场景。缺点是检索参数Top-K、相似度阈值只能用默认值Prompt 模板也不可控。方式二手动检索 自定义 Prompt。这种方式代码多一些但控制力强。ListDocument docs vectorStore.similaritySearch( SearchRequest.builder() .query(question) .topK(5) .similarityThreshold(0.7) .build() ); String context docs.stream() .map(Document::getText) .collect(Collectors.joining(\n\n)); String prompt 请根据以下参考资料回答问题。如果资料中没有相关信息请明确说明。 参考资料 %s 问题%s .formatted(context, question); String answer chatModel.call(prompt);我推荐生产项目用第二种方式。原因很简单你需要控制“检索不到时怎么办”。默认的 Advisor 在检索结果为空时可能仍然会让模型自由发挥而自定义 Prompt 可以明确要求模型在无参考资料时拒绝回答。4.4 相似度阈值与 Top-K 的调参经验Top-K 和相似度阈值是两个最关键的检索参数。Top-K 决定返回多少个 chunk阈值决定低于多少相似度的结果被丢弃。Top-K 设太小可能漏掉关键信息设太大会引入噪声干扰模型判断。我的经验是从 5 开始调。如果发现回答经常缺少细节加到 8 或 10如果发现回答经常被无关内容带偏降到 3 或 4。相似度阈值的设定取决于你的 EmbeddingModel 的相似度分布。不同模型的相似度分数范围不一样有的模型余弦相似度普遍偏高有的偏低。不要照搬别人的阈值要自己测。方法很简单拿几个已知答案的问题去检索看正确 chunk 的相似度分数落在什么区间把阈值设在略低于这个区间下沿的位置。提示相似度阈值设得太高会导致“检索为空”模型收不到任何参考资料。这时候要么降低阈值要么在 Prompt 里做好兜底逻辑。5. 多轮对话与检索的协同设计5.1 为什么单轮 RAG 在多轮场景下会失效单轮 RAG 的逻辑是“用户问什么就检索什么”。但多轮对话里用户的后续问题往往包含指代和省略。比如第一轮问“A100 的额定电流是多少”第二轮问“那它的防护等级呢”。如果直接把“那它的防护等级呢”拿去检索向量里没有任何关于“A100”的信息检索结果必然跑偏。这个问题的本质是检索用的 query 和用户原始输入不是一回事。检索 query 需要是“自包含”的也就是不依赖上下文就能独立表达完整语义。5.2 查询改写让检索 query 自包含解决多轮检索问题的标准做法是“查询改写”。在检索之前先用一次轻量的模型调用把用户问题改写为自包含的 query。具体做法是把最近几轮对话历史 当前问题一起发给模型让它输出一个改写后的独立问题。比如输入“那它的防护等级呢”加上历史“A100 的额定电流是多少”模型输出“A100 的防护等级是多少”。然后用这个改写后的问题去检索。String rewritePrompt 根据以下对话历史将用户的最新问题改写为一个不依赖上下文、 可以独立理解的完整问题。只输出改写后的问题不要解释。 对话历史 %s 最新问题%s .formatted(history, currentQuestion); String standaloneQuery chatModel.call(rewritePrompt);这次改写调用会增加一点延迟和成本但对多轮场景的检索准确率提升非常明显。如果对延迟敏感可以用更小的模型来做改写或者只在检测到指代词“它”“这个”“那个”时才触发改写。5.3 对话历史与检索上下文的拼接顺序拼接顺序影响模型的注意力分配。我试过几种排列最终稳定用的是这个结构系统指令角色设定 回答规则检索到的参考资料对话历史用户当前问题把参考资料放在对话历史之前是因为模型对 Prompt 中靠前和靠后的内容注意力更强也就是常说的“中间迷失”现象。参考资料是回答的依据放在靠前位置能提高被引用的概率。用户问题放在最后紧邻生成位置保证模型不会答非所问。对话历史不宜过长一般保留最近 5 到 10 轮就够了。太长的历史会挤占参考资料的空间而且早期对话对当前回答的帮助有限。5.4 检索结果为空时的兜底策略检索为空是 RAG 系统必须处理的场景。用户问了一个知识库里完全没有的问题如果你不做处理模型可能会编造答案。我的兜底策略分三层第一层在 Prompt 里明确写“如果参考资料中没有相关信息请回答‘根据现有资料无法回答该问题’”。第二层在代码里判断检索结果为空时直接返回预设话术不调用模型。第三层记录这类问题定期分析是否是知识库覆盖不足。第一层是必须的第二层是推荐的第三层是长期优化的手段。三层配合既能保证用户体验又能持续改进知识库。6. 上线前必须验证的五个关键点6.1 检索命中率的量化评估RAG 系统上线前必须做检索命中率的评估。方法不复杂准备一批测试问题每个问题标注好正确答案所在的文档和段落然后跑检索看正确段落是否出现在 Top-K 结果里。命中率低于 80% 就说明检索环节有问题需要回头检查切块策略、EmbeddingModel 选型、相似度阈值。这个评估不需要很复杂的工具写个 JUnit 测试批量跑一遍就行。6.2 幻觉抑制的 Prompt 工程幻觉是 RAG 最需要防范的问题。即使检索到了正确内容模型也可能“自由发挥”添加不存在的信息。抑制幻觉的核心手段是在 Prompt 里建立明确的约束要求模型只使用参考资料中的信息要求模型在引用时标注来源要求模型在资料不足时明确说明禁止模型使用“根据我的知识”这类表述这些约束不能保证 100% 消除幻觉但能显著降低发生率。另外把 temperature 调低0.1 到 0.3也有帮助因为低温度让模型的输出更确定、更保守。6.3 向量库索引的更新与重建知识库不是一成不变的。文档更新后对应的向量也需要更新。这里有两种策略增量更新和全量重建。增量更新适合小范围修改只重新向量化变化的文档删除旧向量写入新向量。关键是每个 chunk 要有一个稳定的唯一 ID通常用“文档ID chunk序号”生成。全量重建适合大规模变更或切块策略调整直接清空索引重新写入。我踩过的一个坑是更新文档后忘了删除旧向量导致同一个问题检索出新旧两个版本的矛盾内容。后来我在写入逻辑里加了“先按文档ID删除再写入”的步骤才解决这个问题。6.4 响应延迟的优化手段RAG 的延迟主要来自三部分检索、Prompt 拼接、模型生成。检索通常在几十毫秒到几百毫秒模型生成是大头可能几秒。优化检索延迟的手段包括给 VectorStore 建索引、减少 Top-K、使用更快的 EmbeddingModel。优化生成延迟的手段包括使用更小的对话模型、减少参考资料的长度、开启流式输出让用户先看到部分结果。流式输出对体感延迟的改善非常明显。用户看到文字一个个蹦出来即使总耗时不变主观感受也会好很多。Spring AI 的stream()方法支持流式返回配合 SSE 推送到前端即可。6.5 权限过滤与数据隔离如果知识库里包含不同权限等级的文档检索时必须做权限过滤。这个逻辑不能放在生成之后必须在检索阶段就过滤掉无权访问的内容否则模型可能把敏感信息泄露给无权限的用户。实现方式是在元数据里存权限标签检索时通过SearchRequest的过滤条件排除掉当前用户无权访问的 chunk。这个过滤条件应该由后端根据用户身份自动生成不能依赖前端传入。注意权限过滤是安全底线不能因为“先上线再说”而省略。一旦发生数据泄露后果远比延迟高几个数量级严重。7. 那些文档里不会写的踩坑记录7.1 中文文档切块后检索效果差的排查过程有一次我处理一批中文产品手册切块、向量化、检索全流程跑通但检索效果就是不好。用户问“这个设备怎么保养”检索出来的都是无关段落。排查过程是这样的第一步确认 EmbeddingModel 是否支持中文。查了文档用的是支持多语言的模型排除。第二步检查切块结果。把 chunk 打印出来一看发现切块把中文按字符切了一个完整的句子被切成好几段。原因是TokenTextSplitter默认按 token 切而中文的 token 化方式和英文不同导致切分点很奇怪。解决方案是改用基于标点符号的切分策略优先在句号、问号、分号处断开。Spring AI 的TokenTextSplitter支持自定义分隔符把中文标点加进去后切块质量明显改善。这个坑的教训是中文和英文的文本处理不能一概而论。tokenizer 的行为差异会直接影响切块质量进而影响检索效果。7.2 EmbeddingModel 切换导致向量空间不匹配另一个坑是模型切换。项目初期用的是某个本地嵌入模型后来为了提升效果换成了云端模型。切换后检索结果全乱了相似度分数完全不可比。原因前面提过不同模型的向量空间不同写入时用模型 A查询时用模型 B相似度计算没有意义。解决办法只有一个切换 EmbeddingModel 后必须全量重建索引。这个坑的隐蔽性在于系统不会报错只是检索结果变差。如果你没有做检索命中率评估可能很久都发现不了。所以我在项目里养成了一个习惯任何涉及 EmbeddingModel 的变更都要触发索引重建流程。7.3 大文档批量写入时的内存溢出批量写入大文档时遇到过 OOM。原因是把所有 chunk 一次性加载到内存再调用vectorStore.add()几千个 chunk 的文本加上向量内存直接爆了。解决办法是分批处理。每读取一批文档切分后立即写入然后释放引用。批大小根据 chunk 的平均大小和可用内存调整我一般设 50 到 100。另外vectorStore.add()内部会调用 EmbeddingModel如果模型服务有并发限制还要控制写入的并发度。7.4 相似度分数“看起来很高”但结果不相关有时候检索返回的相似度分数很高比如 0.9但内容明显不相关。这种情况通常发生在短查询上。用户问“价格”这个 query 太短向量化后和很多文档都有较高相似度但语义上并不匹配。解决办法有两个一是对短查询做扩展把“价格”扩展成“产品价格是多少”二是提高相似度阈值同时增加 Top-K让模型从更多候选里自己判断。前者效果更好但需要额外的模型调用。7.5 多轮对话中检索 query 被历史污染多轮对话里如果把完整历史直接拼进检索 query历史中的无关内容会稀释当前问题的语义。比如历史里聊了很多关于“电压”的内容当前问“电流”拼接后的 query 可能检索出电压相关的文档。正确做法是用前面说的查询改写让模型提取出当前问题的核心意图而不是简单拼接。改写后的 query 应该只包含当前问题的语义历史只作为理解指代的背景。8. 从 RAG 到更复杂的知识增强形态RAG 不是终点。在实际项目中我观察到几个演进方向。方向一Agentic RAG。传统 RAG 是“一次检索一次生成”Agentic RAG 让模型自己决定要不要检索、检索几次、用什么 query 检索。这适合复杂问题比如需要多步推理的场景。Spring AI 的 Advisor 机制和工具调用能力可以支撑这种模式。方向二多路召回 重排序。单一向量检索的召回能力有限。可以同时用向量检索、关键词检索、元数据过滤多路召回然后用重排序模型对结果统一排序。这样能兼顾语义匹配和精确匹配。方向三知识图谱增强。对于实体关系复杂的领域比如产品适配关系、组织架构纯向量检索难以捕捉结构化关系。把知识图谱和向量检索结合用图谱做关系推理用向量做语义匹配能覆盖更多查询类型。这些方向不需要一步到位。我的建议是先把基础 RAG 做扎实把检索命中率和幻觉率控制在可接受范围再考虑叠加更复杂的机制。基础不牢叠加越多越乱。我在实际项目中的体会是RAG 的效果 70% 取决于数据质量和切块策略20% 取决于检索参数调优10% 取决于 Prompt 工程。很多人把精力花在 Prompt 上却忽略了最基础的数据处理这是本末倒置。把文档整理干净、切块切得合理、元数据设计到位比任何花哨的 Prompt 技巧都管用。