在大模型应用爆发的当下RAGRetrieval-Augmented Generation检索增强生成早已不是陌生概念——它解决了大模型“知识过期”“幻觉生成”的核心痛点让AI能基于实时、精准的知识库内容生成回答广泛应用于智能客服、文档问答、企业知识库等场景。但很多开发者面对RAG代码时常常陷入“看得懂单个接口却串不起完整链路”的困境为什么要分8个步骤每段代码在链路中承担什么角色异步处理、流式输出这些细节到底有什么用今天我们就用最通俗的类比、最细致的代码拆解带你吃透RAG完整链路从问题接收到底层输出每一行代码的作用都讲得明明白白还会补充实战中的扩展技巧帮你真正把RAG落地到项目中。一、先搞懂RAG到底是什么很多教程一上来就讲代码容易让人懵圈。其实RAG的核心逻辑特别简单用两个生活场景就能讲透场景1去图书馆找资料。你想写一篇关于“年假政策”的文章第一步是去书架上找到相关的书籍、章节这就是「检索」第二步是读完这些内容用自己的话总结出答案这就是「生成」。RAG的本质就是让AI模仿这个过程避免“凭记忆答题”也就是大模型的幻觉。场景2点外卖。这个类比更贴合代码链路我们可以把RAG的8个步骤对应成点外卖的全流程先建立整体认知用户下单提问→ 商家接单系统接收问题→ 骑手取餐加载对话历史→ 厨房备菜问题重写、改写→ 按菜单炒菜意图识别→ 多路送餐多通道检索→ 装盘打包Prompt 构建→ 骑手送餐LLM 生成→ 送到你手上流式输出记住这个类比后续拆解代码时你就能快速对应到每个步骤的核心作用。先明确核心公式RAG Retrieval检索 Augmented增强 Generation生成——检索是基础增强是核心生成是结果。二、RAG完整链路代码拆解本次拆解基于Java语言Spring Boot框架代码是企业级实战版本包含异步处理、流式输出、故障转移等生产环境必备特性。每个阶段我们遵循「是什么→生活类比→代码逐行解释→扩展补充」的逻辑确保你不仅懂“代码写了什么”还懂“为什么这么写”。阶段1问题接收入口层—— 商家接单建立连接 这是什么用户请求的入口系统接收用户问题并建立流式传输连接确保后续回答能实时推送给用户避免用户长时间等待。 代码GetMapping(value /rag/v3/chat, produces text/event-stream;charsetUTF-8) public SseEmitter chat(RequestParam String question, RequestParam(required false) String conversationId, RequestParam(required false, defaultValue false) Boolean deepThinking) { SseEmitter emitter new SseEmitter(0L); ragChatService.streamChat(question, conversationId, deepThinking, emitter); return emitter; }用表格清晰拆解每段代码的作用代码部分详细解释GetMapping定义GET请求接口访问地址为/rag/v3/chat是用户提问的入口produces text/event-stream核心关键采用SSEServer-Sent Events协议实现“服务器向客户端实时推送数据”也就是我们常说的“流式输出”避免一次性返回完整回答导致的等待String question用户的提问内容必填参数比如“入职不满一年有年假吗”String conversationId会话ID可选参数。新对话可不传继续对话时传入之前的ID用于加载历史记录比如用户追问“那年假怎么申请”需要关联上一轮对话Boolean deepThinking是否开启深度思考模式类似o1模型的推理能力默认关闭开启后会调用更擅长推理的模型适合复杂问题new SseEmitter(0L)创建SSE连接0L表示连接永不超时生产环境可根据需求设置超时时间比如300000L5分钟ragChatService.streamChat(...)调用服务层方法传入用户问题、会话ID、深度思考开关和SSE连接开始处理整个RAG链路return emitter返回SSE连接Spring框架会自动维护这个连接后续有数据就实时推送给客户端 类比理解就像打电话SSE连接就是“电话线路”streamChat就是“开始通话”流式输出就是“对方说话你实时听到”而不是等对方把所有话都说完再一次性听。阶段2会话记忆补全——骑手取餐回顾过往 这是什么加载用户之前的对话历史让AI知道上下文避免“答非所问”。比如用户先问“年假有多少天”再问“怎么申请”AI需要知道上一轮问的是年假才能准确回答申请流程。 生活类比你找客服投诉“上次你们说3天内处理但已经5天了还没处理”。客服需要先查看你之前的投诉记录对话历史才能理解你现在的诉求——这就是会话记忆的作用。 代码public ListChatMessage load(String conversationId, String userId) { // 步骤1并行加载摘要和历史记录 CompletableFutureChatMessage summaryFuture CompletableFuture.supplyAsync( () - loadSummaryWithFallback(conversationId, userId) ); CompletableFutureListChatMessage historyFuture CompletableFuture.supplyAsync( () - loadHistoryWithFallback(conversationId, userId) ); // 步骤2等待所有任务完成后合并结果 return CompletableFuture.allOf(summaryFuture, historyFuture) .thenApply(v - { ChatMessage summary summaryFuture.join(); // 获取摘要 ListChatMessage history historyFuture.join(); // 获取历史 return attachSummary(summary, history); // 合并在一起 }) .join(); // 等待所有任务完成 }代码部分详细解释CompletableFutureJava中的异步任务容器相当于“同时派两个骑手去取货”不用等一个完成再去做另一个提升效率summaryFuture“骑手1”加载对话摘要当对话很长时会将历史压缩成摘要避免历史记录过多导致模型上下文溢出historyFuture“骑手2”加载完整的对话历史记录近期的对话未被压缩的内容supplyAsync异步执行任务两个加载操作同时进行不用串行等待CompletableFuture.allOf(...)等待两个异步任务加载摘要、加载历史都完成相当于“等两个骑手都回来”thenApply任务完成后执行合并操作将摘要和历史记录整合在一起summaryFuture.join() / historyFuture.join()获取两个异步任务的执行结果摘要和历史记录attachSummary(...)将摘要放在历史记录的前面确保模型先看到整体摘要再看详细历史避免上下文混乱 关键优势并行加载的意义为什么要并行加载摘要和历史看一组对比就懂了串行加载慢加载摘要2秒 → 加载历史3秒 → 总计5秒并行加载快加载摘要2秒同时加载历史3秒 → 总计3秒生产环境中对话历史可能很多并行加载能显著提升响应速度改善用户体验。 实战扩展可给异步任务指定专用线程池比如intentClassifyExecutor避免占用主线程同时添加降级策略loadSummaryWithFallback中的Fallback当加载摘要失败时直接加载历史记录避免整个链路中断。阶段3问题重写——厨房备菜规范问题 这是什么用户的提问往往是口语化、模糊的比如“咋整”“怎么弄”直接用于检索会导致“搜不到相关内容”。问题重写就是把口语化问题改写成更适合检索的“标准问题”同时拆分复杂问题比如“请假和出差有什么区别”拆成两个子问题。 生活类比你问朋友“那个...就是...上次说的那个...怎么弄来着” 朋友帮你重写“你问的是上周提到的报销流程怎么申请。” —— 朋友的作用就是“问题重写”。 代码public RewriteResult rewriteWithSplit(String userQuestion, ListChatMessage history) { // 步骤1检查是否启用了 LLM 重写 if (!ragConfigProperties.getQueryRewriteEnabled()) { // 如果没启用就用简单的规则处理 String normalized queryTermMappingService.normalize(userQuestion); ListString subs ruleBasedSplit(normalized); return new RewriteResult(normalized, subs); } // 步骤2使用 LLM 进行智能重写 String normalizedQuestion queryTermMappingService.normalize(userQuestion); return callLLMRewriteAndSplit(normalizedQuestion, userQuestion, history); }代码部分详细解释queryRewriteEnabled配置开关控制是否启用AILLM重写。开发环境可关闭用简单规则测试生产环境开启提升重写效果queryTermMappingService.normalize术语归一化把口语化词汇转换成标准词汇比如“咋整”→“怎么办”“医保咋用”→“医保卡使用”ruleBasedSplit基于规则的拆分比如按标点、“和”“或”等连词拆分比如“请假和出差有什么区别”拆成“请假制度规定”“出差管理规范”RewriteResult重写结果对象包含“改写后的标准问题”和“拆分后的子问题”供后续意图识别和检索使用callLLMRewriteAndSplit调用大模型进行智能重写和拆分结合对话历史让重写更精准比如用户之前问过“年假”现在说“怎么休”会重写成“年假怎么休” 实际效果示例用户原始问题改写后的标准问题拆分的子问题“医保怎么用”“医保卡的使用方法和报销流程”[医保卡的使用方法, 医保报销流程]“报销咋整”“公司费用报销申请流程”[公司费用报销申请流程]“请假和出差有什么区别”“请假制度和出差规定的区别”[请假制度规定, 出差管理规范] 实战扩展可维护一个“术语映射表”把常见的口语化词汇、简称比如“年假”→“年休假”“OA”→“办公自动化系统”统一映射提升归一化效果同时给LLM重写添加模板明确重写要求比如“改写后的问题要简洁、准确适合检索不添加多余内容”。阶段4意图识别——按菜单炒菜精准定位 这是什么判断用户的问题属于哪个领域、哪个类目然后去对应的知识库检索避免“大海捞针”。比如用户问“年假怎么休”要识别出属于“人事领域→请假类目→年假话题”再去人事知识库的请假模块检索而不是去财务、IT知识库。 生活类比你去医院挂号分诊台护士问“你哪里不舒服”你说“头疼、发烧”护士判断“挂内科发烧门诊”——护士的作用就是RAG系统的“意图识别”。 代码public ListSubQuestionIntent resolve(RewriteResult rewriteResult) { // 步骤1从重写结果中提取子问题 ListString subQuestions CollUtil.isNotEmpty(rewriteResult.subQuestions()) ? rewriteResult.subQuestions() // 有子问题就用子问题 : List.of(rewriteResult.rewrittenQuestion()); // 没有就用改写后的问题 // 步骤2并行识别每个子问题的意图 ListCompletableFutureSubQuestionIntent tasks subQuestions.stream() .map(q - CompletableFuture.supplyAsync( () - new SubQuestionIntent(q, classifyIntents(q)), intentClassifyExecutor )) .toList(); // 步骤3收集所有识别结果 ListSubQuestionIntent subIntents tasks.stream() .map(CompletableFuture::join) .toList(); // 步骤4限制意图数量防止检索太多 return capTotalIntents(subIntents); }代码部分详细解释rewriteResult.subQuestions()上一步拆分出的子问题列表比如“请假和出差有什么区别”拆成两个子问题CollUtil.isNotEmpty判断子问题列表是否为空Apache Commons Collections工具类简化空判断并行识别子问题意图用CompletableFuture异步并行处理每个子问题的意图识别提升效率比如两个子问题同时识别不用串行classifyIntents(q)调用AI或规则模型识别单个子问题的意图比如“年假怎么休”识别为“人事领域→请假→年假”intentClassifyExecutor意图识别专用线程池避免占用主线程提升系统并发能力capTotalIntents限制意图数量比如最多保留3个避免识别出太多意图导致后续检索范围过大、效率降低 树形意图分类示意[系统根节点] | ┌───────────────┼───────────────┐ ↓ ↓ ↓ [人事领域] [财务领域] [IT领域] | | | ┌────┴────┐ ┌────┴────┐ ┌────┴────┐ ↓ ↓ ↓ ↓ ↓ ↓ [请假] [考勤] [报销] [发票] [网络] [设备] 用户问年假怎么请 系统识别人事领域 → 请假类目 → 年假话题置信度 0.95 置信度过滤关键优化识别意图时会给每个意图打一个“置信度分数”过滤掉匹配度太低的意图避免检索无关内容private ListNodeScore classifyIntents(String question) { ListNodeScore scores intentClassifier.classifyTargets(question); return scores.stream() .filter(ns - ns.getScore() INTENT_MIN_SCORE) // 过滤低于 0.35 分的 .limit(MAX_INTENT_COUNT) // 最多保留 3 个 .toList(); }分数范围含义处理方式0.95高度匹配✅ 精准检索优先匹配该意图对应的知识库0.6-0.95中度匹配✅ 参与检索作为补充0.35-0.6低度匹配⚠️ 可选参与根据业务需求调整 0.35几乎不匹配❌ 直接过滤不参与检索阶段5多通道检索——多路送餐广泛召回 这是什么根据意图识别结果从知识库中找到与问题相关的文档片段核心步骤检索的质量直接决定AI回答的准确性。采用“多通道”检索兼顾“精准匹配”和“广泛召回”避免漏检或误检。 生活类比你在图书馆找书① 精准检索知道是《哈利波特》直接去J.K.罗琳的书架找② 模糊检索不知道书名只记得“魔法师戴眼镜”在所有书架搜索——多通道检索就是结合这两种方式确保能找到所有相关书籍。 检索架构图用户问题 ↓ ┌─────────────────────────────────────────┐ │ MultiChannelRetrievalEngine │ │ 多通道检索引擎 │ ├─────────────────────────────────────────┤ │ │ │ ┌─────────────────┐ ┌─────────────────┐ │ │ 意图定向检索通道 │ │ 向量全局检索通道 │ │ │ (精准匹配) │ │ (广泛召回) │ │ └────────┬────────┘ └────────┬────────┘ │ │ │ │ └─────────┬─────────┘ │ ↓ │ ┌───────────────────────┐ │ │ 后置处理器链 │ │ │ ① 去重 │ │ │ ② Rerank 重排序 │ │ └───────────────────────┘ │ ↓ │ 检索结果列表 └─────────────────────────────────────────┘ 代码public RetrievalContext retrieve(ListSubQuestionIntent subIntents, int topK) { // 步骤1确定最终返回的数量 int finalTopK topK 0 ? topK : DEFAULT_TOP_K; // 默认返回 10 条 // 步骤2并行处理每个子问题的检索 ListCompletableFutureSubQuestionContext tasks subIntents.stream() .map(si - CompletableFuture.supplyAsync( () - buildSubQuestionContext( si, resolveSubQuestionTopK(si, finalTopK) ), ragContextExecutor // 专门的线程池 )) .toList(); // 步骤3等待所有检索完成并合并结果 ListSubQuestionContext contexts tasks.stream() .map(CompletableFuture::join) .toList(); return mergeContexts(contexts); }代码部分详细解释finalTopK检索结果的数量用户传入topK就用传入的值否则用默认值10DEFAULT_TOP_K为常量避免返回太多结果导致模型上下文溢出并行处理子问题检索每个子问题对应一个检索任务异步并行处理比如两个子问题同时检索提升检索效率buildSubQuestionContext为单个子问题构建检索上下文结合意图信息确定检索的知识库和范围resolveSubQuestionTopK计算每个子问题应返回的检索结果数量比如重要子问题返回更多结果次要子问题返回更少ragContextExecutor检索专用线程池避免检索操作可能耗时阻塞主线程mergeContexts合并所有子问题的检索结果去重、重排序后形成最终的检索上下文 检索流程补充核心细节private KbResult retrieveAndRerank(SubQuestionIntent intent, ListNodeScore kbIntents, int topK) { // 步骤1使用多通道检索 ListRetrievedChunk chunks multiChannelRetrievalEngine .retrieveKnowledgeChannels(subIntents, topK); if (CollUtil.isEmpty(chunks)) { return KbResult.empty(); // 没找到任何相关文档 } // 步骤2按意图节点分组 MapString, ListRetrievedChunkgt;gt; intentChunks new ConcurrentHashMap(); for (NodeScore ns : kbIntents) { intentChunks.put(ns.getNode().getId(), chunks); } // 步骤3格式化上下文 String groupedContext contextFormatter.formatKbContext( kbIntents, intentChunks, topK); return new KbResult(groupedContext, intentChunks); }关键细节多通道检索后会经过“去重”避免重复的文档片段和“Rerank重排序”根据与问题的相似度重新排序检索结果把最相关的放在前面确保后续模型能优先使用最精准的文档。 检索结果示例文档 ID文档内容相似度分数doc_001“员工年假标准入职满1年享受5天年假满3年享受10天满5年享受15天”0.92doc_002“年假申请流程员工通过OA系统提交年假申请部门负责人审批后生效”0.88doc_003“请假制度包括事假、病假、年假、婚假等其中年假需满足入职年限要求”0.75doc_004“法定节假日安排根据国家规定春节放假7天国庆节放假7天”0.45注相似度分数越高说明文档与问题的相关性越强后续会优先作为模型生成回答的依据。阶段6Prompt 构建——装盘打包清晰呈现 这是什么把检索到的文档、对话历史、用户问题组装成一个完整的“Prompt提示词”让大模型能清晰了解“上下文问题参考资料”从而生成准确、不跑偏的回答。 生活类比你去问老师问题只说“年假怎么休”老师可能不清楚你是哪个公司、入职多久但你说“老师好我是XX公司新入职员工想了解公司年假政策比如入职满一年有多少天年假申请流程是怎样的”老师就能精准回答——Prompt构建就是做这种“说清楚上下文”的工作。 代码public ListChatMessage buildStructuredMessages(PromptContext context, ListChatMessage history, String question, ListString subQuestions) { ListChatMessage messages new ArrayList(); // 步骤1添加系统提示词告诉 AI 怎么回答 String systemPrompt buildSystemPrompt(context); if (StrUtil.isNotBlank(systemPrompt)) { messages.add(ChatMessage.system(systemPrompt)); } // 步骤2添加 MCP 工具调用的结果如有 if (StrUtil.isNotBlank(context.getMcpContext())) { messages.add(ChatMessage.system( formatEvidence(MCP_CONTEXT_HEADER, context.getMcpContext()) )); } // 步骤3添加知识库检索结果 if (StrUtil.isNotBlank(context.getKbContext())) { messages.add(ChatMessage.user( formatEvidence(KB_CONTEXT_HEADER, context.getKbContext()) )); } // 步骤4添加历史对话 if (CollUtil.isNotEmpty(history)) { messages.addAll(history); } // 步骤5添加用户问题 messages.add(ChatMessage.user(question)); return messages; }代码部分详细解释systemPrompt系统提示词告诉大模型“怎么回答”比如“你是XX公司的HR助手请根据提供的文档内容准确回答用户问题文档中没有的信息请明确告知不要编造”避免模型生成幻觉MCP_CONTEXTMCP工具调用的实时数据比如实时查询员工的入职年限、考勤记录补充知识库中没有的动态信息KB_CONTEXT上一步检索到的知识库文档内容格式化后添加到Prompt中作为模型回答的参考依据formatEvidence格式化检索结果和工具结果加上标题比如“## 知识库文档”“## 动态数据”让Prompt结构清晰模型更容易识别添加历史对话将之前的对话历史添加到Prompt中确保模型了解上下文比如用户上一轮问了“年假有多少天”这一轮问“怎么申请”模型能关联起来添加用户问题最后添加用户当前的问题让模型明确“需要回答什么”避免答非所问 最终Prompt结构示例【消息 1 - 系统提示词】 你是XX公司的HR助手请根据提供的文档内容准确回答用户问题。如果文档中没有相关信息请明确告知用户不要编造。 【消息 2 - 知识库文档】 ## 文档内容 doc_001员工年假标准入职满1年享受5天年假满3年享受10天满5年享受15天。 doc_002年假申请流程员工通过OA系统提交年假申请部门负责人审批后生效。 【消息 3 - 历史对话】 用户年假有多少天 助手根据公司规定入职满1年享受5天年假满3年享受10天满5年享受15天。 【消息 4 - 当前问题】 用户入职不满一年有年假吗这样的Prompt结构清晰模型能快速找到参考资料、理解上下文生成准确的回答。阶段7模型调用——骑手送餐生成回答 这是什么调用大语言模型LLM传入构建好的Prompt让模型基于检索到的文档和上下文生成回答。同时实现“多模型路由”和“故障转移”确保模型调用稳定一个模型失败自动切换到下一个。 代码public StreamCancellationHandle streamChat(ChatRequest request, StreamCallback callback) { // 步骤1选择合适的模型 ListModelTarget targets selector.selectChatCandidates( request.getThinking()); if (CollUtil.isEmpty(targets)) { throw new RemoteException(无可用大模型提供者); } // 步骤2尝试每个模型 for (ModelTarget target : targets) { ChatClient client resolveClient(target, label); if (client null) { continue; // 这个模型不可用跳过 } try { // 步骤3调用模型 return client.streamChat(request, callback, target); } catch (Exception e) { // 步骤4失败了标记并尝试下一个 healthStore.markFailure(target.id()); log.warn(模型 {} 调用失败切换下一个, target.id()); continue; } } // 所有模型都失败了 throw new RemoteException(大模型调用失败请稍后再试); }代码部分详细解释ModelTarget目标模型对象包含模型名称、提供商、调用地址等信息比如DeepSeek、阿里云百炼、Ollama本地模型selectChatCandidates根据请求特性选择候选模型比如开启深度思考模式优先选择DeepSeek-o1普通问答优先选择阿里云百炼resolveClient根据模型对象获取对应的模型调用客户端不同模型的调用方式不同比如DeepSeek有专属客户端阿里云百炼有对应的SDKclient.streamChat(...)调用模型的流式生成接口传入请求、回调函数和模型对象模型会实时返回生成的内容流式输出故障转移逻辑如果当前模型调用失败比如网络异常、模型服务宕机标记失败状态自动尝试下一个候选模型确保整个链路不中断 故障转移机制生产环境必备请求进来 → 尝试 DeepSeek → 成功✅→ 返回结果 ↓ 失败❌ 尝试 阿里云百炼 → 成功✅→ 返回结果 ↓ 失败❌ 尝试 Ollama 本地模型 → 成功✅→ 返回结果 ↓ 失败❌ 返回错误信息 模型选择策略实战参考场景首选模型备选模型说明深度思考复杂问题DeepSeek-o1-擅长推理适合需要分析、计算的复杂问题比如“年假和事假的薪资区别”普通问答简单问题阿里云百炼DeepSeek响应速度快、成本低适合简单的政策查询比如“年假怎么申请”本地部署无外网Ollama-可本地部署无外网需求适合涉密场景阶段8流式输出——送到手上实时反馈 这是什么把模型生成的回答通过之前建立的SSE连接实时推送给用户就像“打字机”一样逐字逐句显示避免用户长时间等待尤其是复杂问题模型生成回答需要几秒流式输出能提升用户体验。 代码Override public void onContent(String chunk) { // 步骤1检查是否被用户取消 if (taskManager.isCancelled(taskId)) { return; // 用户取消了直接返回 } // 步骤2过滤空内容 if (StrUtil.isBlank(chunk)) { return; // 空内容不处理 } // 步骤3累积回答内容 answer.append(chunk); // 步骤4分块发送 sendChunked(TYPE_RESPONSE, chunk); } Override public void onComplete() { // 步骤1保存回答到历史记录 Long messageId memoryService.append( conversationId, UserContext.get
从零到一理解RAG完整链路:代码逐行拆解+实战解析
发布时间:2026/6/3 21:03:19
在大模型应用爆发的当下RAGRetrieval-Augmented Generation检索增强生成早已不是陌生概念——它解决了大模型“知识过期”“幻觉生成”的核心痛点让AI能基于实时、精准的知识库内容生成回答广泛应用于智能客服、文档问答、企业知识库等场景。但很多开发者面对RAG代码时常常陷入“看得懂单个接口却串不起完整链路”的困境为什么要分8个步骤每段代码在链路中承担什么角色异步处理、流式输出这些细节到底有什么用今天我们就用最通俗的类比、最细致的代码拆解带你吃透RAG完整链路从问题接收到底层输出每一行代码的作用都讲得明明白白还会补充实战中的扩展技巧帮你真正把RAG落地到项目中。一、先搞懂RAG到底是什么很多教程一上来就讲代码容易让人懵圈。其实RAG的核心逻辑特别简单用两个生活场景就能讲透场景1去图书馆找资料。你想写一篇关于“年假政策”的文章第一步是去书架上找到相关的书籍、章节这就是「检索」第二步是读完这些内容用自己的话总结出答案这就是「生成」。RAG的本质就是让AI模仿这个过程避免“凭记忆答题”也就是大模型的幻觉。场景2点外卖。这个类比更贴合代码链路我们可以把RAG的8个步骤对应成点外卖的全流程先建立整体认知用户下单提问→ 商家接单系统接收问题→ 骑手取餐加载对话历史→ 厨房备菜问题重写、改写→ 按菜单炒菜意图识别→ 多路送餐多通道检索→ 装盘打包Prompt 构建→ 骑手送餐LLM 生成→ 送到你手上流式输出记住这个类比后续拆解代码时你就能快速对应到每个步骤的核心作用。先明确核心公式RAG Retrieval检索 Augmented增强 Generation生成——检索是基础增强是核心生成是结果。二、RAG完整链路代码拆解本次拆解基于Java语言Spring Boot框架代码是企业级实战版本包含异步处理、流式输出、故障转移等生产环境必备特性。每个阶段我们遵循「是什么→生活类比→代码逐行解释→扩展补充」的逻辑确保你不仅懂“代码写了什么”还懂“为什么这么写”。阶段1问题接收入口层—— 商家接单建立连接 这是什么用户请求的入口系统接收用户问题并建立流式传输连接确保后续回答能实时推送给用户避免用户长时间等待。 代码GetMapping(value /rag/v3/chat, produces text/event-stream;charsetUTF-8) public SseEmitter chat(RequestParam String question, RequestParam(required false) String conversationId, RequestParam(required false, defaultValue false) Boolean deepThinking) { SseEmitter emitter new SseEmitter(0L); ragChatService.streamChat(question, conversationId, deepThinking, emitter); return emitter; }用表格清晰拆解每段代码的作用代码部分详细解释GetMapping定义GET请求接口访问地址为/rag/v3/chat是用户提问的入口produces text/event-stream核心关键采用SSEServer-Sent Events协议实现“服务器向客户端实时推送数据”也就是我们常说的“流式输出”避免一次性返回完整回答导致的等待String question用户的提问内容必填参数比如“入职不满一年有年假吗”String conversationId会话ID可选参数。新对话可不传继续对话时传入之前的ID用于加载历史记录比如用户追问“那年假怎么申请”需要关联上一轮对话Boolean deepThinking是否开启深度思考模式类似o1模型的推理能力默认关闭开启后会调用更擅长推理的模型适合复杂问题new SseEmitter(0L)创建SSE连接0L表示连接永不超时生产环境可根据需求设置超时时间比如300000L5分钟ragChatService.streamChat(...)调用服务层方法传入用户问题、会话ID、深度思考开关和SSE连接开始处理整个RAG链路return emitter返回SSE连接Spring框架会自动维护这个连接后续有数据就实时推送给客户端 类比理解就像打电话SSE连接就是“电话线路”streamChat就是“开始通话”流式输出就是“对方说话你实时听到”而不是等对方把所有话都说完再一次性听。阶段2会话记忆补全——骑手取餐回顾过往 这是什么加载用户之前的对话历史让AI知道上下文避免“答非所问”。比如用户先问“年假有多少天”再问“怎么申请”AI需要知道上一轮问的是年假才能准确回答申请流程。 生活类比你找客服投诉“上次你们说3天内处理但已经5天了还没处理”。客服需要先查看你之前的投诉记录对话历史才能理解你现在的诉求——这就是会话记忆的作用。 代码public ListChatMessage load(String conversationId, String userId) { // 步骤1并行加载摘要和历史记录 CompletableFutureChatMessage summaryFuture CompletableFuture.supplyAsync( () - loadSummaryWithFallback(conversationId, userId) ); CompletableFutureListChatMessage historyFuture CompletableFuture.supplyAsync( () - loadHistoryWithFallback(conversationId, userId) ); // 步骤2等待所有任务完成后合并结果 return CompletableFuture.allOf(summaryFuture, historyFuture) .thenApply(v - { ChatMessage summary summaryFuture.join(); // 获取摘要 ListChatMessage history historyFuture.join(); // 获取历史 return attachSummary(summary, history); // 合并在一起 }) .join(); // 等待所有任务完成 }代码部分详细解释CompletableFutureJava中的异步任务容器相当于“同时派两个骑手去取货”不用等一个完成再去做另一个提升效率summaryFuture“骑手1”加载对话摘要当对话很长时会将历史压缩成摘要避免历史记录过多导致模型上下文溢出historyFuture“骑手2”加载完整的对话历史记录近期的对话未被压缩的内容supplyAsync异步执行任务两个加载操作同时进行不用串行等待CompletableFuture.allOf(...)等待两个异步任务加载摘要、加载历史都完成相当于“等两个骑手都回来”thenApply任务完成后执行合并操作将摘要和历史记录整合在一起summaryFuture.join() / historyFuture.join()获取两个异步任务的执行结果摘要和历史记录attachSummary(...)将摘要放在历史记录的前面确保模型先看到整体摘要再看详细历史避免上下文混乱 关键优势并行加载的意义为什么要并行加载摘要和历史看一组对比就懂了串行加载慢加载摘要2秒 → 加载历史3秒 → 总计5秒并行加载快加载摘要2秒同时加载历史3秒 → 总计3秒生产环境中对话历史可能很多并行加载能显著提升响应速度改善用户体验。 实战扩展可给异步任务指定专用线程池比如intentClassifyExecutor避免占用主线程同时添加降级策略loadSummaryWithFallback中的Fallback当加载摘要失败时直接加载历史记录避免整个链路中断。阶段3问题重写——厨房备菜规范问题 这是什么用户的提问往往是口语化、模糊的比如“咋整”“怎么弄”直接用于检索会导致“搜不到相关内容”。问题重写就是把口语化问题改写成更适合检索的“标准问题”同时拆分复杂问题比如“请假和出差有什么区别”拆成两个子问题。 生活类比你问朋友“那个...就是...上次说的那个...怎么弄来着” 朋友帮你重写“你问的是上周提到的报销流程怎么申请。” —— 朋友的作用就是“问题重写”。 代码public RewriteResult rewriteWithSplit(String userQuestion, ListChatMessage history) { // 步骤1检查是否启用了 LLM 重写 if (!ragConfigProperties.getQueryRewriteEnabled()) { // 如果没启用就用简单的规则处理 String normalized queryTermMappingService.normalize(userQuestion); ListString subs ruleBasedSplit(normalized); return new RewriteResult(normalized, subs); } // 步骤2使用 LLM 进行智能重写 String normalizedQuestion queryTermMappingService.normalize(userQuestion); return callLLMRewriteAndSplit(normalizedQuestion, userQuestion, history); }代码部分详细解释queryRewriteEnabled配置开关控制是否启用AILLM重写。开发环境可关闭用简单规则测试生产环境开启提升重写效果queryTermMappingService.normalize术语归一化把口语化词汇转换成标准词汇比如“咋整”→“怎么办”“医保咋用”→“医保卡使用”ruleBasedSplit基于规则的拆分比如按标点、“和”“或”等连词拆分比如“请假和出差有什么区别”拆成“请假制度规定”“出差管理规范”RewriteResult重写结果对象包含“改写后的标准问题”和“拆分后的子问题”供后续意图识别和检索使用callLLMRewriteAndSplit调用大模型进行智能重写和拆分结合对话历史让重写更精准比如用户之前问过“年假”现在说“怎么休”会重写成“年假怎么休” 实际效果示例用户原始问题改写后的标准问题拆分的子问题“医保怎么用”“医保卡的使用方法和报销流程”[医保卡的使用方法, 医保报销流程]“报销咋整”“公司费用报销申请流程”[公司费用报销申请流程]“请假和出差有什么区别”“请假制度和出差规定的区别”[请假制度规定, 出差管理规范] 实战扩展可维护一个“术语映射表”把常见的口语化词汇、简称比如“年假”→“年休假”“OA”→“办公自动化系统”统一映射提升归一化效果同时给LLM重写添加模板明确重写要求比如“改写后的问题要简洁、准确适合检索不添加多余内容”。阶段4意图识别——按菜单炒菜精准定位 这是什么判断用户的问题属于哪个领域、哪个类目然后去对应的知识库检索避免“大海捞针”。比如用户问“年假怎么休”要识别出属于“人事领域→请假类目→年假话题”再去人事知识库的请假模块检索而不是去财务、IT知识库。 生活类比你去医院挂号分诊台护士问“你哪里不舒服”你说“头疼、发烧”护士判断“挂内科发烧门诊”——护士的作用就是RAG系统的“意图识别”。 代码public ListSubQuestionIntent resolve(RewriteResult rewriteResult) { // 步骤1从重写结果中提取子问题 ListString subQuestions CollUtil.isNotEmpty(rewriteResult.subQuestions()) ? rewriteResult.subQuestions() // 有子问题就用子问题 : List.of(rewriteResult.rewrittenQuestion()); // 没有就用改写后的问题 // 步骤2并行识别每个子问题的意图 ListCompletableFutureSubQuestionIntent tasks subQuestions.stream() .map(q - CompletableFuture.supplyAsync( () - new SubQuestionIntent(q, classifyIntents(q)), intentClassifyExecutor )) .toList(); // 步骤3收集所有识别结果 ListSubQuestionIntent subIntents tasks.stream() .map(CompletableFuture::join) .toList(); // 步骤4限制意图数量防止检索太多 return capTotalIntents(subIntents); }代码部分详细解释rewriteResult.subQuestions()上一步拆分出的子问题列表比如“请假和出差有什么区别”拆成两个子问题CollUtil.isNotEmpty判断子问题列表是否为空Apache Commons Collections工具类简化空判断并行识别子问题意图用CompletableFuture异步并行处理每个子问题的意图识别提升效率比如两个子问题同时识别不用串行classifyIntents(q)调用AI或规则模型识别单个子问题的意图比如“年假怎么休”识别为“人事领域→请假→年假”intentClassifyExecutor意图识别专用线程池避免占用主线程提升系统并发能力capTotalIntents限制意图数量比如最多保留3个避免识别出太多意图导致后续检索范围过大、效率降低 树形意图分类示意[系统根节点] | ┌───────────────┼───────────────┐ ↓ ↓ ↓ [人事领域] [财务领域] [IT领域] | | | ┌────┴────┐ ┌────┴────┐ ┌────┴────┐ ↓ ↓ ↓ ↓ ↓ ↓ [请假] [考勤] [报销] [发票] [网络] [设备] 用户问年假怎么请 系统识别人事领域 → 请假类目 → 年假话题置信度 0.95 置信度过滤关键优化识别意图时会给每个意图打一个“置信度分数”过滤掉匹配度太低的意图避免检索无关内容private ListNodeScore classifyIntents(String question) { ListNodeScore scores intentClassifier.classifyTargets(question); return scores.stream() .filter(ns - ns.getScore() INTENT_MIN_SCORE) // 过滤低于 0.35 分的 .limit(MAX_INTENT_COUNT) // 最多保留 3 个 .toList(); }分数范围含义处理方式0.95高度匹配✅ 精准检索优先匹配该意图对应的知识库0.6-0.95中度匹配✅ 参与检索作为补充0.35-0.6低度匹配⚠️ 可选参与根据业务需求调整 0.35几乎不匹配❌ 直接过滤不参与检索阶段5多通道检索——多路送餐广泛召回 这是什么根据意图识别结果从知识库中找到与问题相关的文档片段核心步骤检索的质量直接决定AI回答的准确性。采用“多通道”检索兼顾“精准匹配”和“广泛召回”避免漏检或误检。 生活类比你在图书馆找书① 精准检索知道是《哈利波特》直接去J.K.罗琳的书架找② 模糊检索不知道书名只记得“魔法师戴眼镜”在所有书架搜索——多通道检索就是结合这两种方式确保能找到所有相关书籍。 检索架构图用户问题 ↓ ┌─────────────────────────────────────────┐ │ MultiChannelRetrievalEngine │ │ 多通道检索引擎 │ ├─────────────────────────────────────────┤ │ │ │ ┌─────────────────┐ ┌─────────────────┐ │ │ 意图定向检索通道 │ │ 向量全局检索通道 │ │ │ (精准匹配) │ │ (广泛召回) │ │ └────────┬────────┘ └────────┬────────┘ │ │ │ │ └─────────┬─────────┘ │ ↓ │ ┌───────────────────────┐ │ │ 后置处理器链 │ │ │ ① 去重 │ │ │ ② Rerank 重排序 │ │ └───────────────────────┘ │ ↓ │ 检索结果列表 └─────────────────────────────────────────┘ 代码public RetrievalContext retrieve(ListSubQuestionIntent subIntents, int topK) { // 步骤1确定最终返回的数量 int finalTopK topK 0 ? topK : DEFAULT_TOP_K; // 默认返回 10 条 // 步骤2并行处理每个子问题的检索 ListCompletableFutureSubQuestionContext tasks subIntents.stream() .map(si - CompletableFuture.supplyAsync( () - buildSubQuestionContext( si, resolveSubQuestionTopK(si, finalTopK) ), ragContextExecutor // 专门的线程池 )) .toList(); // 步骤3等待所有检索完成并合并结果 ListSubQuestionContext contexts tasks.stream() .map(CompletableFuture::join) .toList(); return mergeContexts(contexts); }代码部分详细解释finalTopK检索结果的数量用户传入topK就用传入的值否则用默认值10DEFAULT_TOP_K为常量避免返回太多结果导致模型上下文溢出并行处理子问题检索每个子问题对应一个检索任务异步并行处理比如两个子问题同时检索提升检索效率buildSubQuestionContext为单个子问题构建检索上下文结合意图信息确定检索的知识库和范围resolveSubQuestionTopK计算每个子问题应返回的检索结果数量比如重要子问题返回更多结果次要子问题返回更少ragContextExecutor检索专用线程池避免检索操作可能耗时阻塞主线程mergeContexts合并所有子问题的检索结果去重、重排序后形成最终的检索上下文 检索流程补充核心细节private KbResult retrieveAndRerank(SubQuestionIntent intent, ListNodeScore kbIntents, int topK) { // 步骤1使用多通道检索 ListRetrievedChunk chunks multiChannelRetrievalEngine .retrieveKnowledgeChannels(subIntents, topK); if (CollUtil.isEmpty(chunks)) { return KbResult.empty(); // 没找到任何相关文档 } // 步骤2按意图节点分组 MapString, ListRetrievedChunkgt;gt; intentChunks new ConcurrentHashMap(); for (NodeScore ns : kbIntents) { intentChunks.put(ns.getNode().getId(), chunks); } // 步骤3格式化上下文 String groupedContext contextFormatter.formatKbContext( kbIntents, intentChunks, topK); return new KbResult(groupedContext, intentChunks); }关键细节多通道检索后会经过“去重”避免重复的文档片段和“Rerank重排序”根据与问题的相似度重新排序检索结果把最相关的放在前面确保后续模型能优先使用最精准的文档。 检索结果示例文档 ID文档内容相似度分数doc_001“员工年假标准入职满1年享受5天年假满3年享受10天满5年享受15天”0.92doc_002“年假申请流程员工通过OA系统提交年假申请部门负责人审批后生效”0.88doc_003“请假制度包括事假、病假、年假、婚假等其中年假需满足入职年限要求”0.75doc_004“法定节假日安排根据国家规定春节放假7天国庆节放假7天”0.45注相似度分数越高说明文档与问题的相关性越强后续会优先作为模型生成回答的依据。阶段6Prompt 构建——装盘打包清晰呈现 这是什么把检索到的文档、对话历史、用户问题组装成一个完整的“Prompt提示词”让大模型能清晰了解“上下文问题参考资料”从而生成准确、不跑偏的回答。 生活类比你去问老师问题只说“年假怎么休”老师可能不清楚你是哪个公司、入职多久但你说“老师好我是XX公司新入职员工想了解公司年假政策比如入职满一年有多少天年假申请流程是怎样的”老师就能精准回答——Prompt构建就是做这种“说清楚上下文”的工作。 代码public ListChatMessage buildStructuredMessages(PromptContext context, ListChatMessage history, String question, ListString subQuestions) { ListChatMessage messages new ArrayList(); // 步骤1添加系统提示词告诉 AI 怎么回答 String systemPrompt buildSystemPrompt(context); if (StrUtil.isNotBlank(systemPrompt)) { messages.add(ChatMessage.system(systemPrompt)); } // 步骤2添加 MCP 工具调用的结果如有 if (StrUtil.isNotBlank(context.getMcpContext())) { messages.add(ChatMessage.system( formatEvidence(MCP_CONTEXT_HEADER, context.getMcpContext()) )); } // 步骤3添加知识库检索结果 if (StrUtil.isNotBlank(context.getKbContext())) { messages.add(ChatMessage.user( formatEvidence(KB_CONTEXT_HEADER, context.getKbContext()) )); } // 步骤4添加历史对话 if (CollUtil.isNotEmpty(history)) { messages.addAll(history); } // 步骤5添加用户问题 messages.add(ChatMessage.user(question)); return messages; }代码部分详细解释systemPrompt系统提示词告诉大模型“怎么回答”比如“你是XX公司的HR助手请根据提供的文档内容准确回答用户问题文档中没有的信息请明确告知不要编造”避免模型生成幻觉MCP_CONTEXTMCP工具调用的实时数据比如实时查询员工的入职年限、考勤记录补充知识库中没有的动态信息KB_CONTEXT上一步检索到的知识库文档内容格式化后添加到Prompt中作为模型回答的参考依据formatEvidence格式化检索结果和工具结果加上标题比如“## 知识库文档”“## 动态数据”让Prompt结构清晰模型更容易识别添加历史对话将之前的对话历史添加到Prompt中确保模型了解上下文比如用户上一轮问了“年假有多少天”这一轮问“怎么申请”模型能关联起来添加用户问题最后添加用户当前的问题让模型明确“需要回答什么”避免答非所问 最终Prompt结构示例【消息 1 - 系统提示词】 你是XX公司的HR助手请根据提供的文档内容准确回答用户问题。如果文档中没有相关信息请明确告知用户不要编造。 【消息 2 - 知识库文档】 ## 文档内容 doc_001员工年假标准入职满1年享受5天年假满3年享受10天满5年享受15天。 doc_002年假申请流程员工通过OA系统提交年假申请部门负责人审批后生效。 【消息 3 - 历史对话】 用户年假有多少天 助手根据公司规定入职满1年享受5天年假满3年享受10天满5年享受15天。 【消息 4 - 当前问题】 用户入职不满一年有年假吗这样的Prompt结构清晰模型能快速找到参考资料、理解上下文生成准确的回答。阶段7模型调用——骑手送餐生成回答 这是什么调用大语言模型LLM传入构建好的Prompt让模型基于检索到的文档和上下文生成回答。同时实现“多模型路由”和“故障转移”确保模型调用稳定一个模型失败自动切换到下一个。 代码public StreamCancellationHandle streamChat(ChatRequest request, StreamCallback callback) { // 步骤1选择合适的模型 ListModelTarget targets selector.selectChatCandidates( request.getThinking()); if (CollUtil.isEmpty(targets)) { throw new RemoteException(无可用大模型提供者); } // 步骤2尝试每个模型 for (ModelTarget target : targets) { ChatClient client resolveClient(target, label); if (client null) { continue; // 这个模型不可用跳过 } try { // 步骤3调用模型 return client.streamChat(request, callback, target); } catch (Exception e) { // 步骤4失败了标记并尝试下一个 healthStore.markFailure(target.id()); log.warn(模型 {} 调用失败切换下一个, target.id()); continue; } } // 所有模型都失败了 throw new RemoteException(大模型调用失败请稍后再试); }代码部分详细解释ModelTarget目标模型对象包含模型名称、提供商、调用地址等信息比如DeepSeek、阿里云百炼、Ollama本地模型selectChatCandidates根据请求特性选择候选模型比如开启深度思考模式优先选择DeepSeek-o1普通问答优先选择阿里云百炼resolveClient根据模型对象获取对应的模型调用客户端不同模型的调用方式不同比如DeepSeek有专属客户端阿里云百炼有对应的SDKclient.streamChat(...)调用模型的流式生成接口传入请求、回调函数和模型对象模型会实时返回生成的内容流式输出故障转移逻辑如果当前模型调用失败比如网络异常、模型服务宕机标记失败状态自动尝试下一个候选模型确保整个链路不中断 故障转移机制生产环境必备请求进来 → 尝试 DeepSeek → 成功✅→ 返回结果 ↓ 失败❌ 尝试 阿里云百炼 → 成功✅→ 返回结果 ↓ 失败❌ 尝试 Ollama 本地模型 → 成功✅→ 返回结果 ↓ 失败❌ 返回错误信息 模型选择策略实战参考场景首选模型备选模型说明深度思考复杂问题DeepSeek-o1-擅长推理适合需要分析、计算的复杂问题比如“年假和事假的薪资区别”普通问答简单问题阿里云百炼DeepSeek响应速度快、成本低适合简单的政策查询比如“年假怎么申请”本地部署无外网Ollama-可本地部署无外网需求适合涉密场景阶段8流式输出——送到手上实时反馈 这是什么把模型生成的回答通过之前建立的SSE连接实时推送给用户就像“打字机”一样逐字逐句显示避免用户长时间等待尤其是复杂问题模型生成回答需要几秒流式输出能提升用户体验。 代码Override public void onContent(String chunk) { // 步骤1检查是否被用户取消 if (taskManager.isCancelled(taskId)) { return; // 用户取消了直接返回 } // 步骤2过滤空内容 if (StrUtil.isBlank(chunk)) { return; // 空内容不处理 } // 步骤3累积回答内容 answer.append(chunk); // 步骤4分块发送 sendChunked(TYPE_RESPONSE, chunk); } Override public void onComplete() { // 步骤1保存回答到历史记录 Long messageId memoryService.append( conversationId, UserContext.get