分类推荐上线之后,加了一个纠偏埋点:每当用户改掉系统推荐的分类,就记一条——系统推荐了什么(suggested)、用户最后选了什么。

这些样本本来是要用来回答一个问题:AI 推荐和关键词引擎推荐,谁更准?

一次错误的归因

攒了 100 条样本后,我按「AI 分流功能上线的时间点」把样本切成前后两段,做了一个对比,得出结论:

上线之后,「被某个大系统淹没」的比例从 5% 涨到了 33%。

看起来很清楚:AI 分流让事情变糟了。

用户当场指出了口径问题。 我回头去追 suggested 这个字段到底是在哪里被写入的——

suggested 只可能是关键词引擎的输出

链路是这样的:

上报点传入 baseline
→ noteBaseline 记录「描述成熟之后的第一次推荐」,并且锁死
→ 用户手工打字时,600ms 防抖那一档(纯本地、不调模型)必然先跑完,抢先落定
→ 2.5 秒后 AI 分流跑完,问题描述没变,noteBaseline 直接 return

AI 的结果一条都进不去。

前后两段样本的 suggested,是同一个关键词引擎的输出。我对比的根本不是「AI 上线前 vs 上线后」,是「关键词引擎 vs 关键词引擎」——两段之间的差异来自别的原因,和 AI 毫无关系。

拿埋点数据做 A/B 归因之前,先追一遍那个字段到底在哪里被写入。

字段名叫 suggested,不代表它记录的是「被推荐的东西」——它记录的是「第一个跑完的那个推荐者的输出」。

修法:拆成三方

一个字段承担了两件事(「推荐了什么」和「谁推荐的」),而且在竞态里只能留下其中一方。拆开:

字段记录什么
suggested关键词引擎的推荐(保持原语义,旧数据仍可用)
aiSuggestedAI 的推荐(+ 分数、候选系统)
source最后填进表单的,是谁给的

这才是能回答「谁更准」的前提。

还有一个配套步骤不能漏:后端(PocketBase)要先建这几列。PB 会静默丢弃 schema 里不存在的字段——POST 返回 200,值不落库,毫无报错(这个坑单独写过:远程配置那篇)。顺序永远是先建列、再发客户端。

新数据要攒起来才能回答问题。旧的 100 条只能用来分析关键词引擎自身的病——而它们在这方面其实很有价值。

旧数据里真正成立的结论:通用叶子是吸铁石

不受这个盲区影响、仍然成立的发现是这个:

78 条误判里,26 条的首选落在「账号 / 登录 / 权限」这类通用叶子上;而正确答案只有 12% 在那里。

这类叶子在分类树里跨很多系统重名——几乎每个系统下面都有一个「账号权限问题」。而用户的描述里,几乎总会出现「登录」「账号」「打不开」这类词。

更值得注意的是分数:

首选分数中位数
误判落在通用叶子上的145
其余误判75

通用叶子的误判,分数反而高了将近一倍。

原因不难理解:通用词和用户描述的字面重合度天然拉满——「登录不了」和「账号登录问题」几乎每个字都对得上。于是引擎高分、而且自信地错了。

这是关键词引擎的病:字面重合度 ≠ 相关度。对通用叶子要单独降权——而不是把所有叶子一视同仁地按字面打分。

顺带:「高分」本身不能当置信度

这组数据还揭示了一件更一般的事:

如果一个打分系统在错的时候分数更高,那它的分数就不能拿来当置信度用。

原本有一个设计是「分数超过阈值就自动填、低于阈值才让用户选」。在这组数据面前,这个设计会把最自信的那批错误自动填进去。

用分数做决策之前,先看一下它在错例上的分布。分数的意义是被验证出来的,不是被定义出来的。


回头看,这次最有价值的不是那个修复,而是被指出口径问题的那一刻。

我手里有数据、有切分方法、有一个清楚的结论——一切看起来都是严谨的。唯一的问题是,数据记录的不是我以为它记录的东西。

埋点是代码写的,它和其它代码一样会有 bug。 而埋点的 bug 比普通 bug 危险得多:普通 bug 让功能坏掉,埋点的 bug 让你对「功能是好是坏」得出错误的判断,然后据此做决策。