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 年夺得欧洲冠军联赛冠军的球队,其主教练在球员时代拿到过几次世界杯?」
这个问题必须串行回答:
- 先查:2026 欧冠冠军是哪支球队? →(如)皇家马德里
- 再查:皇家马德里的主教练是谁? → 需要用上一步答案
- 最后查:那位教练在球员时代拿过几次世界杯? → 需要用上一步答案
每一跳的查询都依赖前一跳的答案——这是 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 这一支#
理由(面试可直接讲):
- 占位符 DAG 有结构性缺陷:
{hop0} 的主教练这个模板本身是 LLM 在没看任何检索结果时的猜测。真实多跳里链的形状常依赖中间内容——「一次性规划用不到检索回来的事实去塑造下一个问题」,这恰恰是多跳最需要的能力。这是 IRCoT 论文的核心洞察:「下一步检索什么,取决于已经推理出什么」。 - B 是该问题类别学术+工业双重验证的范式:IRCoT/Iter-RetGen 多跳检索 +15~21 分;LlamaIndex 官方把它做成
MultiStepQueryEngine(对比类才用SubQuestionQueryEngine并行)。 - 自适应终止天然落地:「证据够了吗」由循环里的 reasoner 每跳判断(Self-Ask 的
Are follow-up questions needed: No),不靠规划时猜死跳数。 - 它是「受约束的 ReAct」:动作只有一个(retrieve),LLM 只负责「生成下一个子问题 + 抽中间答案 + 判停」,不做自由 function-calling。拿到了 ReAct 的适应性,规避了它工具乱选、p95 不可控、推理规划缠绕难调试的毛病——与项目「工具选择走规则引擎、不让 LLM 乱触发」的哲学一致。
- 为什么是 Self-Ask 而非 IRCoT 原味:IRCoT 拿「CoT 句子」当检索 query,会漂移、中间态难展示;Self-Ask/MultiStep 产出干净的「子问题→子答案」对,既好检索又能直接渲染成推理链 trace,对要做面试演示和 agent trace 落库的产品可控性更好。
为什么不选纯 ReAct:自由工具选择与项目规则驱动哲学冲突;每跳一次 function-calling 决策,成本/p95 延迟不可控;推理与规划缠绕难调试。
三、落地前的「体检」:4 路并行审计#
接多跳前,对整条 6 步链路做了一次代码级审计(查询理解 → 路由 → 规则规划 → Supervisor-Worker 检索 → Prompt 组装 → 流式+反思),发现若干会被多跳放大的既存硬伤。核心几条:
| 编号 | 硬伤 | 性质 | 多跳为何放大 |
|---|---|---|---|
| C1 | RRF/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 |
| R2 | SSE 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 处allOf用orTimeout+getNow做部分降级(超时后用已完成结果,而非整体失败)。 - R2 SSE 生命周期:
executor.submit拿Future,onTimeout/onError中future.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_HOP、routePath多跳分支)已在四刀改造中被 agentic 循环取代并删除——多跳是否发生改由 agentic 循环运行时自决,分类器不再判(needDecompose/multiHop字段已删)。以下内容作为历史设计保留;Phase 1/2/5 的工程加固不受影响、仍在代码里。
QueryClassification加boolean multiHop字段(与needDecompose正交:前者串行依赖,后者并行多焦点)。promptquery_understanding.txt加字段说明 + 桥接 vs 对比对照 few-shot。PathDecision.Mode加MULTI_HOP,routePath加分支(灰度 + kbIds 非空门控),优先于 DECOMPOSED。AgentState复用预留字段(currentIteration/recordConfidence/accumulatedEvidence)+ 新增hopQaPairs。
Phase 4 — 循环主体 + 中间答案抽取(核心)#
注:本阶段的自研串行循环机制(
HopAnswerExtractor+SupervisorAgent.handleSequentialMultiHop)已在四刀改造中被 agentic 循环取代并删除——「先查中间事实、抽答案、再构下一跳、判停」现由AgenticSearchOrchestrator里真·LLM 工具调用循环自驱完成(模型每轮决定是否继续调searchDocs,finalize()统一去重→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(串行循环):
hop=1; accumulatedQa=[]; lastHopChunks=[]; keyEvidence=[]; seen={}
while hop <= maxHops:
if interrupted: stop("cancelled")
if duplicate(subQ, seen): stop("duplicate") // 下一跳坍缩为重复 → 收敛
emit "multihop"{hop, subQuestion}
vTopK = hop==1 ? subVectorTopK*1.5 : subVectorTopK // 首跳加权:首跳错全链错
ranked = retrieveOneSubQuery(...) // 复用现有检索+RRF+rerank(已含防御拷贝)
grade = safeGrade(subQ, ranked) // 逐跳 CRAG
state.recordConfidence; state.addEvidence
hopAns = hopAnswerExtractor.extract(subQ, originalQ, accumulatedQa, ranked)
emit "retrieval"/"rerank"{hop, hopAnswer}
lastHopChunks = ranked
if hopAns.unknown: stop("unknown") // 短路但带已累积证据收尾(防错误传播)
accumulatedQa.add([subQ, hopAns.answer]); keyEvidence += top2(ranked)
if hopAns.done || !hopAns.hasNextQuery: stop("sufficient")
subQ = hopAns.nextQuery; hop++
final = compress(dedup(lastHopChunks + keyEvidence)) // 进现有 PromptAssembler → 生成 → 反思plaintext终止条件:max_hops(默认 3) / unknown / duplicate / done。最后一跳 chunks + 前序关键证据进现有 Step5/6,生成与反思完全不动。
Phase 5 — 语义缓存多租户隔离(修 A1)#
SemanticCacheEntry 加 userId,lookup/putIfFrequent 加 userId 参 + 命中前严格校验。旧条目 userId=null 对具体用户安全降级为不命中,零迁移风险。
五、验证#
- 单元测试:新增
RetrievedChunkCopyTest(6) /HopAnswerExtractorTest(7) /SupervisorMultiHopTest(4,验证 done/unknown/duplicate/max_hops 四种终止 + 累积 + fallback) /SemanticCacheUserIsolationTest(3) +QueryUnderstandingDeterministicTest扩多跳。mvn test145 单测全绿。注(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 AIToolCallingManager手动控环(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 风格)。