文档导航路由与执行模式判定
上一篇讲完了路由追踪记录与最终路由文档确定,这一篇我们来看 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();
...
}
这段代码做了四件事:
- 确定主问题:优先用改写后的问题,没有改写结果就退回原问题
- 规范化子问题:把改写结果里的子问题列表去重去空,没有子问题时用主问题兜底
- 构建检索计划:把主问题和子问题打包成
RetrievalQuestionPlan,后续检索引擎会逐个取证 - 拼接路由文本:原问题和改写问题拼在一起,这样既保留了用户原始措辞(比如"这个后面是啥"),也包含了改写后更清晰的表达
来看 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 | 普通内容问题 | 混合检索(可带软章节提示) | "怎么处理异常"、"开户流程是什么" |
- GRAPH_ONLY / GRAPH_THEN_EVIDENCE 里的章节是"强锚点",路由结果直接依赖它来执行图查询或定向取证
- RETRIEVAL 里的章节是"软提示",检索引擎可以参考它来缩小范围,但不强制依赖,检索结果仍然以相关性为准
下一篇我们深入 detectQuestionIntent 的内部实现,看看统一意图识别是怎么通过本地规则引擎来做高置信判断的。
付费内容提示
该文档的全部内容仅对「JavaUp项目实战&技术讲解」知识星球用户开放
加入星球后,你可以获得:
- 超级八股文:100万+字的全栈技术知识库,涵盖技术核心、数据库、中间件、分布式等深度剖析的讲解
- 讲解文档:超级AI智能体、黑马点评Plus、大麦、大麦pro、大麦AI、流量切换、数据中台的从0到1的详细文档
- 讲解视频:超级AI智能体、黑马点评Plus、大麦、大麦pro、大麦AI、流量切换、数据中台的核心业务详细讲解
- 1 对 1 解答:可以对我进行1对1的问题提问,而不仅仅只限于项目
- 针对性服务:有没理解的地方,文档或者视频还没有讲到可以提出,本人会补充
- 面试与简历指导:提供面试回答技巧,项目怎样写才能在简历中具有独特的亮点
- 中间件环境:对于项目中需要使用的中间件,可直接替换成我提供的云环境
- 面试后复盘:小伙伴去面试后,如果哪里被面试官问住了,可以再找我解答
- 远程的解决:如果在启动项目遇到问题,本人可以帮你远程解决
