前一阵做内部知识管理系统时,被“文档能存但搜不到、搜到了但答不对”这个问题折腾得不轻。传统的关键词检索解决不了同义改写,直接丢给大模型又没有私有知识。后来切到 Spring AI 全家桶做 RAG,把知识库问答从“能用”提升到了“基本靠谱”。这篇就把完整的落地过程、踩坑点和可直接抄的代码写清楚,给那些同样打算在 Java 工程里上 RAG 的朋友做参考。
1. 为什么是 RAG,为什么选 Spring AI
1.1 从一次知识库检索失败说起
当时手头有个场景:企业内部有几百份操作手册、制度文档和项目总结,散落在文件系统里。老系统用的是 Lucene 做关键词匹配,用户问“报销流程怎么走”,文档里写的是“费用申报流程如下”,两边分词结果对不上,召回等于零。后来试过把整个文档直接塞给大模型生成回答,效果更差,因为模型没有见过这些内部文档,回答全是编的。
RAG(检索增强生成)的基本思路很直接:先把文档拆成小块转成向量,用户提问时先在向量库里把最相关的几块内容捞出来,再连同问题一起送给大模型,让它基于给定的上下文作答。这样既不用微调模型,又能保证回答内容来自企业自己的文档,更新知识只需要重新索引对应的文档,成本远低于重新训练。
1.2 Spring AI 在这套链路里的位置
之前团队里有人提议用 Python 的 LlamaIndex 那套方案,但我们整个服务是 Java 技术栈,运维和发布流程都围绕 Spring Boot,突然引入 Python 服务会带来额外的部署和联调成本。Spring AI 的优势在于它把 RAG 链路的核心组件都抽象成了 Spring 风格的接口,依赖注入、自动配置、Starter 机制全都有,团队上手成本低,和业务代码也能无缝整合。
实际体验下来,Spring AI 更像是 Spring 生态里那个“翻译官”,它不自己造大模型,而是把各家模型的调用统一成一套 API。换模型厂商时不用改业务代码,只需要换依赖和配置,这对需要长期维护的企业项目来说很有价值。
1.3 RAG 整体流程设计与分工
一套完整的 RAG 知识库分为两条链路:
- 离线索引链路:读取原始文件 -> 文本切分 -> 向量化 -> 写入向量存储
- 在线问答链路:用户问题 -> 向量化 -> 相似度检索 -> 组装提示词 -> 大模型生成回答
离线链路慢一点没关系,关键是要稳定、可重跑;在线链路才是用户直接感知的部分,耗时和回答质量都要严格把关。Spring AI 把这两条链路所需的组件都提供了,文档加载器负责读文件、文本切分器负责切块、向量存储接口负责存取向量、QuestionAnswerAdvisor 负责把检索结果自动塞进提示词,各司其职。
2. 环境准备与项目搭建
2.1 版本选型与依赖配置
先说版本,我用的是 Spring Boot 3.3.x 搭配 Spring AI 1.0,JDK 要求 17 以上。Spring AI 的版本迭代比较快,API 偶尔会有小变动,建议锁定一个稳定的 GA 版本,不要追最新,不然示例代码和实际 API 对不上会很痛苦。
Maven 依赖方面,按模型厂商选择对应的 Starter,下面这份是基础配置。因为不同厂商的模型适配器不同,我这里展示的是兼容通用格式的结构,实际使用时把模型厂商的 Starter 引进来即可:
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <!-- 模型调用 Starter,按厂商选择对应的坐标 --> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-xxx</artifactId> </dependency> <!-- 向量存储 Starter,我这里用的是 PostgreSQL 生态的向量扩展 --> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-vector-store-pgvector</artifactId> </dependency> <!-- 文件解析:支持 PDF、Word、TXT 等格式 --> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-tika-document-reader</artifactId> </dependency> </dependencies>注意:spring-ai-tika-document-reader 依赖底层解析组件,第一次启动时会自动拉取相关依赖。如果公司网络受限,提前把镜像仓库配置好,不然卡在依赖下载上很浪费时间。
2.2 核心配置文件解读
application.yml 里主要配三块:模型接口的密钥和参数、向量存储的连接信息、以及可选的重试策略。配置文件本身不复杂,但有几个参数会影响后续效果,需要先说清楚:
spring: application: name: rag-knowledge-service ai: retry: max-attempts: 3 backoff: initial-interval: 1000ms multiplier: 2.0 vectorstore: pgvector: index-type: HNSW distance-type: COSINE_DISTANCE dimensions: 1536 initialize-schema: true datasource: url: jdbc:postgresql://localhost:5432/knowledge username: ${DB_USER} password: ${DB_PASSWORD}模型接口的 api-key 不要写在配置文件里,用环境变量注入,这是基本安全意识。向量维度必须和嵌入模型输出的维度保持一致,比如说模型输出 1536 维,这里就写 1536,不一致的话写入向量库时会直接报错。
重试策略很关键。实际调用模型接口时经常遇到网络抖动或限流,没有重试机制的话,用户会直接看到超时错误。上面这个配置表示最多重试 3 次,采用指数退避,第一次等 1 秒,第二次等 2 秒,直到请求成功或达到上限。
2.3 嵌入模型与向量存储的选型思路
嵌入模型负责把文本变成向量,这直接决定检索效果的下限。选型时重点看两个指标:输出向量维度和支持的最大输入长度。维度不是越高越好,高维度存储成本大、检索慢,对效果提升有限;最大输入长度则决定文本切分的粒度上限,模型只支持 512 token,你却硬塞 1000 token 去向量化,后面的内容会被截断,信息丢了检索自然不准。
向量存储我之前比较过几个方案,最后选了 PostgreSQL 生态的向量扩展,理由很实在:团队本来就维护着 PostgreSQL,知识库的可信数据、文档元数据都在这套库里,向量也放进去就省了一套中间件。数据量如果到了百万级,再考虑独立的向量数据库。Spring AI 的 VectorStore 接口做了隔离,后面想换存储实现,改动范围会被控制在一个配置类里。
3. 文档加载与切分,这一步决定检索质量的下限
3.1 从文件到 Document 的转换细节
Spring AI 里文件解析统一走 DocumentReader 接口,我日常工作里遇到最多的就是 PDF 和 Word。实际项目里用的是 TikaDocumentReader,它的好处是能处理多种格式,不用为每种格式各写一套解析逻辑:
@Service public class KnowledgeImportService { private final VectorStore vectorStore; public KnowledgeImportService(VectorStore vectorStore) { this.vectorStore = vectorStore; } public void importFile(String filePath) { FileSystemResource resource = new FileSystemResource(filePath); TikaDocumentReader reader = new TikaDocumentReader(resource); List<Document> documents = reader.read(); // 切分 TokenTextSplitter splitter = new TokenTextSplitter(800, 200, 5); List<Document> chunks = splitter.split(documents); // 打上元数据 for (Document chunk : chunks) { chunk.getMetadata().put("source", filePath); chunk.getMetadata().put("importTime", System.currentTimeMillis()); } // 向量化并写入 vectorStore.add(chunks); } }这里有个经验之谈:扫描版的 PDF 用 Tika 解析出来是乱码或空文本,因为 Tika 不负责 OCR。如果知识库里存在这种文件,导入时要跳过或先做 OCR 预处理,否则你以为是文档入库了,实际检索时什么也召不回。
3.2 文本切分参数与中文场景的处理
切分是 RAG 工程里最容易被忽视的环节。切得太碎,一个完整的知识点被拆散,每块都只有片段,上下文不完整,模型回答就会片面;切得太大,检索时整块内容塞进上下文,又浪费 token 又稀释关键信息。TokenTextSplitter 的三个参数是这么配合的:
- 800:每个块的最大 token 数
- 200:相邻块之间的重叠 token 数
- 5:一次最多生成的块数
重叠部分是为了避免一个完整句子恰好被切在中间,导致两边都不完整。中文文本有一个特殊问题:很多文档的段落之间用大量空行和缩进分隔,切分器对空白的处理逻辑和英文不完全一致,结果会把一个段落切成多个碎片。我在导入前会先做一轮文本清洗,去掉多余空行和特殊字符,再交给切分器。
切分参数没有万能值,要结合实际文档的平均长度来试。我的经验是:操作手册类文档,800 token 带 200 重叠效果不错;问答对或条款类文本,可以切小一点,500 token 左右,检索粒度更精准。
3.3 向量化入库的操作顺序
向量化入库本身代码很简单,就是 vectorStore.add(chunks),但有几个操作细节值得说道。第一次大批量导入时,建议分批写入,每批 20 到 50 个文档块。一次性全量提交,不仅内存压力大,如果中间有一块向量化失败,整个批次都要重来。
另外,不要频繁执行向量库的 schema 重建。初始化参数 initialize-schema 只在表不存在时创建,跑一次就够了。如果反复重建,之前的向量全丢,又得重跑一遍导入,这种错误在开发阶段很容易犯。
重要:同一个向量库表里只能使用同一套嵌入模型。中途换模型后,原来写入的向量和新向量做相似度计算,维度可能不同,即使维度相同,向量空间也完全不同,检索结果不可信。换模型必须全量重新向量化。
4. 检索与问答链路,从向量命中到高质量回答
4.1 基于 QuestionAnswerAdvisor 的最小可用方案
Spring AI 提供一个叫 QuestionAnswerAdvisor 的组件,它的作用是把“检索 -> 组装提示词 -> 调用模型”这个过程封装起来。我们不需要自己拼接 prompt,它自动把检索到的文档内容作为上下文注入用户的提问里:
@Configuration public class RagConfig { @Bean public ChatClient chatClient(ChatModel chatModel, VectorStore vectorStore) { return ChatClient.builder(chatModel) .defaultAdvisors(new QuestionAnswerAdvisor(vectorStore)) .build(); } }接下来业务侧就非常简单了:
@RestController public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient chatClient) { this.chatClient = chatClient; } @PostMapping("/chat") public R<String> chat(@RequestBody QuestionRequest request) { String answer = chatClient.prompt() .user(request.question()) .call() .content(); return R.ok(answer); } }这段代码就是一个能跑通的 RAG 问答接口。用户提问“报销流程怎么走”,向量库先召回相关文档片段,再交给大模型生成带依据的回答。不过这里用的是默认提示词,如果你想控制回答风格,需要自定义 System Prompt。
4.2 自定义提示词与检索参数
知识库问答和通用聊天最大的区别在于:回答必须忠实于文档,不知道就要承认不知道,不能瞎编。默认提示词对这方面的约束比较弱,我建议把提示词显式接管过来:
@Bean public ChatClient chatClient(ChatModel chatModel, VectorStore vectorStore) { SearchRequest searchRequest = SearchRequest.builder() .query("placeholder") .topK(5) .similarityThreshold(0.25) .build(); QuestionAnswerAdvisor advisor = new QuestionAnswerAdvisor( vectorStore, searchRequest, new SystemPromptTemplate(""" 你是企业内部知识库助手。只能基于<context>中的内容回答。 如果<context>中没有足够的信息,请直接回答“知识库中没有相关记录”, 不要自行推测。回答时先给出结论,再列出依据的文档片段。 <context> {context} </context> 用户问题:{question} """ ).createSystemPrompt() ); return ChatClient.builder(chatModel) .defaultAdvisors(advisor) .build(); }SearchRequest 里的两个参数直接影响检索效果:topK 是召回几条相似片段,默认 5 条,对知识库问答来说是合理的起点;similarityThreshold 是相似度阈值,低于这个分数的不参与生成。阈值设得太高容易没有召回,设得太低又会把不相关内容硬塞给模型,建议先放开阈值看一轮检索结果分布,再定合理值。
4.3 不把它当黑盒:定位你的检索真实输出
刚开始用 QuestionAnswerAdvisor 时,我遇到过回答明显不对的情况,第一反应是改提示词,结果没作用。后来才意识到,问题出在检索召回的内容本身就不对,提示词写得再好也无济于事。
排查方法很简单:把检索这一步单独拉出来调试。调用 vectorStore.similaritySearch(),看看到底召回了什么:
List<Document> retrieved = vectorStore.similaritySearch( SearchRequest.builder() .query("报销流程怎么走") .topK(5) .build() ); for (Document doc : retrieved) { System.out.println("score = " + doc.getScore()); System.out.println("source = " + doc.getMetadata().get("source")); System.out.println("content = " + doc.getContent()); System.out.println("----"); }这一步是排查 RAG 问题的核心手段。召回结果对了,回答才有根基;召回不对,优先调切分参数和检索阈值,而不是去调生成模型的提示词。
5. 上线后最常见的六个问题排查实录
5.1 问题速查表
项目上线后,我陆续收到不少使用反馈,下面这些问题是出现频率最高的,整理成表方便对照:
| 问题现象 | 可能原因 | 排查与解决方式 |
|---|---|---|
| 写入向量库时报维度错误 | 嵌入模型更换,新模型输出维度与配置不一致 | 修改 dimensions 配置,全量重建索引 |
| 检索结果召不到中文文档 | 文本切分时语句被截断,或文档本身是扫描件 | 检查召回内容,调整切分重叠参数 |
| 回答内容与文档矛盾 | 相似度阈值太低,无关内容混入上下文 | 查看得分分布,调高阈值,降低 topK |
| 多轮对话后回答变差 | 用户问题不带上下文,模型丢失先前意图 | 接入 ChatMemory,自动注入会话历史 |
| 所有检索结果得分都极低 | 文档向量化时传入了大段无关文本,如页眉页脚 | 导入前清洗文本,过滤干扰内容 |
| 重启后检索结果消失 | 用的是纯内存向量存储实现 | 切换为持久化向量存储,如 pgvector |
第二项值得多说一句。中文文档经常出现整段文字被切分器拦腰截断的问题,一句完整的话被拆到两个块里,检索时任何一块都不是完整语义。排查时可以打印切分后的块数,如果一份 800 字的文档被切成了十几个块,说明切分粒度太碎了,先清洗文本再调大 chunk size。
5.2 相似度匹配的“玄学”问题
有次用户反馈搜索“离职流程”,系统返回的却是考勤制度,从欧氏距离算出来关键库里确实是考勤相关的文档得分最高。后来分析发现,这些文档里都包含“人力资源部”这个高频词,向量模型把它们的语义拉近了。
这不是 Bug,是向量检索的固有局限。向量检索擅长的是语义相近,但无法区分“相关但非用户要的那个点”。工程上的缓解手段,一是对文档元数据做预过滤,比如先限定文档类型再检索,减少语义干扰;二是做好问题改写,把用户口语化问题转成更贴近文档的书面表达。有条件的话,可以在检索后加一层重排序,显著提升最终命中率。
5.3 模型生成环节的稳定性问题
回答质量不仅受检索影响,大模型自身的温度参数、上下文长度也会改变最终效果。温度参数在 0.2 左右比较适合知识库问答,过高会让模型发散,编造出文档里没有的内容;过低则显得机械,像一个只会复读的聊天机器人。
上下文长度同样需要测算。预设 topK 是 5,每条召回内容平均 800 token,加上系统提示词和用户问题,已经超过 4000 token。如果用的模型上下文窗口只有 8k,再叠加多轮对话历史,就会超限。要么降低 topK,要么换更大的上下文窗口模型,不要在同一个配置上调来调去。
6. 工程落地:再多走一步
6.1 多轮对话中的上下文保持
问答场景里用户经常连续追问,“那需要什么材料?”如果每轮都把用户问题独立处理,系统根本不知道“那”指的是什么。Spring AI 提供了对话历史能力,用起来也比较直接:
@Bean public ChatClient chatClient(ChatModel chatModel, VectorStore vectorStore, ChatMemory chatMemory) { return ChatClient.builder(chatModel) .defaultAdvisors( new MessageChatMemoryAdvisor(chatMemory), new QuestionAnswerAdvisor(vectorStore) ) .build(); }接入对话历史后,需要留意 token 消耗会快速上升。建议只保留最近几轮对话记录,不重要的历史可以丢掉。ChatMemoryAdvisor 支持控制保留窗口,默认值比较保守,但还是建议根据业务体验测试后调优。
6.2 重排序与检索增强
纯向量检索在知识库这种精确匹配要求较高的场景里,会有明显的天花板。介绍一个工程优化手段:在向量检索之后增加一个重排序环节。先向量召回 20 条候选,再用重排序模型交叉编码器逐条做打分,选出最相关的 3 条送到大模型。重排序会更精细地捕捉字面与语义匹配度,代价是多一次模型调用,延迟会略微上升。
前期系统精度已经满足业务需求,就没上重排序。后面遇到“客户问 A 品牌的退换货政策,返回 B 品牌内容”这类问题时,重排序是非常有效的优化路径。
6.3 成本与性能控制
线上跑了两个多月,成本上最值得关注的反而是向量化这一步。存量文档的一次性导入就消耗了可观 token,增量更新如果不控制频率,每天也会持续产生费用。操作上可以把更新改成按天批量执行,错开业务高峰。
在线查询链路要重点关注响应耗时。有一次用户反馈响应时间接近 10 秒,排查发现是向量检索本身没问题,但模型生成阶段因为上下文太长导致输出速度慢。解决方法是优化检索条件只保留得分最高的 3 条,减少送入生成环节的噪声。检索要快,生成要稳,这两者是不同的性能目标,要分开看待。
结尾
这套系统跑下来,最深的感受是:RAG 的技术框架正在变得越来越成熟,真正决定效果上限的往往是数据治理这个笨功夫。文档清洗、切分策略、命名规范、更新机制,这些看似不起眼的工作,对最终问答质量的影响远大于模型选择。我在给团队做技术分享时经常重复一句话:先确保检索能稳定召回正确内容,再去调模型参数,顺序不能反。如果你现在正准备在公司内部搭一个类似的知识库问答系统,建议先拿一小批高质量文档把全链路跑通,验证效果后设计全量迁移方案。也特别提醒一点:模型厂商和版本尽量早定。一旦向量库里已经有了积累,中途换模型就要全量升级索引,纯属给自己找活干。这套代码和参数放在这里,欢迎拿去跑你自己的文档,跑出来的坑和解决方案,也欢迎在实践中继续验证、互相交流。