ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Spring Boot集成Spring AI:打造带引用来源的RAG知识库问答助手

Spring Boot集成Spring AI:打造带引用来源的RAG知识库问答助手

做 AI 应用开发这一年多,我最怕的是模型“睁眼说瞎话”——明明不知道答案,还要编一个头头是道的回答。企业内部文档问答里,这种幻觉最致命:用户拿着 AI 给的答案去办事,结果来源根本不存在。最近我在一个 Spring Boot 项目里用 Spring AI 搭了一套 RAG 问答助手,核心只干一件事:让回答带上引用来源。AI 说出的关键结论,都能在原始文档里找到出处,幻觉问题被压到了可接受范围。

这个项目本身代码量不算大,但坑不少。从需求拆解到落地实测,我把完整过程整理出来,涉及 RAG 的核心原理、Spring AI 的常用组件、文档切分参数、向量检索配置,以及引用格式这类容易翻车的细节。如果你正打算给 Spring Boot 服务接大模型问答能力,或者纠结怎么让 AI 的回答“有据可查”,这篇实战记录应该能帮你省不少时间。

1. 项目概览与 RAG 核心思路

1.1 先搞清楚:大模型的幻觉是怎么来的

大模型生成回答时,本质是在做概率预测:根据你给的上下文,一个字一个字地推断下一个最可能的词。它没有一个“查数据库”的动作,也没法真正知道自己不知道什么。训练数据中没见过的细节、内部专有名词、时效性很强的配置信息,它都会按“最像那么回事”的方式编出来。这就是幻觉的来源。

做产品的人不能把“幻觉”当段子听,因为幻觉会带来实打实的后果。比如我问内部知识库“生产环境数据库连接池上限是多少”,模型凭印象回了一个数字,而这个数字跟实际配置不符,运维照着去调参,事故就来了。

要缓解幻觉,业界最成熟的方案不是一味换更大的模型,而是 RAG——检索增强生成。思路很直白:模型不知道没关系,你先把相关资料检索出来“喂”给它,让它基于资料作答。手里有了上下文,模型自由发挥的空间就被挤掉了。

1.2 本项目要解决什么问题

这个项目具体解决两件事。

第一,回答有据可依。模型不能只凭训练时的“记忆”作答,必须基于从知识库检索回来的文档片段生成回答。

第二,关键结论带引用。回答里的事实性内容,都能在源文档里找到对应位置。用户点一下引用就能跳过去核对原文,而不是把 AI 的回答当“最终结论”直接用。

这两件事单拆开都不难,但组合在一起放进 Spring Boot 生态,就需要考虑技术栈整合成本。这也是我选 Spring AI 的核心原因。Spring AI 在 Spring 生态里把“文档加载、切分、向量化、存储、检索、对话”这条 RAG 链路基本串好了,我只需要专注业务层的组装,而不是从零对接各家 SDK。

1.3 项目适用范围与非目标

这个方案适合的场景有这几类:

  • 企业内部文档、制度、产品手册的智能问答
  • 需要答案可核查、可追溯的客服支撑系统
  • 已经以 Spring Boot 为主技术栈、不想额外引入 Python 服务的团队

不适合的场景我也说清楚。如果知识库内容是强动态的,比如生产监控数据实时查询,RAG 的离线索引方式有明显滞后性,你得额外处理增量同步。如果要求答案极度精确而检索结果经常不够理想,那问题可能不在模型,而在文档切分策略和 Embedding 选型——这个后面会展开。

2. 技术选型与整体架构

2.1 为什么选 Spring AI 而不是自己拼

在这套项目之前,我做过一个类似的问答系统,当时是手搓:自己写 Embedding 调用、自己连向量库、自己拼 Prompt、自己接大模型 API。做完最大的感受是胶水代码太多,每个环节都要单独对接 SDK,数据结构还得自己定义,维护成本很高。

Spring AI 给我最直接的帮助是统一抽象层。它定义了Document、VectorStore、ChatModel、EmbeddingModel这些核心接口,并且提供了从文档加载到检索、对话的一整套实现。使用这套抽象,你从一开始就在写业务逻辑,而不是写适配代码。

不过 Spring AI 也不是银弹。版本迭代很快,不同大版本间的 API 差异明显,网上资料经常对不上。我项目里用的是相对稳定的版本,后面的代码写法以我实际用的版本为准。如果你用的版本不同,接口可能有微调,但核心链路不会变。

2.2 整体架构:数据怎么流转

整个系统数据流可以拆成离线索引和在线问答两条线。

离线索引线负责把文档变成可检索的向量:

  1. 读取文档(Markdown、PDF、Word、纯文本)
  2. 按语义把文档切成小块,比如每块几百个 token
  3. 对每块调用 Embedding 模型,转成向量
  4. 把向量、原文、来源元数据一并存入向量数据库

在线问答线负责响应用户提问:

  1. 用户输入问题
  2. 问题同样做 Embedding,转成向量
  3. 在向量数据库中做相似度检索,取回最相关的 Top-N 个文档片段
  4. 把问题和这些片段拼进 Prompt,发给大模型
  5. 大模型基于片段生成回答,并在关键句后标注引用编号
  6. 后端把引用编号替换成可跳转的原文链接,返回前端

两条线合起来,就是一条完整的 RAG 链路。Spring AI 对链路每个环节都有组件覆盖,下面逐一实现。

2.3 项目的模块划分

按代码组织,我分了几个包:

  • controller:REST 接口层,接收提问
  • service:问答主流程编排
  • indexer:文档索引构建,跑离线任务
  • retriever:检索逻辑,封装向量库查询
  • prompt:Prompt 模板管理
  • source:引用来源解析与格式化

这个划分看似简单,但实际排查问题时候很有用。RAG 链路长,出了问题必须先定位“是检索不行还是生成不行”,把索引、检索、生成拆成独立模块后,每个环节都能单独验证。我之前没有分包的时候,一个地方报错,排查范围就是全链路,效率很低。

3. 从零搭建 RAG 问答助手:核心实现

3.1 第一步:引入依赖与基础配置

项目基于 Spring Boot 3.x。Spring AI 的起步依赖以 starter 形式提供,按需引入。核心依赖大概是这样的:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-vector-store-pgvector</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-pdf-document-reader</artifactId> </dependency>

需要说明一点,spring-ai-starter-model要配合具体模型的适配器使用。因为不同大模型服务商的接口有差异,Spring AI 针对主流服务商分别提供 starter,artifactId 形如spring-ai-starter-model-xxx,xxx 是服务商标识。如果你的服务商提供了标准兼容接口,只要在配置里填好 base-url 和 api-key 就行。

application.yml 的基础配置:

spring: ai: model: chat: api-key: ${LLM_API_KEY} base-url: ${LLM_BASE_URL} options: model: ${LLM_MODEL_NAME} temperature: 0.2 embedding: api-key: ${EMBEDDING_API_KEY} base-url: ${EMBEDDING_BASE_URL} options: model: ${EMBEDDING_MODEL_NAME} datasource: url: jdbc:postgresql://localhost:5432/rag_demo username: postgres password: postgres

两个细节单独说。第一,temperature调到 0.2,目的是让回答更收敛、更忠实于检索到的文档,减少自由发挥。做知识库问答,温度不宜高,我见过有人开默认值,结果模型动不动就“创造”。第二,Embedding 模型和对话模型可以来自不同服务商,Spring AI 允许分开配置。实际项目里很常见——向量化用便宜的专用模型,对话用更强的模型。

还有个容易忽略的点。PGVector 的 starter 在启动时会去 PostgreSQL 里建向量扩展,但数据库用户必须有权限执行CREATE EXTENSION。我本地是先手动执行了扩展创建,再去启动应用的。如果你是第一次用 PGVector,这条坑基本必踩。

3.2 第二步:文档加载与切分(决定检索质量的关键)

RAG 效果好不好,一半以上责任在文档切分。这里我踩过不少坑,先说结论。

文档加载用 Spring AI 的DocumentReader系列。我整理了三种常见格式的写法:

  • Markdown 或纯文本:用TextReader直接读
  • PDF:用PagePdfDocumentReader按页加载
  • Word:先转成纯文本,再交给TextReader

加载完成得到一批Document对象,每个包含content(正文内容)和metadata(元数据,如文件名、页码、标题)。这两种信息后面都要用。

切分是重头戏。Spring AI 提供了按 token 切分的实现,比按字符切分更贴合模型的实际上下文窗口。我的初始配置是这样的:

TextSplitter splitter = TokenTextSplitter.builder() .withChunkSize(800) .withOverlap(150) .build(); List<Document> chunks = splitter.apply(sourceDocuments);

chunkSize是每块的目标 token 数,overlap是相邻块之间的重叠 token 数。为什么要重叠?因为句子上下文是连续的,如果正好在段落中间切断,后半块就丢了前半块的信息,检索时就可能召回不到。重叠 150 左右,能尽量保住跨块的上下文。

但光切分还不够,来源信息必须留下来。我给每个切分块都补上了原始文件和页码:

chunks.forEach(chunk -> { chunk.getMetadata().put("source", sourcePath); chunk.getMetadata().put("page", pageNumber); });

这一步千万别偷懒。如果切分时没记来源,后面就算检索对了,也不知道这句话来自哪份文档,引用也就无从谈起。

再往后还有一个非常影响检索质量的细节,Markdown 标题层级。标题是强语义信息,但直接切分时经常被忽略。我在实验中发现,切分正文片段时把所属的一级、二级标题拼到片段最前面,检索召回率有肉眼可见的提升。Spring AI 没内置这个能力,我是自己遍历元数据里的标题字段拼上去的。别小看这个操作,知识库文档一旦结构复杂,效果差距很明显。

3.3 第三步:Embedding 与向量存储

建模服务商提供向量化接口,Spring AI 里对应EmbeddingModel。用法很简单:

@Autowired private EmbeddingModel embeddingModel; float[] vector = embeddingModel.embed("数据库连接池配置");

实际工程里不需要手动为每条文本调用 Embedding,Spring AI 的VectorStore会在add()时自动调用。把切分好的 Document 列表直接交给 VectorStore 就行:

@Autowired private VectorStore vectorStore; public void indexDocuments(List<Document> documents) { vectorStore.add(documents); log.info("入库完成,共 {} 个文档块", documents.size()); }

我用的向量库是 PGVector,也就是 PostgreSQL 的向量扩展。选它主要因为三点。第一,团队本来就熟 PostgreSQL,不用额外部署一套独立向量数据库。第二,PGVector 支持 HNSW 索引,百万级文档块规模内检索性能完全够用。第三,Spring AI 对它的集成是官方维护的,初始化时自动建表。

向量表核心字段大致这样:

字段说明
id主键,UUID
content原始文档块文本
metadataJSON 格式,存来源、页码等
embeddingvector 类型,存储向量

索引配置也值得注意。我建的是 HNSW 索引,距离函数用 cosine:

CREATE INDEX ON vector_store USING hnsw (embedding vector_cosine_ops);

选 cosine 还是 L2,取决于 Embedding 模型和检索语义。大多数 Embedding 模型训练时都用 cosine 相似度,所以vector_cosine_ops通常是对的。如果你用的模型官方明确推荐其他度量,再相应调整。

有一个必须提醒的点,Embedding 模型的输出维度和表结构必须一致。某模型输出 1024 维,表里的 embedding 字段就要定义成vector(1024)。Spring AI 的 PGVector 实现会自动建表,但如果表已存在而维度对不上,启动时会直接报错,查起来还挺费劲。

3.4 第四步:问答链路封装

离线索引建好后,在线问答核心代码反而不多。我用 Spring AI 的ChatClient构建问答方法:

@Service public class RagAssistantService { private final VectorStore vectorStore; private final ChatClient chatClient; public RagAssistantService(VectorStore vectorStore, ChatClient.Builder chatClientBuilder) { this.vectorStore = vectorStore; this.chatClient = chatClientBuilder.build(); } public String ask(String question) { // 1. 检索相关文档块 List<Document> similarDocs = vectorStore.similaritySearch( SearchRequest.builder().query(question).topK(5).build() ); // 2. 拼装上下文 StringBuilder context = new StringBuilder(); for (int i = 0; i < similarDocs.size(); i++) { Document doc = similarDocs.get(i); context.append(String.format( "[%d] %s (来源:%s)", i + 1, doc.getContent(), doc.getMetadata().get("source") )).append("\n\n"); } // 3. 组装 Prompt 并调用对话模型 String systemPrompt = PROMPT_TEMPLATE.replace("{context}", context.toString()); return chatClient.prompt() .system(systemPrompt) .user(question) .call() .content(); } }

这里有两个参数值得展开。

Top-K设为 5,指每次从向量库取 5 个最相关文档块。这个值不是越大越好。取回越多,上下文越全面,但噪音也越多,模型可能被不相关信息带偏。我实测下来,文档问答场景 5 到 8 是常见合理区间,具体要在你自己的数据上跑几轮看看。

PROMPT_TEMPLATE是决定“回答是否忠实于资料”的关键。我用的模板大概这样:

你是一个知识库问答助手。请严格根据下面的参考资料回答。 要求: 1. 只使用参考资料中的信息,不要使用你自己的知识。 2. 如果参考资料不足以回答,直接回答"根据现有资料无法确认"。 3. 涉及事实性结论时,在句尾标注引用编号,编号对应参考资料前的 [编号]。 4. 参考资料里没有的信息,绝对不要编造。 参考资料: {context} 用户问题:{question}

这个 Prompt 里“严格根据资料”“不确定就不要答”是核心约束。我做过对照实验,只给上下文但不在 Prompt 里做约束,模型照样会在信息不足时自由发挥。约束写不写,效果差很多。

生产环境我建议把接口改造成流式返回。Spring AI 的ChatClient支持.stream(),前端按 token 逐步展示。用户对大模型问答的等待耐心通常只有几秒,流式输出能显著缩短感知时间,是体验优化的关键手段。

4. 回答带引用:从“看起来是对的”到“能核实”

4.1 引用标注的核心设计

回答带引用这件事,本质上分两步。第一步,让模型在生成时标注引用编号。第二步,把编号映射回真实文档链接。这两步如果混在一起写,代码会非常乱。

我在 Prompt 里给模型规定了引用编号规则:参考资料每条都有[编号],回答中引用哪条资料,就在对应句尾标哪个编号。比如:

根据项目文档,生产环境连接池上限为 200 [1],测试环境为 50 [2]。

模型输出的是这种带编号文本。第二步,在后端解析编号,替换成真实的前端链接。

为什么要模型输出编号而不是直接给它完整链接?因为模型即使知道链接,构造超长 URL 也很容易出错,URL 里的特殊字符模型经常写错。让模型只负责“逻辑位置”,把“链接拼接”放到代码层做,可靠得多。

4.2 引用格式化的实现

后端解析引用的代码大致是这样:

public String renderAnswerWithSources(String rawAnswer, List<Document> sources) { Pattern pattern = Pattern.compile("\\[(\\d+)\\]"); Matcher matcher = pattern.matcher(rawAnswer); Map<Integer, Document> sourceMap = new HashMap<>(); for (int i = 0; i < sources.size(); i++) { sourceMap.put(i + 1, sources.get(i)); } StringBuffer result = new StringBuffer(); while (matcher.find()) { int idx = Integer.parseInt(matcher.group(1)); Document doc = sourceMap.get(idx); if (doc == null) { matcher.appendReplacement(result, matcher.group(0)); continue; } String sourcePath = (String) doc.getMetadata().getOrDefault("source", "未知来源"); String page = String.valueOf(doc.getMetadata().getOrDefault("page", "")); matcher.appendReplacement(result, String.format("<sup class=\"citation\">
RELATED READING

延伸阅读

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