用户问退货政策,3C、家电和服装都举手了
开篇引言
上一篇讲了总量封顶算法——三个子问题命中八个意图,capTotalIntents 用多样性优先 + 分数竞争的策略把意图总数压到 3 个以内。到这里,每个子问题至少有一个代表意图,数量可控,下游检索不会被意图数量拖垮。
封顶解决了数量太多的问题,但有一种场景它管不了:几个意图分数差不多,名字还一样,分属不同品类。封顶算法只看分数和子问题归属,不关心意图之间是不是同名、是不是来自不同品类。它选出来的 3 个意图可能恰好是三个品类的退货政策——分数都在 0.6 附近,你取哪个都不踏实。
回到第 5 篇埋下的伏笔。那篇展示的电商意图树有 11 个叶子节点,对应一个品类不多的中型电商。文章末尾提了一句:如果再加上不同品类(3C 数码、家电、服装)各自的专属知识库,轻轻松松到 20 个。
现在把这个伏笔接上。
业务会长大 。当平台从综合电商发展到多品类运营,原来一个笼统的退换货知识库就不够用了——3C 数码有 15 天无理由退货加厂商质保,家电有 30 天退换加上门安装,服装有 7 天无理由加尺码换货。退货政策、保修政策、售后流程全都按品类拆分了。意图树也跟着长:
商品服务(DOMAIN)
├── 3C 数码(CATEGORY)
│ ├── 退货政策(TOPIC,KB) ← collectionName: kb_3c_return
│ ├── 保修政策(TOPIC,KB)
│ └── 商品参数(TOPIC,KB)
├── 家电(CATEGORY)
│ ├── 退货政策(TOPIC,KB) ← collectionName: kb_appliance_return
│ ├── 保修政策(TOPIC,KB)
│ └── 安装服务(TOPIC,KB)
├── 服装鞋帽(CATEGORY)
│ ├── 退货政策(TOPIC,KB) ← collectionName: kb_clothing_return
│ └── 尺码换货(TOPIC,KB)
...
三个品类的 CATEGORY 下面都有一个叫退货政策的 TOPIC 叶子节点,但绑定的 Collection 各不相同。3C 的退货政策、家电的退货政策、服装的退货政策是三个独立知识库。
这时用户在对话框里输入:
退货政策是什么?
没指定品类。意图分类器诚实地返回了三个分数接近的意图:
- 3C 数码 > 退货政策(0.68)
- 家电 > 退货政策(0.65)
- 服装鞋帽 > 退货政策(0.62)
放个实际效果图:

如果直接取最高分走 3C 的知识库,万一用户买的是家电呢?如果三个都搜,答案里 3C、家电、服装的退货规则混在一起,15 天、30 天、7 天三种政策并排放,核心信息被稀释,用户反而更懵。
最合理的做法是停下来问一句:你想看哪个品类的退货政策?
这就是本篇要讲的——歧义检测与引导式问答。IntentGuidanceService 负责识别跨品类的歧义场景,生成引导文案,通过 SSE 短路输出给用户。多数情况下纯内存计算即可判定,只有分数落在灰色地带时才调一次 LLM 做二次确认,整个过程不走检索,前端零适配。
什么时候算歧义:规则 + LLM 分层判定
1. 歧义的直觉定义
用一句话说清楚:多个品类的意图分数都很接近,系统无法确定用户想问哪个品类,就是歧义场景。
什么情况下不算歧义?看几个反例:
| 场景 | 为什么不算歧义 |
|---|---|
| 3C 退货政策(0.88)+ 家电退货政策(0.35),分数差距大 | 0.88 vs 0.35 一目了然,直接走 3C 没毛病 |
| 用户问"3C 数码的退货政策",分数接近 | 用户已经明示了品类,不需要再问 |
| 3C 线上退货政策(0.68)+ 3C 线下退货政策(0.65),都在同一品类下 | 同一品类内部的节点,下游检索走同一个品类知识库,结果是相关的 |
真正需要引导的场景是:分数接近 + 跨品类 + 用户没有指定品类。
2. findAmbiguityGroup 代码总览
歧义检测的核心逻辑在 IntentGuidanceService 的 findAmbiguityGroup() 方法里。先看完整代码,再逐段拆解。
private AmbiguityGroup findAmbiguityGroup(String question, List<SubQuestionIntent> subIntents) {
if (CollUtil.isEmpty(subIntents) || subIntents.size() != 1) {
return null;
}
List<NodeScore> candidates = filterCandidates(subIntents.get(0).nodeScores());
if (candidates.size() < 2) {
return null;
}
// 按品类分组,每个品类保留最高分的 topic
Map<String, NodeScore> systemBest = candidates.stream()
.filter(ns -> StrUtil.isNotBlank(resolveSystemNodeId(ns.getNode())))
.collect(Collectors.toMap(
ns -> resolveSystemNodeId(ns.getNode()),
ns -> ns,
(a, b) -> a.getScore() >= b.getScore() ? a : b
));
List<NodeScore> ranked = systemBest.values().stream()
.sorted(Comparator.comparingDouble(NodeScore::getScore).reversed())
.toList();
if (ranked.size() < 2) {
return null;
}
// 快速跳过:分数差距大 或 用户已指定系统
if (shouldSkipGuidance(question, ranked)) {
return null;
}
// 三区间判定:明确歧义 / 灰色地带调 LLM / 明确无歧义
if (!confirmAmbiguity(question, ranked)) {
return null;
}
List<NodeScore> trimmedRanked = trimRankedOptions(ranked);
String topicName = trimmedRanked.get(0).getNode().getName();
return new AmbiguityGroup(topicName, trimmedRanked);
}
private record AmbiguityGroup(String topicName, List<NodeScore> ranked) {
}
五个步骤:
- 前置过滤:只处理单子问题场景,KB 类候选至少 2 个
- 按品类分组:通过
resolveSystemNodeId找到每个意图的品类归属,每个品类只保留最高分的 topic 作为代表 - 快速跳过:分数差距明显或用户已指定系统时,直接判定不歧义
- 三区间判定:根据分数比值所在区间,决定是明确歧义、调 LLM 确认还是明确无歧义
- 转换为
AmbiguityGroup:提取主题名 + 品类级选项 ID 列表
AmbiguityGroup 是一个 record,结构很简单——topicName 是触发歧义的那个主题名称(退货政策),ranked 是按分数降序排列的品类代表列表,包含完整的节点信息(名称、路径、分数),方便下游渲染选项时直接使用。