改 hash seed 或 tokenizer 会导致检索 precision@5 从 0.94 骤降至 0.03,而改 embedding 宽度反而因类型检查直接报错被拦截——真正危险的是看不见的变化。
供应商发布了 v2,或者有人改了 tokenizer。索引还在、依然返回五条结果、分数看起来也还合理。
但只要改一下向量维度,形状就不对了,带类型的索引会直接拒绝——而把这个检查去掉的话,precision@5 也只是从 0.9417 跌到 0.7750。
如果改的是哈希种子或 tokenizer,维度完全一致,任何地方都不会报错,而 precision@5 直接跌到 0.0333、0.0917 和 0.1000——相比之下随机返回五个文档的 chance baseline 是 0.0833。
👉 实时演示,所有网格数据在浏览器中计算:https://dev48v.infy.uk/ai/days/day79-embedding-drift.html
这里没有调用任何 embedding API
embedder 是一个用哈希实现的 bag-of-tokens——完整的 FNV-1a、带声明种子的、有符号哈希技巧、可选的 idf、L2 归一化、余弦相似度。一个"版本"就是这个 embedder 的一个具体配置,所以这里的版本变更属于人们真正会做的那种。
语料库是合成数据且有明确标注:12 个主题 × 每主题 8 个特征词,每主题 10 篇文档。因为每篇文档的主题是构造时已知的,检索可以用正确率来评分,而不只是和某次运行结果的一致性。
能检测到的失败,其实是最轻微的那些
按损害程度排序,得到的结果几乎恰好和按可检测性排序的结果相反。3-gram 那一行落到了 0.0333——低于随机返回五个文档能达到的 0.0833。
向量本身没有任何版本标签。余弦相似度乐于比较任意两个等长的向量。在这条路径上没有任何一个节点能检测出版本不匹配。
实际发生的形态,是半迁移的索引
迁移脚本只跑在新摄入的数据上,没有人去做历史回填。索引一半在某个空间里,另一半在另一个:
这些文档并非排名糟糕,它们是根本不可达——而系统每次依然返回五条结果,来自仍然对齐的那一半。不会报错,结果集永远不会为空。
你会自然想到的那个监控指标,形状也不对
没有标签的情况下,唯一能看的就是相似度分数。在完全错配的情况下它确实会动:top-1 余弦均值从 0.5689 跌到约 0.23,跌幅约 60%。设个阈值能捕获到。
但在半迁移的索引上,相似度只跌了 29.8%,而 precision 跌了 50.4%。每条查询在仍然对齐的那一半里总有一个匹配度不错的文档,所以最高分维持得很好,而一半语料已经悄悄离场。
一个针对"响亮失败"调优的阈值,不会对"安静失败"触发——而实际发生的,恰恰是那个安静的类型。
哪怕正确迁移,结果也会变
把全部数据重新 embedding 是正确的修复方式,而且确实有效——precision 回升到 0.858–0.958,有一例甚至略高于原始值。但 top-5 与旧索引的重叠度低至 0.933、高至 0.725:修复后有 6.7% 到 27.5% 的检索文档是不同的。
没有任何东西坏了。只是,任何钉在旧结果上的 prompt、缓存、评估集或人工审批,现在都钉在不再会返回的结果上了——偏偏这一天你做对了所有事情。
修复方式,以及诚实的问题边界
把 embedder 版本写入索引,对版本不匹配的查询直接拒绝服务。上面所有后果都来自向量本身没有携带版本信息。
说清楚边界,是因为这会影响你如何解读那张表:这里的 embedder 是哈希实现的 bag-of-tokens,不是神经网络模型。哈希种子变更会产生两个完全不相关的空间——那是极端情况,是上限,不代表 v1→v2 的训练模型也会这样。tokenizer 变更那几行是更好的类比,而且它们几乎同样糟糕。
这篇文章不涉及语义漂移建模:在这个语料库里,除了词重叠之外没有任何语义可言,所以它对"一个仅仅是更好地理解问题的 v2"不提供任何预测。120 篇文档,24 条查询,top-5,纯余弦,不分块,不重排序,不做混合关键词检索。
28 个页面内检查,95 个验证器断言,0 个失败。