同一文本重复Embedding可能返回微小差异的向量,导致哈希缓存、精确去重、测试断言失败;解释了为何托管模型存在非确定性及工程上的应对方案。
相同的文本并不产生相同的向量
这让很多人感到意外,因为 Embedding 看起来像是文本的纯函数。没有采样,没有 temperature,模型也没有状态——所以很自然地会假设输出是确定性的。但在实践中,对托管的 Embedding 端点多次调用相同输入,返回的向量会有细微差异,开发者们自 2023 年起就一直在报告这个问题。
由此产生什么后果,完全取决于你构建了什么。如果你存储了向量且不再重新 Embed,则没有任何影响。如果你是用新 Embed 的查询与存储的向量做比较,也没有任何影响,因为差异远低于你使用的任何阈值。但是如果你对一个向量做哈希作为缓存的 key,或者用精确的向量相等性去重,或者写一个测试用 toEqual 断言 Embedding,这三种场景都会间歇性失败,而且看起来像是你代码里的 bug。
浮点数加法不满足结合律:对于有限精度的值,(a + b) + c 和 a + (b + c) 可能给出不同结果。模型的一次前向传播是大量的累加操作,而这些累加发生的顺序并不固定。它取决于选择了哪个 GPU kernel,而 kernel 的选择又取决于 batch 的形状,batch 形状又取决于同一时刻到达了多少其他请求。
所以同一个文本单独 Embed 和嵌入在一个包含 200 条数据的 batch 中间,两次会走上数值上不同的路径、到达同一个数学答案,结果在低位比特上有差异。这不是 Provider 的缺陷,也不是通过 seed 能修复的——同样的机制也是固定 seed 不会让语言模型达到位级一致的原因。这是跨并行硬件运行大规模归约操作的固有特性。
选择阈值而非断言相等
能通过的断言是相似度下限。同一次输入的两次 Embedding 之间的余弦相似度应该非常接近 1——远比任意两个真正不同的文本之间的相似度更接近——测试就是检查它保持在这个位置。
一个有用的简化:OpenAI 的 Embeddings 指南指出其 Embedding 被归一化到长度 1,这意味着余弦相似度可以略微更快地计算为纯点积。不要对所有 Provider 或你自己托管的模型做此假设,并注意同一份文档警告说,如果你使用 dimensions 参数截断一个 Embedding,必须在此之后重新归一化——截断后的向量不再是单位长度,对其做点积也不再是余弦相似度。还是写通用的余弦函数吧;就四行代码,而且去除了你测试中的一个假设。
从数据而非口味出发选择这个下限。取几条有代表性的字符串各 Embedding 十次,记录你观察到的最小成对相似度,然后把阈值设得略低于它。然后在注释里写清楚你观察到的值是多少、什么时候观察的——一个没有出处的阈值会在第一次失败时被放松。
// embedding-stability.test.ts
import { describe, expect, it } from "vitest";
import { client } from "./probe";
const MODEL = "text-embedding-3-small";
const STABILITY_FLOOR = 0.9999;
function cosine(a: number[], b: number[]): number {
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
return dot / (Math.sqrt(na) * Math.sqrt(nb));
}
async function embed(input: string | string[]) {
const res = await client.embeddings.create({ model: MODEL, input });
return res.data.map((d) => d.embedding);
}
describe("embedding stability", () => {
const text = "Refund requested for order 88031, item damaged in transit.";
it("repeated calls stay above the stability floor", async () => {
const runs = [];
for (let i = 0; i < 5; i++) runs.push((await embed(text))[0]);
for (let i = 1; i < runs.length; i++) {
const sim = cosine(runs[0], runs[i]);
expect(sim, "run " + i + " similarity " + sim).toBeGreaterThan(STABILITY_FLOOR);
}
}, 60_000);
it("returns the documented dimensionality", async () => {
const [vec] = await embed(text);
expect(vec).toHaveLength(1536);
});
it("distinguishes this text from an unrelated one", async () => {
const [a] = await embed(text);
const [b] = await embed("The 14:02 train to Utrecht departs from platform 3.");
expect(cosine(a, b)).toBeLessThan(0.5);
});
});
第三个测试不是多余的padding。单独的稳定性测试在端点损坏、始终返回同一个常量向量时会被轻易通过——这种故障模式确实发生过,通常是通过配置错误的本地服务器或 key 太粗糙的缓存。一个负面用例让整个测试套件变得有意义,而且选择一个真正无关的句子可以让边界保持宽松、不至于产生 flaky。
Batch 位置是另一个变量
上面的测试每次只发送一个输入,这是简单场景。有趣的问题是:一个文本单独 Embed 与它作为包含 100 个元素的 batch 中第 47 个元素 Embed 是否一致——因为这正是你对语料库做索引与你对查询做 Embedding 之间的区别,而且 batching 正是 kernel 形状发生变化的地方。
it("is stable across batch position", async () => {
const target = "Refund requested for order 88031.";
const filler = Array.from({ length: 99 }, (_, i) => "Unrelated record " + i);
const [alone] = await embed(target);
const batch = await embed([...filler.slice(0, 47), target, ...filler.slice(47)]);
expect(cosine(alone, batch[47])).toBeGreaterThan(STABILITY_FLOOR);
}, 60_000);
如果单个输入测试通过而这个测试失败了,你就发现了值得在索引一百万份文档之前了解的信息:索引和查询的 Embedding 在你假设的精度下不能直接比较,而且你在一种上调整的阈值到另一种上会略有偏差。这同样是在改变 batch size 之后要跑的测试——否则对索引中的数字的改动是不可见的。
上面的下限是示例,不是测量值——根据你自己的观察结果针对你自己的模型来设置,并在你更换模型或 Provider 时重新推导。一个从网上复制来的阈值是一个在触发时没有人能为之辩护的阈值。
除了测试之外,有两个后果值得付诸行动。首先,不要用向量作为缓存的 key。用输入文本和模型 ID 的哈希作为 key,这样从构造上就是稳定的;以向量作为 key 会产生一个永远不会命中且无限增长的缓存。其次,如果你按 Embedding 相似度而非内容哈希对文档去重,你用于此目的的阈值会受到阈值页面上描述的同样的漂移影响,而一个悄悄放松的去重规则会导致语料库悄悄充满近似重复的文档。
这个测试套件应该在什么地方跑?不是每次 commit——它会发出真实的请求,而且它所关注的变化是以模型发布为单位发生的。夜间运行是正确的选择,加上在任何改变 Embedding 模型、维度设置或 batching 代码的时候。同时记录它观察到的数字,而不仅仅是它的通过或失败:一个相似度从 0.99999 漂移到 0.9997 但没有越过你的下限,这仍然是新闻,而且是一种在失败之前就会到来的新闻。