向量相似度测试如何摆脱网络和密钥
文章用 TypeScript 实现文本向量相似度评分,并为测试提供确定性的本地替代方案。生产环境调用真实嵌入服务,测试无需网络或 API 密钥,便于在缺少 secrets 的分支 CI 中运行。
AI 深度解读
给文本相似度检查接上 embedding,测试就可能多出网络请求、API key 和不稳定因素。尤其是外部贡献者提交的 fork,CI 拿不到密钥,测试可能连执行的机会都没有。
作者在 evalgate 这个 CI 评估工具中采用了一种拆分:生产环境用真实 embedding,离线测试用确定性的本地向量,共用后面的打分和阈值判断。这套 TypeScript 实现值得借鉴,但要先分清:它能验证评分流程,不能替你证明语义判断准确。
把可替换的部分收窄到 embed 函数
两段文本分别变成向量,再用余弦相似度比较方向,是整套方案的基础。余弦相似度忽略向量长度,返回值在 -1 到 1 之间,1 代表方向相同。
作者的实现加了两个保护:只遍历两个向量中较短的长度,避免长度不一致时访问越界;分母为零时返回 0,避免零向量产生 NaN。
真正影响可测试性的设计,是评分器不直接依赖 OpenAI 或某个 SDK。它只要求 provider 能提供 embed():有就调用,没有就使用本地实现。
下面这段代码把向量来源和评分逻辑接在一起:
const embed = ctx.provider.embed
? (t: string) => ctx.provider.embed!(t)
: (t: string) => Promise.resolve(localEmbedding(t));
const [outVec, expVec] = await Promise.all([embed(output), embed(expected)]);
const cos = cosineSimilarity(outVec, expVec);
// 将 [-1, 1] 映射到 [0, 1],让阈值更直观。
const score = (cos + 1) / 2;
const passed = score >= threshold;
要看清的是 const score = (cos + 1) / 2。这里的分数经过了线性转换,threshold = 0.8 实际对应原始余弦相似度 0.6,并不表示「有 80% 的概率语义相同」。
Promise.all 并发生成两个向量。之后的余弦计算、分数转换和通过判断,都不再关心向量来自哪个服务。这让离线测试可以覆盖真实的评分逻辑,而不用把整个评分器替换成一个固定结果。
离线向量为什么稳定,又为什么不懂语义
本地实现使用的是哈希词袋:把单词映射到固定数量的桶,统计每个桶出现了多少次,再做 L2 归一化。可以把它理解为一张固定长度的词频表,只是多个词可能挤进同一个位置。
作者选择 FNV-1a 作为稳定哈希函数,初始值为 0x811c9dc5,每轮异或字符编码后,用 Math.imul 乘以 0x01000193,保持 32 位整数运算,最后通过 hash >>> 0 转成无符号整数。
下面这段代码完成分词、计数和归一化,依赖前述 fnv1a():
export function localEmbedding(text: string, dims = 64): number[] {
const vec = new Array<number>(dims).fill(0);
const words = text
.toLowerCase()
.replace(/[^a-z0-9\s]/g, " ")
.split(/\s+/)
.filter(Boolean);
for (const word of words) {
const idx = fnv1a(word) % dims;
vec[idx] = (vec[idx] ?? 0) + 1;
}
const norm = Math.sqrt(vec.reduce((s, v) => s + v * v, 0)) || 1;
return vec.map((v) => v / norm);
}
要特别检查 .replace(/[^a-z0-9\s]/g, " "):它只保留英文字母、数字和空白,中文会被清除。默认的 dims = 64 则决定向量有 64 个桶。
在实现和参数固定时,同一输入总会得到同一向量,不需要网络、密钥或随机数。这足以测试评分器能否运行、阈值是否生效,以及一些预先设计好的输入是否通过。
但它没有理解文本。作者用猫的例句说明:The cat sat on the mat 和 A feline rested on the rug 意思接近,表面词汇却几乎不重合,本地实现会给出较低相似度。
这套替代实现验证的是评分流程,不是语义质量。
我的判断
我赞成把外部依赖收敛到 embed() 这个接口。相比直接 mock 整个评分器,它保留了更多真实逻辑;相比每次测试都调用真实服务,它更适合需要离线执行、拿不到密钥的 CI。
不过,原文没有提供测试耗时、误判率或与其他方案的对照数据。「测试更可信」是有工程逻辑支撑的经验判断,不能当成已经量化验证的结论。
还有几个边界需要补齐。首先,作者说无共同词的文本得分接近零,放到最终 score 上并不成立:即使原始余弦为 0,映射后也是 0.5。哈希碰撞还可能让不同词产生重叠。因此,「无关文本一定失败」取决于具体输入和阈值。
其次,纯中文输入会变成零向量。按原文的零分母处理,最终得分仍是 0.5;两个相同的空字符串也一样。不能把「相同字符串通过」当成无条件保证。
最后,截取较短向量虽然避免了异常,也可能掩盖 provider 返回维度错误。生产中缺少 embed() 时自动降级,同样需要谨慎,因为作者自己明确反对用哈希向量承担生产语义判断。原文没有展示如何强制这个边界。
离线用例通过,只能说明这部分逻辑通过了验证。 真实服务的鉴权、返回格式和语义效果,仍要单独检查。
能拿来做什么
- 给 Node 或 TypeScript 项目的相似度评分器抽出
embed(text)接口,让余弦计算和阈值判断可以脱离服务 SDK 独立执行。 - 在离线测试中明确覆盖词汇重复、无关英文、空字符串和纯中文输入,按映射后的分数设计断言,避免误把 0.5 当成零相似度。
- 为真实 provider 保留单独的集成检查,验证向量维度、调用行为和代表性语义样例;这些问题无法由哈希词袋代替验证。
- 如果采用自动回退,在生产配置中明确要求真实
embed()可用,避免配置缺失后继续输出看似正常的语义评分。