跳到主要内容

淘汰嵌入模型:在不中断搜索的情况下重新索引数百万个向量

· 阅读需 11 分钟
Tian Pan
Software Engineer

有一种特定类型的故障永远不会表现为宕机。服务状态保持绿色,延迟平稳,错误率为零,而搜索结果却悄然开始返回垃圾信息。这就是当你将一个新的嵌入模型(embedding model)指向由旧模型构建的索引时会发生的情况。没有任何程序崩溃。只是搜索结果不再有意义了。

原因在于几何学。嵌入模型不会为某个概念分配固定的坐标——它定义了一个 空间,而同一句话落在哪里完全取决于绘制地图的模型。去年模型产生的向量与今年模型嵌入的查询之间不存在“近”或“远”的关系。它们是根据不同的尺子衡量的。它们之间的余弦相似度(Cosine similarity)只是一个数字,而且这个数字毫无意义。

因此,当有人提交一个标题为“升级到新嵌入模型”的工单时,他们提交的并不是一个配置更改。他们提交的是一个披着单行代码差异(diff)外衣的完整数据迁移。如果你把它当作普通的库版本升级来处理,你就是在发布一个无声的故障。

为什么模型更换是迁移,而非版本升级

误导人们的直觉是,嵌入感觉像是一个 设置。你在一个地方把 text-embedding-old 改成了 text-embedding-new,代码编译通过了,API 接受了调用。所有的直觉都告诉你,这与更换一个 JSON 解析器是同一类变更。

事实并非如此,因为旧模型的输出持久地存储在你的数据库中。这数亿个向量中的每一个,都是你试图停用的那个模型的冷冻产物。查询路径改变了,但数据没有变。在整个语料库使用新模型重新嵌入之前,你的索引就是一个“闹鬼的房子”——里面堆满了来自一张无人再使用的地图的坐标。

这也是为什么你不能进行局部或惰性的迁移(例如“在读取时重新嵌入”)。搜索不是一次读取一个文档;它会将查询一次性与整个空间进行比较。在 top-k 结果中出现一个旧向量并不只是一个小错误——这就像是用苹果去比尺子,它可能会排在真正相关的、由新模型生成的向量之前。空间必须保持内部一致性,这意味着在 查询所见 的层面上,迁移必须是“全或无”的,即便重新嵌入本身是在后台逐渐完成的。

一旦你接受了这种设定,剩下的操作手册就顺理成章了。你不需要就地编辑数值。你需要构建第二个索引,填充它,证明它更好,然后进行原子化切换——这与你对任何不敢锁表的表进行模式迁移(schema migration)时所应用的纪律是一样的。

双索引切换

核心模式是针对向量的蓝绿部署,它由四个活动部分组成。

在旧索引旁构建新索引。 准备第二个集合(或者如果你的数据库支持,在同一个集合中准备第二个命名向量),并根据新模型的维度和距离度量进行配置。旧索引继续服务于所有查询。目前还没有任何面向用户的更改。

开启双写。 从这一刻起,每一个新文档和每一次更新都要通过 两个 模型进行嵌入,并写入两个索引。这是人们最容易跳过的步骤,而跳过它就意味着迁移必然失败:当你花费数天时间处理存量数据的重新嵌入时,实时流量仍在不断改变语料库。如果没有双写,新索引在你完成回填的那一刻就已经过时了——因为它缺失了迁移期间创建的所有文档。双写能将这种差异(delta)冻结在零。

在后台回填历史数据。 批量扫描现有语料库,用新模型重新嵌入每条记录,并将其更新(upsert)到新索引中。这是最耗时的环节——根据规模可能需要数小时到数天——但它以低优先级运行,与服务路径解耦。一个有用的纪律是“仅插入回填”:永远不要覆盖双写已经注入了更新向量的记录,否则你会用过时的回填数据覆盖掉新数据。

原子化切换读取。 当新索引完全填充并经过验证后,一举切换查询路径。最干净的机制是 别名(alias):你的应用程序查询 search-current(这是一个指针),然后你将其从旧索引指向新索引。这种切换是瞬时的,不存在一半流量看旧索引、另一半看新索引的中间状态,而且回滚操作也只需反向操作即可。

一个值得深入理解的注意事项:该模式的理想版本假设的是插入和更新插入。如果你的工作负载在迁移中期涉及硬删除或局部更新,双写则需要额外的逻辑来保持两个索引的一致性,或者你需要在迁移期间暂停这些操作。在开始之前就决定好这一点,而不是在发现计数不一致时才去处理。

加载中…
References:Let's stay in touch and Follow me for more thoughts and updates