做 AI 问答系统最怕什么?不是答得慢,不是不会答,而是答得特别自信,结果全是编的。传统大模型天生是个“话痨”,训练时见的数据多,生成时想象力也大,遇到知识盲区就一本正经地胡编,业内叫“幻觉”。尤其是企业内部做知识库问答时,员工问一句制度或操作规范,模型洋洋洒洒几百字,表面上条理清晰,实际上是它自由发挥的,这种回答放到生产环境几乎等于事故。
Spring AI 的 RAG(检索增强生成)就是用来治这个毛病的。思路很简单:回答之前先在知识库里检索出相关段落,把真实证据拼到提示词里,再让模型基于证据作答,同时把引用来源一并返回给用户。这样一来,每句话都有出处,AI 就没办法睁眼说瞎话了。
我最近把一个内部知识库问答助手从“裸聊大模型”升级到“带引用的 Spring AI RAG”方案,跑了大概一个多月,效果很直观:不懂装懂的情况大幅减少,用户能点击引用直接跳到原始文档,社群里对 AI 回答的信任度一下子起来了。这篇文章把我从选型、设计到落地的完整过程拆开讲,包括分块策略、向量存储、引用透传、提示词怎么写,以及我踩过的一堆坑。适合正在做 Java 技术栈、想把大模型接到真实业务里的同学参考。
1. 为什么检索增强是治“AI 睁眼说瞎话”最有效的方案
1.1 大模型幻觉的本质:答案不可信比答错更可怕
先聊一个基础问题:大模型为什么爱胡编?因为它本质上是个条件概率模型,生成下一个 token 时,它选择的不是“事实”,而是“最像话的话”。训练语料里如果某类知识稀少、或者问题超出模型掌握范围,它就会从相似主题中“借鉴”内容补全,组合出一段语法通顺、逻辑自洽但与事实相悖的答案。
我早先用裸模型做过一个客服问答原型,用户问“公司年假怎么休”,模型答“入职满一年可休 15 天”,乍一听挺合理,但实际公司制度写的是“满一年 5 天,满三年 10 天”。这就是幻觉的典型场景:它不是在回答你的制度,而是在“写一篇像制度的散文”。
更麻烦的是,幻觉回答往往语气笃定,用户分辨不出来。企业场景里,员工会拿 AI 答案当政策依据,一旦内容偏差,轻则闹笑话,重则引发劳动争议。所以很多团队做内部助手时,第一诉求不是“答案多流畅”,而是“答案能不能信、能不能查”。
1.2 RAG 的完整链条:先查资料再写答案
RAG 的思路放到现实中很好理解——就像你写行业报告之前,先翻一摞参考资料,而不是凭记忆现编。RAG 全称 Retrieval-Augmented Generation,流程分两段:
- 检索阶段:把用户问题转化为向量,去向量数据库中做相似度查询,找出与问题语义最相关的若干知识片段。
- 生成阶段:把检索到的片段拼进提示词,作为“参考资料”提供给大模型,让模型只依据资料来组织答案。
之所以用“向量相似度”而不是传统关键词搜索,是因为自然语言表达太灵活了。用户问“休假能不能跨年休”,文档里写的是“年假不得跨年度累计”,两者字面上几乎没有重叠词,但语义高度相关。向量检索可以解决这种同义改写问题。Spring AI 在这个链条上做了一层统一封装,Java 开发者不需要去跟各个 AI 厂商挨个对接,也不用自己搭一套检索框架,一个VectorStore接口就把检索端接住了,上层再配合ChatClient做生成,整体代码量比我想象中小得多。
1.3 带引用输出:把“证据链”交到用户手里
光做 RAG,回答质量确实提升了,但用户还是看不到依据。站在使用者角度,一句“AI 说”很难让人完全信服;站在维护者角度,内容更新后你也不知道哪些回答还在引用旧文档。所以我在设计需求时就把“引用”列成了硬指标,不是锦上添花,而是核心功能。
具体来说,回答的每一条结论都要对应一个来源标识,我采用的是“编号引用”的形式:回答正文里出现[1]、[2]这类标记,回答末尾列出引用清单,包含文档标题、原文片段和跳转链接。用户在系统里点了引用,能直接看到原始文档,自己判断 AI 说得对不对。这个设计一开始看着简单,实际落实时牵扯到文档分块、元数据携带、检索结果传递、提示词约定等多个环节,一环断了引用就会错位。后面我会展开讲每个环节怎么做。
2. 整体设计思路:技术选型与组件划分
2.1 需求拆解与核心指标
动手之前,先把需求拆成三类:输入、处理、输出。
- 输入侧,我的场景是“文档问答”,知识源主要是操作手册、制度文件、产品 FAQ,格式有 Markdown、Word、PDF。所以你至少要有一个文档导入通道,能把各种格式解析成纯文本。
- 处理侧,知识库不是固定的,业务文档会不断修订,所以系统要支持增量导入、内容重写、旧版本淘汰。
- 输出侧,除了回答正文,还必须返回引用来源,且引用要准确到“原文片段”级别,不能只给一个整篇文档的链接。
当时我定下的核心指标有三条:回答可追溯到原文片段;检索 Top-5 内必须包含正确答案(否则你再怎么提示,模型也无米下锅);端到端回答延迟不超过 5 秒。前两点关乎“可信”,最后一点关乎“可用”,缺一个都没法上线。
顺带说一句,很多人纠结要不要在 RAG 系统里塞一个“追问澄清”模块,让 AI 先反问用户再搜索。我建议第一版不要做,先把检索和引用链路跑通。RAG 的调试本来就链条长,再引入多轮澄清会叠加对话状态管理,问题排查难度直接翻倍。
2.2 关键选型:向量库、嵌入模型与框架版本
技术选型这块我踩过不少坑,逐个说一下。
先说向量库。Spring AI 官方支持的向量存储很多:内存版SimpleVectorStore、Redis、PGVector、Milvus、Elasticsearch、Chroma 等。我第一版图省事用了SimpleVectorStore,数据全放内存,重启就丢,适合本地体验,生产显然不行。后来迁到 PGVector,直接复用 PostgreSQL 实例,多一张表的事,运维压力最小。如果你公司本身有 Elasticsearch,也可以直接把知识块存到 ES 里,用它的 dense_vector 字段做向量检索。我的建议是:知识量低于百万级、团队不想引入新中间件,无脑选 PGVector;如果知识量巨大且对检索性能要求很高,再考虑专门的向量数据库。
再说嵌入模型。OpenAI 的text-embedding-3-small性价比高,1536 维,效果稳定;如果数据不能出内网,可以用本地部署的嵌入模型,比如 BGE、M3E 这类中文友好的方案。我实测下来,中文场景里领域术语越多,越推荐用微调过的中文嵌入模型,通用英文嵌入模型对中文同义改写支持差不少。Spring AI 的抽象层设计得不错,嵌入模型和向量库都可以通过配置切换,不用改业务代码。
SimpleVectorStore转PGVector时我几乎没动 Java 代码,只改了配置和依赖。
最后说框架本身。Spring AI 当时我用的是 1.0.0 版本,API 已经比较稳定了。如果你之前看过老版本的AiClient写法,注意新版本的入口类是ChatClient,构建方式也从构造器改成 builder 模式。后面代码示例我都按 1.0.0 写,senior 版本差异我会在第五节提一下。
2.3 数据流转与模块划分
整个系统的数据流我抽象成两条链路:导入链路和问答链路。
导入链路:文档解析器读取源文件,转成Document对象;文本切分器把长文档切成短片段;每个片段交给嵌入模型生成向量;向量连同片段内容、源文件信息、片段序号一起存入向量库。
问答链路:用户问题通过同一个嵌入模型转成向量;在向量库里做相似度搜索,取 Top-K 片段;把片段拼进提示词上下文,让模型只基于这些片段作答;最后把模型回答连同片段元数据一并返回给前端。
模块划分上,我按这两个链路拆了三个包:ingest管文档导入,retrieve管检索,generate管提示词和模型调用。项目虽小,但这三个包的边界得划清楚,因为后续你大概率会换嵌入模型或向量库,边界清晰才能改一处而不殃及其他。
3. 几个最容易漏掉的核心细节
3.1 文档分块策略:不是简单截断,而是保语义、保引用
RAG 系统最容易被轻视的就是分块。很多人上来就把文档按固定字符数切片,比如每 500 个字符一刀切下去,结果就是语义完整的段落被切断,检索到的片段总是“半句话”,模型拿这些残缺内容做依据,回答自然漏洞百出。
我在项目里先按标题层级把文档切成顶层章节,再对超长章节做二次切分,尽量保持段落完整性。具体参数:单块长度 300 到 800 个 token,重叠 50 到 150 个 token。重叠段很重要,它保证跨块的上下文不会在切点处断掉。比如一个流程说明分成两步,切块时第一块结尾可能只有“上一步完成后”,第二块开头是“进入审批环节”,如果没有重叠,检索时你可能就只找到“进入审批环节”这块,缺失了前提条件。
拿我处理的“年假制度”文档举例:标题是“年假管理细则”,下面有条款“入职满一年享受 5 天年假……不可跨年累计”。按标题切后,一条条款落成一个 chunk,检索时用户问“年假能不能跨年”,模型拿到的就是完整条款,而不是半截话。所以分块策略一定跟文档结构强绑定,不同文档类型要设计不同的分块规则。
这里有个细节你必须在导入时做:给每个切块补上“来源元数据”。我在元数据里存了source_file(文档路径)、title(标题)、chunk_id(该块在文档中的序号)、page(页码)。这些元数据由后面生成引用清单直接用,提前不做后面非常难补。
3.2 元数据贯穿始终:引用是否可靠的关键
带引用的 RAG 和普通 RAG 有一个本质区别:普通 RAG 只关心“内容片段够不够准”,带引用的 RAG 还关心“每个片段能不能被准确追溯”。追溯能力几乎全部依赖元数据。
我做引用设计时把来源拆成两级:
- 文档级来源:能定位到是哪一份文件,比如“《员工手册_2025版.pdf》”;
- 片段级来源:能定位到文件里哪个段落,比如“第三章 第二节 年假条款”。
Document的元数据天然支持这种层级结构。向向量库写入时,我会把片段内容、元数据作为同一个Document保存,查询时从相似度结果里把元数据再取出来。注意,一定要让“回答正文里出现的引用编号”和“检索结果的排序索引”对应上,否则你返回的引用列表跟正文里的[1][2]对不上,就成了新的“骗人”。我在代码里是这么约定的:检索结果 List 的 index +1,就是引用编号。后续拼提示词和返回引用时都沿着同一个顺序操作,避免错位。
3.3 检索质量决定回答质量:Top-K、相似度阈值与混合检索
模型生成能力再强,检索到的内容是错的,它也回天乏术。所以我把研发精力一大半都花在检索质量上。
检索参数里最核心的是 Top-K 和相似度阈值。Top-K 太小,正确答案可能排到后面被截掉;Top-K 太大,无关内容混进提示词,模型容易被干扰。我业务场景里 Top-K 设 5 比较合适,知识库规模不大,5 段上下文足够覆盖大部分问题。相似度阈值设 0.35 到 0.45,低于阈值的片段直接丢弃,宁可答“知识库中没有相关信息”,也不要硬答。这里要说明一下,相似度分数跟嵌入模型相关,不同模型得分区间不一样,第一次接入新模型时最好抽取一批问题跑一遍,看正确片段的得分分布再定阈值,不要照搬网上的经验值。
如果你的知识库文档量很大,可以上混合检索:向量召回 + 关键词召回,再用 RRF(Reciprocal Rank Fusion)合并排序。Spring AI 里可以同时查向量库和 Elasticsearch,然后自己写个合并器。我的项目数据量还没到非做混合检索不可的程度,但架构上我留了接口。不要一开始就追求完美,先让主链路跑通。
4. 从零搭建 Spring AI RAG 问答助手
4.1 工程结构与依赖
我用的是 Spring Boot 3.2 + Spring AI 1.0.0,Maven 管理依赖。项目结构如下:
src/main/java/com/example/rag/ ├── config/ │ └── RagConfig.java ├── ingest/ │ ├── DocumentIngestService.java │ └── ChunkingStrategy.java ├── retrieve/ │ └── VectorSearchService.java ├── generate/ │ └── RagChatService.java └── web/ └── ChatController.java核心依赖就三个:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-tika-document-reader</artifactId> <version>1.0.0</version> </dependency>如果你只是本地测试,把 PGVector 换成spring-ai-simple-vector-store或干脆不用 starter,直接SimpleVectorStore.builder(embeddingModel).build()也行。配置方面,我放一份application.yml的关键片段:
spring: ai: openai: api-key: ${OPENAI_API_KEY:sk-your-key} chat: options: model: gpt-4o-mini temperature: 0.2 embedding: options: model: text-embedding-3-small vectorstore: pgvector: host: localhost port: 5432 database: ai_kb username: postgres password: postgres table-name: knowledge_chunk initialize-schema: truetemperature我压到 0.2,RAG 场景下宁可回答保守也不要放飞。
4.2 知识库导入:读文档分块入库
导入服务的核心逻辑分三步:读文档、分块、写向量库。我直接用TikaDocumentReader解析 PDF 和 Word,它对格式兼容性比正则硬解析好得多。
@Service public class DocumentIngestService { private final VectorStore vectorStore; private final TextSplitter textSplitter; public DocumentIngestService(VectorStore vectorStore) { this.vectorStore = vectorStore; this.textSplitter = TokenTextSplitter.builder() .withChunkSize(500) .withChunkOverlap(100) .build(); } public void ingest(Resource resource, String docTitle) { TikaDocumentReader reader = new TikaDocumentReader(resource); List<Document> documents = reader.get(); List<Document> chunks = textSplitter.split(documents); for (int i = 0; i < chunks.size(); i++) { Document chunk = chunks.get(i); chunk.getMetadata().put("source", resource.getFilename()); chunk.getMetadata().put("title", docTitle); chunk.getMetadata().put("chunk_id", i); } vectorStore.add(chunks); } }入库之后再删旧数据也很重要。文档修订后不能只往里加,否则同一问题会同时检索到新旧两版内容。我维护了一张文档版本表,导入时先按source删掉旧版本的所有 chunk 再重新写入。
4.3 检索与回答:向量搜索 + 引用透传
接下来是问答链路的核心。我定义了一个返回结构,同时保留回答文本和引用列表:
public record CitationInfo(String source, String title, String snippet) {} public record RagResponse(String answer, List<CitationInfo> citations) {}检索服务很直接,一个similaritySearch搞定:
@Service public class VectorSearchService { private final VectorStore vectorStore; public VectorSearchService(VectorStore vectorStore) { this.vectorStore = vectorStore; } public List<Document> search(String question, int topK) { SearchRequest request = SearchRequest.builder() .query(question) .topK(topK) .withSimilarityThreshold(0.35) .build(); return vectorStore.similaritySearch(request); } }然后生成服务把检索结果拼进上下文:
@Service public class RagChatService { private final ChatClient chatClient; private final VectorSearchService vectorSearchService; public RagChatService(ChatClient.Builder chatClientBuilder, VectorSearchService vectorSearchService) { this.vectorSearchService = vectorSearchService; this.chatClient = chatClientBuilder.build(); } public RagResponse answer(String question) { List<Document> docs = vectorSearchService.search(question, 5); if (docs.isEmpty()) { return new RagResponse("知识库中未找到相关信息。", List.of()); } StringBuilder context = new StringBuilder(); List<CitationInfo> citations = new ArrayList<>(); for (int i = 0; i < docs.size(); i++) { Document doc = docs.get(i); context.append("[").append(i + 1).append("] ") .append(doc.getFormattedContent()).append("\n"); String source = (String) doc.getMetadata().getOrDefault("source", "未知来源"); String title = (String) doc.getMetadata().getOrDefault("title", source); String snippet = doc.getFormattedContent(); citations.add(new CitationInfo(source, title, snippet)); } String answer = chatClient.prompt() .system("你是企业知识库助手。回答必须基于用户提供的参考资料," + "所有结论都要在句末用 [编号] 标注引用来源。" + "如果参考资料无法回答,请直接说明。") .user(context + "\n\n用户问题:" + question) .call() .content(); return new RagResponse(answer, citations); } }这里有个容易踩的坑:doc.getFormattedContent()在有些实现里会返回带元数据头的长文本,拼进上下文倒无所谓,但放进引用snippet时就会很啰嗦。我在实践里改成从 metadata 取原始内容字段,或者干脆用getText()。具体字段名跟嵌入模型和存储实现有关,写单元测试时盯着输出看一次就能发现。
4.4 提示词怎么设计:让模型“写论文”,不是“写作文”
RAG 的提示词跟普通 Chat 提示词完全不一样,重点是约束“证据范围”和“引用格式”。我系统提示词的核心是这一条:
你是企业知识库问答助手。用户消息中会包含引用片段,每个片段以 [编号] 开头。你的回答必须严格基于这些片段,不得使用片段之外的内部知识。回答时,涉及引用片段内容的句子末尾必须标注对应编号,例如 [1]。若片段不足以回答问题,明确回答“知识库中未找到相关信息”。
注意,提示词里必须强调“不得使用片段之外的内部知识”。这条能有效压制模型自由发挥的冲动。我实测过,删掉这句话之后,模型偶尔还是会根据自己的预训练知识往外扩,尤其是遇到热门常识时。加上这句话以后,幻觉率明显下降,虽然偶发模型还是会忍不住补一句常识性解释,但整体可控。
末尾引用清单是我的代码拼出来的,不是让模型生成。这是刻意为之,因为模型生成引用格式不稳定,可能漏项也可能会编一个根本不存在的文件名。既然检索结果始终在我们手里,那就应该由代码负责提供引用,模型只负责写正文。这也是“带引用 RAG”落地时一个非常值得坚持的设计原则:引用不由大模型生成,由工程系统保证。
5. 常见的问题与排查实录
5.1 检索空洞:问啥都查不到
症状是用户问的问题明明文档里有,但检索结果为空或全是无关内容。排查思路分三步:
第一步看问题向量化是否正常。我遇到过配置了 OpenAPI key 但 embedding 模型没配,系统悄悄用了默认模型,中文向量质量极差,换个模型立刻正常。
第二步看阈值。0.35 是我当前模型的合理阈值,但换嵌入模型后得分分布会整体偏移。排错时可以先把阈值降到 0,看 Top-5 里有没有正确答案,如果有再把阈值逐步调高。
第三步看分块。如果文档被切成过碎的片段,一条完整信息被拆到多块里,单块和问题的相似度都不高。把分块参数调大、保证语义完整性,检索召回率通常立竿见影。
5.2 引用错位:正文和引用对不上
这是做带引用功能最容易遇到的坑。现象是回答里写了 [3],但引用列表里第 3 条明显跟这句话没关系。
排查时先看提示词里的拼接顺序和引用列表构造顺序是否一致。我在早期版本里先用stream.map()处理引用列表,又单独遍历 docs 拼上下文,两个顺序都基于同一个 docs 列表,看起来没问题,但中间夹了一个filter操作,把列表过滤短了,编号就全乱了。后来我统一改成一次遍历同时做两件事,就是上面代码里的写法,从此这个 bug 再没出现过。
另外提醒一点:不要让模型自己决定编号。模型生成的编号一定要能被映射到我们的引用列表,所以提示词里给到模型的编号就是检索结果的下标,模型只要照着写就行,不应该让它自己重新统计。
5.3 回答内容不完整,引用了但总是“话只说一半”
这个问题我排查很久,一度以为是提示词不够强,后来发现是上下文塞太多无关内容,模型被“干扰信息”带偏了。Top-K=5 时,如果 5 段里只有 2 段相关,模型面对五段材料,它会试图都照顾一下,结果每段都说一点,反而说不精准。
我的解决办法是双阈值:先用较宽的阈值召回相关片段,然后按分数排序,从高到低取满需要的片段数,如果前列已经足够就不往后排。本质上还是把 Top-K 调小,同时提高片段本身的质量。另外,系统提示词里加了一句“只依据与你回答内容相关的片段”,也能减少模型材料的浪费。
5.4 性能、成本与版本兼容
延迟方面,最大的开销往往不在向量检索,而在模型生成。gpt-4o-mini 相比大杯模型已经快很多,首字延迟大概 1 到 2 秒,全量回答 3 到 5 秒。如果你嫌慢,可以做两层优化:一层是让检索结果更精准,减少塞给模型的 token 数;另一层是流式输出,先让用户看到前面的字再等后面,体感上好很多。
成本方面,RAG 的钱主要花在嵌入 API 的调用上。文档量大时入库会跑很多次 embedding,建议做增量导入,只在文档变更时重新算向量。另外,问题向量也可以做一层简单缓存,热门问题命中缓存后可以跳过检索直接走提示词模板生成,实测能省不少 API 调用。
版本兼容这块尤其要注意。Spring AI 从 0.8 到 1.0 的 API 变化非常大,网上很多教程是旧版的AiClient写法。你搜资料时看到AiClient,OpenAiChatClient,ChatResponse这些类,基本都是旧版,1.0 统一入口是ChatClient。如果项目锁在旧版本,也别急着升,等业务稳定再说,因为升级意味着配置项、Advisor、输出解析器都要跟着过一遍。
5.5 我踩过的一个隐藏坑:初始化 SQL 被关闭
PGVector 集成时,我把initialize-schema: false配成了默认,结果一直报“relation does not exist”。后来发现 Spring AI 不会自动建表,除非你显式开启initialize-schema: true。这个配置涉及的目录敏感,一旦关掉,系统并不会报启动错误,而是到第一次检索才暴露,很容易让人误判为网络或模型问题。建议开发环境直接开initialize-schema: true,生产环境手动建表后关掉,建表脚本在官方文档里可以直接拿。
最后说两句实操体会
这个项目从原型到能上线,最大的收获不是学会了 Spring AI 的 API,而是建立起一个认知:AI 应用工程的复杂度不在模型,而在“可控”。RAG 让模型从“自由创作”变成“基于证据的转述”,引用机制又让每一条回答都可验证,这两步叠加,才真正让 AI 助手在企业场景里有资格被信任。
如果你也想做类似的东西,我的建议是从小处下手,先拿一个文档集跑通链路,感受一下分块、检索、提示词之间的联动,再逐步加功能和优化。不要一上来就追热门架构。
再补一个实操小技巧:在开发阶段,把检索结果原样打到日志里,看每一次问答模型到底拿到什么材料。很多“回答很奇怪”的灵异问题,其实都是检索环节偷偷给你塞了不该塞的东西。你能看到模型视角,才谈得上调试 AI 应用。