
做 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低于阈值的片段直接丢弃宁可答“知识库中没有相关信息”也不要硬答。这里要说明一下相似度分数跟嵌入模型相关不同模型得分区间不一样第一次接入新模型时最好抽取一批问题跑一遍看正确片段的得分分布再定阈值不要照搬网上的经验值。如果你的知识库文档量很大可以上混合检索向量召回 关键词召回再用 RRFReciprocal Rank Fusion合并排序。Spring AI 里可以同时查向量库和 Elasticsearch然后自己写个合并器。我的项目数据量还没到非做混合检索不可的程度但架构上我留了接口。不要一开始就追求完美先让主链路跑通。4. 从零搭建 Spring AI RAG 问答助手4.1 工程结构与依赖我用的是 Spring Boot 3.2 Spring AI 1.0.0Maven 管理依赖。项目结构如下src/main/java/com/example/rag/ ├── config/ │ └── RagConfig.java ├── ingest/ │ ├── DocumentIngestService.java │ └── ChunkingStrategy.java ├── retrieve/ │ └── VectorSearchService.java ├── generate/ │ └── RagChatService.java └── web/ └── ChatController.java核心依赖就三个dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId version1.0.0/version /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-pgvector-store-spring-boot-starter/artifactId version1.0.0/version /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-tika-document-reader/artifactId version1.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.2RAG 场景下宁可回答保守也不要放飞。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); ListDocument documents reader.get(); ListDocument 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, ListCitationInfo citations) {}检索服务很直接一个similaritySearch搞定Service public class VectorSearchService { private final VectorStore vectorStore; public VectorSearchService(VectorStore vectorStore) { this.vectorStore vectorStore; } public ListDocument 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) { ListDocument docs vectorSearchService.search(question, 5); if (docs.isEmpty()) { return new RagResponse(知识库中未找到相关信息。, List.of()); } StringBuilder context new StringBuilder(); ListCitationInfo 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-K5 时如果 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 应用。