Spring AI RAG实战:如何让问答助手每句话都有出处
1. 为什么“带引用的回答”才是RAG落地的分水岭做检索增强生成RAG的同行大概都有过这种体验Demo阶段效果惊艳一旦交给业务方试用第一个被质疑的问题永远是——“这个结论你是从哪份文档里看出来的”如果答不上来哪怕答案本身是对的用户也不敢信。这不是用户挑剔而是RAG系统从“玩具”走向“可用”必须跨过的一道坎。我最初搭第一版问答助手时走的是最朴素的路线把文档切片、向量化、存进向量库用户提问就召回Top-K片段拼进Prompt让大模型自由发挥。结果上线第二天就翻车了。有同事问“差旅报销的标准是多少”模型洋洋洒洒写了一段数字看着挺像那么回事但翻遍制度文档根本找不到对应条款——典型的“睁眼说瞎话”。更麻烦的是这种幻觉往往裹着专业术语的外衣不逐字核对很难发现。问题的根子在于纯生成模式下模型没有“必须忠于原文”的硬约束。它只是在做概率续写而不是在做信息检索。RAG的价值恰恰在于把“生成”锚定在“检索到的真实片段”上但如果输出层不把引用关系显式暴露出来这个锚定就是隐形的用户无法验证系统也无法自证。所以这篇要聊的就是怎么用Spring AI把RAG问答助手做成“每句话都有出处”的形态。核心目标有三个回答里的关键结论能对应到具体文档片段引用来源可点击、可追溯当检索不到相关内容时系统要老实说“不知道”而不是硬编。适合正在做企业知识库、客服助手、文档问答这类场景的开发者参考无论你是刚接触Spring AI还是已经跑通过基础RAG链路都能从引用链路的实现细节里拿到可复用的东西。2. 引用机制的技术底座从向量召回 to 可溯源输出2.1 RAG的“检索-生成”两段式结构里引用信息在哪一环丢失先把链路拆开看。一个标准RAG流程大致是文档加载 → 文本切分 → 向量化 → 存入向量库 → 用户提问向量化 → 相似度检索 → 召回片段拼入Prompt → 大模型生成回答。引用信息天然存在于“召回片段”这一步——每个片段都带着元数据来源文件、页码、段落序号。但在“拼入Prompt”和“生成回答”这两步之间元数据往往被丢掉了。原因很直接传统做法是把召回片段的纯文本拼接成一个长字符串塞给模型模型看到的只是一堆没有身份标识的文字。它生成回答时自然无法告诉你“这句话来自片段3”。要解决这个问题必须在Prompt构造阶段就给每个片段打上可识别的标记并在输出解析阶段把标记还原成引用。2.2 Spring AI的Document结构与元数据承载能力Spring AI对RAG的支持体现在它的Document抽象上。一个Document对象不仅包含文本内容还包含一个MapString, Object类型的元数据容器。这个设计是引用机制的基石——你可以在文档加载阶段就把来源信息写进元数据比如Document doc new Document( chunkText, Map.of( source, 员工手册_v3.pdf, page, 12, chunkIndex, 5, section, 差旅报销 ) );向量化时Spring AI的VectorStore会把文本转向量存储同时保留元数据。检索时返回的Document列表里元数据是完整带回来的。这意味着引用所需的一切信息在检索环节就已经就绪关键是怎么把它传递到最终输出。2.3 让模型“学会引用”的Prompt工程要点模型不会天生就懂“引用”这件事你得在Prompt里明确教它。我的做法是在系统提示词里定义一套引用标记规范比如要求模型在每句结论后面用[来源:片段编号]的格式标注。同时把召回片段按编号排列每个片段前加上【片段1】这样的标识。这里有个容易踩的坑片段编号和元数据的映射关系必须由程序维护不能让模型自己编。模型只负责在回答里写[来源:1]程序拿到回答后根据编号去查对应的Document元数据渲染成真实的文件名和页码。如果让模型直接输出文件名它极有可能记错或编造。提示Prompt里要明确告诉模型“如果召回片段中没有相关信息直接回答‘根据现有资料无法回答’不要编造”。这句话对抑制幻觉的作用比任何后处理都大。2.4 引用粒度句子级、段落级还是文档级引用粒度直接决定实现复杂度和用户体验。文档级引用实现最简单但用户还得自己去文档里翻段落级是折中方案能定位到具体章节句子级最精确但要求模型在生成时逐句标注对模型指令遵循能力要求高且容易因为标注过密影响可读性。我的建议是段落级为主、句子级为辅对于关键结论数字、条款、定义要求句子级引用对于综述性描述允许段落级引用。在Prompt里可以这样区分“涉及具体数据或条款时必须在句末标注来源一般性总结可在段末统一标注。”3. 用Spring AI搭一条可溯源的问答链路3.1 文档入库阶段就把“出处”焊死很多人做RAG时文档切分完只存文本元数据随手填个文件名了事。等到要做引用时才发现信息不够。我的经验是入库时多花十分钟补元数据后面省十小时排查。具体做法是在DocumentReader和TextSplitter之间加一层元数据注入。以PDF为例用PagePdfDocumentReader读取时每页会生成一个Document此时就可以把页码写进元数据。切分时Spring AI的TokenTextSplitter会保留原文档的元数据并附加chunk序号。代码大致如下ListDocument rawDocs new PagePdfDocumentReader(resource).get(); ListDocument enriched rawDocs.stream().map(doc - { MapString, Object meta new HashMap(doc.getMetadata()); meta.put(source, fileName); meta.put(ingestedAt, Instant.now().toString()); return new Document(doc.getContent(), meta); }).toList(); TokenTextSplitter splitter new TokenTextSplitter(500, 100, 5, 10000, true); ListDocument chunks splitter.apply(enriched); vectorStore.add(chunks);注意TokenTextSplitter的构造参数第一个是目标chunk大小token数第二个是重叠量。重叠量设得太小会导致跨段落的语义被切断引用时出现“半句话”的情况设得太大又会让召回片段冗余。我实测下来500/100这个组合对中文技术文档比较友好。3.2 检索环节的元数据保留与重排序检索本身用VectorStore.similaritySearch就能跑通但要做引用得关注返回的Document里元数据是否完整。Spring AI默认会保留但如果你用了自定义的EmbeddingModel或后处理逻辑要确认元数据没被剥离。另一个提升引用准确率的技巧是加一层重排序。向量相似度召回有时会把语义相近但来源不相关的片段排前面导致引用错位。可以在召回Top-10后用一个轻量级的交叉编码器或简单的关键词匹配做二次排序把真正包含答案的片段顶上来。Spring AI目前没有内置重排序组件但可以自己实现一个DocumentPostProcessor接口在检索后、拼Prompt前介入。public class KeywordBoostPostProcessor implements DocumentPostProcessor { Override public ListDocument process(ListDocument documents, String query) { // 对包含query关键词的片段加权 return documents.stream() .sorted((a, b) - Double.compare( score(b, query), score(a, query))) .limit(5) .toList(); } }3.3 Prompt模板里怎么安放引用标记Prompt模板是引用机制的“合同”。我用的模板结构是这样的你是一个严谨的问答助手。请仅根据以下参考资料回答问题。 参考资料 【片段1】{片段1内容} 【片段2】{片段2内容} ... 回答要求 1. 每个具体结论后必须标注来源格式为[来源:片段编号] 2. 如果参考资料中没有相关信息回答“根据现有资料无法回答该问题” 3. 不要编造任何未在参考资料中出现的信息 问题{用户问题}这里的关键是片段编号从1开始连续排列且程序里维护一个MapInteger, Document的映射。模型输出[来源:2]后程序查映射表拿到片段2的元数据渲染成[员工手册_v3.pdf 第12页]这样的可读引用。3.4 输出解析把[来源:1]还原成可点击的引用模型返回的原始文本里带着[来源:1]标记需要用一个解析器把它替换成真实引用。我用正则匹配\[来源:(\d)\]然后查映射表替换。如果同一个片段被多次引用可以合并显示。public String renderCitations(String rawAnswer, MapInteger, Document chunkMap) { Pattern pattern Pattern.compile(\\[来源:(\\d)\\]); Matcher matcher pattern.matcher(rawAnswer); StringBuilder sb new StringBuilder(); while (matcher.find()) { int idx Integer.parseInt(matcher.group(1)); Document doc chunkMap.get(idx); String citation doc ! null ? [ doc.getMetadata().get(source) 第 doc.getMetadata().get(page) 页] : [来源未知]; matcher.appendReplacement(sb, citation); } matcher.appendTail(sb); return sb.toString(); }前端拿到渲染后的文本可以把引用部分做成可点击的链接或悬浮卡片展示片段原文。这样用户既能看答案又能一键核对出处。4. 实测中暴露的五个坑与对应解法4.1 模型不按格式标注引用怎么办这是最常见的问题。即使Prompt里写了要求模型有时还是会漏标或标错格式。我的解法是双保险一是在Prompt里用few-shot示例强化格式给一两个“问题-带引用回答”的样例二是在输出解析时做容错如果整段回答没有任何引用标记就在末尾追加一句“本回答未标注具体来源请谨慎参考”同时记录日志用于后续优化。如果漏标率持续偏高可以考虑换指令遵循能力更强的模型或者降低单次召回片段数量——片段太多时模型容易“顾此失彼”。4.2 召回片段本身不相关引用就成了“伪证”引用机制有个前提召回的片段确实包含答案。如果检索环节拉胯模型要么硬编要么引用一个不相关的片段后者更危险——因为它看起来“有出处”实则误导。所以引用机制必须和检索质量一起优化。我的做法是加一个相关性阈值向量相似度低于某个值的片段直接丢弃不进入Prompt。阈值需要根据你的embedding模型和语料特点调一般从0.7开始试。另外对于“根据现有资料无法回答”的情况要在前端明确区分展示不要和正常回答混在一起。4.3 中文文档切分导致的引用错位中文没有天然的空格分隔按token切分时容易出现“断句”问题。比如一个完整的条款被切成两半召回时只命中后半段引用显示出来就是半句话。解法是在切分时优先按段落和标题切Spring AI的TokenTextSplitter支持按字符切分可以配合自定义的TextSplitter先按\n\n分段再对超长段落做token切分。另外中文的标点符号。应该作为切分的优先边界。我写过一个简单的后处理器在token切分后检查chunk末尾是否是完整句子不是的话把最后一个标点之后的内容挪到下一个chunk。4.4 多文档来源时的引用去重与合并当知识库里有多个版本文档时同一个问题可能召回来自不同文档的相似片段。如果模型分别引用了片段1和片段3而这两个片段其实来自同一份文档的相邻页引用显示就会冗余。可以在渲染阶段做合并如果两个引用的source相同且页码相邻合并成“第12-13页”。更复杂的情况是同一事实在不同文档中有不同表述这时引用反而要保留多个来源让用户看到差异。判断逻辑可以交给模型在Prompt里要求“如果多个片段对同一问题有不同说法请分别列出并标注各自来源”。4.5 引用带来的Token开销与性能权衡把片段编号、引用标记、元数据都塞进Prompt和输出会显著增加token消耗。实测下来带引用要求的Prompt比普通RAG Prompt长约30%-40%输出也长20%左右。对于高频调用的场景这个成本不能忽略。优化方向有三个一是只对关键结论要求引用综述性内容不强制二是压缩片段内容召回后先用一个小模型做摘要再拼入Prompt三是缓存映射关系避免每次请求都重建MapInteger, Document。如果用的是按token计费的API建议先算一笔账假设每次多消耗500 token日调用1万次一个月就是1.5亿token的增量值不值得取决于业务对可信度的要求。5. 让“不知道”成为一等公民拒答策略的设计5.1 为什么拒答比胡答更难做好让模型说“我不知道”听起来简单做起来难。模型的默认行为是“尽力回答”哪怕信息不足也会编一个看似合理的答案。要让它老实拒答需要在Prompt、检索阈值、输出校验三个层面同时施压。Prompt层面已经说了要明确写“没有相关信息就拒答”。但这还不够因为模型对“没有相关信息”的判断标准很模糊。更可靠的做法是在检索层面就做判断如果Top-1片段的相似度低于阈值直接不走生成返回预设的拒答话术。这样既省token又避免模型“强行发挥”。5.2 检索置信度阈值怎么定阈值定高了很多能回答的问题被拒定低了不该答的也答了。我的经验是分场景设阈值事实型问题数字、日期、条款阈值设高一些比如0.75开放型问题解释、总结可以低一些比如0.6。另外可以结合召回片段的数量做判断如果Top-3片段的相似度都低于0.65基本可以判定知识库覆盖不到。这个阈值不是拍脑袋定的要用一批标注好的测试问题跑一遍画一条“阈值-准确率/拒答率”曲线找平衡点。我一般会准备50-100个测试问题其中一半是知识库能覆盖的一半是不能覆盖的用来自动化调参。5.3 拒答话术的设计与用户体验拒答不是冷冰冰地说“不知道”就完事。好的拒答应该告诉用户为什么答不了、可以怎么调整。比如“根据现有资料无法回答该问题建议补充相关文档或换个问法”。如果检索到了一些弱相关片段可以附上“你可能想了解”的推荐把弱相关片段作为参考列出但不作为答案。前端展示上拒答回答要用不同的视觉样式比如灰色背景、提示图标和正常回答区分开避免用户误以为这是正式答案。6. 从能用到好用引用体验的进阶优化6.1 引用悬浮预览与原文高亮基础版引用只是文本标注进阶版应该支持悬浮预览鼠标移到引用上弹出片段原文并把命中的关键词高亮。这需要前端配合但后端要把片段原文和命中位置一起返回。实现方式是在渲染引用时除了返回文件名和页码再附带片段文本和query关键词的位置信息。6.2 多轮对话中的引用继承多轮对话里用户追问“那第二条呢”系统需要知道“第二条”指的是上一轮引用的文档里的第二条。这要求对话状态里保存上一轮的引用上下文。Spring AI的ChatMemory可以存对话历史但引用映射需要额外维护。我的做法是在ChatMemory之外再挂一个CitationContext记录每轮回答的引用片段ID追问时把相关片段重新召回或直接从上下文取。6.3 引用准确率的自动化评估上线后怎么知道引用准不准人工抽查成本太高。可以做一个自动化评估脚本用一批测试问题跑完RAG链路后用另一个模型判断“回答中的每个引用是否真的支持对应结论”。虽然这个判断本身也有误差但能筛出明显错位的引用作为优化线索。评估指标可以定两个引用覆盖率有引用的结论占比和引用准确率引用正确的占比。前者低说明模型不爱标注后者低说明检索或映射有问题。两个指标一起看能快速定位问题环节。7. 一些个人实践中的体会这套带引用的RAG方案我在几个内部知识库场景里跑过最大的感受是引用机制的价值不只是“可信”更是“可调试”。以前模型答错了你只能猜是检索问题还是生成问题有了引用一眼就能看出是召回片段不对还是模型没按片段说。排查效率提升非常明显。另一个体会是不要追求100%的引用覆盖率。有些综述性、过渡性的句子本来就不需要引用强行标注反而让回答变得支离破碎。把精力放在关键结论的引用准确性上比追求形式上的“句句有出处”更有价值。最后分享一个小技巧在Prompt里给模型一个“引用示例”比单纯描述格式要求有效得多。我通常会在系统提示词里放一个完整的问答示例展示“问题-召回片段-带引用回答”的全过程模型模仿这个示例的格式准确率能到90%以上。这个示例不用长两三句话就够但一定要包含引用标记的正确位置和格式。