本文是「Spring Boot AI 全栈后端」系列第 06 篇。前五篇我们解决了能不能对话“成本能不能降”“输出有没有形状”能不能查实时数据这些问题这一篇处理一个更普遍的痛点模型根本不知道你们公司自己写的那份手册。示例基于 Spring AI 2.0 / Boot 4.1全部已通过测试。一个让售后背锅的场景V哥 给一家做工业真空机组的客户做方案时他们的售后总监倒了一肚子苦水。厂里卖的是 VM-200 型真空机组客户买了设备遇到问题就打电话问“这台机器保修多久”“控制面板上显示 E-04 是什么意思”“过保了上门维修怎么收费”答案全都写在《VM-200 产品手册》《操作指南》《2025 版售后政策》这三份文档里加起来两百多页。但接电话的是人新人背不下老员工凭记忆偶尔记串。于是他们把希望寄托在 AI 上接了个通用大模型做客服助手。结果更糟。客户问VM-200 保修多久模型挺自信地回答通常这类工业设备保修一年——它是照着行业常识推测的根本没有读过他们家的保修条款。而真实的条款是整机 24 个月、真空泵主机 36 个月、密封圈等易耗件不在保修范围。客户拿着这个一年的答复去索赔售后只能背锅。问题到这里非常具体了不是模型不会说话是它的脑子里压根没有你公司这份私有资料。通用模型有海量常识但常识里没有你的产品型号、你的保修政策、你上个月刚改的价格表。为什么让它学一遍行不通遇到这个问题团队通常先想到三条路我在这里一并说清它们为什么都不合适一是把整本手册塞进提示词。两百页文档几十万字一次对话塞不下就算塞下了模型也会被无关内容稀释注意力命中率反而下降token 费用还照单全收。二是微调。让模型记住手册内容。问题是产品手册一个月改一次你打算每月微调一次成本和时间都不现实而且微调记进去的知识依然可能胡编因为它并没有真的查。三是给每个人发 PDF 让他自己搜。那还要 AI 干什么真正卡住的是三件事连在一起模型没有你的私有知识、加上它不敢说不知道、于是它只能用常识编一个像答案的答案。第三件才是最可怕的——错误答案听着比我不知道专业多了。解决思路先检索再让它照着资料说RAGRetrieval-Augmented Generation检索增强生成的思路朴素得不能再朴素入库把手册切成小段每段算出一个向量embedding存进向量库检索用户提问时把问题也做成向量去库里找最相似的几段增强生成把这几段原文塞进提示词告诉模型只准照着这些资料回答兜底一段都没命中就直接说没查到不许它自由发挥。第 3、4 步是整套方案的价值所在——模型从靠记忆答题变成开卷考试而且监考老师看着。第一步文档切块别整篇扔进去切块chunking这一步容易被新手跳过但它直接决定召回质量。整篇手册作为一个片段就意味着任何问题都会召回整篇等于没召回。Spring AI 提供了TokenTextSplitter按 token 数量切并且尽量在句号、分号这些地方断开ServicepublicclassKbIngestService{privatefinalVectorStorevectorStore;privatefinalTextSplittersplitter;publicKbIngestService(VectorStorevectorStore){this.vectorStorevectorStore;this.splitterTokenTextSplitter.builder().withChunkSize(120).withMinChunkSizeChars(20).withKeepSeparator(true).build();}publicintingest(ListKbDocdocs){ListDocumentchunksdocs.stream().flatMap(doc-splitter.split(newDocument(doc.content(),Map.of(source,doc.title()))).stream().peek(chunk-chunk.getMetadata().put(source,doc.title()))).toList();vectorStore.add(chunks);returnchunks.size();}}KbDoc只是标题和正文两个字段的记录publicrecordKbDoc(Stringtitle,Stringcontent){}注意那句peek每段我们都把来源标题写进 metadata。这是后面答案能给出处的前提——没有来源的片段等于没有引用价值的答案。第二步向量库选哪个都不影响业务代码Spring AI 把向量库抽象成VectorStore内存版、PGVector、Milvus、Redis、Elasticsearch 等等业务层看到的都是同一个接口。想离线把逻辑跑通用最简单的SimpleVectorStore就行ConfigurationpublicclassKbTestConfig{BeanPrimaryEmbeddingModelembeddingModel(){returnnewStubEmbeddingModel();}BeanVectorStorevectorStore(EmbeddingModelembeddingModel){returnSimpleVectorStore.builder(embeddingModel).build();}}上线的项目换成 PGVector也只是在同一个地方换掉这个 bean——引入spring-ai-starter-vector-store-pgvector依赖配置数据库连接即可KnowledgeBaseService一行都不用改。这就是抽象的价值存储选型改到最后也只是配置层的事。第三步问答——把检索到的资料塞进提示词这一步 Spring AI 给了现成的 advisorRetrievalAugmentationAdvisor。它在调用模型之前先执行一遍检索把命中的片段拼进提示词再发给你惯用的模型。ServicepublicclassKnowledgeBaseService{privatefinalVectorStorevectorStore;privatefinalChatClientchatClient;privatefinalinttopK;privatefinaldoublesimilarityThreshold;publicKnowledgeBaseService(ChatModelchatModel,VectorStorevectorStore,Value(${kb.top-k:3})inttopK,Value(${kb.similarity-threshold:0.35})doublesimilarityThreshold){this.vectorStorevectorStore;this.topKtopK;this.similarityThresholdsimilarityThreshold;this.chatClientChatClient.builder(chatModel).defaultSystem(SYSTEM).defaultAdvisors(RetrievalAugmentationAdvisor.builder().documentRetriever(VectorStoreDocumentRetriever.builder().vectorStore(vectorStore).topK(topK).similarityThreshold(similarityThreshold).build()).queryAugmenter(ContextualQueryAugmenter.builder().promptTemplate(newPromptTemplate(AUGMENT_TEMPLATE)).emptyContextPromptTemplate(newPromptTemplate(EMPTY_CONTEXT_TEMPLATE)).allowEmptyContext(false).build()).build()).build();}}RetrievalAugmentationAdvisor做三件事用VectorStoreDocumentRetriever取回片段topK控制取几条、similarityThreshold控制多相似才算数交给ContextualQueryAugmenter把资料拼进模板最后把增强后的 prompt 发给模型。模板必须换成中文而且兜底模板有个坑默认的模板是英文的用在中文客服系统里有两个问题一是风格不统一二是它会在没检索到时允许模型基于常识回答。所以两个模板都自己写publicstaticfinalStringAUGMENT_TEMPLATE 请依据下面的资料回答用户的问题。只使用资料中的内容资料里没有就说不知道。 资料 {context} 用户问题{query} ;publicstaticfinalStringEMPTY_CONTEXT_TEMPLATE 当前知识库里没有任何与用户问题相关的资料。 请直接回复“抱歉这个问题在我们的产品手册和售后政策里没查到依据我帮您转人工核实。” 不要尝试用常识作答也不要做任何推测。 ;这里有个 2.0.1 的实际坑V哥 踩过一次正常模板里的变量是{query}和{context}但兜底模板渲染时框架不传任何变量——默认的兜底模板本身连一个占位符都没有。你要是在EMPTY_CONTEXT_TEMPLATE里写{query}运行时就直接抛Not all variables were replaced。兜底话术要么写死成静态文案要么自己在服务层拼好后传进去。关键一步没查到就别让它编很多 RAG 教程到上一步就结束了但真正决定系统敢不敢上线的是这一段——前置一次检索自己判断是否命中publicKbAnsweranswer(Stringquestion){ListDocumenthitsvectorStore.similaritySearch(SearchRequest.builder().query(question).topK(topK).similarityThreshold(similarityThreshold).build());if(hits.isEmpty()){returnKbAnswer.miss(question);}StringanswerchatClient.prompt().user(question).call().content();returnnewKbAnswer(question,answer,sourcesOf(hits),true);}/** 出处去重答案里挨着的同一份手册不必重复列出。 */privatestaticListStringsourcesOf(ListDocumenthits){returnhits.stream().map(hit-String.valueOf(hit.getMetadata().getOrDefault(source,未标注来源))).distinct().toList();}为什么还要再检索一次因为它换来三样东西出处sourcesOf(hits)拿到了回答依据了哪几份手册前端可以显示本答复依据《VM-200 产品手册》客户查得到、法务认得出硬拦截一段都没命中时直接返回兜底话术连模型都不调——既省钱也从根上杜绝了编造可审计每一次回答是不是有据可依用grounded这个布尔值记下来方便后面做人工抽检。KbAnswer就是干这事的publicrecordKbAnswer(Stringquestion,Stringanswer,ListStringsources,booleangrounded){staticKbAnswermiss(Stringquestion){returnnewKbAnswer(question,MISS_MESSAGE,List.of(),false);}publicstaticfinalStringMISS_MESSAGE抱歉这个问题在我们的产品手册和售后政策里没查到依据我帮您转人工核实。;}于是你有两道防线服务层的前置拦截是第一道ContextualQueryAugmenter的EMPTY_CONTEXT_TEMPLATE是第二道——哪怕有人绕过服务层直接调 advisor模型收到的依然是请告诉用户没查到而不是裸的那个问题。对外接口就两个一个灌库一个问答RestControllerRequestMapping(/api/kb)publicclassKnowledgeBaseController{privatefinalKbIngestServiceingestService;privatefinalKnowledgeBaseServiceknowledgeBaseService;publicKnowledgeBaseController(KbIngestServiceingestService,KnowledgeBaseServiceknowledgeBaseService){this.ingestServiceingestService;this.knowledgeBaseServiceknowledgeBaseService;}PostMapping(/ingest)publicResponseEntityMapString,Integeringest(RequestBodyKbIngestRequestrequest){intchunksingestService.ingest(request.docs());returnResponseEntity.ok(Map.of(chunks,chunks));}PostMapping(/ask)publicResponseEntityKbAnswerask(ValidRequestBodyKbAskRequestrequest){returnResponseEntity.ok(knowledgeBaseService.answer(request.question()));}}怎么验证离线也能把这套链路跑通RAG 看着链路长但每一环都能在没有密钥、没有网络的环境里验证。重点验证这几件事切块与召回手册灌进去之后问VM-200 保修多久能不能召回包含24 个月的那一段片段真的进了提示词用一个会记录收到内容的桩模型断言它收到的用户消息里含手册原文——这一步证明增强是真的发生了而不是你以为发生了答案带出处同一个问题返回的sources里应当有《VM-200 产品手册》问E-04 是什么意思则应当来自《VM-200 操作指南》无关问题走兜底问公司团建去哪里玩应当返回grounded false、sources为空、答案是转人工且根本没有调用模型兜底模板生效单独用 advisor 问那个无关问题桩模型收到的应当是兜底话术而不是原问题入参校验空问题返回 400。向量化那一环不需要真实 embedding 模型把文本按词哈希成固定维度向量、再做归一化就能让共享词越多余弦相似度越高。它当然没有真实 embedding 模型的语义能力但验证逻辑够用了真上线换成 OpenAI / 国产 embedding业务代码一个字不动。落地要点阈值要自己调similarityThreshold不是拍脑袋定的。0.35 是个起步值具体要拿真实问题一批一批试——设高了答不知道设低了召回垃圾把这个数放到配置里kb.similarity-threshold上线后还能热改。切块大小跟着业务走手册段落清晰就用小块100-200 token法律条款这种前后关联强的用大块必要时让相邻片段重叠一部分避免一句话被切成两半。来源元数据别省售价表、售后政策、培训手册混在一个库里时少了source字段的片段既没法给前端展示出处也没法按文档做权限过滤。入库要幂等、要能增量更新文档改了就重新灌别让旧版本留在库里和客户说话生产上给每份文档一个版本号按版本覆盖。权限别忘知识库里可能有机密报价、内部政策按用户角色过滤 handler 后再检索比事后审核便宜得多。成本账要算两次一次是 embedding 入库的开销一次性一次是每次问答 embedding 的成本持续。文档总量大时增量入库很重要。最后 V哥 总结一句通用模型最大的问题从来不是不会说话而是没有读过你写的东西还能说得很自信。RAG 干的事就是先把你的手册找出来再把它的嘴按在资料上——答得出来给依据答不出来就老老实实说没查到。带上出处的答案才敢给客户看答不上来会喊停的助手才敢上线。下一篇07V哥 带你解决更难的一步单轮问答搞不定的多步运营任务——查数据、算出图、再发周报这种一串动作的任务怎么交给 Agent 自动跑完。最后一句别再把几百页手册塞进提示词赌模型记住——切成块存进向量库问一句召回一段让它在你的资料里开卷作答答得出给依据、答不出转人工这才叫能落地的企业知识库。