面试知识库

18 · 串行多跳检索与 RAG 链路生产加固#

⚠️ 迁移说明(2026-06-07):本文记录的自研串行多跳机制HopAnswerExtractor / SupervisorAgent.handleSequentialMultiHop / PathDecision.Mode.MULTI_HOP)已在「对标主流四刀改造」中移除;多跳能力改由真·LLM 工具调用 agentic 循环AgenticSearchOrchestrator,模型在运行时自驱「先查中间事实再构下一跳」)覆盖。但本文记录的 4 路工程加固——C1 防御性拷贝 / C2 dedupeKey / R1 超时 / R2 SSE 生命周期 / A1 缓存 userId 隔离——均已落地并保留在代码里。本文的范式选型分析(迭代循环 vs 一次性规划 vs 纯 ReAct)正是最终走向 agentic 循环的推理过程,作为设计思路保留。详见 19-对标主流产品改造报告20-改造实现设计04-优化迭代记录 #22。

本文记录一次完整的能力升级 + 工程加固:让系统能回答「子问题之间有依赖关系」的链式多跳问题,并在落地前修掉了几个会被多跳放大的既存硬伤。 适合面试场景:「你的 RAG 能处理多跳推理吗」「为什么不用 Plan-and-Execute」「ReAct 和显式规划怎么选」「并发下的数据竞争」「缓存多租户隔离」。

关联:02-Agentic-RAG核心设计03-检索与排序链路07-项目深度扩展方向12-Agentic-RAG改造执行计划


一、问题:系统答不了「链式多跳」问题#

触发案例#

「2026 年夺得欧洲冠军联赛冠军的球队,其主教练在球员时代拿到过几次世界杯?」

这个问题必须串行回答:

  1. 先查:2026 欧冠冠军是哪支球队? →(如)皇家马德里
  2. 再查:皇家马德里的主教练是谁? → 需要用上一步答案
  3. 最后查:那位教练在球员时代拿过几次世界杯? → 需要用上一步答案

每一跳的查询都依赖前一跳的答案——这是 multi-hop / bridge-entity 型问题。

现有架构为什么答不了#

系统原有两条多子问题路径,都解决不了:

路径机制为什么不行
DECOMPOSED(已有)一次性分解 → 并行 fan-out → RRF 融合只支持相互独立的子问题(对比类「A 和 B 分别是什么」)。query_decompose.txt 第一条原则就明令「子问题必须语义自洽、不依赖其它子问题」。SubQuery 结构无 dependsOn,子问题用 CompletableFuture.allOf 同时派发,无先后、无中间结果传递
Plan-and-Execute(Phase 2 曾有、默认关闭,现已移除LLM 一次性产出带 dependsOn 的多步计划看似有依赖,实则两处致命缺陷:① 计划是一次性全部生成的,规划那一刻 LLM 还不知道「2026 冠军是谁」,第二步 query 只能写成「冠军球队的主教练」这种带未解析指代的查询,召回极差;② PlanExecutor.injectDependencyOutputs 只把前置步骤的 chunks 塞给后续步骤,不抽取中间答案、不改写下一跳 query——它解决的是「多源证据汇总」,不是「query 依赖前序答案」

一句话:现有路径是「parallel decomposition」,缺的是「interleaved retrieve-then-reason」。打开 Plan-and-Execute 开关也没用,这是机制缺失而非配置缺失。


二、方案选型:为什么是「受约束有界迭代」#

调研了 2023–2026 串行多跳的主流范式后,决策轴其实是两类:

A. 一次性规划(ReWOO / LLMCompiler / 占位符 DAG)B. 有界迭代循环(Self-Ask / IRCoT / LlamaIndex MultiStep)
下一跳子问题规划时写死{hop0} 的主教练看到上一跳答案之后才生成
用检索内容塑形 query❌ 不能(规划早于检索)✅ 能(这正是多跳的本质)
跳数规划时猜死动态,答够了就停
多跳 QA benchmark一般SOTA(HotpotQA / MuSiQue)
成本低(1 次规划)高(每跳 1 次 LLM)
可控/可审计取决于实现

最终选 B,且是 Self-Ask / MultiStep 这一支#

理由(面试可直接讲):

  1. 占位符 DAG 有结构性缺陷{hop0} 的主教练 这个模板本身是 LLM 在没看任何检索结果时的猜测。真实多跳里链的形状常依赖中间内容——「一次性规划用不到检索回来的事实去塑造下一个问题」,这恰恰是多跳最需要的能力。这是 IRCoT 论文的核心洞察:「下一步检索什么,取决于已经推理出什么」。
  2. B 是该问题类别学术+工业双重验证的范式:IRCoT/Iter-RetGen 多跳检索 +15~21 分;LlamaIndex 官方把它做成 MultiStepQueryEngine(对比类才用 SubQuestionQueryEngine 并行)。
  3. 自适应终止天然落地:「证据够了吗」由循环里的 reasoner 每跳判断(Self-Ask 的 Are follow-up questions needed: No),不靠规划时猜死跳数。
  4. 它是「受约束的 ReAct」:动作只有一个(retrieve),LLM 只负责「生成下一个子问题 + 抽中间答案 + 判停」,不做自由 function-calling。拿到了 ReAct 的适应性,规避了它工具乱选、p95 不可控、推理规划缠绕难调试的毛病——与项目「工具选择走规则引擎、不让 LLM 乱触发」的哲学一致。
  5. 为什么是 Self-Ask 而非 IRCoT 原味:IRCoT 拿「CoT 句子」当检索 query,会漂移、中间态难展示;Self-Ask/MultiStep 产出干净的「子问题→子答案」对,既好检索又能直接渲染成推理链 trace,对要做面试演示和 agent trace 落库的产品可控性更好。

为什么不选纯 ReAct:自由工具选择与项目规则驱动哲学冲突;每跳一次 function-calling 决策,成本/p95 延迟不可控;推理与规划缠绕难调试。


三、落地前的「体检」:4 路并行审计#

接多跳前,对整条 6 步链路做了一次代码级审计(查询理解 → 路由 → 规则规划 → Supervisor-Worker 检索 → Prompt 组装 → 流式+反思),发现若干会被多跳放大的既存硬伤。核心几条:

编号硬伤性质多跳为何放大
C1RRF/rerank/MMR 全程 chunk.setRerankScore() 就地 mutate 同一实例并发 data race同一 KB chunk 会被多跳召回,跳间保留它作中间证据时 rerankScore 被后续跳偷偷覆盖
C2去重 key 用 content.substring(0,50/60)正确性 bug模板化开头(「第十二条…」「## 参数说明…」)的不同 chunk 被判同一条,静默丢证据;跳数越多碰撞概率越大
R1全链 allOf().join() 无超时;reranker new RestTemplate() 无超时韧性任一跳 HTTP hang,整个循环死等;叠加反思的 N+3 次串行 LLM 顶爆 120s
R2SSE emitter 无 onTimeout/onError,断开后后台 blockLast() 跑完整条 pipeline韧性多跳串行 LLM 更长,空转烧 token 更多
A1语义缓存 key 零 userId 维度安全/越权多跳答案综合更多 chunk + 用户记忆,跨用户串答泄露面更大

面试点:「先修硬伤再上功能」——新功能会把偶发问题放大成必现。这是工程成熟度的体现。


四、实现:5 个阶段(演进式,不重写编排层)#

保留 DocMindAgent 瀑布式 stage + Supervisor-Worker 架构,演进式接入。开发期用 multihop.enabled 灰度门控保证 P1~P3 非多跳路径零变更;当前已默认开启sys_ai_config: multihop.enabled=true,可热关闭)。

Phase 1 — 前置硬伤修复(无行为变更)#

  • C1 防御性拷贝RetrievedChunk.copy()/copyAll(),在「Worker 产出进入编排层」的边界(4 处,全在 SupervisorAgent)拷贝,保证每跳/每子问题持有独立 chunk 图。RRF/rerank/MMR 内部不改——改的是副本。
  • C2 统一去重 key:抽出 RetrievedChunk.dedupeKey()(id 优先,缺失退化到内容指纹),替换 3 处 substring 前缀 key。
  • R1 超时:reranker 换带超时的 SimpleClientHttpRequestFactory;3 处 allOforTimeout + getNow部分降级(超时后用已完成结果,而非整体失败)。
  • R2 SSE 生命周期executor.submitFutureonTimeout/onErrorfuture.cancel(true),多跳循环每跳检查 Thread.interrupted()
  • 顺带修掉两个真 bug:truncateLongChunks 之前漏拷 topicRelevance/tags/docVersion 等元数据;fallbackRerank 返回 subList 视图别名。

Phase 2 — SSE 加 hop 维度#

复用现有事件加 hop/maxHops/mode/hopAnswer 字段 + 新增唯一 multihop 事件(每跳发一次)。前端 ChatView.vue 加 handler/标签/样式,推理链逐跳渲染「第 N/M 跳:<子问题>」。

Phase 3 — 多跳判别与状态通电#

注:本阶段的多跳专属改动QueryClassification.multiHop 字段、PathDecision.Mode.MULTI_HOProutePath 多跳分支)已在四刀改造中被 agentic 循环取代并删除——多跳是否发生改由 agentic 循环运行时自决,分类器不再判(needDecompose/multiHop 字段已删)。以下内容作为历史设计保留;Phase 1/2/5 的工程加固不受影响、仍在代码里。

  • QueryClassificationboolean multiHop 字段(与 needDecompose 正交:前者串行依赖,后者并行多焦点)。prompt query_understanding.txt 加字段说明 + 桥接 vs 对比对照 few-shot。
  • PathDecision.ModeMULTI_HOProutePath 加分支(灰度 + kbIds 非空门控),优先于 DECOMPOSED。
  • AgentState 复用预留字段(currentIteration/recordConfidence/accumulatedEvidence)+ 新增 hopQaPairs

Phase 4 — 循环主体 + 中间答案抽取(核心)#

注:本阶段的自研串行循环机制HopAnswerExtractor + SupervisorAgent.handleSequentialMultiHop)已在四刀改造中被 agentic 循环取代并删除——「先查中间事实、抽答案、再构下一跳、判停」现由 AgenticSearchOrchestrator 里真·LLM 工具调用循环自驱完成(模型每轮决定是否继续调 searchDocsfinalize() 统一去重→rerank→MMR→compress→CRAG)。以下循环伪码作为历史设计与「为什么走向 agentic 循环」的推理保留。

HopAnswerExtractor(新组件):用小模型,一个 prompt 同时产出本跳答案 + 下一跳决策(每跳 1 次 LLM 调用):

record HopAnswer(String answer, boolean unknown, boolean done, String nextQuery, double confidence)
java

强约束「片段不足只输出 UNKNOWN,不得编造」——这是 Plan-and-Execute 缺失的那一环。

SupervisorAgent.handleSequentialMultiHop(串行循环)

终止条件:max_hops(默认 3) / unknown / duplicate / done。最后一跳 chunks + 前序关键证据进现有 Step5/6,生成与反思完全不动。

Phase 5 — 语义缓存多租户隔离(修 A1)#

SemanticCacheEntryuserIdlookup/putIfFrequent 加 userId 参 + 命中前严格校验。旧条目 userId=null 对具体用户安全降级为不命中,零迁移风险。


五、验证#

  • 单元测试:新增 RetrievedChunkCopyTest(6) / HopAnswerExtractorTest(7) / SupervisorMultiHopTest(4,验证 done/unknown/duplicate/max_hops 四种终止 + 累积 + fallback) / SemanticCacheUserIsolationTest(3) + QueryUnderstandingDeterministicTest 扩多跳。mvn test 145 单测全绿。

    注(2026-06-07):其中 HopAnswerExtractorTest / SupervisorMultiHopTest多跳专属测试已随机制移除而删除;当前测试套件为 126 单测全绿(四刀改造后)。验证加固相关的 RetrievedChunkCopyTest / SemanticCacheUserIsolationTest(C1/A1)等仍保留。

  • 回归护栏:P1~P3 保证非多跳路径行为不变;多跳仅当 classification.multiHop() 命中(且有 kbIds、开关开启)时激活,独立问题/对比类不受影响;开关默认开启,可热关闭。
  • 端到端docker compose up -d + 对桥接型问题走 SSE,确认 multihop 逐跳渲染、UNKNOWN 短路、done 收尾。

六、面试高频追问应答#

注(现状口径):本节「为什么不用纯 ReAct / 自由 function-calling」的分析当时落在「受约束的 ReAct」(动作固定为 retrieve)。四刀改造后系统更进一步——多跳现由真·LLM 工具调用的 agentic 循环回答:AgenticSearchOrchestrator 用 Spring AI ToolCallingManager 手动控环(internalToolExecutionEnabled(false)max_iterations=4),只暴露 3 个读工具白名单searchDocs/webSearch/recall_memory,硬排除 store_memory/kb_meta),由模型自驱拆/跳/补/停。本文「受约束的 ReAct」的推理正是走向它的设计起点:把工具收敛到只读、加迭代上限、加白名单,就在「拿到 ReAct 适应性」与「保住可控性」之间取到了平衡。下列各 Q 的分析仍成立,作为推理过程保留。

Q:打开 Plan-and-Execute 开关不就行了? A:不行,是机制缺失不是配置缺失。① 它一次性生成所有 query,规划时还不知道中间实体,第二跳 query 写不出来;② 它只传 chunks 不抽中间答案、不改写 query;③ 路由优先级上 decomposed 还盖过它。

Q:为什么不用纯 ReAct / LangGraph agent loop? A:自由 function-calling 与项目规则驱动哲学冲突,p95 延迟和成本不可控,推理规划缠绕难调试。我用的是「受约束的 ReAct」——动作固定为 retrieve,LLM 只做「抽答案+定下一跳+判停」,拿到适应性又保住可控性。

Q:怎么防止错误传播(一跳错全链错)? A:① 逐跳 CRAG grading;② 抽取强约束「片段不足输出 UNKNOWN」,UNKNOWN 立即短路、带已累积证据收尾而非硬推;③ 首跳加权(topK×1.5),因为首跳错误会复利放大;④ 重复检测,下一跳查询坍缩为重复即收敛。

Q:怎么控制迭代次数 / 防死循环? A:硬上限 max_hops=3(业界经验值,深度 4 几乎无收益)+ UNKNOWN 短路 + 重复检测 + done 自适应停。四重终止。

Q:C1 那个并发 bug 具体怎么发生的? A:RetrievedChunk 是 Lombok @Data,RRF/rerank 直接 setRerankScore 改对象。并行子问题在不同线程对同一实例写,无 happens-before。对比类「A vs B」最容易踩——SubQueryMerger 按 rerankScore 排序时可能读到「另一个子问题视角」的分。修法是 worker 产出进编排层的边界做防御性拷贝。

Q:缓存多租户那个为什么是安全问题不只是正确性? A:缓存的 answer 正文可能被 LLM 写进了写入者的长期记忆(身份/技术栈)。key 没有 userId 维度时,用户 B 对同 kb 提语义相近问题会直接拿到 A 的答案——跨用户信息泄露,不只是命中错答案。


七、STAR 故事(简历可用)#

  • S/T:系统无法回答「子问题间有依赖关系」的链式多跳问题(如「X 球队主教练球员时代世界杯次数」),现有并行分解和 Plan-and-Execute 都解决不了。
  • A:调研业界范式后选定「受约束有界迭代循环」(Self-Ask/MultiStep),而非占位符 DAG 或自由 ReAct;落地前先做 4 路代码审计,修掉会被多跳放大的并发 data race、证据丢失、全链无超时、缓存越权 4 类硬伤;用 5 阶段演进式接入,灰度门控保证非多跳路径零变更;每跳「检索→逐跳 grading→抽中间答案→回填下一跳」,配 max-hops/UNKNOWN 短路/首跳加权/重复检测四重护栏。
  • R:新增桥接型多跳能力,145 单测全绿、默认灰度可关;顺带修复 1 个并发 data race + 1 个静默丢证据 bug + 1 个缓存跨用户越权。可在面试中讲清「为什么不用 Plan-and-Execute / ReAct」「错误传播怎么防」「并发 mutate 怎么修」三个深挖点。

八、能力边界与后续#

注(现状口径):本节描述的是自研串行多跳机制的能力边界。该机制已被 agentic 循环取代——现在「下一跳查什么、跳几次、何时停」全部由 AgenticSearchOrchestrator 里的 LLM 在工具调用循环中运行时自决,不再有手写循环与显式 max_hops/UNKNOWN/重复检测护栏(边界改由迭代上限 agentic.max_iterations=4 + CRAG 闸 + 读工具白名单约束)。以下后续方向作为「自研路线本可如何演进」的设计思考保留。

  • 当前是动态生成下一跳(不预规划 DAG),适合线性链;树形多跳(一个问题分叉出多个独立桥接链再汇总)需要 DAG + 并行分层,可在 SubQuery.dependsOn(已预留)基础上扩展。
  • 终止目前是规则 + 抽取自判,可升级为学习型停止控制器(Stop-RAG 风格)。
  • 错误传播可进一步加 rejection sampling 过滤不一致中间答案(RT-RAG 风格)。