Spring AI Alibaba 系列写到第四篇,我把主角让给了 RAG。前三篇我们聊过 ChatClient 的基本调用、Prompt 模板和结构化输出,这些都是把大模型“用起来”的地基;但从这一篇开始,你会遇到一个绕不开的问题:模型只记得训练时的知识,你公司内部的文档、产品手册、这周刚更新的排期,它一概不知道。RAG 就是解决这个问题的标准姿势,而 Spring AI Alibaba 恰好把这条链路做了很大程度的封装。这篇文章我打算用一套完整可跑的代码,带你从文档解析一路做到问答联调,读完你可以直接把知识库问答搬进自己的 Spring Boot 项目。适合已经跟着前三篇写过代码的读者,也适合没看过系列、但想搞清楚 Java 里 RAG 到底怎么落地的朋友。
1. 为什么第四篇要把主角让给 RAG
1.1 前三篇解决了什么,又留下了什么
前几篇的路径很清晰:先创建项目、把spring-ai-alibaba-starter引进来,然后通过ChatClient发一个简单的对话请求;接着用 Prompt Template 把用户输入拼进更复杂的指令里,让模型按照既定格式输出;再往后是用结构化输出把返回内容映射成 Java 对象,避免自己写一堆字符串解析代码。
这几步做完,你的应用已经能“问模型”了。但仔细想想:模型回答的内容,全部来自它自己权重里存着的“世界知识”。你问它 Java 8 和 Java 17 的区别,它能答;你问它自己公司的《差旅报销管理办法》里住宿标准是多少,它大概率开始一本正经地编。这不是模型变笨了,而是训练数据里根本没有这段信息。此时你需要做的,是让模型在作答之前“看”到这些业务文档。
1.2 RAG 本质上是一场开卷考试
RAG,全称 Retrieval-Augmented Generation,中文叫检索增强生成。一句话解释:先把你的业务资料切成一段段文本,转成向量存进向量库;用户提问时,从向量库里找出最相关的几段原文,连同问题一起交给大模型,让它基于这些原文作答。
这个思路像极了开卷考试。闭卷作答,模型依赖记忆,遇到没背过的题就容易胡编;开卷作答,模型先翻目录定位到相关章节,把原文摘出来再组织语言,答错的概率立刻下降。更重要的是,资料更新不需要重新训练模型,改文档、重灌向量库就能生效,这正是企业内部知识库场景最需要的灵活性。
| 对比维度 | RAG | 微调 |
|---|---|---|
| 知识更新成本 | 低,重新跑一遍文档即可 | 高,每次调整都要重新训练 |
| 硬件成本 | 低,普通应用服务器即可 | 高,需要 GPU 资源 |
| 可控性与可解释性 | 高,能追溯引用的原文片段 | 低,行为像一个黑盒 |
| 适合场景 | 私域知识、持续变化的内容 | 固定风格、稳定逻辑的领域适配 |
1.3 为什么用 Spring AI Alibaba 来做这件事
如果你的目标是快速做出一套知识库问答,用 Spring AI Alibaba 的原因很直接:它对 DashScope 百炼的模型做了自动装配,接入成本低,中文场景效果好。向量化用的是text-embedding-v3,一个专门为中文内容训练的 Embedding 模型,检索出来的结果比很多通用模型更贴题意。再加上 Spring AI 本身抽象出的标准接口,数据管线里那些 Document、Splitter、VectorStore 都能复用,将来哪怕要换底层向量库,改动范围也能被限制在配置层。
提醒一句:Spring AI Alibaba 的版本迭代非常快,不同 M 版本之间的 API 会有调整。本文以当前 1.0 系列的写法为准,核心思路不变,具体类名和包名以你实际引入版本为准。
2. 环境准备:把最小可跑骨架立起来
2.1 依赖要引哪些
先看 Maven 依赖。我这里以 Spring AI Alibaba 1.0 系列为例,实际使用时到官方 Maven 仓库确认最新版本号。
<dependency> <groupId>com.alibaba.cloud.ai</groupId> <artifactId>spring-ai-alibaba-starter</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-vector-store</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-advisors-vector-store</artifactId> <version>1.0.0</version> </dependency>第一个依赖负责把 DashScope 的 Chat 模型、Embedding 模型自动配好;后两个提供向量存储和检索增强 Advisor。如果你的 Spring AI 版本里这些类已经合并进核心模块,就不需要重复引入,具体看 IDE 里能否 import 到对应类。
2.2 配置项与密钥管理
在application.yml里加配置。API Key 一定别写死在文件里,用环境变量注入。
spring: application: name: rag-demo ai: dashscope: api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen-plus embedding: options: model: text-embedding-v3 vectorstore: simple: persist: path: ./data/vector-store.jsonqwen-plus是通义千问的中等规模模型,日常问答性价比不错;text-embedding-v3做中文向量化,最大输入长度和维度都能满足入门项目需求。向量库持久化路径建议单独建目录,避免和打包产物混在一起。
2.3 验证自动配置是否生效
写一个最简单的检查类,启动后看是否正常注入了ChatModel和EmbeddingModel。如果 Spring AI Alibaba 的自动配置生效,容器里会直接出现这两个 Bean,什么都不用手动 new。
@Component public class StartupCheck { private static final Logger log = LoggerFactory.getLogger(StartupCheck.class); public StartupCheck(EmbeddingModel embeddingModel, ChatModel chatModel) { log.info("EmbeddingModel: {}", embeddingModel.getClass().getSimpleName()); log.info("ChatModel: {}", chatModel.getClass().getSimpleName()); } }启动日志里能看到对应的实现类名,就说明链路已经连通。这一步过了,后面所有操作才有意义。
3. RAG 数据管线:从一份 TXT 到可召回的知识库
3.1 准备样本文档
我习惯在src/main/resources/docs下放一份测试文档,这里用一份虚构的员工手册片段做示例。
差旅报销标准:国内出差住宿费,一线城市每晚不超过 600 元,其他城市不超过 450 元。单次报销金额在 5000 元以下的,直接在 OA 系统提交发票和行程单;5000 元以上需额外附部门负责人审批意见。餐饮补助按实际出差天数计算,每天 100 元,无需提供发票。
你的真实场景可能是产品 FAQ、售后话术、合同模板,内容换成自己的即可。
3.2 读取文档并按 Token 切分
RAG 的第一个关键操作是切分。文档不可能整篇塞进 Prompt,一方面超长文本会稀释相关性,另一方面 Embedding 模型对输入长度也有上限。标准做法是把文档切成一个个语义相对完整的段落,每个段落独立向量化、独立检索。
Spring AI 提供了TextReader和TokenTextSplitter,可以直接这么写:
@Service public class KnowledgeBaseBuilder { private final VectorStore vectorStore; public KnowledgeBaseBuilder(VectorStore vectorStore) { this.vectorStore = vectorStore; } public void build(Resource resource) throws IOException { List<Document> documents = new TextReader(resource).read(); TokenTextSplitter splitter = new TokenTextSplitter(); List<Document> chunks = new ArrayList<>(); for (Document document : documents) { List<Document> split = splitter.split(document); for (Document chunk : split) { chunk.getMetadata().put("source", resource.getFilename()); } chunks.addAll(split); } vectorStore.write(chunks); } }这段代码的逻辑很直白:读入文档,按 Token 数量切成多段,给每段加上来源元数据,最后统一写入向量库。不同版本的 Spring AI 在TextReader.read()的返回类型上有改动,有的返回单个Document,有的返回List<Document>,如果你拿到手的是旧版本,用Collections.singletonList(document)包一下就行。
3.3 切分参数怎么定
切分粒度直接影响检索质量。我踩过几次坑之后,目前比较稳定的经验值如下。
| 参数 | 建议值 | 说明 |
|---|---|---|
| chunk size | 300 到 800 Token | 太小语义不完整,太长噪声太多 |
| overlap | 80 到 160 Token | 防止关键信息被切在两段中间 |
| 切分单位 | Token 而非字符 | 中文一句往往对应多个 Token,字符切分会把句子拆碎 |
在实际项目里,如果文档结构很强,建议先按标题拆成章节,再对每个章节做二次切分。比如产品手册通常有明确的“第一章、第二章”,直接固定长度切会把章节的上下文切断,检索时容易召回一个缺头少尾的段落。Spring AI 的DocumentTransformer接口可以串多条切分规则,先按正则分节,再按 Token 拆段,效果会比单一切分好不少。
3.4 向量化与落库
切好的每一段文本都会被 Embedding 模型变成向量。这里不需要自己写调用逻辑,vectorStore.write(chunks)内部会自动逐个调 Embedding 模型。
@Configuration public class RagConfiguration { @Bean public VectorStore vectorStore(EmbeddingModel embeddingModel) throws IOException { SimpleVectorStore store = SimpleVectorStore.builder(embeddingModel).build(); File storeFile = new File("./data/vector-store.json"); if (storeFile.exists()) { store.load(storeFile); } return store; } }SimpleVectorStore是入门最合适的向量库:零额外依赖,数据落在一个 JSON 文件里,调试时可以直接打开看每一条向量对应哪段原文。生产环境可以换成 Redis、PGVector 或 Elasticsearch 向量索引,因为上层代码用的是统一的VectorStore接口,切换成本很低。
启动项目后调用一次build(),观察日志里是否出现向量写入记录。然后打开vector-store.json,你会发现每条记录除了向量本身,还带着source等元数据。这正是后面过滤检索范围的基础。
4. 问答联调:一个能回答业务问题的最小系统
4.1 用 Advisor 把检索结果自动注入问答
Spring AI 里提供了一条捷径:QuestionAnswerAdvisor。这个 Advisor 会在每次提问时自动完成“向量检索 → 拼接上下文 → 调用模型”三个步骤,你只需要把它注册进ChatClient。
@Bean public ChatClient chatClient(ChatClient.Builder builder, VectorStore vectorStore) { return builder .defaultAdvisors(new QuestionAnswerAdvisor(vectorStore)) .build(); }当用户发起提问,QuestionAnswerAdvisor会把问题转成向量,在库里做相似度搜索,取回最相关的几段原文,然后拼到 Prompt 里,告诉模型“请根据以下资料回答”。模型被约束在给定资料内作答,而不是天马行空自己发挥。
4.2 提供一个 REST 接口接收问题
@RestController @RequestMapping("/api/rag") public class RagController { private final ChatClient chatClient; public RagController(ChatClient chatClient) { this.chatClient = chatClient; } @PostMapping("/ask") public String ask(@RequestBody String question) { return chatClient.prompt() .advisors(advisor -> advisor.param("topK", 4)) .user(question) .call() .content(); } }这里最值得关注的是topK参数。它决定召回多少段原文,值太小可能漏掉关键信息,值太大可能把无关段落也带进来。我建议入门阶段先固定为 4,后面再根据反馈微调。
4.3 问答效果验证
启动服务,用 curl 模拟一次真实提问:
curl -X POST http://localhost:8080/api/rag/ask \ -H "Content-Type: text/plain" \ -d "差旅住宿费标准是多少?"如果链路正常,模型会根据刚才灌进去的文档回答类似这样的话:
根据公司制度,国内出差住宿费标准为:一线城市每晚不超过 600 元,其他城市不超过 450 元。
接着你可以做一个反向测试,问一个文档里完全没有的信息:
curl -X POST http://localhost:8080/api/rag/ask \ -H "Content-Type: text/plain" \ -d "公司组织架构中研发中心下设几个部门?"理想情况下,模型应该表示“根据提供的资料无法回答”。如果它还是强行编了一个答案,说明 Advisor 的限定指令没有生效,或者你的 Prompt 里没有强调“资料外信息不要答”。这时可以手动在ChatClient的system里补一句约束:如果资料中没有相关内容,直接说明没有找到,不要推测。
4.4 拆开看 Advisor 的内部行为
如果你好奇检索到底召回了什么,可以绕过 Advisor,直接调向量库看查询结果:
List<Document> hits = vectorStore.similaritySearch( SearchRequest.builder() .query("差旅住宿费标准") .topK(4) .build() ); for (Document hit : hits) { System.out.println(hit.getText()); }这一步能帮你定位问题:如果召回结果本身就答非所问,那问题出在切分或向量化阶段;如果召回结果没问题但模型回答不对,那问题出在 Prompt 或参数设置上。先定位再调优,效率比瞎调高得多。
5. 调参与避坑清单
5.1 召回结果差,先查切片再查模型
很多刚上手的朋友遇到回答不准,第一反应是换更大更强的模型。但我实测下来,绝大多数 RAG 效果差,根源都在召回阶段。打开上一节写的相似度检索输出,看看召回的前几段和问题是否相关。
一个常见问题是 chunk size 设得太大。比如一份 2000 Token 的制度文档被切成两段,每段 1000 Token,检索时即使命中了,也会把大量无关内容带进上下文,模型容易被噪声带偏。把 chunk size 调到 400 左右,overlap 设 80,召回精度通常会明显提升。代价是知识库里的片段数量变多,但向量检索的速度足够快,体量在几十万条以内都不需要担心性能。
5.2 topK 不是越大越好
topK 过大时,第 3、4 段很可能已经和问题关系不大。这些低相关文本混进 Prompt,不仅浪费 Token,还会增加模型“被带偏”的概率。我习惯把 topK 控制在 3 到 5 之间。如果你的文档质量很高、切分很干净,可以压到 3;如果文档杂、冗余多,建议配合相似度阈值一起用,只保留相关度超过阈值的片段。
5.3 中文乱码是新手第一坑
Windows 环境下读取 TXT 文件时,TextReader默认按系统编码读取,如果文档是 UTF-8 编码而系统默认 GBK,读进来的内容就会变成乱码,向量化出来的结果自然毫无意义。解决方法是建文档时统一保存为 UTF-8,并在代码里显式指定字符集:
Resource resource = new ClassPathResource("docs/company-rules.txt"); String content = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);不要相信“我打开文档看着没问题”,因为编辑器和终端很可能已经悄悄转换了编码。最稳妥的判断方法是写入向量库后,把某一条 chunk 的文本打印出来看一眼。
5.4 多轮对话时不要让历史问题污染召回
做知识库问答,很容易顺手把历史聊天记录也传给 Advisor 做召回。后果很常见:用户上一轮问过考勤制度,这一轮问“那请假呢”,如果召回时只拿“那请假呢”这句话去检索,结果可能什么都召不回;但如果把整段历史拼进去,检索向量会被上一轮的“考勤制度”主导,结果永远召回到上一轮相关的内容。
我的做法是:RAG 检索只用当前这一轮的问题去向量化,历史消息只作为对话背景放进ChatClient的 message 历史里,不参与召回查询。这样既保证了多轮对话的连贯性,也避免了旧话题的干扰。
5.5 别忘了 RAG 不是万能钥匙
最后说一句可能得罪人但很重要的经验:如果业务数据本身是结构化表格,比如数据库里的订单表、账单明细,RAG 是绕远路,更合适的是 NL2SQL,让模型把自然语言问题转成 SQL 再查库。RAG 的舒适区是非结构化的文本资料,比如制度、手册、问答对。认清边界,选型就不会翻车。
我个人做这个项目时最大的体会是,RAG 的成功与否,七分在前期的切分和召回,三分在模型和 Prompt。先把文档切好、召回结果打出来验证,再谈什么高级用法。骨架搭好之后,后续可以往里面加文档实时更新、多知识库路由、Source 引用标注等功能。下一篇我准备聊聊 Spring AI Alibaba 里 Agent 方向的玩法,让模型不只是被动回答,还能主动调工具。专栏写到这,希望你手里的知识库已经不是 Demo,而是能真正回答业务问题的小系统。