有一种特定类型的故障永远不会表现为宕机。服务状态保持绿色,延迟平稳,错误率为零,而搜索结果却悄然开始返回垃圾信息。这就是当你将一个新的嵌入模型(embedding model)指向由旧模型构建的索引时会发生的情况。没有任何程序崩溃。只是搜索结果不再有意义了。
原因在于几何学。嵌入模型不会为某个概念分配固定的坐标——它定义了一个 空间,而同一句话落在哪里完全取决于绘制地图的模型。去年模型产生的向量与今年模型嵌入的查询之间不存在“近”或“远”的关系。它们是根据不同的尺子衡量的。它们之间的余弦相似度(Cosine similarity)只是一个数字,而且这个数字毫无意义。
因此,当有人提交一个标题为“升级到新嵌入模型”的工单时,他们提交的并不是一个配置更改。他们提交的是一个披着单行代码差异(diff)外衣的完整数据迁移。如果你把它当作普通的库版本升级来处理,你就是在发布一个无声的故障。
为什么模型更换是迁移,而非版本升级
误导人们的直觉是,嵌入感觉像是一个 设置。你在一个地方把 text-embedding-old 改成了 text-embedding-new,代码编译通过了,API 接受了调用。所有的直觉都告诉你,这与更换一个 JSON 解析器是同一类变更。
事实并非如此,因为旧模型的输出持久地存储在你的数据库中。这数亿个向量中的每一个,都是你试图停用的那个模型的冷冻产物。查询路径改变了,但数据没有变。在整个语料库使用新模型重新嵌入之前,你的索引就是一个“闹鬼的房子”——里面堆满了来自一张无人再使用的地图的坐标。
这也是为什么你不能进行局部或惰性的迁移(例如“在读取时重新嵌入”)。搜索不是一次读取一个文档;它会将查询一次性与整个空间进行比较。在 top-k 结果中出现一个旧向量并不只是一个小错误——这就像是用苹果去比尺子,它可能会排在真正相关的、由新模型生成的向量之前。空间必须保持内部一致性,这意味着在 查询所见 的层面上,迁移必须是“全或无”的,即便重新嵌入本身是在后台逐渐完成的。
一旦你接受了这种设定,剩下的操作手册就顺理成章了。你不需要就地编辑数值。你需要构建第二个索引,填充它,证明它更好,然后进行原子化切换——这与你对任何不敢锁表的表进行模式迁移(schema migration)时所应用的纪律是一样的。
双索引切换
核心模式是针对向量的蓝绿部署,它由四个活动部分组成。
在旧索引旁构建新索引。 准备第二个集合(或者如果你的数据库支持,在同一个集合中准备第二个命名向量),并根据新模型的维度和距离度量进行配置。旧索引继续服务于所有查询。目前还没有任何面向用户的更改。
开启双写。 从这一刻起,每一个新文档和每一次更新都要通过 两个 模型进行嵌入,并写入两个索引。这是人们最容易跳过的步骤,而跳过它就意味着迁移必然失败:当你花费数天时间处理存量数据的重新嵌入时,实时流量仍在不断改变语料库。如果没有双写,新索引在你完成回填的那一刻就已经过时了——因为它缺失了迁移期间创建的所有文档。双写能将这种差异(delta)冻结在零。
在后台回填历史数据。 批量扫描现有语料库,用新模型重新嵌入每条记录,并将其更新(upsert)到新索引中。这是最耗时的环节——根据规模可能需要数小时到数天——但它以低优先级运行,与服务路径解耦。一个有用的纪律是“仅插入回填”:永远不要覆盖双写已经注入了更新向量的记录,否则你会用过时的回填数据覆盖掉新数据。
原子化切换读取。 当新索引完全填充并经过验证后,一举切换查询路径。最干净的机制是 别名(alias):你的应用程序查询 search-current(这是一个指针),然后你将其从旧索引指向新索引。这种切换是瞬时的,不存在一半流量看旧索引、另一半看新索引的中间状态,而且回滚操作也只需反向操作即可。
一个值得深入理解的注意事项:该模式的理想版本假设的是插入和更新插入。如果你的工作负载在迁移中期涉及硬删除或局部更新,双写则需要额外的逻辑来保持两个索引的一致性,或者你需要在迁移期间暂停这些操作。在开始之前就决定好这一点,而不是在发现计数不一致时才去处理。
没人会先算的成本与时间账
全量重新嵌入的资金成本通常是最不可怕的数字,尽管人们往往最关注它。算一次账,它就不再神秘了。
假设你有 1 亿个分块(chunks),平均每个分块 400 个 token。这意味着有 400 亿个 token 需要通过嵌入 API。按照目前大型嵌入模型的价格——大约每百万个 token 0.13 美元——一次性处理大约需要 5,200 美元,如果使用半价的批处理端点(batch endpoint),则只需约 2,600 美元。对于更小、更便宜的嵌入模型,可能只需要几百美元。虽然是一笔钱,但不足以让项目夭折。
真正让项目陷入困境的是 吞吐量。嵌入 API 受每分钟 token 数(TPM)配额的限制,而 400 亿个 token 是一个庞大的数字。在有效速率为每分钟 500 万个 token(这是一个很高级别的配额)的情况下,你需要 8,000 分钟,也就是超过 5 天的持续嵌入,这还是在零重试和完美流水线处理的前提下。一旦触发表率限制并退避,时间就会进一步拉长。这就是为什么批处理端点除了折扣之外更重要的原因:它在独立的配额下异步运行,因此你可以用延迟(结果在周转期内返回而非立即返回)换取在不耗尽线上流量配额的情况下处理海量数据的能力。
有两个在粗略估算中不会出现的约束。首先,批处理任务有上限——通常每次提交只有几万个输入——因此全量语料库处理不是一个任务,而是数百个编排好的任务,且需要对重试和局部失败进行状态跟踪。其次,你的 实时 查询流量和你的回填任务是在共享相关的配额池;如果你不进行隔离,迁移就会拖垮生产环境。计划回填时要设定一个速率上限,为真实用户留出余地,并因此做好耗时更长的准备。
坦率地说,估算结果是:资金成本低,时间成本高,且受限于速率限制——你通常需要提前数周申请提高配额。
证明新索引确实更好
有一种失败模式会让所有精心准备的切换机制变得毫无意义:你完美地迁移到了一个 对你的数据而言更糟 的模型。基准测试是针对非自有语料库的平均值。在公开排行榜上名列前茅的模型,在处理你所在领域的专业术语、文档长度或用户的查询风格时,表现可能不如现有的模型。你无法从模型卡片(model card)中发现这一点。
唯一的可靠检查方法是建立一个黄金查询集(golden query set)—— 几百个带有已知正确结果的真实查询,这些查询取自你的实际流量,并由了解你领域中“相关性”含义的人进行标注。由于双索引设置让两个空间同时存在,你可以针对两个索引运行每一个黄金查询并直接进行对比。
要根据检索自身的标准来衡量检索效果,而不是通过肉眼观察最终答案。最重要的指标是 recall@k:对于每个黄金查询,包含答案的文档是否出现在前 k 个结果中?计算旧索引和新索引的指标并观察其差异(delta)。持平或负的差异意味着这次“升级”实际上是降级,而你在用户发现之前就察觉到了这一点。你甚至可以进一步对新索引进行流量镜像(shadowing)—— 将真实查询镜像到新索引而不向任何人显示结果 —— 并在几天内观察其 top-k 结果在人们实际提问的全部分布中与生产环境的偏离情况。
只有当数据达标时,你才会切换别名(alias)。并且你需要保留旧索引一段时间作为回滚窗口 —— 删除它是最后一步,通常在切换几天后进行,而不是在新索引上线的那一刻。
当你无法承受全量重新嵌入时
有时语料库确实太大,或者模型更迭太频繁,导致无法每次都进行全量重新向量化。最近的一项研究提供了一个“逃生舱”:与其重新嵌入,不如学习一个小的变换,将旧模型的向量映射到新模型空间中。这类技术 —— 使用由两个模型共同嵌入的一组少量文档来拟合两个空间之间的线性对齐 —— 可以以极小的计算成本恢复原生重新嵌入约 85–95% 的准确度。
权衡之处正如你所预料的:近似并非真实。与重新计算的向量相比,变换后的向量带有可测量的误差,误差大小取决于两个模型最初在语义上的相似程度。这使得学习型适配器(learned adapters)非常适合用来争取时间 —— 在后台进行真实的存量数据回填时, 今天 就切换到新的查询模型 —— 或者适用于那些为了省去耗时数天、耗资五位数的任务而愿意接受几个 recall 点数损失的场景。对于一个以检索质量为核心产品的索引,你最终仍需完成真正的重新嵌入。适配器是一座桥梁,而不是终点。
一句话总结
嵌入模型的更换本质上是一次披着配置标志(config flag)外衣的数据库迁移。在旧索引旁构建新索引,进行双写以确保两者永不偏离,在不影响生产环境的速率限制下进行回填,在任何人看到之前用你自己的查询通过 recall@k 证明升级效果,通过别名进行切换,并保持旧索引热备份直到你确信无误。这样做,整个操作中最可怕的特性 —— 即失败的迁移看起来与健康的迁移完全一样 —— 就不再是威胁,而成了你通过测量能够从容应对的事情。
会员专享
余下内容仅对会员开放。
会员可读完整内容 —— 每一个公开发布的观点背后,那些框架、决策与推理的全貌。
- —完整文章,包含未公开存档的部分
- —可落地的工作框架,附带权衡与决策依据
- —新文章抢先看,先于公开发布
随时取消 · 一次订阅,畅读全部