ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-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: rag123

temperature 我设成了 0.2,比默认值低。原因很简单:问答场景要的是准确和稳定,不是创意。温度越低,模型越倾向于选概率最高的词,答案越保守、越贴近原文。如果你做的是创意写作,那另说。

3.2 文档读取与切分的代码实现

读取文档这一步,Spring AI 提供了多种 Reader,PDF、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); List<Document> docs = reader.get(); List<Document> 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 用文件名或文档 ID,section 用章节标题,page 用页码。如果文档有版本号,也建议加上,这样引用能精确到版本,避免"引用了旧版内容"的尴尬。

3.4 检索环节的参数调优

检索是 RAG 的质量瓶颈。检索不准,后面模型再强也白搭。核心参数有两个:topK 和相似度阈值。

topK 是返回最相似的几个片段。设太小,可能漏掉关键信息;设太大,噪音多,还会撑爆上下文窗口。我一般从 4 开始试,根据效果调整。相似度阈值是过滤掉那些"不太相关"的片段,避免把无关内容塞给模型。

SearchRequest request = SearchRequest.query(question) .withTopK(5) .withSimilarityThreshold(0.7); List<Document> 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) { List<Document> 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(); List<Citation> 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,开流式输出
重复内容多切分重叠过大减小 overlap

4.5 几个容易踩的坑

第一个坑是元信息丢失。前面反复强调,这里再提一次,因为真的太常见了。入库时不带元信息,后面做引用只能干瞪眼。

第二个坑是文档更新后没重新入库。文档改了,向量库里还是旧的,检索出来的是过时内容。建议做个文档版本管理,文档一变就触发重新入库。

第三个坑是忽略 token 限制。检索片段加上提示词模板加上问题,总长度不能超过模型的上下文窗口。塞太多会被截断,截断的位置很随机,可能正好把关键信息切掉。入库前算一下,留足余量。

第四个坑是没做权限过滤。如果文档有权限分级,检索时必须带上权限过滤条件,否则低权限用户可能通过问答拿到高权限文档的内容。这个在元信息里加个权限字段,检索时作为过滤条件就行。

5. 效果验证与迭代方向

5.1 怎么判断 RAG 做得好不好

光靠肉眼看几个回答不够,得有一套评估方法。我一般从两个维度看:检索质量和生成质量。

检索质量看召回率和准确率。准备一批测试问题,每个问题标注好应该命中哪些片段,然后跑检索,看命中的比例。召回率低说明有该找的没找到,准确率低说明找回来一堆没用的。

生成质量看答案的事实性和引用准确性。事实性就是答案有没有编,引用准确性就是标注的来源对不对。这两个都可以人工抽检,量大的话可以借助另一个模型来打分。

5.2 后续可以怎么扩展

这套基础链路跑通之后,有几个方向可以继续做深。

一是多路召回。除了向量检索,再加一路关键词检索,两路结果融合。向量检索擅长语义相似,关键词检索擅长精确匹配,两者互补,能覆盖更多场景。

二是对话式追问。现在的实现是单轮问答,用户追问"那第二点呢"的时候,系统不知道"第二点"指什么。加上对话历史管理,把上下文带进去,就能支持多轮追问。

三是引用高亮。前端拿到引用信息后,不只是列出文件名,还能定位到原文的具体段落并高亮,用户体验会好很多。

四是增量更新。现在文档更新是全量重新入库,数据量大时很慢。可以做成增量,只处理变化的文档,配合文档指纹(比如内容哈希)来判断哪些需要更新。

我在实际项目里跑下来,这套方案对"基于内部文档问答"这个场景的改善是立竿见影的。以前用户问一个问题,模型十有八九在编,现在大部分回答都能给出准确出处,用户自己点开引用一看就放心了。引用这个功能看着不起眼,但它是建立信任的关键——用户不是要一个"听起来对"的答案,而是要一个"能验证"的答案。把引用做扎实,RAG 的价值才真正落地。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进