历史工单里沉淀着大量真实的处理结果——某个问题以前是怎么解决的,原话就在那里。这比维护一份手写知识库有价值得多,而且零维护:数据自然在增长。
所以想做一个「相似工单」:给一段问题描述,找出历史上类似的单,把当初的处理结果拿出来。
问题是那个工单系统的列表接口没有任何模糊匹配能力。
第一条:过滤器写错不报错
在生产环境里逐个试了一遍这个列表接口的过滤条件。判据很朴素:加上这个条件之后,返回的总数有没有变小。
| 条件 | 结果 |
|---|---|
| 按状态 | 总数变小 ✅ |
| 按分类(精确值) | 总数变小 ✅ ——这是整个功能的地基 |
| 按客户 | 总数变小 ✅ |
| 按更新时间区间 | 生效 ✅ |
| 按标题精确等值 | 生效 ✅,但只能精确匹配 |
| 按标题,换了五六种常见的模糊运算符写法 | 返回全量 ❌ |
| 顶层加 keyword / search / q 之类的参数 | 返回全量 ❌ |
| 字段名带上它在数据里的层级前缀 | 返回全量 ❌(必须用裸字段名) |
| 按描述正文 | 返回全量 ❌ |
最后四行是关键。写错的条件不会报错,也不会返回空,而是被静默忽略——于是你拿到的是一个「看起来查到了很多相关工单」的结果。
写错运算符的表现不是异常,而是「看起来查到了全量的相关工单」。
如果不做任何检查,你会把全量里的头 20 条当成「相似工单」展示给用户——而它们和当前问题毫无关系。
所以检索前后各有一道闸:
// 前置闸:没有分类也没有客户条件 → 直接拒绝(这种查询必然是全量流)if (!category && !customerId) return { ok: false, reason: 'no-narrowing-condition' };
const res = await searchTickets(filter);
// ★ 过滤生效自检:总数必须显著小于全量,否则判定过滤失效、放弃本次检索if (res.total >= FULL_TOTAL * 0.5) { return { ok: false, reason: 'filter-ineffective' };}宁可不给结果,也不能给一个错的结果。 用户看到「没找到相似单」会去自己搜;看到一个错的相似单会信它。
架构:服务端能做的只有粗筛
既然服务端只能做精确过滤,那就分两层:
① 查询源:当前工单的问题描述 + 分类 + 客户② 服务端粗筛:同分类(或同客户)+ 已关闭 + 近 6 个月,取 100 条,最多翻 2 页 ⚠ 过滤生效自检③ 本地精排:TF-IDF + 中文 2-gram,零网络④ 出口:侧栏卡片(默认不调模型)/ 一个只读 tool(模型可自主调)运气好的一点是:列表接口每一行已经内联了完整描述和处理结果,不需要逐条请求详情。实测 100 条 = 2.4 秒 / 511KB,其中 92 条有实质处理结果。
为什么是关键词而不是向量
这是个真选择——向量模型当时是可用的。
但相似工单的候选是每次临时取回的 100 条,不是预先建好索引的固定语料。向量方案每次都要为这 100 条现场算向量,缓存不了,延迟和成本随查询次数线性增长。
「一次建库、多次检索」才是向量的主场。这里是「每次换一批候选」,关键词零依赖、零延迟、没配向量服务的用户也能用。
如果将来召回不理想,再只对关键词筛出的前 20 条做向量重排——并且必须 fail-soft,向量服务挂了就退回关键词结果。
长查询必须先浓缩
这条是实测校准出来的,同一批 98 条候选:
| 查询 | 最佳得分 | 命中质量 |
|---|---|---|
| 整段描述(约 120 字) | 0.154 | 前 4 条混进了不相关的单 |
| 同上,截到首句约 40 字 | 0.347 | 首条就是同一个报表编号的问题 |
| 一句口语短语 | 1.0 | 前 3 条的处理结果完全对口 |
| 一句无关的话 | — | 0 条,没有误召回 |
原因在归一化方式:覆盖率的分母是全部查询词的 IDF 之和。长描述里的细节词(型号、时间、房间号)几乎不可能被历史单命中,它们的存在只是在拉低分数。
所以超过 40 字的查询先截到首句。
检索质量不只取决于匹配算法,也取决于你喂进去的查询。 用户写的描述是为人读的,不是为检索写的。
同一套分词,不要写两份
中文分词(2-gram)在知识库检索里已经有一份实现。相似工单直接复用那个导出的函数,不另写。
理由不是省事:同一逻辑写两处,改一处漏一处——这个项目里反复踩过。分词规则一旦不一致,两个检索功能对同一句话会给出不同的判断,而没有人会想到去比较它们的分词。
同一个坑犯了两次:工单号 vs 内部 id
工单有两个编号:
| 字段 | 是什么 | 给谁 |
|---|---|---|
| 内部数字 id | 一串数字 | 只用来拼 URL |
| 工单号 | 带前缀和日期的编号 | 人认的、搜索时输入的就是它 |
取错不会报错,只会安静地给出一个搜不到的编号。所以它犯了两次:
- 质检导出的 CSV 里,「工单号」一列给的是内部 id。用户发现了,修了;
- 修完之后,侧栏 AI 的上下文那条链漏了——AI 回答「这单工单号是 1367……」,而真实工单号完全不是这个。
第二次暴露的是第一次修得不彻底:只改了用户报的那个出口,没有把所有出口一起过一遍。
修完之后的状态:所有出口统一用工单号;tool 返回里保留内部 id 但注释「仅拼链接」;tool 的描述里明写「转述时必须报工单号,内部 id 用户搜不到」——光靠字段名指望模型自己挑对是不够的。
另外 tool 的入参做了显式校验:传进来的是工单号而不是纯数字 id 时,报错说明,而不是返回一个 null——否则模型会把「我传错了参数」理解成「这单不存在」。
修「字段取错」这类 bug 时,先 grep 出这个字段的全部消费点,一次改完。 只修报上来的那一处,剩下的会在别的出口以同样的面貌再冒出来。
结果必须在 tool 内裁剪
一页原始返回 511KB。侧栏外层有一道「超过 4000 字就截断」的保护,但那道保护是按字符截的——会把 JSON 从中间切断,喂给模型半截结构,然后它会一本正经地解释这段坏掉的数据。
所以 tool 返回的是已经裁剪好的紧凑卡片:问题不超过 120 字、处理结果不超过 200 字、最多 5 条。外层截断只作为兜底。
这个功能里最有价值的一行代码,不是精排算法,是那道「过滤生效自检」。
它承认了一件事:你调用的接口可能在骗你——不是恶意的,只是它对不认识的参数选择了沉默。而在这种接口上,所有的「查到了」都需要一个旁证。