news 2026/9/30 3:02:56

Spring AI Alibaba RAG实战:从文档解析到知识库问答

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring AI Alibaba RAG实战:从文档解析到知识库问答

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.json

qwen-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 size300 到 800 Token太小语义不完整,太长噪声太多
overlap80 到 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,而是能真正回答业务问题的小系统。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 3:02:55

Prompt指令设计绿皮书:从模板到指令库的实战拆解

简介&#xff1a;《AI引擎&#xff1a;Prompt指令设计绿皮书》是一份面向ChatGPT、Claude、Bard等AI工具使用者的实用指南&#xff0c;适合新媒体运营、内容创作者及希望提升AI交互效率的职场人群。资源围绕Prompt指令写作展开&#xff0c;系统讲解明确定义需求、提供上下文、使…

作者头像 李华
网站建设 2026/9/30 3:02:41

基于Node.js的校园二手闲置物品共享平台开发实战

Campus第二件套&#xff1f;做这类“校园二手闲置物品共享平台”的人&#xff0c;十个里有八个是被毕业设计逼的&#xff0c;剩下两个是想在比赛里拿个奖。前阵子正好带学生从零跑通了一个基于Node.js的校园闲置交易项目&#xff0c;标题就叫“nodejs校园二手闲置物品共享平台”…

作者头像 李华
网站建设 2026/9/30 3:02:15

C++与人工智能框架:从环境搭建到模型部署的实战指南

先说个结论放在前面&#xff1a;不管前端怎么包装、Python 怎么火&#xff0c;AI 框架真正跑起来的那一刻&#xff0c;绝大多数代码都是 C 写的。你打开 PyTorch 的源码&#xff0c;底层是 C。你部署 TensorRT 模型&#xff0c;调用的是 C 接口。甚至你用 OpenCV 做图像预处理&…

作者头像 李华
网站建设 2026/9/30 3:01:46

汽车电子核心知识:ECU、BCM、CAN总线与OTA升级实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 3:01:02

百考通AI辅助毕业论文全流程实战:从选题到降重避坑指南

又到毕业季&#xff0c;宿舍楼里飘着打印店的油墨味&#xff0c;图书馆走廊里全是抱着电脑来回踱步的人。写论文这件事&#xff0c;几乎把所有人的耐心和睡眠一起磨没了。选题改了七次、框架推倒重来、文献读了五十篇还是下不了笔、查重报告红得跟番茄炒蛋似的——这些场景我太…

作者头像 李华
网站建设 2026/9/30 3:00:52

OpenCV DNN跨平台2D人体关键点检测:Python/Android/C++三端实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华