1. AI 总是自己编答案?先把幻觉的病根说清楚做知识库问答助手的时候,我最大的痛点是 AI 一本正经地胡说八道。明明知识库里没有这个数据,它也能给你编出个一二三四来,语气还特别笃定。为了解决这个毛病,我把 Spring AI 和 RAG 接在一起,做了一版"回答带引用"的问答助手——每个答案后面跟着来源文档、片段和页码,用户一眼就能看出 AI 说的是真是假。这个项目不大,但确实把 AI 从"看着像那么回事"变成了"可以用来干活"。1.1 一个每次回答都像新编的翻车现场先讲个真实场景。同事 A 在内部行政知识库里问:"报销发票的抬头格式要求是什么?"模型回答得头头是道,说抬头必须是"公司全称+税号",还补充了一句"如果是电子发票,需要保存 PDF 原件"。问题是,知识库里根本没有关于抬头格式的条目,那套回答完全是模型根据训练数据里的通用财务常识"脑补"出来的。更麻烦的是,这种错误很难一眼看出来。它不像明显的乱码,而是像模像样的专业表述,普通用户没有精力去逐条核实。我刚开始做问答助手时,团队内部的共识是:宁可答"不知道",也不能给错答案。但大模型天生就是个"话痨",你问它任何问题,它都倾向于给一个完整答复,而不是承认自己不会。于是就有了 RAG 的必要。RAG 的全称是检索增强生成,核心思路很简单:先把知识库里的文档切块、向量化,放进向量库;用户提问时,先从向量库里检索出最相关的几个片段,再把这些片段和问题一起交给大模型生成答案。这样模型就不是凭空回答了,而是"拿着资料作答"。1.2 RAG 为什么能解决问题:闭卷考变开卷考如果让我用一句话解释 RAG,我会说:它让 AI 从"闭卷考试"变成了"开卷考试"。闭卷时,模型只能靠训练阶段见过的知识回答问题,训练数据之外的信息自然就靠猜。开卷时,我们把相关文档放在它面前,让它照着写,准确率当然高得多。但"开卷考试"也有一个隐患:模型不一定会严格照着资料说,它可能自己又加戏。这时候仅仅做 RAG 还不够,还必须让回答"带引用"。所谓带引用,不是让 AI 在回答末尾凭空写几个链接,而是把检索到的每一段文档都带上来源元数据,比如文件名、章节、页码,然后要求模型在生成回答时标注这段内容来自哪一份资料。带引用的价值有三个层面。第一层是用户侧:用户看到回答里有 [1][2] 这样的标注,点开就能看到原文,信任感完全不一样。第二层是开发侧:如果答案引用了错误来源,开发人员可以据此定位是检索问题还是生成问题,不用对着黑盒猜。第三层是质量兜底:如果一个回答的引用列表为空,我们可以直接判断这次生成"不可信",在界面上给出提示,或者干脆不让它展示。所以,"回答带引用"不是 RAG 的附属品,而是整个方案里最重要的质量阀门。没有这个阀门,RAG 只是让 AI 多看了几页资料,并没有真正把责任落到可验证的源头。2. Spring AI RAG 问答助手怎么设计:选型与整体流程2.1 为什么不用纯手写去拼接 RAG 流程早几年做 RAG,常见的做法是用 Python 把向量库、Embedding 接口、大模型接口、提示词模板一个个串起来。Python 生态确实丰富,但在 Java 为主的技术团队里,引入一整套 Python 服务意味着要额外维护一套部署链路、监控体系和权限模型,成本不低。我选择 Spring AI 的原因很简单:它可以把 RAG 的完整链路嵌入 Spring Boot 应用,省掉"胶水代码"。你不需要自己处理向量库连接池、Embedding 调用的重试、 token 长度控制这些事,Spring AI 把大模型、Embedding、向量存储、提示词都抽象成了统一接口。更实用的一点是,它的ChatClient和Advisor机制非常接近业务代码的直觉:你组装 prompt,注入上下文,然后拿结果。对比过自己拼方案之后,我更确定 Spring AI 的优势在"可替换性"。模型厂商的接口经常调整,如果所有调用都散落在业务代码里,换模型等于重构。Spring AI 把模型客户端放在 starter 里,配置一变,底层实现就换了,业务代码基本不用动。尤其在做 RAG 时,Embedding 模型和生成模型可能是不同厂商,统一抽象能让这种混搭舒服很多。当然,Spring AI 也不是零成本。它的版本迭代比较快,API 在不同版本之间会有调整,网上很多示例代码直接复制是跑不起来的。这一点我在后面会提醒。只要把"版本对齐"这个坑提前避开,它带来的开发效率提升非常明显。2.2 Spring AI 里 RAG 的四个核心组件我理解一个 Spring AI RAG 项目,其实是四件事的组装:文本加载、文档切分、向量检索、生成回答。第一是文档加载。知识库里的 PDF、Word、Markdown、网页,都需要通过对应 loader 转成统一的Document对象。这一步最关键的不是解析文本,而是把来源信息塞进Document的 metadata 里。第二是文档切分。大模型的上下文窗口有限,Embedding 模型也有输入长度限制,所以文档不能整个塞进去,要切成几百 token 的块。切分质量直接决定检索效果,切太大会引入噪音,切太小会丢失上下文。第三是向量检索。切好的 chunk 通过 Embedding 模型转成向量,存入向量库。用户问题时也转成向量,然后做相似度搜索。这一步的输出是一批与问题最相关的文档片段,同时还带着原来的 metadata。第四是生成回答。把检索到的片段按指定格式拼进 prompt,要求模型"只能基于资料回答",并且标注引用编号。生成完成后,我们再从结果里提取引用编号,映射回metadata,最终返回结构化数据给前端。这四个环节里,前两步通常离线执行,后两步是用户请求时实时执行。离线部分可以做成一个数据同步任务,知识库有更新就重建索引;在线部分则封装成问答接口。Spring AI 没有强制你用什么架构,只要把VectorStore作为核心接点,前后都能灵活替换。3. 从零跑通一个带引用的 RAG 问答助手:完整实现3.1 准备依赖和配置:能跑起来就行我先声明一点:Spring AI 的依赖 artifact 在不同迭代版本里改了多次,这里给的代码是基于目前比较常见的 API 写法。如果你引入的版本不同,类名和配置前缀可能略有差异,以官方文档为准。项目本身是一个标准的 Spring Boot 工程。需要引入 Spring AI 的模型 starter、向量存储相关依赖,以及你自己的 PDF 解析等工具。以 Maven 为例:<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-xxx</artifactId> <version>你使用的版本</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-vector-store-simple</artifactId> <version>你使用的版本</version> </dependency>模型 starter 的 artifactId 要根据你对接的模型服务来换。如果你用的是某个兼容 OpenAI 网关协议的服务,就找对应的 starter;如果你用的是本地模型部署,就用本地推理那一类。关键是要同时拿到ChatModel和EmbeddingModel两个 bean,前者负责生成回答,后者负责把文本转向量。配置方面,你至少需要确认三件事:模型服务地址、模型名称、向量库存储位置。我用的是团队内网统一的模型网关,base-url 指向网关地址,模型名由网关向下发路由。这样写的好处是,换模型不用改代码,只改配置:ai: base-url: ${AI_BASE_URL:http://localhost:8000} api-key: ${AI_API_KEY:} chat-model: ${CHAT_MODEL_NAME:text-embedding-demo} embed-model: ${EMBED_MODEL_NAME:local-embedding-demo}配置完成后,先写一个最小启动类,注入ChatClient和VectorStore,确认应用能正常启动,再继续后面的逻辑。3.2 文档加载与切分入库:注意 metadata 里的来源信息我习惯把入库过程写成一个独立的IngestionService,它接收文件列表,输出向量库中的索引。这样知识库更新时可以单独触发这个服务,不用影响在线问答接口。先把文档读进来。假设我们有一批 Markdown 文件,最简单的做法是扫描目录,每个文件读成字符串,然后构建Document。如果是 PDF,就引入解析库提取文本,再构建Document。重点是构建时一定要放 metadata:Document doc = new Document(fileContent, Map.of( "source", file.getName(), "title", extractTitle(file), "page", extractPageIfApplied(file) ));source字段我强烈建议保留,其他字段看你需要。引用要展示什么,后面就从这里取什么。有些资料还有版本号、文档归属部门、更新时间,这些都可以放进 metadata。接下来切分。切分器的选择很多,按 token 数切是最稳的,因为 Embedding 模型和 LLM 的 token 限制都是按 token 算的。我用的是TokenTextSplitter:TextSplitter splitter = new TokenTextSplitter(); List<Document> chunks = splitter.apply(List.of(doc));默认切分参数可能不够通用,我通常会把 chunkSize 设在 500 token 左右,chunkOverlap 设在 100 token 左右。Overlap 的作用是避免一句话在切分时被拦腰截断,导致检索时匹配到不完整的上下文。切完之后,每个 chunk 仍然会保留原 Document 的 metadata,这一点非常重要,是引用信息不丢失的前提。最后把切好的片段写入向量库:vectorStore.add(chunks);如果向量库是内存版,重启数据就没了。所以我的入库任务会在每次启动后重新执行,或者把向量持久化到本地文件。真正的生产环境,建议用独立向量库服务,性能和容量都更可控。3.3 问答接口实现:把检索和生成接起来问答接口的核心逻辑是:检索、拼装、生成、析出引用。我先手动执行相似度搜索,而不是直接依赖 Spring AI 内置的 RAG Advisor,这样能更清楚地控制引用信息的处理。@Service public class RagAssistant { private final ChatClient chatClient; private final VectorStore vectorStore; public RagAssistant(ChatClient chatClient, VectorStore vectorStore) { this.chatClient = chatClient; this.vectorStore = vectorStore; } public AnswerResponse ask(String question) { // 1. 向量检索 List<Document> docs = vectorStore.similaritySearch( SearchRequest.query(question) .withTopK(4) .withSimilarityThreshold(0.5)); // 2. 拼装带编号的资料上下文 StringBuilder context = new StringBuilder(); for (int i = 0; i < docs.size(); i++) { context.append("[").append(i + 1).append("] ") .append(docs.get(i).getContent()) .append("\n\n"); } // 3. 提示词里明确要求标注引用 String prompt = """ 你是知识库问答助手。请只基于下面的资料回答问题。 如果资料中没有答案,直接说“资料中未找到相关内容”。 每个论点后面请标注引用编号,例如[1]或[2]。 资料: %s 问题:%s """.formatted(context.toString(), question); String content = chatClient.prompt() .user(prompt) .call() .content(); // 4. 返回回答和对应文档 return new AnswerResponse(content, docs); } }这里有几个细节。withTopK(4)表示最多取 4 个相关片段,withSimilarityThreshold(0.5)是相似度门槛,低于这个分数直接丢弃。这两个参数直接影响回答质量,后面我会展开讲。用SearchRequest手动检索的好处是:docs列表就是最终展示给用户的引用源,不需要从模型输出里反向猜测。模型只负责在回答文本里写 [1][2],我们负责把编号映射到docs里的具体文档。3.4 返回结果里的引用清单:从 [1] 到来源卡片AnswerResponse我设计成两个字段:回答文本和引用文档列表。引用文档的展示信息从 metadata 里取:public record AnswerResponse( String answer, List<Citation> citations ) { public record Citation( String source, String title, String content, String page ) {} }从Document转成Citation时,注意不要直接返回原始 Document 对象。原因有两个:一是 Document 里可能还有切分嵌入产生的内部字段,没必要暴露给前端;二是我们要控制引用内容的长度,避免把整个 chunk 都推出去,只展示关键片段即可。前端拿到citations后,渲染成"来源卡片"。每张卡片显示文件标题、来源名、页码和命中片段。用户点引用编号 [1],可以定位到第一张卡片。这样的交互体验比纯文本引用链接好很多,尤其是在移动端上,卡片式布局更清楚。为了让模型更稳定地输出引用编号,我在 prompt 里还会加一个例子。比如给出一段标准回答,告诉它"如果引用了资料[1],就在句末加[1];多个引用用[1][2]并列"。模型是 few-shot 友好的,给一个范例之后,格式错误率会明显下降。4. 引用信息怎么从检索结果一路跟到回答里4.1 元数据链路:引用不是生成的,是检索带的很多人以为引用是模型自己生成的,其实不是。模型只能看到检索结果的文本,并不认识"这份文档叫什么、在哪一页"。它只是在回答末尾标一个编号,而编号对应的文档信息,完全靠代码从检索结果里带回来。所以关键在设计元数据链路。我在离线入库时就把 source、title、page 全部写进 metadata。切分时 metadata 会继承到每个 chunk。检索命中某个 chunk 时,这些信息原样返回来。拼装 prompt 时,编号顺序就是检索结果的顺序;生成回答后,我们再按编号反向映射。这套链路有一个好处:引用信息天然可信。因为它是从实际检索命中的文档里来的,不是模型编的。即使模型在回答里乱标了 [9],我们取不到对应的 9 号文档,就能做校验拦截。没有这一步,引用就是花架子。我在实践中发现一个容易踩的坑:有些文档加载器会把整篇文档内容塞进 Document 的 metadata 里,用来做全文检索。这些冗余字段在切分后会被复制到每一个 chunk,导致向量库存储膨胀。建议入库前做一次 metadata 清理,只保留展示和溯源需要的字段。4.2 引用的清洗与合并:模型也会乱编号模型不是格式化工具,它输出的引用编号经常不合规范。我见过输出 [1,2] 的,见过 [1] 和 [2] 连在一起变成 [12] 的,也见过回答里标了 [3] 但检索结果只有 2 个文档的。所以引用析出不能只靠正则硬抠,需要一套清洗逻辑。我的做法分成三步。第一步,从回答文本里提取所有[数字]模式,得到一个候选编号集合。第二步,过滤掉超过检索文档数量的编号,比如最多 4 个文档,那出现 [5] 就直接丢弃。第三步,对编号做排序和去重,按照首次出现顺序排列引用,再映射到文档列表。如果同一个文档在检索结果里出现了两次,只是不同的 chunk,我还会做合并。合并规则是:同一source且同一title的引用,合并成一张卡片,content 把多个命中片段拼接起来,页码用顿号连接。这样用户不会看到两张几乎一样的来源卡片。另外,回答文本里的编号不一定要强制清洗。如果模型输出 [1] [2],保留在文本里完全没问题。真正要清洗的是传给前端的引用列表,因为它要驱动 UI 交互,结构必须干净。4.3 引用安全性:避免把内部路径直接甩给用户把引用信息展示给用户之前,要过一道"脱敏"检查。最常见的坑是,程序员为了方便直接把文件绝对路径写进 metadata,比如/home/admin/kb/hr/员工手册.md。结果前端一展示,内部服务器路径全暴露了。更稳妥的做法是单独设置一个displaySource字段,只放适合展示的文件名或文档名,原始路径永远不接口返回。还有一种情况是文档本身含敏感信息。比如知识库里有员工身份证复印件提取出的文本,检索时恰好命中,然后不经处理直接返回了。这不是引用设计的问题,而是知识库权限控制的问题。我的建议是在检索层做权限过滤:如果用户所属角色无权访问某个来源,哪怕向量检索命中了,也要在返回前过滤掉。Spring AI 的SearchRequest支持带过滤表达式,可以把权限维度作为 metadata 一并过滤。引用内容本身也应该截断。不要把整个 chunk 全部塞进引用卡片,而是截取命中句的前后几十个字。一方面减少接口体积,另一方面也避免用户无需授权就拿到完整敏感文档。5. 回答带引用能上线前必须调好的几个参数5.1 引用回答质量不行的三个元凶第一版跑通后,我的回答带是带了引用,但质量惨不忍睹。排查下来,绝大多数问题出在三个地方。第一是 chunk 切太大。一开始我用默认切分,一个 chunk 可能有上千 token。用户问题只涉及文档里的一个细节,但检索返回的大块内容里混了大量无关信息,模型容易被带偏,引用也显得不精准。第二是 topK 设置太随意。topK 代表取几个命中片段。给太少,相关材料可能漏掉;给太多,上下文窗口被无关内容挤占,模型会开始抓瞎。我一开始 topK 设成 8,结果模型经常引用到第 5、6 个低相关度的片段,反而拉低准确率。第三是相似度阈值几乎没设。很多向量库默认不设置 threshold,只要分数排名在前 topK 就返回。但如果知识库本身噪音多,低相关内容的排名也可能挤进前几名,模型又分不清哪条有用,就乱引用。这三个坑,我建议一开始就把参数夹紧,宁可少召回,不要错召回。以下是我常用的检索参数建议:参数建议初始值作用chunkSize500 token控制检索粒度chunkOverlap100 token保留跨块上下文topK3 到 4控制上下文容量similarityThreshold0.5 左右过滤低相关片段5.2 两个最值得调的参数:topK 和相似度阈值如果你时间只够调两个参数,我会选 topK 和相似度阈值。topK 的调法不是越大越好。我用一个测试问题的集合,逐步增加 topK,观察回答是否有改善。经验规律是:从 3 到 5 之间往往有一个拐点,超过 5 之后,新增的片段大多是边缘内容,回答质量不再提升,甚至开始下降。原因很简单,模型注意力是有限的,塞太多低相关材料,它会困惑。相似度阈值更直接。阈值设高一点,比如 0.6,召回的内容质量高,但可能漏答,模型会说"资料中未找到相关内容";阈值设低一点,比如 0.3,几乎所有文档都能进候选,引用会变得很杂。我的做法是先跑一批真实问题,统计命中文档的相似度分布,找到"有效引用"和"无效引用"的分界点,然后把它设成阈值。如果你不知道怎么选,可以从 0.5 起步,然后观察两种失败模式:回答里出现明显的无关引用,就把阈值往上调;回答频繁说找不到,就把阈值往下调。每次调整后,用同一批测试问题对比,不要凭感觉。5.3 我踩过的坑:速查表我把实际开发中遇到的问题整理成一个速查表,方便你对照排查。问题现象可能原因解决方向回答没有引用编号prompt 里没有给格式示例在 prompt 中加入少量示例约束引用编号出现在回答里,但引用列表为空编号是模型瞎编的清洗逻辑过滤无效编号,不能展示空卡片引用来源和回答内容对不上检索到噪音片段提高相似度阈值,减小 topK多个 chunk 引用同一文件切分粒度太细按 source 合并引用卡片同一次提问结果不稳定模型参数或检索阈值不稳定固定生成参数,检索时使用确定性的 topK知识库更新后引用旧内容入库任务没有覆盖原索引更
1. AI 总是自己编答案?先把幻觉的病根说清楚做知识库问答助手的时候,我最大的痛点是 AI 一本正经地胡说八道。明明知识库里没有这个数据,它也能给你编出个一二三四来,语气还特别笃定。为了解决这个毛病,我把 Spring AI 和 RAG 接在一起,做了一版"回答带引用"的问答助手——每个答案后面跟着来源文档、片段和页码,用户一眼就能看出 AI 说的是真是假。这个项目不大,但确实把 AI 从"看着像那么回事"变成了"可以用来干活"。1.1 一个每次回答都像新编的翻车现场先讲个真实场景。同事 A 在内部行政知识库里问:"报销发票的抬头格式要求是什么?"模型回答得头头是道,说抬头必须是"公司全称+税号",还补充了一句"如果是电子发票,需要保存 PDF 原件"。问题是,知识库里根本没有关于抬头格式的条目,那套回答完全是模型根据训练数据里的通用财务常识"脑补"出来的。更麻烦的是,这种错误很难一眼看出来。它不像明显的乱码,而是像模像样的专业表述,普通用户没有精力去逐条核实。我刚开始做问答助手时,团队内部的共识是:宁可答"不知道",也不能给错答案。但大模型天生就是个"话痨",你问它任何问题,它都倾向于给一个完整答复,而不是承认自己不会。于是就有了 RAG 的必要。RAG 的全称是检索增强生成,核心思路很简单:先把知识库里的文档切块、向量化,放进向量库;用户提问时,先从向量库里检索出最相关的几个片段,再把这些片段和问题一起交给大模型生成答案。这样模型就不是凭空回答了,而是"拿着资料作答"。1.2 RAG 为什么能解决问题:闭卷考变开卷考如果让我用一句话解释 RAG,我会说:它让 AI 从"闭卷考试"变成了"开卷考试"。闭卷时,模型只能靠训练阶段见过的知识回答问题,训练数据之外的信息自然就靠猜。开卷时,我们把相关文档放在它面前,让它照着写,准确率当然高得多。但"开卷考试"也有一个隐患:模型不一定会严格照着资料说,它可能自己又加戏。这时候仅仅做 RAG 还不够,还必须让回答"带引用"。所谓带引用,不是让 AI 在回答末尾凭空写几个链接,而是把检索到的每一段文档都带上来源元数据,比如文件名、章节、页码,然后要求模型在生成回答时标注这段内容来自哪一份资料。带引用的价值有三个层面。第一层是用户侧:用户看到回答里有 [1][2] 这样的标注,点开就能看到原文,信任感完全不一样。第二层是开发侧:如果答案引用了错误来源,开发人员可以据此定位是检索问题还是生成问题,不用对着黑盒猜。第三层是质量兜底:如果一个回答的引用列表为空,我们可以直接判断这次生成"不可信",在界面上给出提示,或者干脆不让它展示。所以,"回答带引用"不是 RAG 的附属品,而是整个方案里最重要的质量阀门。没有这个阀门,RAG 只是让 AI 多看了几页资料,并没有真正把责任落到可验证的源头。2. Spring AI RAG 问答助手怎么设计:选型与整体流程2.1 为什么不用纯手写去拼接 RAG 流程早几年做 RAG,常见的做法是用 Python 把向量库、Embedding 接口、大模型接口、提示词模板一个个串起来。Python 生态确实丰富,但在 Java 为主的技术团队里,引入一整套 Python 服务意味着要额外维护一套部署链路、监控体系和权限模型,成本不低。我选择 Spring AI 的原因很简单:它可以把 RAG 的完整链路嵌入 Spring Boot 应用,省掉"胶水代码"。你不需要自己处理向量库连接池、Embedding 调用的重试、 token 长度控制这些事,Spring AI 把大模型、Embedding、向量存储、提示词都抽象成了统一接口。更实用的一点是,它的ChatClient和Advisor机制非常接近业务代码的直觉:你组装 prompt,注入上下文,然后拿结果。对比过自己拼方案之后,我更确定 Spring AI 的优势在"可替换性"。模型厂商的接口经常调整,如果所有调用都散落在业务代码里,换模型等于重构。Spring AI 把模型客户端放在 starter 里,配置一变,底层实现就换了,业务代码基本不用动。尤其在做 RAG 时,Embedding 模型和生成模型可能是不同厂商,统一抽象能让这种混搭舒服很多。当然,Spring AI 也不是零成本。它的版本迭代比较快,API 在不同版本之间会有调整,网上很多示例代码直接复制是跑不起来的。这一点我在后面会提醒。只要把"版本对齐"这个坑提前避开,它带来的开发效率提升非常明显。2.2 Spring AI 里 RAG 的四个核心组件我理解一个 Spring AI RAG 项目,其实是四件事的组装:文本加载、文档切分、向量检索、生成回答。第一是文档加载。知识库里的 PDF、Word、Markdown、网页,都需要通过对应 loader 转成统一的Document对象。这一步最关键的不是解析文本,而是把来源信息塞进Document的 metadata 里。第二是文档切分。大模型的上下文窗口有限,Embedding 模型也有输入长度限制,所以文档不能整个塞进去,要切成几百 token 的块。切分质量直接决定检索效果,切太大会引入噪音,切太小会丢失上下文。第三是向量检索。切好的 chunk 通过 Embedding 模型转成向量,存入向量库。用户问题时也转成向量,然后做相似度搜索。这一步的输出是一批与问题最相关的文档片段,同时还带着原来的 metadata。第四是生成回答。把检索到的片段按指定格式拼进 prompt,要求模型"只能基于资料回答",并且标注引用编号。生成完成后,我们再从结果里提取引用编号,映射回metadata,最终返回结构化数据给前端。这四个环节里,前两步通常离线执行,后两步是用户请求时实时执行。离线部分可以做成一个数据同步任务,知识库有更新就重建索引;在线部分则封装成问答接口。Spring AI 没有强制你用什么架构,只要把VectorStore作为核心接点,前后都能灵活替换。3. 从零跑通一个带引用的 RAG 问答助手:完整实现3.1 准备依赖和配置:能跑起来就行我先声明一点:Spring AI 的依赖 artifact 在不同迭代版本里改了多次,这里给的代码是基于目前比较常见的 API 写法。如果你引入的版本不同,类名和配置前缀可能略有差异,以官方文档为准。项目本身是一个标准的 Spring Boot 工程。需要引入 Spring AI 的模型 starter、向量存储相关依赖,以及你自己的 PDF 解析等工具。以 Maven 为例:<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-xxx</artifactId> <version>你使用的版本</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-vector-store-simple</artifactId> <version>你使用的版本</version> </dependency>模型 starter 的 artifactId 要根据你对接的模型服务来换。如果你用的是某个兼容 OpenAI 网关协议的服务,就找对应的 starter;如果你用的是本地模型部署,就用本地推理那一类。关键是要同时拿到ChatModel和EmbeddingModel两个 bean,前者负责生成回答,后者负责把文本转向量。配置方面,你至少需要确认三件事:模型服务地址、模型名称、向量库存储位置。我用的是团队内网统一的模型网关,base-url 指向网关地址,模型名由网关向下发路由。这样写的好处是,换模型不用改代码,只改配置:ai: base-url: ${AI_BASE_URL:http://localhost:8000} api-key: ${AI_API_KEY:} chat-model: ${CHAT_MODEL_NAME:text-embedding-demo} embed-model: ${EMBED_MODEL_NAME:local-embedding-demo}配置完成后,先写一个最小启动类,注入ChatClient和VectorStore,确认应用能正常启动,再继续后面的逻辑。3.2 文档加载与切分入库:注意 metadata 里的来源信息我习惯把入库过程写成一个独立的IngestionService,它接收文件列表,输出向量库中的索引。这样知识库更新时可以单独触发这个服务,不用影响在线问答接口。先把文档读进来。假设我们有一批 Markdown 文件,最简单的做法是扫描目录,每个文件读成字符串,然后构建Document。如果是 PDF,就引入解析库提取文本,再构建Document。重点是构建时一定要放 metadata:Document doc = new Document(fileContent, Map.of( "source", file.getName(), "title", extractTitle(file), "page", extractPageIfApplied(file) ));source字段我强烈建议保留,其他字段看你需要。引用要展示什么,后面就从这里取什么。有些资料还有版本号、文档归属部门、更新时间,这些都可以放进 metadata。接下来切分。切分器的选择很多,按 token 数切是最稳的,因为 Embedding 模型和 LLM 的 token 限制都是按 token 算的。我用的是TokenTextSplitter:TextSplitter splitter = new TokenTextSplitter(); List<Document> chunks = splitter.apply(List.of(doc));默认切分参数可能不够通用,我通常会把 chunkSize 设在 500 token 左右,chunkOverlap 设在 100 token 左右。Overlap 的作用是避免一句话在切分时被拦腰截断,导致检索时匹配到不完整的上下文。切完之后,每个 chunk 仍然会保留原 Document 的 metadata,这一点非常重要,是引用信息不丢失的前提。最后把切好的片段写入向量库:vectorStore.add(chunks);如果向量库是内存版,重启数据就没了。所以我的入库任务会在每次启动后重新执行,或者把向量持久化到本地文件。真正的生产环境,建议用独立向量库服务,性能和容量都更可控。3.3 问答接口实现:把检索和生成接起来问答接口的核心逻辑是:检索、拼装、生成、析出引用。我先手动执行相似度搜索,而不是直接依赖 Spring AI 内置的 RAG Advisor,这样能更清楚地控制引用信息的处理。@Service public class RagAssistant { private final ChatClient chatClient; private final VectorStore vectorStore; public RagAssistant(ChatClient chatClient, VectorStore vectorStore) { this.chatClient = chatClient; this.vectorStore = vectorStore; } public AnswerResponse ask(String question) { // 1. 向量检索 List<Document> docs = vectorStore.similaritySearch( SearchRequest.query(question) .withTopK(4) .withSimilarityThreshold(0.5)); // 2. 拼装带编号的资料上下文 StringBuilder context = new StringBuilder(); for (int i = 0; i < docs.size(); i++) { context.append("[").append(i + 1).append("] ") .append(docs.get(i).getContent()) .append("\n\n"); } // 3. 提示词里明确要求标注引用 String prompt = """ 你是知识库问答助手。请只基于下面的资料回答问题。 如果资料中没有答案,直接说“资料中未找到相关内容”。 每个论点后面请标注引用编号,例如[1]或[2]。 资料: %s 问题:%s """.formatted(context.toString(), question); String content = chatClient.prompt() .user(prompt) .call() .content(); // 4. 返回回答和对应文档 return new AnswerResponse(content, docs); } }这里有几个细节。withTopK(4)表示最多取 4 个相关片段,withSimilarityThreshold(0.5)是相似度门槛,低于这个分数直接丢弃。这两个参数直接影响回答质量,后面我会展开讲。用SearchRequest手动检索的好处是:docs列表就是最终展示给用户的引用源,不需要从模型输出里反向猜测。模型只负责在回答文本里写 [1][2],我们负责把编号映射到docs里的具体文档。3.4 返回结果里的引用清单:从 [1] 到来源卡片AnswerResponse我设计成两个字段:回答文本和引用文档列表。引用文档的展示信息从 metadata 里取:public record AnswerResponse( String answer, List<Citation> citations ) { public record Citation( String source, String title, String content, String page ) {} }从Document转成Citation时,注意不要直接返回原始 Document 对象。原因有两个:一是 Document 里可能还有切分嵌入产生的内部字段,没必要暴露给前端;二是我们要控制引用内容的长度,避免把整个 chunk 都推出去,只展示关键片段即可。前端拿到citations后,渲染成"来源卡片"。每张卡片显示文件标题、来源名、页码和命中片段。用户点引用编号 [1],可以定位到第一张卡片。这样的交互体验比纯文本引用链接好很多,尤其是在移动端上,卡片式布局更清楚。为了让模型更稳定地输出引用编号,我在 prompt 里还会加一个例子。比如给出一段标准回答,告诉它"如果引用了资料[1],就在句末加[1];多个引用用[1][2]并列"。模型是 few-shot 友好的,给一个范例之后,格式错误率会明显下降。4. 引用信息怎么从检索结果一路跟到回答里4.1 元数据链路:引用不是生成的,是检索带的很多人以为引用是模型自己生成的,其实不是。模型只能看到检索结果的文本,并不认识"这份文档叫什么、在哪一页"。它只是在回答末尾标一个编号,而编号对应的文档信息,完全靠代码从检索结果里带回来。所以关键在设计元数据链路。我在离线入库时就把 source、title、page 全部写进 metadata。切分时 metadata 会继承到每个 chunk。检索命中某个 chunk 时,这些信息原样返回来。拼装 prompt 时,编号顺序就是检索结果的顺序;生成回答后,我们再按编号反向映射。这套链路有一个好处:引用信息天然可信。因为它是从实际检索命中的文档里来的,不是模型编的。即使模型在回答里乱标了 [9],我们取不到对应的 9 号文档,就能做校验拦截。没有这一步,引用就是花架子。我在实践中发现一个容易踩的坑:有些文档加载器会把整篇文档内容塞进 Document 的 metadata 里,用来做全文检索。这些冗余字段在切分后会被复制到每一个 chunk,导致向量库存储膨胀。建议入库前做一次 metadata 清理,只保留展示和溯源需要的字段。4.2 引用的清洗与合并:模型也会乱编号模型不是格式化工具,它输出的引用编号经常不合规范。我见过输出 [1,2] 的,见过 [1] 和 [2] 连在一起变成 [12] 的,也见过回答里标了 [3] 但检索结果只有 2 个文档的。所以引用析出不能只靠正则硬抠,需要一套清洗逻辑。我的做法分成三步。第一步,从回答文本里提取所有[数字]模式,得到一个候选编号集合。第二步,过滤掉超过检索文档数量的编号,比如最多 4 个文档,那出现 [5] 就直接丢弃。第三步,对编号做排序和去重,按照首次出现顺序排列引用,再映射到文档列表。如果同一个文档在检索结果里出现了两次,只是不同的 chunk,我还会做合并。合并规则是:同一source且同一title的引用,合并成一张卡片,content 把多个命中片段拼接起来,页码用顿号连接。这样用户不会看到两张几乎一样的来源卡片。另外,回答文本里的编号不一定要强制清洗。如果模型输出 [1] [2],保留在文本里完全没问题。真正要清洗的是传给前端的引用列表,因为它要驱动 UI 交互,结构必须干净。4.3 引用安全性:避免把内部路径直接甩给用户把引用信息展示给用户之前,要过一道"脱敏"检查。最常见的坑是,程序员为了方便直接把文件绝对路径写进 metadata,比如/home/admin/kb/hr/员工手册.md。结果前端一展示,内部服务器路径全暴露了。更稳妥的做法是单独设置一个displaySource字段,只放适合展示的文件名或文档名,原始路径永远不接口返回。还有一种情况是文档本身含敏感信息。比如知识库里有员工身份证复印件提取出的文本,检索时恰好命中,然后不经处理直接返回了。这不是引用设计的问题,而是知识库权限控制的问题。我的建议是在检索层做权限过滤:如果用户所属角色无权访问某个来源,哪怕向量检索命中了,也要在返回前过滤掉。Spring AI 的SearchRequest支持带过滤表达式,可以把权限维度作为 metadata 一并过滤。引用内容本身也应该截断。不要把整个 chunk 全部塞进引用卡片,而是截取命中句的前后几十个字。一方面减少接口体积,另一方面也避免用户无需授权就拿到完整敏感文档。5. 回答带引用能上线前必须调好的几个参数5.1 引用回答质量不行的三个元凶第一版跑通后,我的回答带是带了引用,但质量惨不忍睹。排查下来,绝大多数问题出在三个地方。第一是 chunk 切太大。一开始我用默认切分,一个 chunk 可能有上千 token。用户问题只涉及文档里的一个细节,但检索返回的大块内容里混了大量无关信息,模型容易被带偏,引用也显得不精准。第二是 topK 设置太随意。topK 代表取几个命中片段。给太少,相关材料可能漏掉;给太多,上下文窗口被无关内容挤占,模型会开始抓瞎。我一开始 topK 设成 8,结果模型经常引用到第 5、6 个低相关度的片段,反而拉低准确率。第三是相似度阈值几乎没设。很多向量库默认不设置 threshold,只要分数排名在前 topK 就返回。但如果知识库本身噪音多,低相关内容的排名也可能挤进前几名,模型又分不清哪条有用,就乱引用。这三个坑,我建议一开始就把参数夹紧,宁可少召回,不要错召回。以下是我常用的检索参数建议:参数建议初始值作用chunkSize500 token控制检索粒度chunkOverlap100 token保留跨块上下文topK3 到 4控制上下文容量similarityThreshold0.5 左右过滤低相关片段5.2 两个最值得调的参数:topK 和相似度阈值如果你时间只够调两个参数,我会选 topK 和相似度阈值。topK 的调法不是越大越好。我用一个测试问题的集合,逐步增加 topK,观察回答是否有改善。经验规律是:从 3 到 5 之间往往有一个拐点,超过 5 之后,新增的片段大多是边缘内容,回答质量不再提升,甚至开始下降。原因很简单,模型注意力是有限的,塞太多低相关材料,它会困惑。相似度阈值更直接。阈值设高一点,比如 0.6,召回的内容质量高,但可能漏答,模型会说"资料中未找到相关内容";阈值设低一点,比如 0.3,几乎所有文档都能进候选,引用会变得很杂。我的做法是先跑一批真实问题,统计命中文档的相似度分布,找到"有效引用"和"无效引用"的分界点,然后把它设成阈值。如果你不知道怎么选,可以从 0.5 起步,然后观察两种失败模式:回答里出现明显的无关引用,就把阈值往上调;回答频繁说找不到,就把阈值往下调。每次调整后,用同一批测试问题对比,不要凭感觉。5.3 我踩过的坑:速查表我把实际开发中遇到的问题整理成一个速查表,方便你对照排查。问题现象可能原因解决方向回答没有引用编号prompt 里没有给格式示例在 prompt 中加入少量示例约束引用编号出现在回答里,但引用列表为空编号是模型瞎编的清洗逻辑过滤无效编号,不能展示空卡片引用来源和回答内容对不上检索到噪音片段提高相似度阈值,减小 topK多个 chunk 引用同一文件切分粒度太细按 source 合并引用卡片同一次提问结果不稳定模型参数或检索阈值不稳定固定生成参数,检索时使用确定性的 topK知识库更新后引用旧内容入库任务没有覆盖原索引更