在大模型接手之前,工单分类靠的是一个本地的关键词引擎:拿问题描述去和分类树里每个叶子的标题比,算分,排序,推荐第一名。
它有过一段能用的时期。然后分三次失效了。这篇按时间顺序讲这三次,因为每一次失效都在教同一件事:关键词打分的天花板,由分类树的形状决定。
起点:38% 的叶子重名
最早的引擎是纯加分制:描述里出现了某个叶子标题里的词,就给那个叶子加分。
跑了一段时间,发现准确率怎么调都上不去。统计了一下树:
729 个三级叶子里,38% 重名。 最常见的那个通用叶子名重复了 57 次,第二、第三名也各有二十多次。
同一个三级名字挂在几十个不同的二级下面。描述里只要命中了那个通用词,这五十七个叶子就得到一模一样的分数。
加分改变不了「同名叶子无法区分」这个事实。 调权重、调阈值都是在同一堆并列第一里抽签。
必须先剔除不相干的子树,再在剩下的里面打分。
第一次改:先分流,再打分
加了一层「分流」:用描述里的词,先判断这个问题属于哪个系统(二级),把候选范围收缩到那几棵子树,再在里面打分。实测候选从 729 个收缩到 2~14 个。
这一步最关键的是一个认知修正。
最初的构想是:描述里出现了某个产品品牌名 → 分到那个品牌的系统里去。 很自然。
实测推翻了它:品牌词是限定词,不是分流信号。 某个品牌名在树里跨 4 个大类出现了 44 次——因为很多叶子的标题本身就带着品牌名(比如「某品牌打印接口」),而这些叶子分散在「接口服务」「硬件设备」等完全不同的大类下。
真正决定分类的,是问题对象:打印、门锁、证件、结账。
于是分流规则定了优先级:
P1 三级专名(叶子标题里独一无二的词)P2 问题对象(打印 / 门锁 / 证件 / 结账……)P3 业务系统专名P4 客户所属 → 对应的产品版本P2 必须高于 P4。 否则「某品牌的打印机没反应」会被客户所属拽进那个品牌的主系统子树,而它其实是个打印机问题。
两张表,不要混
分流用到两张配置表,它们都指向「某个系统」,但信号来源完全不同:
| 表 | 信号来自 | 作用方式 |
|---|---|---|
| 锚点表 | 客户字段(客户所属 → 产品版本) | 加分 |
| 分流表 | 问题描述里的词 → 系统 | 收缩候选域 |
而且分流判定只用问题描述,不含客户全称——客户名里往往带着品牌,混进来就退化回了「品牌词当分流信号」的老病。
收缩要有兜底
分流是「硬排除」:命中了就只在那几棵子树里找。但必须有兜底——
排除之后一条候选都不剩 → 放弃分流,退回全量。
原因很现实:分流规则存在云端,而分类树由别人维护、会改。规则指向的子树某天改名了,没有兜底的话候选就是空的,录单直接卡死。有兜底,最坏只是「这条规则失效了」。
还写了一个离线验证器,直连云端拉线上配置,逐条跑一遍规则,看有没有「收缩后 0 候选」的。
第二次失效:树被改了层级
某天维护方重建了分类树。不是加了几个节点,是改变了层级的语义:
旧:一级 = 业务大类 / 二级 = 系统名 / 三级 = 问题新:一级 = 系统名 / 二级 = 功能域 / 三级 = 问题一级从 14 个变成了 66 个。三级重名率从 38% 涨到 76%。
引擎的锚点和分流都是按层下标取标题的——「第 2 层是系统名」这个假设写死在代码里。系统名换了一层,所有按名字匹配的逻辑静默全失效:
- 锚点覆盖从几百条掉到个位数;
- 100 条分流规则里,45 条收缩后变成 0 候选(门锁、证件、打印机全中招)。
不报错、不为空,只是悄悄不命中。
事后在「分类树更新」的检查清单最前面加了一项:
diff 新旧树时,先看层级语义有没有变(一级都是些什么?数量剧变了吗?),再看内容增删。层级变了就不是「从容处理」,是代码级故障。
这份清单做成了一个工具页:分类树 Diff。粘两棵树进去,按严重度出报告——⓪ 层级语义 → ① 编码变更 → ② 重名 → ③ 疑似改名 → ④ 叶子增删。示例用的是假数据,第一个示例就是「层级整体上移」。
顺序是故意的。叶子增删最多影响召回,编码变了会让历史数据全部失效,而层级变了会让所有规则静默失效。 检查顺序应该跟着「失效时有多安静」走,而不是跟着「变化量有多大」走。
第三次失效:锚点淹没了小系统
代码修完、层级对上之后,出现了一个新现象。
有一张单的描述开头就点名了某个小系统,大意是「某某巡检系统里的检查表不见了,访问还卡」。引擎给出的 12 条候选里,11 条来自一个完全不相干的大系统,正确答案连候选池都没进。
三个原因叠在一起,缺一不可:
① 叶子数极度不均。 那几个大系统加起来占全树叶子的 53.8%;那个小系统只有 3 个叶子。大树天然占据候选池。
② 锚点是无差别的绝对加分。 客户所属命中了锚点,那个大系统下每一个叶子只要有点基础分,就统一加 30。而小系统一个锚点分都拿不到。
③ 正确答案的三级名和口语对不上。 描述说的是「检查表不见了」,而正确的三级叫「任务单据异常」——「单据」两个字在描述里一次都没出现。
锚点的本意是「同名叶子选对产品版本」。在旧树里大系统是二级、锚点只影响一小片;在新树里大系统变成了一级,锚点变成了「把整棵大系统顶到候选池顶部」。
绝对加分在不均匀的树上,等于按子树大小给系统投票。
顺带:分词器切不开连写的中文
还有一个更基础的问题。标题分词是这么写的:
title.split(/[\//()()\s,,、]+/) // 只按分隔符切旧树的三级名多带分隔符或编号(「1.1 某某」「3.2 扫描仪」),切得开。
新树的叶子名普遍是连写短语:「某品牌门锁接口相关」「终端参数配置」「账号停用处理」这种。一个分隔符都没有 → 整串就是一个 token → 描述里不可能出现这一整串 → 走不到「完整包含」的强命中,只能拿模糊分。
一棵子树里有二十几个门锁品牌的叶子,彼此只差品牌名两个字,模糊分几乎一样。品牌名恰恰是唯一的区分信息,而它被整串淹没了。
修法方向是对连写中文标题做 2~4 字的滑窗子串匹配——但要小心别让「接口」「系统」「相关」这种泛词也吃到强命中。泛词表只挡整个 token,挡不住子串。
结论:引擎的三级准确率是 2.2%
最后拿 1158 条真实的纠偏反馈量了一下:
| 一级准确率 | 完整三级准确率 | |
|---|---|---|
| 关键词引擎 | 46.6% | 2.2% |
| 大模型 | 71.9% | 49.7% |
引擎一级能对一半,三级几乎从不对。
这不是调参能解决的:76% 的叶子重名、叶子名是连写短语、正确答案的用词和口语对不上——这些是语义问题,关键词匹配在结构上就做不到。
于是三级分类整个交给了大模型,引擎退居兜底(后来的演进见让模型查分类树,而不是背它)。
这个引擎的三次失效,有一个共同的前提:分类树是别人的。
它的层级、命名、叶子分布,全都由维护方决定,而且随时会变。关键词引擎的每一条规则都在隐式地依赖「树长这样」——树一变,规则就静默失效。
对依赖外部数据结构的逻辑,我现在会先问一句:如果这个结构明天换一种组织方式,我的代码会报错,还是会安静地给出错误的结果? 如果是后者,至少要有一个工具能在它变的那天告诉你。