Spring AI 实战:RAG 问答助手引用溯源与幻觉治理

发布时间:2026/10/12 18:23:25
Spring AI 实战:RAG 问答助手引用溯源与幻觉治理
RAG 这个词在过去一年里被聊得很多但真正落到代码层面很多人卡在同一个地方检索回来的内容怎么塞给模型模型又怎么在回答里标出引用来源。我最近用 Spring AI 把一套带引用的问答助手跑通了从文档切分、向量入库到回答附上出处整条链路走下来踩了不少坑也攒了一些可以直接抄的配置。这篇就把我实际落地的过程完整拆一遍重点讲清楚引用这件事到底是怎么实现的以及为什么它能治住模型张口就来的毛病。1. 先搞清楚 RAG 到底在解决什么问题1.1 大模型睁眼说瞎话的根因大模型的知识全部来自训练时喂进去的数据训练一结束它的知识就冻结了。你问它一个训练截止之后发生的事情或者问它一份它从没见过的内部文档里的细节它不会说我不知道而是会基于语言概率编一个看起来很像那么回事的答案。这个现象业内叫幻觉。幻觉不是模型坏了而是它的工作机制决定的。它本质上是一个下一个词预测器目标是把话说通顺而不是把话说对。当它没有依据的时候它依然会沿着最可能的语言路径往下生成于是就编了。RAG 的思路很直接既然模型自己不知道那就在它回答之前先把相关资料找出来塞进它的上下文里让它看着材料答题。这样它的答案就有了依据幻觉自然大幅减少。1.2 RAG 和微调的区别在哪很多人第一反应是那我微调一下不就行了。微调确实能让模型记住新知识但它有几个绕不开的问题。第一成本高每次文档更新都要重新训练。第二微调后的模型依然可能编因为它学的是风格和模式不是精确事实。第三你没法知道它某句话是从哪份文档里学来的溯源基本做不到。RAG 恰好在这三点上都有优势文档更新只需要重新入库不用动模型答案基于检索到的原文事实性强检索到的片段可以直接作为引用展示出来来源清清楚楚。所以对于基于一堆文档做问答这个场景RAG 基本是首选方案。1.3 为什么选 Spring AI 来做市面上做 RAG 的框架不少Python 生态里 LangChain 最出名。但如果你的主业务是 Java 技术栈引入 Python 服务意味着多一套部署、多一套运维、多一套团队技能要求。Spring AI 的价值就在于它把这套能力搬到了 Spring 生态里用你熟悉的 Bean 注入、配置管理、依赖注入那一套就能搞定和现有 Spring Boot 项目无缝衔接。Spring AI 提供了几个关键抽象ChatClient负责和模型对话VectorStore负责向量存储和相似度检索DocumentReader负责读取文档TextSplitter负责切分。这几个东西组合起来一条 RAG 链路就成型了。下面我按实际搭建顺序来讲。2. 整体架构设计与关键选型2.1 一条完整的 RAG 链路长什么样先把全景图在脑子里过一遍后面每一步才知道自己在干嘛。一条标准的 RAG 问答链路分两个阶段。离线阶段也叫入库阶段读取原始文档把长文档切成小片段对每个片段做向量化embedding把向量和原文一起存进向量数据库。这个阶段是一次性的文档更新时重跑。在线阶段也叫查询阶段用户提问把问题也做向量化去向量数据库里找最相似的几个片段把这些片段拼进提示词连同问题一起发给大模型模型基于这些片段生成答案同时把用到的片段作为引用返回。关键点在于引用不是模型生成出来的而是我们在检索阶段就知道用了哪些片段回答完之后把这些片段的元信息附上去。这一点想通了引用功能就实现了一大半。2.2 向量数据库怎么选向量数据库的选择直接决定了部署复杂度和检索效果。我对比了几个常见选项。方案部署方式适合场景我的评价SimpleVectorStore内存本地开发、Demo零依赖重启数据就没了只适合调试Redis独立服务已有 Redis 的项目复用现有中间件运维成本低PostgreSQL pgvector独立服务数据量中等、要事务关系型数据库顺手SQL 能直接查专用向量库独立集群大规模、高并发功能强但运维重小项目没必要我这次用的是 PostgreSQL 加 pgvector 扩展。理由很实在项目里本来就有 Postgres加个扩展就能用不用再引入一个新的中间件而且向量和业务数据在同一个库里做联合查询、事务处理都方便。如果你项目里已经有 Redis用 Redis 也完全没问题Spring AI 对这些都有对应的实现。2.3 切分策略为什么这么重要文档切分是 RAG 里最容易被忽视、但影响最大的一环。切得太粗一个片段里混了好几个主题检索出来噪音大切得太细一个完整的语义被切断模型拿到半句话也答不好。我的经验是切分粒度要匹配文档的天然结构。技术文档按标题层级切合同按条款切FAQ 按问答对切。如果文档结构不明显就用固定长度加重叠的方式重叠部分是为了防止关键信息正好卡在切分边界上被切断。Spring AI 里的TokenTextSplitter就是干这个的它按 token 数切分比按字符数切更贴近模型的实际处理单位。我一般把 chunk size 设在 500 到 800 token 之间overlap 设在 100 到 200 token。这个范围是实测下来比较稳的太小检索不准太大噪音多。3. 核心环节的实操落地3.1 依赖引入与基础配置先把依赖加上。Spring AI 的版本迭代比较快建议用当前稳定版别用快照版快照版 API 说变就变。dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-pgvector-store-spring-boot-starter/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId /dependency配置文件里把模型和向量库的地址填上。这里有个细节要注意embedding 模型和 chat 模型可以是不同的embedding 负责把文本转向量chat 负责生成回答。embedding 模型一旦选定入库和查询必须用同一个否则向量空间对不上检索结果全是乱的。spring: ai: openai: api-key: ${API_KEY} chat: options: model: gpt-4o-mini temperature: 0.2 embedding: options: model: text-embedding-3-small datasource: url: jdbc:postgresql://localhost:5432/ragdb username: rag password: rag123temperature 我设成了 0.2比默认值低。原因很简单问答场景要的是准确和稳定不是创意。温度越低模型越倾向于选概率最高的词答案越保守、越贴近原文。如果你做的是创意写作那另说。3.2 文档读取与切分的代码实现读取文档这一步Spring AI 提供了多种 ReaderPDF、Markdown、纯文本都有对应的实现。我这次处理的是 Markdown 格式的内部文档。Component public class DocumentIngestor { private final VectorStore vectorStore; private final TokenTextSplitter splitter; public DocumentIngestor(VectorStore vectorStore) { this.vectorStore vectorStore; this.splitter new TokenTextSplitter(600, 150, 5, 10000, true); } public void ingest(Resource resource) { MarkdownDocumentReader reader new MarkdownDocumentReader(resource); ListDocument docs reader.get(); ListDocument chunks splitter.apply(docs); vectorStore.add(chunks); } }TokenTextSplitter的构造参数依次是目标 chunk 大小、重叠大小、最小 chunk 字符数、最大 chunk 字符数、是否保留分隔符。我设的 600 和 150 就是前面说的那个范围。最后一个参数true表示保留段落分隔符这样切出来的片段语义更完整。注意切分后的每个 Document 一定要带上元信息比如来源文件名、章节标题、页码。这些元信息就是后面引用的来源。很多人入库时图省事不带元信息等到要做引用的时候发现根本不知道片段来自哪只能推倒重来。3.3 给每个片段打上可追溯的元信息元信息是引用功能的命根子。我在读取阶段就给每个 Document 附加了来源信息。Document doc new Document(content, Map.of( source, fileName, section, sectionTitle, page, String.valueOf(pageNum) ));这样每个片段都知道自己从哪来。检索的时候Spring AI 会把元信息一起带回来我们就能在回答里标注这段来自《XX文档》第X节。元信息的字段设计也有讲究。source 用文件名或文档 IDsection 用章节标题page 用页码。如果文档有版本号也建议加上这样引用能精确到版本避免引用了旧版内容的尴尬。3.4 检索环节的参数调优检索是 RAG 的质量瓶颈。检索不准后面模型再强也白搭。核心参数有两个topK 和相似度阈值。topK 是返回最相似的几个片段。设太小可能漏掉关键信息设太大噪音多还会撑爆上下文窗口。我一般从 4 开始试根据效果调整。相似度阈值是过滤掉那些不太相关的片段避免把无关内容塞给模型。SearchRequest request SearchRequest.query(question) .withTopK(5) .withSimilarityThreshold(0.7); ListDocument docs vectorStore.similaritySearch(request);阈值 0.7 是个经验值。设太高可能一个片段都检索不到设太低什么垃圾都往里塞。这个值跟你的 embedding 模型有关不同模型的分值分布不一样建议先用一批测试问题跑一遍看看相关片段的实际分值落在哪个区间再定阈值。实操心得检索这块我强烈建议加一个重排序环节。先用向量检索召回一批候选比如 topK 设 20再用一个重排序模型对候选精排取前 5 个。向量检索快但粗重排序慢但准两者结合效果提升很明显。Spring AI 也支持接入重排序模型。3.5 把检索结果拼进提示词检索到片段之后要把它们组织成模型能理解的提示词。这里有个模板设计的技巧明确告诉模型只根据以下材料回答材料里没有的就说不知道。String template 你是一个严谨的问答助手。请严格根据下面提供的资料回答问题。 如果资料中没有相关信息直接回答根据现有资料无法回答不要编造。 回答时请在句末用 [来源: 文件名] 的格式标注依据。 资料 {context} 问题{question} ;这个模板有三个关键设计。第一明确约束只根据资料回答这是抑制幻觉的核心。第二给出无法回答的兜底话术让模型有退路不至于硬编。第三要求标注来源格式引导模型在生成时就带上引用标记。不过要注意模型标注的来源不一定百分百准确它可能标错。所以更稳妥的做法是引用信息由我们在代码层面根据实际检索到的片段生成不完全依赖模型自己标。模型标的是软引用代码生成的是硬引用两者结合最可靠。3.6 引用信息的组装与返回回答生成完之后把实际用到的片段元信息组装成引用列表返回给前端。public Answer ask(String question) { ListDocument docs retrieve(question); String context docs.stream() .map(Document::getContent) .collect(Collectors.joining(\n\n)); String answer chatClient.prompt() .user(u - u.text(template) .param(context, context) .param(question, question)) .call() .content(); ListCitation citations docs.stream() .map(d - new Citation( d.getMetadata().get(source).toString(), d.getMetadata().get(section).toString())) .distinct() .toList(); return new Answer(answer, citations); }Citation就是引用对象包含来源文件名和章节。前端拿到之后可以在答案下方列出参考资料或者做成可点击的角标点开跳到原文对应位置。这样用户就能自己验证答案对不对信任感一下就上来了。4. 常见问题与排查实录4.1 检索不到相关内容怎么办这是最常见的问题表现是用户问了一个文档里明明有的问题但检索结果为空或者全是无关内容。排查思路按顺序来。先确认文档真的入库了。直接查向量库的表看行数对不对。我遇到过好几次是入库任务没跑成功或者跑到了另一个库白折腾半天。再确认 embedding 模型一致。入库用的模型和查询用的模型必须是同一个这个前面强调过。如果中途换过模型旧数据全部作废必须重新入库。然后看切分粒度。如果文档切得太碎一个完整答案被切成好几段每段单独看都不够相关检索就召不回来。这时候把 chunk size 调大试试。最后看相似度阈值。阈值设太高会把相关片段也过滤掉。临时把阈值调到 0 或者很低看看能不能召回能召回就说明是阈值的问题。4.2 回答里引用标错或标漏引用不准通常有两个原因。一是模型自己标的引用不可靠它可能张冠李戴。解决办法是弱化模型标注强化代码生成以代码返回的引用列表为准。二是检索到的片段本身就不对模型基于错误材料回答引用自然跟着错。这还是要回到检索质量上加个重排序环节通常能改善。引用标漏的情况往往是提示词里没强调要标注或者模型偷懒。可以在提示词里加一句每个事实性陈述都必须标注来源并在后处理阶段检查答案里有没有引用标记没有的话补上。4.3 回答太慢怎么优化RAG 的延迟主要来自三块向量检索、模型生成、网络往返。向量检索通常很快几十毫秒级别除非数据量特别大。模型生成是大头尤其是答案长的时候。优化方向有几个。第一控制上下文长度检索片段别塞太多topK 从 5 降到 3 试试。第二用流式输出让用户先看到字感知上快很多。第三如果模型支持用更小的模型做初筛。第四把 embedding 结果缓存起来相同问题不用重复检索。4.4 常见问题速查表现象可能原因排查动作检索结果为空文档未入库 / 阈值过高查库行数临时降阈值检索结果不相关切分过碎 / 模型不一致调大 chunk核对 embedding 模型回答编造内容提示词约束不足强化只根据资料回答约束引用标错依赖模型自标改为代码生成引用回答慢上下文过长降 topK开流式输出重复内容多切分重叠过大减小 overlap4.5 几个容易踩的坑第一个坑是元信息丢失。前面反复强调这里再提一次因为真的太常见了。入库时不带元信息后面做引用只能干瞪眼。第二个坑是文档更新后没重新入库。文档改了向量库里还是旧的检索出来的是过时内容。建议做个文档版本管理文档一变就触发重新入库。第三个坑是忽略 token 限制。检索片段加上提示词模板加上问题总长度不能超过模型的上下文窗口。塞太多会被截断截断的位置很随机可能正好把关键信息切掉。入库前算一下留足余量。第四个坑是没做权限过滤。如果文档有权限分级检索时必须带上权限过滤条件否则低权限用户可能通过问答拿到高权限文档的内容。这个在元信息里加个权限字段检索时作为过滤条件就行。5. 效果验证与迭代方向5.1 怎么判断 RAG 做得好不好光靠肉眼看几个回答不够得有一套评估方法。我一般从两个维度看检索质量和生成质量。检索质量看召回率和准确率。准备一批测试问题每个问题标注好应该命中哪些片段然后跑检索看命中的比例。召回率低说明有该找的没找到准确率低说明找回来一堆没用的。生成质量看答案的事实性和引用准确性。事实性就是答案有没有编引用准确性就是标注的来源对不对。这两个都可以人工抽检量大的话可以借助另一个模型来打分。5.2 后续可以怎么扩展这套基础链路跑通之后有几个方向可以继续做深。一是多路召回。除了向量检索再加一路关键词检索两路结果融合。向量检索擅长语义相似关键词检索擅长精确匹配两者互补能覆盖更多场景。二是对话式追问。现在的实现是单轮问答用户追问那第二点呢的时候系统不知道第二点指什么。加上对话历史管理把上下文带进去就能支持多轮追问。三是引用高亮。前端拿到引用信息后不只是列出文件名还能定位到原文的具体段落并高亮用户体验会好很多。四是增量更新。现在文档更新是全量重新入库数据量大时很慢。可以做成增量只处理变化的文档配合文档指纹比如内容哈希来判断哪些需要更新。我在实际项目里跑下来这套方案对基于内部文档问答这个场景的改善是立竿见影的。以前用户问一个问题模型十有八九在编现在大部分回答都能给出准确出处用户自己点开引用一看就放心了。引用这个功能看着不起眼但它是建立信任的关键——用户不是要一个听起来对的答案而是要一个能验证的答案。把引用做扎实RAG 的价值才真正落地。