在系列前面的几篇里,我们已经把 Spring AI Alibaba 的环境搭建、基础对话、提示词模板和 Function Calling 都过了一遍。走到这一步,不少同学心里其实一直压着一个问题——光靠大模型聊天,或者能调用几个函数,距离“真正回答我们业务问题”还差得很远。模型记不住我们公司的内部规范,也不会凭空知道我们的历史项目文档、产品手册和售后记录。这一篇就来解决这个最实际的痛点:用 Spring AI Alibaba 把文档变成可检索的知识库,再通过 RAG(Retrieval-Augmented Generation,检索增强生成)的方式,让模型基于真实资料回答问题。适合刚跑通基础接口、想往落地场景再往前走一步的读者,也适合正在做内部知识库问答、客服辅助这类项目的同学参考。
1. 为什么企业落地绕不开 RAG
1.1 大模型的天花板,和 RAG 的破局方式
大模型在训练时见过的数据是固定的,训练完成那一刻,它的“知识”就冻结了。哪怕是用最新的模型,它对你公司的产品 SKU、历史工单、售后话术,也是一无所知。这不是模型不够聪明,而是它的训练语料里根本没有这些东西。如果你直接去问它“我们公司 X 设备在 Y 场景下的故障率是多少”,它大概率会一本正经地编一个听起来很合理的答案出来——这就是业内常说的“幻觉”。
要解决这个问题,思路其实很直白:模型不知道,那就给它看;模型记不住,那就帮它翻资料。人进新公司第一天怎么干活?先翻文档、问同事、查系统,而不是靠脑子硬记。RAG 做的就是这件事:先把文档切碎、变成向量存起来,用户提问的时候,去这个知识库里把最相关的几段内容捞出来,连同问题一起塞给大模型,让模型“看着资料回答”。这样答案不是模型脑补的,而是有出处的,准确率和可信度完全是两个层级。
这里有一个容易被忽略的好处:RAG 让知识的更新成本变得极低。模型不需要重新训练,你只需要往向量库里加一份新文档,第二天它就能回答新内容。对于业务文档频繁变动的场景,这个优势比任何微调方案都现实。我见过不少团队打算用微调解决知识更新问题,结果训一次模型两三天,发布一次还要走审批,等文档上线,业务早就又变了。
1.2 Spring AI Alibaba 把 RAG 链路抽象成了什么
RAG 整套流程拆开看是固定的四个动作:加载文档、切分文本、向量化存储、检索并生成回答。Spring AI Alibaba 做的事情,就是把这四个动作封装成了一套可以组合的组件,让开发者不需要自己从零去拼接 PDF 解析、向量计算、相似度检索这些脏活累活。
具体来说,它给出了几个抽象层。文档加载对应 DocumentReader,负责把 PDF、Word、Markdown 等不同来源读成统一的 Document 对象;文本切分对应 TextSplitter,负责把长文档切成符合模型上下文窗口的小块;存储和检索对应 VectorStore,负责把文本变成向量并支持相似度查询;最后回答阶段有 QuestionAnswerAdvisor,它把“检索 + 拼接上下文 + 限制模型只能基于上下文回答”这一整套逻辑打包好了。
这套抽离的收益在切换组件时最明显。举个例子,你今天先用内存版向量库把流程跑通,明天线上要接 Redis 或者 PGVector,只需要换一个 VectorStore 的 Bean 实现,其余业务代码一行不用动。这就是框架存在的意义——它不替你写业务,但它把你的实现成本和切换成本都压到了很低。我前几年自己用 Python 写过一套类似的流程,光是把各种文档解析器的异常处理调明白就花了一周,而在 Spring AI Alibaba 里,这些边界情况基本都被框住了。
2. 搭建前的准备:依赖、模型和向量库怎么选
2.1 最小依赖清单与版本对照
先看依赖。核心只需要引入spring-ai-alibaba-starter,它会把 ChatModel、EmbeddingModel 这些默认组件都自动装配好。我做 RAG 的时候会再加一个文档解析器的依赖,如果是处理 PDF、Word 这类办公文档,用spring-ai-tika-document-reader,一个依赖就能覆盖绝大多数格式。
<dependency> <groupId>com.alibaba.cloud.ai</groupId> <artifactId>spring-ai-alibaba-starter</artifactId> <version>1.0.0-M5.1</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-tika-document-reader</artifactId> </dependency>版本这里要提醒一句:Spring AI 的版本迭代非常快,M5.1 是我当前在用的版本,等你看这篇文章的时候,正式版或者更高的 M 版应该已经发布了。建议直接去 Spring AI Alibaba 官方仓库看最新版本号,并且让 Spring AI BOM 统一管理这些依赖的版本,避免出现 reader 和 starter 版本不一致导致的兼容问题。我第一次踩过的坑就是手动指定了 reader 的版本,结果 starter 实际用的 Spring AI Core 版本比我指定得高,运行时报了一堆方法签名找不到的错误。
2.2 模型搭配:Chat 模型和 Embedding 模型的分工
RAG 链路里有两个模型在协同工作,很多人第一次做的时候会混淆它们。
对话生成的模型,就是 Chat 模型,负责根据用户问题和检索到的上下文组织回答。这个没什么好说的,你前面几篇用哪个,继续用哪个就行。真正容易被忽视的是 Embedding 模型,它负责把文本转成向量。这里有个关键要求:写入向量库和查询向量库,必须使用同一个 Embedding 模型。如果你写入的时候用的 A 模型,查询的时候用的 B 模型,两个向量的分布空间都不一样,相似度计算出来的结果就是一堆乱码,检索效果惨不忍睹。
Spring AI Alibaba 默认会配置好 DashScope 的 Embedding 模型,不用额外写代码。你在application.yml里只要配好 API Key:
spring: ai: dashscope: api-key: ${DASHSCOPE_API_KEY}框架会自动注入一个可用的 EmbeddingModel Bean。如果你有特殊需求,比如内部部署了私有的 Embedding 模型,只需要自己声明一个 EmbeddingModel 的 @Bean 覆盖掉默认的即可。我个人建议前期不要折腾这一步,先用默认模型的跑通整体链路,再去考虑替换,否则你在调通之前,根本分不清问题是出在模型上还是自己的代码上。
2.3 向量数据库:先内存跑通,再谈持久化
Spring AI Alibaba 首选的默认向量库是内存版的 SimpleVectorStore,对于入门和测试来说这个是体验最好的选择,不需要额外部署任何服务,项目启动的时候往里面塞数据就能用。但它有个硬伤:数据只存在内存里,服务重启就全丢了,而且单机内存容量有限,不适合生产。
所以我的建议非常明确:第一阶段,不管最终目标是什么,先用 SimpleVectorStore 把整套 RAG 链路跑通。原因很简单,排除变量。等你确认了“文档解析没问题、切分没问题、检索没问题、回答没问题”,再去切换正式的向量库。切换的成本很低,因为 VectorStore 是一个接口,你只需要换一个实现类。第二阶段再上 Redis 或者 PGVector 这类持久化方案,只需要在 pom 里加对应的 starter,然后配置一下连接地址。
这里贴一下 SimpleVectorStore 的配置方式:
@Bean public VectorStore vectorStore(EmbeddingModel embeddingModel) { return new SimpleVectorStore(embeddingModel); }判断一个向量库适不适合你的场景,我一般看三个维度:检索性能、数据持久化、运维成本。Redis 适合已经有 Redis 基础设施的团队,PGVector 适合把 PostgreSQL 作为统一数据存储的团队,各有各的取舍。入门阶段别纠结,跑通了再去纠结不迟。
3. 把 PDF 文档变成可检索的知识库:解析、切分与向量化
3.1 文档加载:不同格式选不同的 Reader
整个 RAG 流程里,文档加载是最容易被低估的一步。很多人以为 PDF 解析就是把文字抽出来,实际做一遍就明白了,格式多样的文档会让异常处理变得非常麻烦。Spring AI Alibaba 给了一组统一的 DocumentReader 接口,不同的格式对应不同的实现类。
| 文档类型 | 推荐方案 | 说明 |
|---|---|---|
| PdfDocumentReader 或 TikaDocumentReader | 文本型 PDF 直接解析;扫描件需要 OCR | |
| Word/PPT/Excel | TikaDocumentReader | Tika 统一处理,覆盖格式广 |
| 网页链接 | UrlDocumentReader | 直接抓取页面文本 |
| Markdown/纯文本 | 直接用 Spring AI 的 TextReader | 简单场景够用 |
在我实际项目里,使用 PdfDocumentReader 加载一个产品手册,代码是这样的:
public List<Document> loadPdf(String path) { PdfDocumentReader reader = new PdfDocumentReader( new FileSystemResource(path)); return reader.read(); }读到的是统一的 Document 对象列表,每个 Document 里面有文本内容、有元数据。元数据这个东西前期容易被忽视,等你要在回答里标注“这段内容出自哪份文档哪一页”的时候,就知道它有多重要了。所以我的习惯是加载完就打印一下每个 Document 的 id 和 metadata,看看有没有 source 信息,没有的话手动补上。
3.2 切分策略:chunk 大小和重叠怎么定
文档加载完之后往往是几页甚至几十页的连续文本,不可能整篇塞给模型,必须切成小块,也就是 chunk。这一步直接决定了检索质量的上限。切得太大,每块包含太多无关信息,向量之间的区分度下降,检索出来的内容不够精准;切得太小,语义可能被截断,一段完整的意思被切到两块里,哪一块都检索不到关键信息。
Spring AI 提供了几个切分器,我常用的是 TokenTextSplitter,按 Token 数量切分,同时可以设置重叠:
TextSplitter splitter = new TokenTextSplitter(600, 150); List<Document> chunks = splitter.apply(docs);第一个参数是每块的最大 Token 数,第二个参数是相邻块之间的重叠 Token 数。600 这个数字不是拍脑袋定的,普通技术文档的一段话大概占 200 到 300 个 Token,600 能保证一个 chunk 里放得下一到三个完整段落;150 的重叠保证跨段的语义不会因为硬切而断掉。用生活里的场景来类比,切文本就像切菜,刀不能太钝导致切不断,也不能一刀切太大块导致炒不熟。
如果你处理的文档结构比较规整,比如强标题结构的帮助中心,建议先按标题拆成章节,再用 TokenTextSplitter 切一次;如果是聊天记录这种短文本,把 chunk 调小到 200 到 300,反而效果更好。没有万能参数,我把这组参数理解为“默认起点”,每换一类文档都要手动验证调整。
3.3 写入向量库,先验证检索再谈问答
所有的准备工作,最终都是为了把切好的文本块向量化之后存进 VectorStore。这一步的代码短到让人怀疑是不是漏了什么:
public void buildKnowledgeBase(VectorStore vectorStore, List<Document> chunks) { vectorStore.add(chunks); }但真正重要的是写入之前和写入之后的验证。写入之前,先打印一下 chunks 的数量和每块的字符长度,确认不是所有内容都被切成了同一个空块,这种情况我遇到过,原因是文档解析出来本身就是空白文本。写入之后,不要急着去写问答接口,先单独跑一次相似度检索,看看返回的 chunk 内容和预期的知识是否匹配。
List<Document> results = vectorStore.similaritySearch( SearchRequest.query("设备保修期是多久").withTopK(3));这一步做得好,你的问答链路就成了一半。检索这一步如果捞上来的内容驴唇不对马嘴,后面再怎么调 Prompt 也没用,因为模型拿到的“参考资料”本身就是错的。我在每次接新领域的文档时都会先做这个验证,发现检索不准就回头调切分参数,而不是闷头写后面的代码。
4. 组装问答链路:从向量检索到大模型回答
4.1 QuestionAnswerAdvisor 做了什么
检索验证通过之后,就到了整个 RAG 链路最后一步:把检索到的文档片段和用户问题组装起来,交给大模型生成回答。这里如果全手写,你要做三件事:拼接 Prompt 模板、控制检索结果的 Token 长度、在 Prompt 里反复强调“只能根据参考资料回答,不要编造”。每次写都容易出错,而且容易漏掉细节。
Spring AI Alibaba 提供了一个现成的组件 QuestionAnswerAdvisor,把这三件事都封装好了。它内部做的事情是:先根据用户问题去 VectorStore 检索,取出 TopK 个相关片段,然后把这些片段作为系统上下文拼进 Prompt,再调大模型回答。它内置的 Prompt 模板会自动告诉模型“只根据给定上下文回答,如果上下文里没有相关内容,就老实说不知道”。
使用起来非常简洁:
QuestionAnswerAdvisor advisor = QuestionAnswerAdvisor.builder(vectorStore) .similarityThreshold(0.6) .topK(4) .build();这里有两个参数值得展开说。similarityThreshold 是相似度阈值,只有当相关度高于这个值的文档片段才会被采纳;topK 是取前多少个片段。这两个参数是检索质量的双保险,一个管下限,一个管数量。它们的调优不是一劳永逸的,具体值要看你的文档特征,也需要结合实验结果去确认,我后面会在问题排查那一节细讲。
4.2 一个完整的 RAG 问答接口
把前面的组件串起来,就是一套完整的 RAG 问答代码。我通常会写一个 Service 类,专门负责知识库构建和问答:
@Service public class RagService { private final ChatModel chatModel; private final VectorStore vectorStore; public RagService(ChatModel chatModel, VectorStore vectorStore) { this.chatModel = chatModel; this.vectorStore = vectorStore; } public String answer(String question) { ChatClient chatClient = ChatClient.builder(chatModel).build(); QuestionAnswerAdvisor advisor = QuestionAnswerAdvisor.builder(vectorStore) .similarityThreshold(0.6) .topK(4) .build(); return chatClient.prompt() .advisors(advisor) .user(question) .call() .content(); } }对应的 Controller 就更简单了:
@RestController public class QuestionController { private final RagService ragService; public QuestionController(RagService ragService) { this.ragService = ragService; } @PostMapping("/ask") public String ask(@RequestParam String question) { return ragService.answer(question); } }整个代码量比大多数人想象中要少得多。但代码少不代表没东西要注意。我这里特别说明一下,如果你的应用里有多个不同的知识库,比如一个产品文档库、一个运维故障库,不要把它们混在同一个 VectorStore 里,建议按业务域拆成多个 VectorStore 实例,或者在写入时给 Document 的 metadata 里打上分类标签,检索的时候按标签过滤。混在一个库里,检索出来的内容会很杂,模型会把不相干的两类资料混在一起回答,非常容易出错。
4.3 关键参数:相似度阈值、引用来源与流式返回
答疑接口跑通之后,你还想让它更贴近真实场景,有几个进阶点要处理。
第一个是相似度阈值。默认的 0.6 是我个人习惯的起始值,具体调法是拿一批有标准答案的测试问题跑一遍,观察检索返回的 TopK 内容里到底有多少是真正相关的。如果发现返回一堆不相关片段,就把阈值往上调;如果相关片段被过滤掉了导致模型说“不知道”,就把阈值往下调。我见过有同事为了追求“回答率”,把阈值设到 0.1,结果模型答得天花乱坠但没几个字是对的,这就是典型的指标选错了。
第二个是回答里带上引用来源。Spring AI 的 Document 对象自带 metadata,我在写入向量库之前会把 source 信息塞进去,回答之后把 QuestionAnswerAdvisor 返回的上下文取出来,提取 source 字段展示给用户。企业内部场景里,用户看到“这段结论出自《XX设备运维手册》第 3 章”,信任度完全不一样。
第三个是流式返回。问答本身要联网调大模型,接口如果同步返回动辄五六秒,前端体验很差。用.stream()替代.call()就能拿到流式的 Flux:
Flux<String> answer = chatClient.prompt() .advisors(advisor) .user(question) .stream() .content();前端配合 SSE 就能实现一个字一个字往外蹦的效果。这个看似只是体验优化,但在客服辅助这类真实业务里几乎是必选项,没人愿意盯着转圈等六秒。
5. 实战避坑指南:解析失败、检索偏差和幻觉残留
5.1 文档解析最容易踩的三个坑
文档解析是 RAG 链路里异常处理需求最密集的环节。我把自己反复踩过的坑集中整理一下。
第一个坑:扫描版 PDF 解析出来全是乱码或者干脆是空文本。很多公司内部手册是扫描件转的 PDF,里面根本没有文字层,PdfDocumentReader 只能解析出图片,Tika 默认也做不了 OCR。解决办法是在 Tika 里配置 Tesseract OCR,或者干脆换一个思路,先用 OCR 工具把 PDF 转成带文字层的版本再喂给解析器。这个坑的可怕之处在于,程序不报错,只是解析出来的内容为空,你会误以为向量库是好的,直到用户提问时半天检索不到东西。
第二个坑:PDF 里的表格被解析成一大坨文字。表格本身就是视觉结构,解析成文本之后行列关系全丢了。我处理产品参数表的时候,一开始检索出来的是“型号 A 电压 220V 型号 B 电压 380V”这种粘连文本,切块之后语义更乱。现在的处理方法是,能用 Markdown 转存的就先转成结构化文本,实在不行就把表格单独按行切小块,让每个 chunk 尽量包含完整的一行数据,反而检索效果更好。
第三个坑:加载外部 URL 的时候被反爬或者拿到一堆页面导航文本。UrlDocumentReader 抓到的页面里混杂了导航栏、页脚版权信息这些噪音,直接向量化会严重污染检索结果。我现在的做法是抓取之后先做一轮清洗,删掉.header、.footer这类标签里的内容再入库。
| 问题现象 | 主要原因 | 解决方向 |
|---|---|---|
| 解析结果乱码/空白 | 扫描件无文字层 | OCR 预处理或换带文字层的文件 |
| 表格信息混乱 | 文本解析丢失行列结构 | 结构化转存或按行切块 |
| 抓取页面噪音太多 | 导航/页脚混入正文 | 清洗标签后再入库 |
5.2 检索结果不理想的排查顺序
用户问了一个问题,结果答案没对上号,大部分人第一反应是去改提示词,这是方向性错误。检索不对,提示词改出一朵花来也没用。我建议按照下面的顺序排查。
先确认向量库里有没有数据。听起来像废话,但我真的遇到过几次向量库写了一半就报错,重启之后数据回滚,服务看起来正常,实际上库里空空如也。写一个启动自检,打印向量库里的文档总数,这个操作能省下大量排查时间。
再确认切分参数是否合理。如果检索出来的 chunk 明显把一句话切成了两半,导致永远搜不到关键词,就调大重叠量或者换用按段落切分。如果发现检索结果和问题高度相关,但 chunk 太多导致上下文超长被截断,就调小 chunk 大小或者降低 topK。
最后检查 Embedding 模型是否一致。如果你升级了依赖,或者曾经切换过模型,库里已有的旧向量是旧模型生成的,和新模型不兼容。这个问题的表现非常隐蔽:不是完全检索不到,而是相关度都很低,整体效果远不如之前。解决办法就是更新依赖后重新全量向量化。
5.3 回答还有幻觉?检查这三处
如果你确信检索结果没问题,上下文也正确拼进去了,模型却还是给出了不在资料里的内容,这个时候要查三处地方。
第一处,相似度阈值是不是设得太低。阈值低意味着很多不相关内容被塞进上下文,模型面对一堆不相关文字,更容易强行找逻辑,从而编造答案。这是最常犯的错误,用标准测试题目跑一遍对比,往往立刻就能发现问题。
第二处,你的 Prompt 有没有被默认模板覆盖。QuestionAnswerAdvisor 内置的 Prompt 是“根据上下文回答”,但如果你自己又叠了一层 system prompt,两者可能打架。我见过的一种情况是,自定义 system prompt 里写了“你是一个知识渊博的助手”,于是模型即使在上下文里找不到答案,也会因为系统人格设定而强行补充发挥。解决方法是把自定义 Prompt 和 Advisor 的指令对齐,明确写上“如果上下文中没有对应信息,直接回答不知道”。
第三处,你问的问题本身是不是不在知识库里。如果用户问“设备价格”,而你的知识库里只放了设备参数没有任何价格信息,模型被强制要求“根据上下文回答”,它只好输出一句“上下文中未提供价格信息”。这时要区分清楚:到底是没有检索到,还是没有这个知识。前者是检索问题,后者建议返回一个标准的“知识库中暂无相关内容”话术。
这里提供一组我常用的兜底写法,给 Prompt 加一个硬性约束:
请只根据上面提供的资料回答问题。 如果资料中没有直接答案,请明确回答“当前资料中未找到相关信息”,不要尝试猜测或补全。 内容请尽量引用原文,保留关键数据和结论。最后再分享一个真香技巧:把每次检索到的 chunk 内容连同最终回答一起打日志,哪怕只是输出到控制台。这能在你搞不清到底是检索问题还是生成问题的时候,直接撕开黑盒看清楚模型到底看到了什么。这个习惯让我少熬了无数次夜。RAG 这条路,框架能帮你省掉大量重复劳动,但最终的效果还是得靠你对自己文档的理解和持续的参数调优来打磨。