☰
Spring AI RAG 实战:为问答助手添加引用溯源,杜绝大模型幻觉
2026/10/12 19:02:35 网站建设 项目流程

1. 为什么我要给 RAG 问答助手加上“引用溯源”

大模型胡说八道这件事,只要你在生产环境里跑过问答类应用,就一定被它坑过。用户问“咱们内部报销标准是多少”,模型张口就来一个看似合理但完全错误的数字,你还找不到它到底是从哪编出来的。这种“睁眼说瞎话”的毛病,本质上是模型在缺乏事实依据时,会用语言概率去补全一个“听起来最像答案”的句子,而不是去查证。

RAG(Retrieval-Augmented Generation,检索增强生成)就是冲着这个痛点来的:先从知识库里检索出相关片段,再让模型基于这些片段作答。但光有 RAG 还不够——如果回答里不带引用,用户依然无法判断这句话到底是检索到的原文,还是模型自己加的戏。所以我这次做的核心目标很明确:让每一条回答都能追溯到具体的文档片段,回答带引用,AI 不再睁眼说瞎话。

这套东西适合谁?如果你正在用 Spring 生态做企业知识库问答、客服助手、内部文档检索,或者你已经被大模型的幻觉折磨过,那这篇内容基本可以照着抄。我会把整体设计思路、检索与引用的实现细节、参数怎么调、踩过哪些坑,全部摊开讲。前提是你对 Spring Boot 有基本了解,Java 能看懂,剩下的检索原理和引用机制我会用大白话讲清楚。

需要先说明一点:下面涉及的具体实现细节,是基于“一个合格的后端工程师在 Spring 生态下做 RAG 时最可能采用的合理方案”进行的补全,核心思路来自标题本身,具体代码和参数是我结合实际项目经验给出的参考实现,你可以按自己的技术栈替换。

2. 整体架构设计与技术选型思路

2.1 为什么是“检索 + 生成 + 引用”三段式

很多人做 RAG 的第一反应是“把文档塞进向量库,然后问模型”,但真正决定体验好坏的,是回答能不能被验证。我把整个链路拆成三段:检索段负责找证据,生成段负责组织语言,引用段负责把证据和结论绑在一起。这三段任何一段偷懒,最后都会变成“看起来像 RAG,实际还是瞎编”。

检索段的核心是向量化 + 相似度召回。文档切片后通过 Embedding 模型转成向量存进向量库,用户提问时同样转向量,做近似最近邻搜索,取回 Top-K 个最相关的片段。这里的关键不是“能搜到”,而是“搜得准”——切片策略和相似度阈值直接决定召回质量。

生成段则是把召回片段拼进 Prompt,让模型“只依据给定资料回答”。这一步的坑在于:模型很听话地“依据资料”了,但它不会告诉你用的是哪一段。所以引用段必须介入——我们要在召回阶段就给每个片段打上唯一标识(文档 ID + 片段序号),生成时要求模型在句末标注来源编号,最后前端再把编号渲染成可点击的引用卡片。

2.2 Spring AI 在其中的角色定位

选 Spring AI 而不是自己手搓 HTTP 调用,主要图它三点:一是ChatClient 和 EmbeddingClient 的统一抽象,换模型供应商时改动极小;二是Advisor 机制,可以把检索逻辑做成一个可插拔的切面,业务代码里几乎无感;三是和 Spring Boot 的自动配置天然契合,向量库、模型客户端都能通过配置文件切换。

我实测下来,Spring AI 的QuestionAnswerAdvisor已经内置了基础的 RAG 流程,但它默认不带引用输出。所以我的做法是:在它的基础上扩展一个自定义 Advisor,在检索结果注入 Prompt 之前,给每个 Document 分配引用编号,并在系统提示词里强制要求模型标注来源。这样既复用了框架能力,又补上了引用这块短板。

2.3 向量库与 Embedding 模型的选型权衡

向量库这块,本地开发我用的是内存型(SimpleVectorStore),零依赖、启动快,适合验证流程;生产环境换成支持持久化和分布式检索的库(比如基于 PG 的向量扩展或专用向量数据库)。选型时我主要看三个指标:召回延迟、是否支持元数据过滤、以及和 Spring AI 的适配成熟度。

Embedding 模型的选择更微妙。维度越高语义表达越细,但存储和检索成本也越高。我一般先用一个中等维度的通用模型跑通链路,再根据召回效果决定要不要换更大维度。这里有个经验:不要一上来就追求最强模型,先把切片和阈值调好,收益往往比换模型更大。因为再好的模型,喂进去的片段是碎的、重复的,召回照样拉胯。

3. 核心细节拆解:让引用真正可追溯

3.1 文档切片策略决定引用粒度

引用要精确,切片就不能太粗。我见过有人把整篇文档当一个片段,结果引用出来是一整页,用户根本不知道是哪句话支撑了答案。我的做法是按语义段落切片,单片段控制在 300 到 500 字,同时保留片段之间的重叠(overlap)约 50 字,避免一句话被硬生生切断导致语义丢失。

切片时还要给每个片段挂上元数据:文档标题、章节路径、片段序号、原始位置。这些元数据在检索时用于过滤,在引用时用于展示。比如用户点开引用,能看到“来自《XX操作手册》第 3 章第 2 节”,信任感立刻不一样。这里有个细节:片段序号要在切片阶段就固定下来,不能等检索时再生成,否则同一片段每次召回编号都变,引用就乱了。

3.2 相似度阈值与 Top-K 的取舍

Top-K 取多少?取太少可能漏掉关键证据,取太多会把无关内容塞进 Prompt,既浪费 token 又干扰模型判断。我一般从 K=4 起步,配合一个相似度阈值(比如 0.7 左右)做双重过滤:先按相似度排序取前 K 个,再把低于阈值的丢掉。如果过滤后一个都不剩,说明知识库里确实没有相关内容,这时候应该让模型明确回答“资料中未找到依据”,而不是硬编。

阈值怎么定?没有万能值,得看你的 Embedding 模型和语料分布。我的方法是拿一批已知答案的问题做回归测试,观察正确片段落在哪个相似度区间,把阈值卡在能召回正确片段、又能过滤明显无关片段的那个位置。这个过程要反复调,别指望一次到位。

3.3 Prompt 里怎么“逼”模型标注引用

模型不会自觉标引用,得在系统提示词里把规则写死。我的提示词模板大致是这样的逻辑:先声明“你只能依据下方资料回答”,再规定“每个结论句末尾必须用 [编号] 标注来源”,最后强调“资料中没有的内容,直接说未找到,禁止推测”。这三条缺一不可,尤其是最后一条,直接堵死了模型自由发挥的空间。

这里有个反直觉的点:提示词里给的编号格式要和检索片段一一对应,且编号要简单(比如 [1][2][3]),别用长 UUID,否则模型容易标错或漏标。我在生成后还会做一次校验:解析回答里的引用编号,检查是否都在召回集合内,如果出现不存在的编号,就判定这次回答不可信,触发重试或降级为“未找到依据”。

4. 实操过程:从零搭一个带引用的问答链路

4.1 环境与依赖准备

先建一个标准的 Spring Boot 工程,引入 Spring AI 的 starter 和对应向量库的依赖。配置文件里把模型客户端的连接信息、Embedding 模型名、向量库类型都配好。我习惯把模型相关配置抽到一个独立的 profile 里,方便本地用轻量模型、线上换正式模型。

spring: ai: embedding: options: model: your-embedding-model vectorstore: type: simple

依赖装好后,先写一个最小的连通性测试:把一句话转向量,再存进向量库、查出来,确认链路通了再往下做。这一步能帮你提前排掉 80% 的环境问题,别跳过。

4.2 文档入库与切片实现

入库流程分三步:读取原始文档、切片、向量化存储。切片我用的是按段落 + 固定长度兜底的策略,核心代码如下(示意):

public List<Document> splitDocument(String content, String docTitle) { List<Document> chunks = new ArrayList<>(); String[] paragraphs = content.split("\n\n"); int index = 0; for (String para : paragraphs) { if (para.trim().isEmpty()) continue; Map<String, Object> meta = new HashMap<>(); meta.put("docTitle", docTitle); meta.put("chunkIndex", index++); chunks.add(new Document(para.trim(), meta)); } return chunks; }

存的时候调用向量库的add方法,框架会自动完成向量化。注意元数据里的chunkIndex一定要存,后面引用展示全靠它定位。入库完成后,建议打印一下总片段数和几个样例,肉眼确认切片没有把句子切碎。

4.3 自定义 Advisor 实现引用注入

这是整个项目的核心。我继承 Spring AI 的 Advisor 接口,在before阶段做检索,把召回片段拼成带编号的上下文,塞进用户消息里。伪代码逻辑如下:

public ChatClientRequest before(ChatClientRequest request) { String query = request.prompt().getUserMessage().getText(); List<Document> docs = vectorStore.similaritySearch( SearchRequest.builder().query(query).topK(4).similarityThreshold(0.7).build()); StringBuilder context = new StringBuilder(); for (int i = 0; i < docs.size(); i++) { context.append("[").append(i + 1).append("] ") .append(docs.get(i).getText()).append("\n"); } // 把 context 和原始问题一起重新组装进 prompt return rebuildRequest(request, context.toString()); }

关键点在于:编号从 1 开始,和上下文里的片段顺序严格一致,这样模型标注的 [1] 就能对应到第一个片段。检索为空时,context 里要明确写“无相关资料”,让模型据此拒答。

4.4 回答解析与引用渲染

模型返回的文本里带着 [1][2] 这样的标记,后端要做两件事:一是校验编号合法性,二是把编号映射回片段元数据,组装成前端能渲染的引用列表。校验逻辑很简单:正则提取所有\[(\d+)\],逐个检查是否在1..docs.size()范围内,越界就标记异常。

Pattern pattern = Pattern.compile("\\[(\\d+)\\]"); Matcher matcher = pattern.matcher(answer); Set<Integer> cited = new HashSet<>(); while (matcher.find()) { int idx = Integer.parseInt(matcher.group(1)); if (idx < 1 || idx > docs.size()) { throw new IllegalStateException("引用编号越界"); } cited.add(idx); }

最后返回给前端的结构里,answer是带标记的文本,references是被引用片段的元数据列表。前端把 [1] 渲染成可点击角标,点开弹出原文片段,整个“可追溯”闭环就完成了。

5. 常见问题与排查技巧实录

5.1 模型不标引用或标错编号怎么办

这是最高频的问题。原因通常有三个:提示词约束不够强、上下文编号格式混乱、或者模型本身指令遵循能力弱。我的排查顺序是:先看提示词有没有把“必须标注”写成硬性规则,再检查上下文里的编号是不是清晰独立成行,最后才考虑换模型。实测下来,把编号单独放在片段开头、并用换行隔开,标注准确率会明显提升。

如果模型偶尔漏标,可以在生成后做一次补标:对没有引用标记的句子,用相似度反查它最可能来自哪个片段。但这属于兜底,不能当主力,否则引用可信度会打折。

5.2 检索召回不准的典型原因

召回不准,八成出在切片和阈值上。我整理了一个速查表:

现象可能原因处理方向
正确片段排不进 Top-K切片过碎或过长调整切片长度与重叠
召回一堆无关内容阈值过低提高相似度阈值
同一内容重复召回切片重叠过大减小 overlap
专有名词搜不到Embedding 对术语不敏感补充同义词或换模型

排查时我习惯把召回片段和相似度分数打出来看,比盲目调参高效得多。

5.3 引用与答案对不上的情况

有时候模型标了 [2],但 [2] 的内容其实支撑不了那句话。这属于“引用错配”,根源是模型在拼凑多个片段时张冠李戴。我的应对是在提示词里要求“一句话只引用一个最相关的片段”,减少跨片段拼接。另外,生成后可以做一次轻量校验:计算答案句与所引片段的语义相似度,低于某个值就标记为“引用存疑”,提示用户谨慎参考。

注意:引用校验会增加一次额外的相似度计算,对延迟敏感的场景要权衡,可以只对关键业务问答开启。

5.4 性能与成本上的实操心得

带引用的 RAG 比普通问答多了一次检索和一次校验,延迟会上升。我的优化经验是:检索结果做短期缓存,相同或相似问题直接复用召回片段;Embedding 结果持久化,避免每次启动重新向量化整个知识库。成本方面,Top-K 别贪大,K=4 到 6 通常够用,K 翻倍带来的 token 消耗是实打实的。

还有个小技巧:把文档元数据(标题、章节)也拼进上下文,模型在组织语言时会更自然地引用“根据《XX手册》”,用户体验比干巴巴的 [1] 好很多。

6. 引用机制带来的信任增量

我最初做引用只是为了“看起来专业”,但实际用下来发现它的价值远超预期。当用户能看到答案背后的原文片段,他们对系统的信任度是质变的——即使偶尔答错,用户也能顺着引用发现是知识库本身的问题,而不是怀疑系统在乱编。这种“可验证性”才是 RAG 区别于普通问答的真正护城河。

后续我打算在引用基础上再加一层“置信度提示”:根据召回相似度和引用校验结果,给回答标一个可信等级,让用户一眼知道哪些答案可以放心用、哪些需要人工复核。这个方向对内部知识库场景特别实用,毕竟不是所有问题都值得无条件相信 AI。

如果你也在做类似的东西,我的建议是:先把引用链路跑通,再谈效果优化。没有引用的 RAG,优化到最后也只是让幻觉看起来更合理而已。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询