Engineering
每一个带自动整理功能的笔记工具,最后都会长出一个叫"其他"、"杂项"或"未分类"的抽屉。我们的也长了。某天早上打开 Nebula,36 条记忆躺在一个真的叫"未归类"的主题里,旁边还有一个叫"其他"的兜底。同一个失败,两个抽屉。
我们把这当成 bug 处理,而不是当成数据的属性。这篇讲 bug 最后是什么,以及我们最终的设计。
"未分类"里到底装的是什么
追踪那 36 条记录,没有一条是真的无法归类。它们来自两个机械的缺口:本该接住低置信记录的兜底主题,因为一个谁也够不到的阈值而不可达;一个从大簇里拆出来的小簇在路上丢了父级标签,到可视化的时候就没有主题了。
这就是普遍规律。"未分类"很少是内容模糊,它是管线停止做决定的地方,然后把"没有决定"包装成了一个类别。
十个门,为了稳定而定
修复从一个听起来显然、其实不显然的决定开始:分类的顶层是固定的。十个门,手写,目标是对一个人多年对着麦克风说的东西做到不重不漏:
- Building(构建)
- Work & People(工作与同事)
- Business & Growth(业务与增长)
- Money & Investing(金钱与投资)
- Learning & Ideas(学习与想法)
- Creating & Expression(创作与表达)
- People & Relationships(人与关系)
- Body & Mind(身心)
- Life Admin(生活事务)
- Direction & Meaning(方向与意义)
涌现式主题在 demo 里令人兴奋,在日常里让人疲惫。顶层一旦允许漂移,"career"和"work"这个月是两个本子,下个月合成一个,所有依赖这些名字的界面都得跟着改。顶层的稳定,是下面一切可以流动的前提。
每个门下面有一小组"槽",总共四十来个,同样手写。真正的归属发生在槽这一层。
一个必须做选择的分类器
归属由一个小型托管语言模型按批完成,对着封闭列表做。它看到门、门下的槽、每个槽一句描述,以及十几条笔记;必须以 JSON 返回每条笔记一个槽 id,别的什么都不能返回。不在列表里的 id 会被拒绝,那一行单独重试。
比模型更重要的是两个细节:
- 没有"置信度低于多少就送去兜底"的阈值。每条记录都拿到当前最合适的槽。模型不确定,就标成低置信,界面可以展示,但记录仍然有家。
- 有一个单独的池子,装那些根本不是记忆的东西:测试语句、一句光秃秃的"好的"、语音引擎从静音里幻听出来的碎片。这些是被隐藏,不是被上架。隐藏是一个有明确规则的明确决定,而一个叫"其他"的抽屉两者都不是。
标签直接存在记录上,旁边放着生成它的那版分类文本的哈希。改了某个槽的描述,只有旧版本打的标签会被重新排队。用户亲手挪过的记录,机器永远不再碰。
让第三层自己长出来
槽的下面,结构被允许自己生长。记录会被向量化,簇在每个槽内部按几何关系形成。簇只在长到很大(大约 150 条)时才拆,而且只在两半真的不一样时才拆——我们把拆分跑三次,候选看起来像重复就拒绝。保留原始锚点的那一半保留名字,另一半起新名字。
这一层就是"音乐创作"和"音乐表演"可以作为两个簇出现、却不需要任何人预先定义的地方,同时两者仍然在同一个槽、同一个门里。在犯错便宜的地方涌现,在犯错昂贵的地方稳定。
为什么配图不是生成的
每个门、每个槽都有配图,没有一张是图像模型按需生成的。它是一套人工策划的图库,每个门、每个槽一张,同一种画风。涌现出来的簇继承它所在槽的那张图。
另一条路我们试过。生成的封面惊艳一周,然后书架看起来像十个插画师吵了一架。一段你会看很多年的记忆,不该每次模型重训就换一张脸。
仍然会出错的地方
向量空间不是各向同性的。在一份真实语料上,一个门吞掉了大部分体积,只因为模型对工作类文本的表示更密。让语言模型读字来分类,远好于在那个空间里量距离——这也是为什么门的判断不用最近质心。
而且分类器是判断,不是测量。它会把一条关于副业定价的笔记放进 Business & Growth,而当事人觉得那是 Money。没关系:人可以挪,机器记住不挪回去,分类名称本地化成八种语言,纠正的感觉像整理书架,不像跟模型较劲。
系统再也不会做的一件事是耸肩。没有一个抽屉是留给它懒得决定的东西的。
这不是营销,是我们自己踩过的坑。