跳到主要内容

文档导航路由与执行模式判定

上一篇讲完了路由追踪记录与最终路由文档确定,这一篇我们来看 prepare 方法的最后一个大环节——文档导航路由

这一步要解决的核心问题是:用户问了一个问题,后面到底应该走哪种检索策略来找答案?

DocumentQuestionRouter.route(...) 方法的设计比较精细。它引入了统一意图识别机制,把多个维度的判断整合到一次调用里,避免各种关键词函数互相打架。

整体流程概览

先用一张流程图看看 route 方法从头到尾做了什么:

流程图
流程图

route 方法入口与输入准备

进入 DocumentQuestionRouter.route(...) 方法,第一步是把输入参数整理好:

public DocumentNavigationDecision route(Long documentId,
String originalQuestion,
RagRewriteResult rewriteResult) {
String rewrittenQuestion = firstNonBlank(
rewriteResult == null ? "" : rewriteResult.getRewrittenQuestion(),
originalQuestion
);
List<String> subQuestions = normalizeSubQuestions(rewriteResult, rewrittenQuestion);
RetrievalQuestionPlan retrievalPlan = new RetrievalQuestionPlan(rewrittenQuestion, subQuestions);
// routeText 把原问题和改写后的独立问题合并起来,避免只看改写结果时丢失用户最初的表达习惯。
String routeText = (safeText(originalQuestion) + " " + rewrittenQuestion).trim();
...
}

这段代码做了四件事:

  1. 确定主问题:优先用改写后的问题,没有改写结果就退回原问题
  2. 规范化子问题:把改写结果里的子问题列表去重去空,没有子问题时用主问题兜底
  3. 构建检索计划:把主问题和子问题打包成 RetrievalQuestionPlan,后续检索引擎会逐个取证
  4. 拼接路由文本:原问题和改写问题拼在一起,这样既保留了用户原始措辞(比如"这个后面是啥"),也包含了改写后更清晰的表达

来看 normalizeSubQuestions 的实现:

private List<String> normalizeSubQuestions(RagRewriteResult rewriteResult, String fallbackQuestion) {
if (rewriteResult == null || rewriteResult.getSubQuestions() == null || rewriteResult.getSubQuestions().isEmpty()) {
return List.of(fallbackQuestion);
}
return rewriteResult.getSubQuestions().stream()
.filter(StrUtil::isNotBlank)
.map(String::trim)
.distinct()
.toList();
}

逻辑很简单:如果改写结果里没有子问题,就用主问题作为唯一的子问题,保证 retrievalPlan 里至少有一个可用的检索问题。有子问题的话就去空去重返回。

统一意图识别

输入准备好之后,紧接着就是整个方法最关键的一步——统一意图识别

// 统一意图判断会一次性识别 graphOnly / analytic / outline / item / structureHint,避免多个关键词函数互相打架。
DocumentQuestionIntentDecision questionIntent = detectQuestionIntent(
routeText,
originalQuestion,
rewrittenQuestion,
subQuestions
);
// GRAPH_ONLY 仍然使用独立结果对象承载动作和原因,便于后续图查询执行器保持原来的输入语义。
GraphOnlyIntentDecision graphOnlyIntent = questionIntent.graphOnlyIntent();
// 分析型问题是 item 分支和 graphOnly 排除的重要信号,统一从 questionIntent 中读取。
boolean analyticQuestion = questionIntent.analytic();

detectQuestionIntent 把所有意图维度收敛到一个方法里统一判断,返回一个 DocumentQuestionIntentDecision 对象,包含五个维度:

维度含义
graphOnlyIntent是否适合结构图直答,以及具体的导航动作
analytic是否是分析型问题(需要解释、对比、推理)
outline是否是目录展开型问题
itemLookup是否是步骤/条目定位型问题
structureHint是否带有结构线索(可辅助检索)
为什么要统一识别?

如果各分支各自调用判断函数,容易出现冲突。比如"3.2 章节下面有哪些步骤"这个问题,asksOutline 会说"是目录展开",但 asksItemLookup 也会说"是步骤查询",两个函数打架了。统一识别可以在一个地方综合考虑所有信号,给出一个协调一致的结论。

detectQuestionIntent 的内部实现比较复杂,涉及本地规则引擎和 LLM 兜底分类,我们放到下一篇详细讲。这里先关注 route 方法拿到意图结果后怎么做分支判定。

第一分支:GRAPH_ONLY — 结构图直答

// 只有当结构导航意图被明确识别,且问题本身没有被拆成多个子问题时,才允许进入 GRAPH_ONLY。
boolean singleQuestionGraphOnlyMatched = graphOnlyIntent.matched() && subQuestions.size() <= 1;
if (singleQuestionGraphOnlyMatched) {
GraphSection section = resolveSection(documentId, originalQuestion, rewrittenQuestion);
return buildDecision(
ExecutionMode.GRAPH_ONLY,
graphOnlyIntent.action(),
section,
null,
retrievalPlan,
graphOnlyIntent.reason()
);
}

进入 GRAPH_ONLY 需要同时满足两个条件:

  • graphOnlyIntent.matched() 为 true:统一意图识别明确判定这是一个结构导航问题
  • 子问题数量 ≤ 1:如果问题被拆成了多个子问题,说明它可能同时包含结构查询和内容查询,直接走图查询会丢掉其他子问题

满足条件后,调用 resolveSection 定位目标章节,然后用 graphOnlyIntent.action() 作为导航动作。这个 action 可能是:

  • SECTION_ADJACENCY_LOOKUP:查相邻章节("上一节是什么")
  • CHILD_SECTION_DESCEND:展开子章节("有哪些小节")
信息

这里不是简单地用 asksAdjacency || asksOutline 来判断,而是依赖统一意图识别的结果。统一意图识别内部会综合考虑"是否有分析词阻断"、"是否有正文诉求"、"锚点和方向词是否同时出现"等多个因素,判断更精准。

第二分支:GRAPH_THEN_EVIDENCE — 图定位 + 取证

Integer itemIndex = resolveExplicitItemIndex(routeText);
boolean itemLookupMatched = itemIndex != null || questionIntent.itemLookup();
boolean shouldUseGraphThenEvidence = itemLookupMatched && !analyticQuestion;
if (shouldUseGraphThenEvidence) {
GraphSection section = resolveSection(documentId, originalQuestion, rewrittenQuestion);
return buildDecision(
ExecutionMode.GRAPH_THEN_EVIDENCE,
DocumentNavigationAction.ITEM_REFERENCE,
section,
itemIndex,
retrievalPlan,
"编号项或步骤型问题走图定位取证"
);
}

这个分支处理的是"第3步讲了什么"、"第二项的具体内容"这类问题。触发条件:

  • itemLookupMatched:要么解析出了显式编号(itemIndex != null),要么统一意图识别判定为步骤/条目型问题
  • 非分析型问题:如果是"第3步为什么要这样做",虽然提到了编号,但本质是分析型问题,应该走混合检索

这类问题的执行策略是"先用结构图定位到章节,再去正文里取证据",所以叫 GRAPH_THEN_EVIDENCE

resolveExplicitItemIndex:显式编号解析

private Integer resolveExplicitItemIndex(String question) {
Matcher stepMatcher = STEP_REFERENCE_PATTERN.matcher(safeText(question));
if (stepMatcher.find()) {
return parseChineseNumber(stepMatcher.group(1));
}
Matcher ordinalMatcher = ORDINAL_REFERENCE_PATTERN.matcher(safeText(question));
if (ordinalMatcher.find()) {
return parseChineseNumber(ordinalMatcher.group(1));
}
return null;
}

这个方法用两个正则来提取编号:

  • STEP_REFERENCE_PATTERN:匹配"第几步",比如"第3步"、"第十二步"
  • ORDINAL_REFERENCE_PATTERN:匹配"第几条/点/项/个",比如"第二项"、"第5条"

匹配到之后,用 parseChineseNumber 把中文数字转成阿拉伯数字。来看这两个正则的定义:

// 匹配"第几步",该类问题应交给 GRAPH_THEN_EVIDENCE,而不是 GRAPH_ONLY。
private static final Pattern STEP_REFERENCE_PATTERN = Pattern.compile("第\\s*([0-9一二三四五六七八九十百]+)\\s*步");
// 匹配"第几条/点/项/个",用于保留原有编号项定位能力。
private static final Pattern ORDINAL_REFERENCE_PATTERN = Pattern.compile("第\\s*([0-9一二三四五六七八九十百]+)\\s*(条|点|项|个)");

parseChineseNumber:中文数字转换

private Integer parseChineseNumber(String text) {
String normalized = safeText(text);
if (normalized.isBlank()) {
return null;
}
if (normalized.chars().allMatch(Character::isDigit)) {
return Integer.parseInt(normalized);
}
Map<Character, Integer> digitMap = Map.of(
'一', 1, '二', 2, '三', 3, '四', 4, '五', 5,
'六', 6, '七', 7, '八', 8, '九', 9
);
if ("十".equals(normalized)) {
return 10;
}
if (normalized.startsWith("十") && normalized.length() == 2) {
return 10 + digitMap.getOrDefault(normalized.charAt(1), 0);
}
if (normalized.endsWith("十") && normalized.length() == 2) {
return digitMap.getOrDefault(normalized.charAt(0), 0) * 10;
}
if (normalized.contains("十") && normalized.length() == 3) {
return digitMap.getOrDefault(normalized.charAt(0), 0) * 10 + digitMap.getOrDefault(normalized.charAt(2), 0);
}
return digitMap.getOrDefault(normalized.charAt(0), null);
}

这个方法能处理的范围是 1~99 的中文数字,覆盖了绝大多数文档里的编号场景:

  • 纯阿拉伯数字直接 parseInt
  • "十" → 10
  • "十二" → 12(十 + 个位)
  • "二十" → 20(十位 × 10)
  • "二十三" → 23(十位 × 10 + 个位)
  • 单个中文数字 "三" → 3

第三分支:RETRIEVAL — 混合检索

如果前两个分支都没命中,就走混合检索。这部分的 resolveSection 和最终的 buildDecision 调用已经在后面的文档中详细讲解了,这里只简单说一下触发"结构辅助检索"的条件:

GraphSection assistedSection = null;
boolean needsStructureAssistedRetrieval = analyticQuestion
|| questionIntent.outline()
|| itemIndex != null
|| questionIntent.structureHint();
if (needsStructureAssistedRetrieval) {
assistedSection = resolveSection(documentId, originalQuestion, rewrittenQuestion);
}

即使最终走混合检索,只要满足以下任一条件,也会先解析出一个"软章节提示"辅助检索引擎聚焦:

  • 是分析型问题(虽然不能图直答,但章节线索能帮助缩小检索范围)
  • 是目录展开型问题(可能是多子问题场景下被挡在 GRAPH_ONLY 外面的)
  • 有显式编号(可能是分析型问题里带了编号)
  • 有结构线索(提到了章节、标题、编号等)

三种执行模式对比

执行模式适用场景执行策略典型问题
GRAPH_ONLY纯结构导航问题直接查结构图,不检索正文"上一节是什么"、"3.2 下面有哪些小节"
GRAPH_THEN_EVIDENCE编号/步骤定位问题先图定位章节,再取正文证据"第3步讲了什么"、"第二项的具体内容"
RETRIEVAL普通内容问题混合检索(可带软章节提示)"怎么处理异常"、"开户流程是什么"
强锚点 vs 软提示
  • GRAPH_ONLY / GRAPH_THEN_EVIDENCE 里的章节是"强锚点",路由结果直接依赖它来执行图查询或定向取证
  • RETRIEVAL 里的章节是"软提示",检索引擎可以参考它来缩小范围,但不强制依赖,检索结果仍然以相关性为准

下一篇我们深入 detectQuestionIntent 的内部实现,看看统一意图识别是怎么通过本地规则引擎来做高置信判断的。

付费内容提示

该文档的全部内容仅对「JavaUp项目实战&技术讲解」知识星球用户开放

加入星球后,你可以获得:

  • 超级八股文:100万+字的全栈技术知识库,涵盖技术核心、数据库、中间件、分布式等深度剖析的讲解
  • 讲解文档:超级AI智能体、黑马点评Plus、大麦、大麦pro、大麦AI、流量切换、数据中台的从0到1的详细文档
  • 讲解视频:超级AI智能体、黑马点评Plus、大麦、大麦pro、大麦AI、流量切换、数据中台的核心业务详细讲解
  • 1 对 1 解答:可以对我进行1对1的问题提问,而不仅仅只限于项目
  • 针对性服务:有没理解的地方,文档或者视频还没有讲到可以提出,本人会补充
  • 面试与简历指导:提供面试回答技巧,项目怎样写才能在简历中具有独特的亮点
  • 中间件环境:对于项目中需要使用的中间件,可直接替换成我提供的云环境
  • 面试后复盘:小伙伴去面试后,如果哪里被面试官问住了,可以再找我解答
  • 远程的解决:如果在启动项目遇到问题,本人可以帮你远程解决
进入星球后,即可享受上述所有服务,保证不会再有其他隐藏费用。
知识星球二维码

1. 打开微信 -> 扫描左侧二维码 -> 加入「JavaUp项目实战&技术讲解」知识星球

2. 查看星球使用指导,获取完整项目讲解资料索引

👉 点击解锁全部付费内容
🎁优惠