
做企业内部运维知识库问答系统时我一开始想走捷径把文档全部塞进Prompt让模型直接回答。结果长文档直接超出上下文窗口短文档又经常张冠李戴问“发布回滚步骤”它能答出隔壁部门的离职流程。换到RAG检索增强生成之后整个思路才顺过来。Java生态里能落地的RAG方案其实不多Spring AI算是一个官方化、且迭代速度很快的选择。这篇文章我把Spring AI实现RAG的完整链路、核心API、最小可运行代码从头过一遍重点讲分块、TopK、相似度阈值这些参数到底怎么调以及上生产前必须处理的权限隔离、数据更新和版本兼容问题。适合刚开始接触RAG的Java开发者也适合已经跑通Demo但检索质量不稳定、正急着定位问题的人。1. 先从选型说起Java服务里搭RAG为什么最终留下Spring AI1.1 市面上的路线其实就三类很多Java团队第一次做RAG第一反应是搜教程一搜发现全是Python的FastAPILangChainLangGraphpgvector方案。方案本身没毛病但对一个技术栈以Java为主的后端团队来说代价是维护两套服务一个Java业务系统一个Python AI服务中间还得自己封装API、处理鉴权、搞日志链路。我当时权衡过三条路线。第一条是Python全家桶。生态最强文档多遇到问题基本搜得到答案。缺点是要额外养一套Python服务团队里不是每个人都愿意碰。第二条是LangChain4jJava移植版理念和Spring AI很像社区也活跃但它跟Spring生态的整合没有官方背书很多starter要自己封装。第三条是纯手写自己调Embedding接口、自己算余弦相似度、自己拼接Prompt。代码量确实可控小Demo还能跑但很快你会发现重复造轮子的地方太多尤其当你需要切换模型、换向量库、加过滤条件的时候。最终我选了Spring AI核心原因不是它功能最全而是它的抽象层设计刚好卡在Java后端开发者的使用习惯上你面对的是ChatModel、EmbeddingModel、VectorStore这些接口替换实现只需要改配置业务代码几乎不动。1.2 Spring AI给了什么没给什么Spring AI给到的是标准化的RAG链路抽象它把从文档解析、切分、向量化到检索、生成的通用环节都定义成了接口。更实际的一点是它对模型供应商做了大量适配OpenAI、通义千问、Ollama、智谱这些都有对应的starter切换成本很低。这点如果自己写光是各家SDK的API差异就能耗掉几天时间。但我要强调一句Spring AI没有帮你解决的问题更多。数据质量清洗、权限隔离、检索效果评估、文档增量更新这些都不在框架范围内。RAG项目的效果瓶颈往往出在数据处理环节而不是模型能力或者框架功能。所以选型时别指望框架帮你兜底它只是把工程化链路给你铺好了。我选型时列过一个对比表直接贴出来供参考方案与Spring生态契合度维护成本可观测性适合场景Python LangChain全家桶低需跨服务高一般AI专项团队LangChain4j中中一般已有LangChain经验的Java团队纯手写EmbeddingPG检索高低但重复代码多自己控制小规模快速验证Spring AI高低与Spring Boot日志体系天然集成Java后端团队长期迭代2. 把RAG拆开看Spring AI里一条检索增强链路由哪些组件组成2.1 一条标准RAG链路的四个环节先用一句大白话解释RAG模型回答问题之前先从一个资料库里把可能相关的段落捞出来然后让模型只根据这些捞出来的段落作答。相当于考试时给你一本可以翻阅的参考书但只允许你看划了重点的几页。四个环节分别是加载、切分、向量化、检索与生成。加载就是把PDF、Word、Markdown、TXT这类原始文件解析成纯文本。切分是把长文本切成固定大小的块因为模型上下文窗口是有限的而且整篇塞进去检索精度会大幅下降。向量化是把每个文本块通过Embedding模型转成一个高维向量这个向量在空间里的位置反映了文本的语义。最后用户提问时同样把问题转成向量从向量库里找出最相近的若干个文本块拼进Prompt交给大模型。这里面有个很容易忽略的点向量检索是“相似度匹配”不是“精确匹配”。也就是说它捞出来的内容只能保证“看着像有关”不能保证“一定正确”。所以后面所有调优工作本质都是在提高这个“看着像”的准确率。2.2 Spring AI核心API与环节对应关系Spring AI把上面四个环节抽象得比较规整每个环节都有对应的接口或类RAG环节Spring AI组件说明文档加载DocumentReader接口常见TikaDocumentReader、PagePdfDocumentReader、JsonReader负责把各种格式转成Document对象文本切分DocumentSplitter接口常见TokenTextSplitter按Token数切分支持重叠向量化EmbeddingModel接口实现如OpenAI、DashScope、Ollama将文本变成向量向量存储VectorStore接口实现如PgVectorStore、RedisVectorStore、MilvusVectorStore保存向量并提供相似度检索检索与生成QuestionAnswerAdvisor或自行调用VectorStore ChatModel检索排序、拼Prompt、调模型生成回答这套分层设计最直接的好处是每个环节都可以替换。你今天用OpenAI的Embedding模型写好了代码明天要换成通义千问或者本地Ollama的bge模型需要改的只是配置文件链路代码完全不用动。我实际切换过一次感受确实方便。另外Document对象值得多说一句。它内部包含文本内容、元数据Metadata和文档ID。元数据非常关键后面讲权限隔离、分类过滤全靠它比如你可以给每一片文档打上category、department、owner这类标签检索的时候按标签过滤。2.3 两种落地写法自动Advisor与手动检索Spring AI里实现RAG有两种典型写法。第一种是用QuestionAnswerAdvisor它会自动完成“检索→拼Prompt→生成回复”这一串动作代码量最少。第二种是手动检索自己调用VectorStore的similaritySearch拿到候选文档再自己拼Prompt调用ChatModel。代码多一点但调试、日志、过滤条件、多路召回这些全部由你自己控制。两种写法没有绝对优劣。项目早期验证效果时用QuestionAnswerAdvisor最省事进入生产后如果发现需要精细控制检索过程手动写会更好用。我在第三章里会把两种方式的代码都贴出来。我个人的经验是先用Advisor快速跑通然后看检索结果再做取舍。因为RAG的优化必须建立在能看到中间过程的基础上如果你一开始就用黑盒式调用连检索到什么内容都不知道出了问题很难定位。3. 跑通最小可用版本依赖、入库、问答三件事3.1 工程依赖与基础配置我用的版本组合是Spring Boot 3.4.x加Spring AI 1.0.0。这里强烈建议通过BOM引入Spring AI的依赖避免各个模块版本不一致。dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement核心依赖两个一个是模型starter我示例里以DashScope通义千问为例一个是VectorStore的pgvector starter。dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-dashscope/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-vector-store-pgvector/artifactId /dependency dependency groupIdorg.postgresql/groupId artifactIdpostgresql/artifactId /dependency注意如果你用的是OpenAI官方或者本地部署的兼容OpenAI接口的服务把模型依赖换成spring-ai-starter-model-openai即可然后在application.yml里把base-url指向对应服务地址就可以了。Spring AI对OpenAI兼容接口的支持很通用这也是很多人用它对接国内模型商或本地模型的原因。application.yml配置如下spring: datasource: url: jdbc:postgresql://localhost:5432/rag_demo username: postgres password: postgres ai: dashscope: api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen-plus embedding: options: model: text-embedding-v3 vectorstore: pgvector: index-type: HNSW distance-type: COSINE_DISTANCE dimensions: 1024 initialize-schema: true这里有个参数容易踩坑dimensions必须和Embedding模型输出的维度一致。text-embedding-v3输出1024维所以配置里写1024。如果你换了模型比如OpenAI的text-embedding-3-large默认维度是3072配置不改的话检索结果就是乱的。initialize-schema设为true可以让Spring AI启动时自动建表开发环境方便生产环境建议关掉自己管理表结构。3.2 知识入库读取、切分、向量化、写入知识入库是RAG链路上我最看重的环节因为它直接决定后续检索质量。下面这段代码把上传文件解析、切分、加元数据、写入向量库一次完成。Service public class DocumentIngestService { private final VectorStore vectorStore; public DocumentIngestService(VectorStore vectorStore) { this.vectorStore vectorStore; } public void ingestFile(MultipartFile file, String category) throws IOException { Resource resource new InputStreamResource(file.getInputStream()); TikaDocumentReader reader new TikaDocumentReader(resource); ListDocument documents reader.get(); documents.forEach(doc - { doc.getMetadata().put(source, file.getOriginalFilename()); doc.getMetadata().put(category, category); }); TokenTextSplitter splitter new TokenTextSplitter(500, 100, 5, 500, true); ListDocument chunks splitter.apply(documents); vectorStore.add(chunks); } }关于TikaDocumentReader我多说一句。它内部调用了Apache Tika对PDF、Word、HTML等常见格式都能直接抽文本。相比之下PagePdfDocumentReader只处理PDF而且如果PDF是扫描件没有OCR能力抽出来全是空文本。所以我一般默认用Tika。TokenTextSplitter的五个参数分别是chunkSizeTokens500每块约500个TokenchunkOverlapTokens100前后块有100个Token的重叠minChunkSizeChars5过滤掉太小的残留maxNumChunks500单文档最多分500块keepSeparatortrue切分时保留分隔符。重叠的作用很关键它保证被切在边界上的完整语义不会丢失。这里还想强调一个点写入之前最好看一眼切分结果。我刚开始做的时候直接入库几千个文件后来检索效果差才发现文本块里全是页眉页脚和空行。开发阶段可以在入库时把chunk内容打到日志里抽样确认。3.3 问答链路Advisor自动版与手动版先看最简写法用QuestionAnswerAdvisorService public class RagQueryService { private final ChatClient chatClient; public RagQueryService(ChatModel chatModel, VectorStore vectorStore) { this.chatClient ChatClient.builder(chatModel) .defaultAdvisors(new QuestionAnswerAdvisor(vectorStore, SearchRequest.builder() .topK(5) .similarityThreshold(0.5) .build())) .build(); } public String ask(String question) { return chatClient.prompt() .user(question) .call() .content(); } }这段代码的含义是每次调用都会拿用户问题去向量库检索TopK5个相关文档过滤掉相似度低于0.5的结果然后把命中文档和问题一起交给模型生成回答。QuestionAnswerAdvisor会控制Prompt模板不需要你手写上下文拼接逻辑。再看手动版本适合需要控制中间过程或加日志的场景public String askWithManualRetrieval(String question) { ListDocument documents vectorStore.similaritySearch(SearchRequest.builder() .query(question) .topK(5) .similarityThreshold(0.5) .build()); String context documents.stream() .map(Document::getContent) .collect(Collectors.joining(\n\n---\n\n)); String prompt 你是一个企业内部知识库助手。 请严格基于下面给出的资料回答用户问题不要编造资料中不存在的内容。 如果资料里没有相关内容请直接回答“资料中没有找到”。 资料 %s 用户问题 %s .formatted(context, question); return chatModel.call(prompt); }两个版本对比手动版的好处是你能拿到检索到的documents排查问题非常方便。我之前遇到答非所问靠打印检索结果一眼就发现是切分太碎正确答案被切成两半只捞回来一半。用Advisor的话这种问题很难定位。3.4 用真实问题验证效果我拿一个内部《服务发布规范》的文档做过测试里面有一段内容如果发布后发现错误率超过1%或P95延迟超过300ms立即执行回滚。回滚操作包括将镜像回退到上一个稳定版本同时回滚数据库迁移脚本对应的版本号然后再观察15分钟。我提问“什么情况下需要回滚”检索命中的正好是包含错误率、延迟条件的那一段模型给出的回答是“发布后发现错误率超过1%或P95延迟超过300ms时需要立即回滚具体操作包括镜像回退、数据库迁移脚本版本回滚并观察15分钟。”这算是一个正常的RAG闭环。如果检索没命中那大概率要回头检查分块参数或Embedding模型选择而不是怀疑模型笨。4. 检索质量调优分块、TopK、相似度阈值这几个参数怎么摸4.1 分块策略太大容易串味太小容易切碎分块是RAG里最影响效果、也最需要实验的参数。块太大一个块里包含多个主题的内容。检索的时候模型把不相关的内容也一起拿进来了回答容易出现“串味”。块太小语义被切断。比如一句“该策略不适用于生产环境”可能独立成块检索时只看到“适用于生产环境”意思完全反了。块太小还容易检索不到因为向量是从块级文本生成的信息不足时向量的位置会偏移。我日常使用的参考区间是chunkSizeTokens在400到800之间overlap在50到100之间。如果文档本身是Markdown这类有标题结构的我更推荐先按标题切段再对超长的段按Token切分。这个做法实现起来也不复杂就是先用正则按标题把文档拆开再对每个子块判断长度是否超限。另外还要说一个中文场景的细节TokenTextSplitter按Token数切分但中文一个Token不一定等于一个字。不同模型的分词器切出来的Token数量不一样。同一个中文文本在OpenAI分词器下可能500个Token在别的模型下可能是700个。所以不要想当然地用字符数换Token数自己打印几个切分结果看看比较靠谱。4.2 TopK和相似度阈值一对配合使用的旋钮TopK是指检索返回多少条候选文档。这个参数直接决定两件事召回率和上下文开销。TopK太小比如2相关文档很容易被漏掉尤其当答案分散在多段文本时。TopK太大比如20噪声文档会把模型带偏而且Token消耗也翻倍。一般从5开始调如果你发现很多问题是因为答案根本不在检索结果里试试加大到8或10。相似度阈值则是判断“这条结果算不算相关”的门槛。不同Embedding模型的评分分布差异极大。text-embedding-v3的相似度普遍偏高经常在0.6到0.8波动有些本地模型的分值则在0.3到0.6之间徘徊。所以照抄别人的0.5阈值不一定有效必须看你自己模型的实际分布。我的建议是0.5作为起点然后专门用一批已知正确答案的问题去打印检索相似度观察“正确答案对应的文档”和“错误结果”的分数区间把阈值放在两类结果分布有区分度的位置。4.3 我调参时用的一套笨办法说一套我实测下来比较好用的调参流程虽然土但有效。第一步准备20到30条标准问答对。这些问题必须覆盖你真实业务里最常被问的那类问题答案一定要可以从知识库原文里找到。第二步跑检索记录每条问题在某个参数组合下的TopK结果里是否包含“答案来源所在的那个文本块”。这一步不要看模型回答只看检索命中情况。第三步调整分块大小、TopK、阈值反复跑记录命中率。第四步检索命中率满意后再批量跑问答看回答质量。我当时记录过一组数据chunkSizeTokensTopK阈值检索命中率回答可用率50050.516/2014/2080050.518/2017/2080080.519/2015/2080080.420/2014/20很直观地看到检索命中率和回答可用率不是一回事。加大TopK之后命中率上去了但噪声多了模型回答质量反而下降。最后我选了800块大小、TopK5、阈值0.5的组合。这说明调参不能只看单一指标一定要把“检索命中”和“最终回答”分开看。提示如果你发现调了很久检索命中率还是上不去先怀疑数据质量而不是参数。极有可能是文档切分前抽取出来的文本本身就丢了关键内容比如表格里的数据没被抽出来。5. 从Demo到生产权限隔离、数据更新、脏数据和版本迁移这些坑要提前填5.1 多租户和权限隔离检索阶段就要过滤这是企业知识库最容易出事故的地方。向量检索本身是不知道权限的它只计算文本向量的距离谁的问题和哪份文档更接近它就返回什么。如果HR的离职流程文档被检索到并且被你拼进Prompt交给模型回答那这算一次严重的数据暴露事故。解决方案是在入库阶段就把权限维度写进元数据检索时用过滤条件限制范围。SearchRequest.builder() .query(question) .topK(5) .filterExpression(category hr) .build();Spring AI的pgvector实现支持通过filterExpression对元数据做过滤。如果你的场景是按部门隔离就维护一个部门到category的映射检索前先算出当前用户能看到哪些类目再把过滤条件拼进SearchRequest。这里有个细节容易被忽略过滤条件匹配的是元数据字段不是向量里的语义。所以入库时元数据的值一定要规范化比如统一用部门编码而不是部门名称。我之前遇到过一个线上问题就是同一个部门在文档里有时叫“技术部”有时叫“技术中心”导致按部门过滤时漏数据。后来统一改成编码字段问题才解决。5.2 数据更新向量库里躺着的旧文档怎么处理VectorStore提供了add和delete接口但真实业务里“更新”不是一个简单的覆盖动作。你上传一份新版本的《考勤制度》旧版本还在向量库里如果不删掉检索时新老条款同时被捞出来模型根据哪条回答就看随机性了。我维护了一套映射业务文档ID对应一批向量文档ID。入库时把业务ID写在元数据里同时记录写入后返回的Document ID列表。更新时先按业务ID找到旧向量ID列表执行delete再入库新版本。这套逻辑自己写也不复杂但必须做。对中小规模知识库比如几万块以内偷懒的办法是每次更新时全量重建。把知识库文档全部重新读取、切分、向量化、写入中间短暂停机或者用两套集合切换。这个方法看着笨但逻辑简单不容易出现新旧数据共存的问题。数据量大了以后再换成增量更新方案。5.3 脏数据与格式解析RAG项目里脏数据是常态。PDF的分页页眉、页脚、目录、页码Word里的批注、修订Markdown里的HTML嵌套标签这些都会干扰切分质量。我遇到过最典型的一个坑某份PDF每一页都重复打印了公司名称和页脚Tika抽出来的文本里这些内容穿插在正文之间。TokenTextSplitter切分后很多块的第一句都是页眉向量化之后这些块的特征被严重稀释检索效果非常差。处理办法是在入库前做文本清洗把连续重复出现的行去除或者把页眉页脚对应的文本区域在拆分阶段排除掉。另外Excel文件直接让Tika抽文本的话表格结构会丢失行与行的对应关系会乱。建议针对表格类文档做定制处理每一行转成一条文本并加上表头信息。这种细节对检索质量影响很大因为很多企业知识库查的就是表里的数据。5.4 索引与性能HNSW、Embedding耗时、批量入库pgvector提供了两种常见的索引类型IVFFlat和HNSW。对于中小规模数据量我推荐直接上HNSW它的查询召回率更稳定对参数不敏感。IVFFlat需要训练过程而且如果你的数据分布和训练数据不一致召回率会明显下降。指标IVFFlatHNSW建索引速度快较慢查询速度快快召回率与训练数据分布强相关稳定内存占用低较高性能上一次RAG请求的耗时分布大概是这样用户问题Embedding一次网络往返可能几十到上百毫秒向量检索在索引正常的情况下是毫秒级LLM生成占据大头几百毫秒到几秒。所以你要优化体验首先盯的是LLM生成耗时和搜索结果的方案选择而不是向量检索本身。入库阶段要注意Embedding调用频繁。大批量文档入库时如果逐条循环调用API速度很慢而且容易触发限流。建议用批量接口一次传一批文本既省时间又降低费用。Spring AI的EmbeddingModel接口也提供了批量方法值得用起来。5.5 版本迁移从0.8到1.0再到2.0API改了什么Spring AI的版本迭代速度很快API也远没有达到稳定。我从0.8.x时代用起到1.0.0 GA期间踩过不少兼容性的坑。0.8到1.0有一批明显的改名EmbeddingClient变成了EmbeddingModelOpenAiChatClient变成了OpenAiChatModel依赖坐标也从spring-ai-openai-spring-boot-starter改成了spring-ai-starter-model-openai。不少网上教程用的还是旧写法照抄前先确认版本。到了2.0又有一次较大调整具体以官方迁移文档为准。这里我给一个通用建议升级版本时不要直接改版本号就完事先用官方example仓库里的最小示例跑通再回头改自己的业务代码。Spring AI的example仓库里覆盖了各个版本的标准用法这是排查API问题的第一手资料。版本问题无法完全避免但可以通过锁版本和集中管理依赖来减少意外。项目里Spring AI版本必须由BOM统一管理严禁各模块自己写死不同版本号。6. 进阶路线重排序、GraphRAG和Agentic RAG值不值得上6.1 粗排到精排用重排序模型抢救TopK基础RAG的检索依赖向量相似度本质上是一个“粗排”过程。向量模型处理不了太复杂的语义关系比如同义词、长尾表达、需要多条件匹配的问题排名靠前的可能并不是真正相关的内容。重排序的思路是用一个专门的Rerank模型把向量检索出来的TopK比如20条重新打分排序只取前5条进入Prompt。Rerank模型的计算代价比向量检索高但它只处理少量候选整体延迟仍可接受。部署方式一般独立一个Rerank服务Java代码通过HTTP调用。public ListString rerank(String question, ListString candidates) { // 调用本地/远程 rerank 服务的HTTP接口 // 请求体里带 question 和 candidates // 返回按相关性分数排序后的候选列表 ListScoreItem scored rerankHttpClient.rerank(question, candidates); return scored.stream() .filter(item - item.score() 0.1) .limit(5) .map(ScoreItem::text) .toList(); }我自己实测下来的感受是Rerank对“症状描述型”问题提升最明显。比如问“登录页面一直转圈怎么排查”向量粗排可能把“登录报错信息”排在前面Rerank能把“页面加载卡顿排查”排上来。这类效果的提升单纯调向量参数很难达到。6.2 GraphRAG解决什么问题基础RAG处理不了多跳问题。比如“A系统依赖B服务的哪个接口B服务挂了会影响哪些上游”这个答案分散在多篇文档里而且彼此之间的关联关系比文本相似度更关键。GraphRAG的思路是把文档里的实体和关系抽取出来构建成知识图谱回答问题时沿着图结构去检索而不是只看文本向量距离。Spring AI也在往这个方向扩展但生态还比较早期。如果团队真的有强关系型问答需求更成熟的做法是自建链路用LLM抽取实体关系存入Neo4j这类图数据库问答时先做实体识别再查图。GraphRAG的问题是建设成本高。实体抽取的准确性需要反复调试图Schema设计要懂业务维护成本比普通RAG高一个量级。我建议普通知识库先用基础RAG加Rerank等确实遇到多跳检索瓶颈再考虑。6.3 Agentic RAG多轮检索与工具调用Agentic RAG是近期的热门方向核心区别是基础RAG一次检索就回答Agentic RAG可以拆解问题、多次检索、调用工具、反思修正。举个实际场景用户问“对比一下测试环境和预发环境的Nginx配置差异”。理想流程是先识别出这是对比型问题然后分别检索两个环境的配置文档最后汇总差异。如果只做一次向量检索很可能只返回其中一份文档回答就不完整。用Spring AI落地可以利用ChatClient配合工具调用或Agent模块把“检索测试环境文档”“检索预发环境文档”设计成两个工具让模型自主决定调用哪个再汇总结果。但对大多数内部知识库来说Agentic RAG的稳定性仍然是挑战。多轮调用意味着Token消耗翻倍、失败节点增多、耗时变长。一定要事先做好路由简单问题直接一次检索回答复杂问题才走多轮链路。不要盲目把所有流量都导向Agent成本和体验都会失控。做完整套链路后我最大的体会是RAG的代码只占三成剩下七成在数据清洗、质量评估和权限设计上。Spring AI把框架层的链路封装得很顺手但它不会告诉你哪些文本块是脏的、哪些用户没有权限看到这段内容、分块参数应该定多少。最后分享一个我一直保留的土办法维护一份二三十条的标准问答对每次改分块参数或者升级版本后用同一份问题集跑一遍回归把检索命中的片段和最终回答一起存档。这套办法看着笨但对检索质量下降的感知比什么监控都直观。你一旦发现某次版本升级后命中率掉了回头对照存档就能快速定位问题。