去年年底我们团队接手了一个内部知识库问答的需求:几百份产品文档、运维手册、项目复盘散落在各处,每次新同学入职光找资料就要折腾一周。当时试过直接把文档往模型上下文里塞,又贵又慢,还经常答非所问。后来我们决定用检索增强生成(RAG)来做——把文档切碎、向量化、存起来,用户提问时先检索相关片段,再让大模型基于这些片段回答。方案定了,技术选型上有个现实问题摆在我们面前:团队全是Java背景,跑Python那一套LangChain + FastAPI + pgvector,先不说学习成本,后面的维护和链路排查就够喝一壶的。这时候我注意到Spring AI,Spring官方出的AI框架,思路和LangChain类似,但整个编程模型完全贴合Spring的习惯。这篇文章就结合我们的实战过程,聊聊怎么用Spring AI把RAG落地,让模型真正“掌握”私有的业务知识。
1. 先搞清楚RAG到底解决什么问题
1.1 大模型的“知道”与“不知道”
所有做AI应用的团队都会撞上同一个墙:大模型再能说,它的知识边界在训练完成那一刻就冻结了。拿GPT系、通义千问、DeepSeek这些模型来说,你问它今年公司最新的故障处理流程,它要么一本正经地编一个,要么直接说不知道。更麻烦的是,企业内部的文档、制度、代码注释、历史工单,这些内容从来不会出现在公网训练语料里。
有一种粗暴的做法是微调,把私有知识通过训练融进模型参数。但要积累足够多的成对问答数据、要机器、要时间,而且知识一更新就得重新训练,对大多数团队来说性价比很低。RAG的思路完全反过来:知识不塞进模型,而是放在外部,让模型在回答前“查资料”。
1.2 RAG的标准流水线拆解
RAG的全称是Retrieval-Augmented Generation,核心就两个动作:先检索,再生成。知识先经过清洗、分块、向量化后存入向量数据库;等用户提问时,同一个向量化模型把问题变成向量,去库里找回最相似的几个片段;最后把“问题 + 检索到的片段”拼成提示词交给大模型,让它基于这些素材做回答。
拆开来看主要有五个环节:
- 文档加载(Document Loading):从PDF、Word、Markdown、HTML里抽取文本。
- 文本分块(Splitting):把长文本切成合适大小的块,控制检索粒度和上下文容量。
- 向量化(Embedding):把文本变成高维向量,语义相近的文本向量距离也近。
- 向量存储与检索(Vector Store & Retrieval):存向量、算相似度、取Top-K。
- 增强生成(Generation):把检索结果注入提示词,交给大模型组织最终回答。
如果用传统方案,比如Java里现拼接各种组件,每一步都要自己对接一个第三方库,光是文档解析和向量库客户端的版本兼容就能折腾几天。Spring AI的价值不是发明了新的AI理论,而是把上面这套流水线抽象成了统一的API,让Java开发者能用Spring的方式写AI应用。
2. 为什么Java团队选Spring AI而不是Python方案
2.1 团队结构和维护成本是硬约束
很多团队在选型时会忽略一个关键变量:这套系统将来谁来维护。我们的情况很典型,后端是纯Java,运维体系基于Spring Boot,如果为了一个RAG功能引入Python服务,就得同时维护两套部署链路、两套日志规范、两套监控体系。单独的RAG服务还好,一旦要和企业现有的鉴权、审批流、工单系统打通,异构服务的沟通成本会指数级上升。
Spring AI最大的好处是它长在Spring生态里,配置方式、Bean管理、拦截器、异常处理、Metrics监控全部沿用Spring Boot那套玩法。你不需要为了AI功能重新学一门语言的Web框架,原先怎么写Controller,现在还怎么写。
2.2 Spring AI的模块结构与核心抽象
Spring AI把功能按模块拆得很清楚:
- spring-ai-core:核心抽象,定义Model、ChatClient、EmbeddingModel、VectorStore这些接口。
- spring-ai-openai:对接OpenAI接口协议的模型,通义千问等兼容OpenAI协议的国内模型也能用。
- spring-ai-ollama:对接本地Ollama部署的开源模型。
- spring-ai-pgvector:基于PostgreSQL+pgvector的向量存储实现。
- spring-ai-pdf、spring-ai-tika:负责PDF、Word等格式的文档解析。
这套分层结构和Spring Data的思路几乎一样:接口统一,实现可插拔。你今天用OpenAI,明天想换本地DeepSeek,只改依赖和配置,业务代码基本不动。
2.3 我们选型时的几个关键对比点
| 对比维度 | Spring AI | Python系(LangChain/LlamaIndex) |
|---|---|---|
| 与Java技术栈的融合度 | 原生Spring Boot,配置和编程模型统一 | 需要额外部署Python服务,走接口通讯 |
| 团队学习成本 | 会Spring Boot就能快速上手 | 需要熟悉Python生态和LangChain的Chain机制 |
| 模型接入 | 支持OpenAI、Ollama、通义等,切换成本低 | 也支持多模型,但Java调用要自己封装 |
| 向量存储 | 内置pgvector、Redis、Milvus等实现 | 依赖外部库,配置自由度更大 |
| 生态成熟度 | 仍在快速迭代,版本变化较快 | 更成熟,社区案例多 |
纠结过、也踩过坑之后我得说一句公道话:如果你的团队主力是Java,Spring AI现阶段确实值得押注;如果团队本来就是Python背景或者要做很复杂的Agent编排,LangChain的灵活性和案例储备仍然有优势。技术选型没有绝对的对错,只有适不适合自己的团队结构。
3. 关键环节怎么设计才不踩坑
3.1 文档接入:格式解析比想象中麻烦
RAG的第一步是让系统“读得懂”你的文档。企业里最常见的三种格式是Markdown、Word和PDF,它们的解析难度完全不一样。Markdown最友好,本身是纯文本,按标题切分就能得到结构良好的内容。Word文档用docx4j或者Spring AI里的Tika也能处理。最麻烦的是扫描版PDF,本质是图片,解析出来全是乱码,这种情况先得接OCR,我们用的方案是PaddleOCR。千万别把扫描件直接丢给解析器,结果会让你怀疑人生。
Spring AI里提供了一个叫PagePdfDocumentReader的类,可以按页读取PDF。但原生它只处理文本型PDF,遇到扫描件还是要自己接OCR。我们最后做了一层文档处理管道:上传文件 → 判断格式 → 文本型PDF直接提取,扫描型走OCR → 统一输出成规范的Markdown → 进入分块环节。
3.2 分块策略:直接决定检索命中的质量
分块是整个RAG链路里最玄学也最影响效果的环节。块太大,塞进上下文的内容会超出模型窗口,而且一个大片段里混着多个主题,向量化后的语义容易被稀释;块太小,语义不完整,检索容易返回碎片化的、缺乏上下文语境的片段,模型照着回答就会前言不搭后语。
我们实践下来的经验是:默认先按500~800字左右分,重叠区设为50~150字。重叠区很关键,它保证了跨块边界的语义能在两边都保留下来。另外要注意按文档结构分块,Markdown里按标题层级切,代码文档按代码块切,比纯按字符数硬切效果好得多。
Spring AI里可以用TokenTextSplitter按token数切,也可以自定义分块逻辑。如果你追求更高阶的效果,可以试语义分块,计算相邻句子之间的向量相似度,相似度低就在那里断开,但计算成本会高一些。我们目前生产环境还是用固定大小+重叠,简单可靠。
3.3 向量化:Embedding模型选型不能随便用中文模型
很多人图省事直接调模型API里的embedding接口,但中文场景下embedding模型的选择直接决定了检索准不准。我们最初用某个面向英文优化的embedding模型测中文文档,检索结果的准确率只能用惨不忍睹来形容。后来换成了针对中文优化的模型(BAAI/bge-large-zh),同一套检索逻辑,命中率明显提升。
另外要注意的一点是,文档入库时用的embedding模型,和线上检索时用的模型必须一致。如果你中途换模型,之前库里的向量就全部失效了,必须重新跑一遍入库流程。这个坑我们踩过,线上问答答非所问排查了半天,最后发现是两套embedding模型不统一。
3.4 向量库里应该存什么
向量库不是只存向量就完事了。生产级的RAG还需要存原始文本内容、文档来源、上传时间、业务标签这些元数据。Spring AI的VectorStore接口在写入时会接收Document对象,它本身带了content和metadata两个字段,这点设计得比一些Python框架还直观。
我们通常会在metadata里放三个东西:source(文件路径或URL)、docType(文档分类)、department(所属业务线)。后面做过滤检索、权限控制都会用到。举个例子,某个部门的人提问时,可以在检索阶段直接过滤掉其它部门的文档,既提高检索精准度,也做了数据隔离。
3.5 检索:相似度计算和Top-K怎么定
检索环节最关键的是相似度算法和Top-K参数。Spring AI内置支持多种相似度算法,常用的有余弦相似度(Cosine)、欧几里得距离(L2)、内积(Inner Product)。文本向量一般默认用余弦相似度,它对向量模长不敏感,更适合语义相似度场景。
Top-K则决定了最终交给模型多少个片段。取少了可能漏掉关键信息,取多了会让提示词变得臃肿,模型容易被无关片段带偏。我们压测下来的经验是:知识库单次回答取4~6个片段,每个片段约500字,加上问题本身,总上下文控制在3000字以内。现在主流模型的上下文窗口动辄几万token,但并不是塞得越多回答就越准,过多的孤立片段反而会引入噪声。
4. 实操:用Spring AI搭一个私有知识库问答
4.1 工程初始化和依赖引入
我们用的是Spring Boot 3.2 + Spring AI 0.8.1这个组合。创建工程后,先引入核心依赖:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>0.8.1</version> <type>pom</type> <scope>import</scope> </dependency>再引入模型和向量库相关依赖:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-ollama-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-pdf-document-reader</artifactId> </dependency>当时选择Ollama是为了把知识库问答部署在内网,数据不出域。用Ollama在服务器上跑一个本地模型(比如qwen2.5),交互方式模拟OpenAI的接口,但请求只走内网。这对很多技术团队来说是硬需求,企业文档不能随便丢给外部API。
4.2 配置向量库连接和模型地址
在application.yml里做基础配置。我们用PostgreSQL + pgvector插件,省去单独维护Milvus等专用向量数据库的运维成本,毕竟公司已有的PostgreSQL实例可以直接复用。
spring: ai: ollama: base-url: http://localhost:11434 chat: model: qwen2.5:7b embedding: model: bge-m3 vectorstore: pgvector: index-type: HNSW distance-type: COSINE_DISTANCE dimensions: 1024 initialize-schema: true几个容易踩的细节说明一下。distance-type建议用COSINE_DISTANCE,跟上文说的余弦相似度对齐;dimensions必须和embedding模型输出的维度一致,bge-m3输出1024维,如果配置成1536会直接报错;initialize-schema: true的意思是首次启动时自动创建向量表,生产环境建议由DBA统一管理表结构。
4.3 文档入库:从文件到向量的完整链路
先把核心Service写出来。我们需要一个方法接收上传的文档,解析、分块、向量化、存入VectorStore:
@Service @RequiredArgsConstructor public class KnowledgeService { private final VectorStore vectorStore; private final EmbeddingModel embeddingModel; public void ingestDocument(MultipartFile file, String docType, String department) { // 1. 解析文档文本 String content = parseDocument(file); // 2. 文本分块 List<String> chunks = splitContent(content); // 3. 构建Document对象列表,附带元数据 List<Document> documents = chunks.stream() .map(chunk -> { Map<String, Object> metadata = new HashMap<>(); metadata.put("source", file.getOriginalFilename()); metadata.put("docType", docType); metadata.put("department", department); return new Document(chunk, metadata); }) .collect(Collectors.toList()); // 4. 向量化并写入向量库 vectorStore.add(documents); } }vectorStore.add()内部其实做了两件事:调用EmbeddingModel把每个Document的内容转成向量,然后把向量和原始文本、元数据一起写入pgvector表。整个过程对使用者来说是透明的,这也是Spring AI框架顺手的地方。
分块方法当时做了个简单的重载,支持固定字符数和按标题切分两种策略:
private List<String> splitContent(String content) { TokenTextSplitter splitter = TokenTextSplitter.builder() .withChunkSize(800) .withChunkOverlap(150) .build(); return splitter.split(content); }这里提个容易忽略的点:TokenTextSplitter是按token数来切,不是按字符数切。中文一个字有时候对应一个token,有时候一个字就占两个token,所以800个token大概对应五六百字的中文。如果你希望控制得更精细,可以自己实现基于字符数或段落结构的分块器。
4.4 问答接口:检索增强生成完整实现
问答环节的核心是一个接口:接收用户问题,内部完成向量化、检索、拼装提示词、调用大模型、返回答案。
@RestController @RequiredArgsConstructor public class ChatController { private final VectorStore vectorStore; private final ChatClient chatClient; @PostMapping("/api/chat") public String chat(@RequestBody ChatRequest request) { // 1. 根据用户问题检索相似片段 List<Document> documents = vectorStore.similaritySearch( SearchRequest.builder() .query(request.getQuestion()) .topK(5) .build() ); // 2. 拼装上下文 String context = documents.stream() .map(Document::getContent) .collect(Collectors.joining("\n\n---\n\n")); // 3. 构造提示词 PromptTemplate promptTemplate = new PromptTemplate(""" 你是一个企业知识库助手。请基于下面的资料回答问题。 如果资料中没有相关信息,请直接说明"资料库中未找到相关内容"。 引用资料时请标注来源。 资料: {context} 问题:{question} """); Message message = promptTemplate.createMessage(Map.of( "context", context, "question", request.getQuestion() )); // 4. 调用大模型生成回答 return chatClient.call(message).getContent(); } }细节都在注释里了。我单独说一下topK(5)这个参数,它不是拍脑袋定的。我们压测过topK从1到10的情况,结果很有意思:topK=1时答案经常信息不全;topK=5时效果最好;再往上加到8~10,虽然召回了更多片段,但模型经常被不相关的内容干扰。建议各团队按自己的文档质量做一次同样的实验,不要照抄别人的参数。
4.5 进阶:元数据过滤和来源溯源
上面那版是demo级别的,生产环境还得加两样东西:检索过滤和来源标注。元数据过滤用起来很简单,比如只允许某个部门的员工搜索本部门的文档:
Filter.Expression expression = FilterExpressionBuilder.expression("department == :department") .bind("department", request.getDepartment()) .build(); List<Document> documents = vectorStore.similaritySearch( SearchRequest.builder() .query(request.getQuestion()) .topK(5) .filterExpression(expression) .build() );来源标注则是在组装提示词时,让模型把引用的文档来源也说出来:
String contextWithSource = documents.stream() .map(doc -> "[来源: " + doc.getMetadata().get("source") + "]\n" + doc.getContent()) .collect(Collectors.joining("\n\n"));这样回答里如果引用了某个手册的内容,大模型会顺手带出“来源:xx手册.pdf”,员工可以自己点开原文核对,信任度一下就上去了。我们做用户调研时,这是被点赞最多的一个功能。
4.6 最终效果与性能表现
整套系统部署之后,我们对知识库里的200多份文档做了评测。随机提出50个问题,人工判定回答准确可用的大概占了80%左右。对于“某个模块的配置项在哪里”、“xx系统的故障恢复步骤是什么”这类定位型问题,准确率能到90%以上。但涉及多个文档交叉推理的问题,效果明显变差。
性能上,本地部署的qwen2.5:7b在普通GPU上生成一次回答大概需要3~8秒,向量检索部分不到100毫秒。对内部知识库场景来说完全能接受。如果对延迟敏感,可以接企业级API模型,检索逻辑完全不用改。
5. 常见问题与排查技巧实录
5.1 检索结果不准:先区分“查不到”和“排不对”
检索不准是最常见的投诉。排查时要先区分两种情况:是向量库里根本没有相关内容,还是相关内容没被搜出来。前者去检查文档是否入库成功,后者要重点排查embedding模型和分块逻辑。
我们遇到过一个非常隐蔽的问题:某个文档入库时解析出来的内容是空的,因为PDF里有几页是图片。这种问题通过打印日志里的Document内容就能发现。生产环境建议在vectorStore.add()前后都打日志,记录文档数、token数、metadata信息,定位问题会快很多。
5.2 大模型答非所问:提示词指令不明确不是模型的错
很多人会直接把检索结果拼上去就完事,然后怪模型笨。实际上是你没告诉它“不知道的时候要承认”。我们在提示词里加了一段“如果资料中没有相关信息,请直接说明资料库中未找到相关内容”,幻觉率立刻降了一半以上。
另外建议在提示词里约束回答格式,比如“先回答问题,再补充说明来源”。不约束格式的话,模型经常会把资料里的内容原样抄一遍,而不是用自己的话组织。
5.3 pgvector写入报维度不符
pgvector创建表时指定的向量维度必须和embedding模型输出维度一致。bge-m3输出1024维,如果你配置的是1536,稍微一用就报错。解决方法是看报错信息里的维度数字,然后改配置。注意,改配置后要把原来的表drop掉重建,因为pg的索引对维度是强绑定的。
5.4 数据更新了,回答还是旧内容
RAG有一个天然问题:文档重新入库后,旧数据不会自动删除。如果你用同一个source标识了不同版本的内容,库里会同时存在新旧两个版本。检索时可能命中旧版,导致回答过时。
解决办法是做覆盖式写入:入库前先按source删除旧的Document,再写入新的。Spring AI的VectorStore提供了delete方法,配合元数据过滤可以很方便地实现。
5.5 Ollama本地模型回答质量不稳定
本地部署的开源小模型在效果上确实不如大参数API模型,这是物理规律没法改变。我们的经验是:对回答质量要求高的场景,可以做成双模型策略——检索用本地模型先兜底,如果用户对回答点了“踩”,再调一次企业级API模型重新生成。不过双模型会拉高成本,不是所有场景都值得。
6. 给准备上手RAG的团队几个建议
如果这篇文章对你有帮助,最后这三条建议希望能帮你少走弯路。
第一,先小规模跑通闭环再谈优化。找一个文档量小、问题边界清晰的业务场景,比如IT服务台的故障排查手册,先把“上传文档→分块→入库→提问→回答”这条链路跑通,再逐步扩充知识范围。不要一上来就建一个超大规模的知识库,出问题的时候你连根因都找不到。
第二,评测集比代码更值钱。我们在上线前整理了几十个典型问题,按业务模块分类,每次改动后都跑一遍回归。没有评测集的RAG项目,优化全凭感觉,改一个分块参数也不知道是变好了还是变差了。哪怕是手工维护一个文档记录的评测问题列表,也比没有强得多。
第三,给用户一个反馈渠道。我们在问答界面加了“回答对/错”两个按钮,用户点击“错”时会记录问题和当时检索到的上下文。这个反馈数据是后续调优最宝贵的素材,哪些文档没被检索到、哪些回答是模型在瞎编,都藏在里面。
RAG并不是一门高不可攀的技术,它本质上就是用工程手段把大模型和你的业务知识连接起来。Spring AI把这套流程简化到了Java开发者能轻松驾驭的程度,剩下的事情,就看你怎么把知识整理好、把评测做扎实了。