资讯动态

基于Spring AI和RAG构建带引用溯源的知识库问答助手

发布时间:2026/10/21 8:02:22 来源:尧图企业网站定制
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 groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-xxx/artifactId version你使用的版本/version /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-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。重点是构建时一定要放 metadataDocument doc new Document(fileContent, Map.of( source, file.getName(), title, extractTitle(file), page, extractPageIfApplied(file) ));source字段我强烈建议保留其他字段看你需要。引用要展示什么后面就从这里取什么。有些资料还有版本号、文档归属部门、更新时间这些都可以放进 metadata。接下来切分。切分器的选择很多按 token 数切是最稳的因为 Embedding 模型和 LLM 的 token 限制都是按 token 算的。我用的是TokenTextSplitterTextSplitter splitter new TokenTextSplitter(); ListDocument 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. 向量检索 ListDocument 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, ListCitation 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知识库更新后引用旧内容入库任务没有覆盖原索引更

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑