换模型、双写和混合召回解决的是检索策略,管不了库里已经脏掉的向量。空向量、NaN、维度错位和近重复一旦入库,召回会静默变差,事后很难对账。把质检前移到写入路径:校验维度与范数、拦截异常嵌入、用指纹隔离近重复,失败进隔离区而不是默默覆盖。
脏向量为什么比差策略更难查
检索变差时,团队习惯先动策略:换 Embedding 模型、新旧向量双写、关键词加向量做混合召回。这些手段都合理,前提却是库里的向量本身可信。策略调的是「怎么找」;脏数据改的是「能找到什么」。后者更隐蔽。
典型事故并不戏剧。一次模型升级漏改了 expectedDim,新向量按 1536 写入,旧索引按 3072 读,客户端有的直接抛错,有的在驱动层截断或填零。一次批量任务里上游文本是空字符串,模型仍返回数组,但范数接近 0,这些点在空间里挤成一团,谁靠近谁都像相关。一次重试把半截响应写进去,中间出现 NaN,ANN 索引不一定拒绝,查询时距离变成无意义的数。还有更常见的:同一段文档被增量任务重复嵌入,近重复向量把 Top-K 名额占满,真正该露出的邻居被挤掉。
这些都不会在仪表盘上显示成 5xx。召回指标慢慢往下掉,你却说不清是模型、分块,还是某天的一次失败写入。对账也难:向量库往往只留最终那条,覆盖发生后,当时的原文、模型版本、失败原因都不在同一条链上。所以质检不该做成「检索前再滤一遍」的补丁,而应做成写入路径上的门禁:不合格的记录根本不要进主索引。
门禁放在写入路径,而不是检索侧补丁
一套能落地的入库门禁,只做四件事:
- 校验形状:类型、长度、维度必须与当前模型契约一致。
- 校验数值:每个分量有限,L2 范数落在合理区间,拒绝全零和爆炸值。
- 用量化指纹识别近重复,避免无信息量的覆盖。
- 失败写入隔离区,带上原因、原文摘要、模型与维度,供重试和对账;成功才进主库,并保留版本,而不是原地覆盖。
门禁是写入链路的纯函数加两侧存储,不依赖某个向量数据库的专有插件。下面的示例只用 Node.js 内置模块,可以直接 node embedding-gate.mjs 跑通。生产里把 live / quarantine 换成你现有的 Postgres、对象存储或向量库即可,判断逻辑不用改。
维度契约必须来自模型卡,而不是「第一条成功写入的长度」。否则错误维度会把自己洗成新的基准。范数阈值也一样:先按你用的模型看一眼正常样本的 L2,再设下限和上限;下面默认 1e-6 和 1e2 只是保守兜底,用来抓住空向量和明显异常,不是调参建议。
校验维度、有限性与范数
先把检查收成一个函数。它不请求网络、不读写库,输入是向量和契约,输出是 ok、问题列表、范数和指纹。这样增量同步任务、手动补写脚本、离线回填都能复用同一套规则。
// embedding-gate.mjs
import crypto from "node:crypto";
import assert from "node:assert/strict";
export function fingerprintOf(vec, decimals = 3) {
const quantized = vec
.map((x) => (Number.isFinite(x) ? x.toFixed(decimals) : "nan"))
.join(",");
return crypto.createHash("sha256").update(quantized).digest("hex").slice(0, 16);
}
export function inspectEmbedding(
vec,
{ expectedDim, minNorm = 1e-6, maxNorm = 1e2, decimals = 3 } = {},
) {
const issues = [];
if (!Array.isArray(vec) || vec.length === 0) {
return {
ok: false,
issues: [{ code: "EMPTY", message: "embedding 为空" }],
dim: 0,
norm: 0,
fingerprint: null,
};
}
if (expectedDim != null && vec.length !== expectedDim) {
issues.push({
code: "DIM_MISMATCH",
message: `期望 ${expectedDim} 维,实际 ${vec.length}`,
});
}
for (let i = 0; i < vec.length; i++) {
if (typeof vec[i] !== "number" || !Number.isFinite(vec[i])) {
issues.push({ code: "NON_FINITE", message: `index ${i} 为 ${String(vec[i])}` });
break;
}
}
let sumSq = 0;
for (const x of vec) sumSq += Number.isFinite(x) ? x * x : 0;
const norm = Math.sqrt(sumSq);
if (norm < minNorm) issues.push({ code: "NEAR_ZERO", message: `L2=${norm}` });
if (norm > maxNorm) issues.push({ code: "EXPLODED", message: `L2=${norm}` });
return {
ok: issues.length === 0,
issues,
dim: vec.length,
norm,
fingerprint: fingerprintOf(vec, decimals),
};
}
function cosine(a, b) {
if (a.length !== b.length) return 0;
let dot = 0;
let na = 0;
let 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];
}
const d = Math.sqrt(na) * Math.sqrt(nb);
return d === 0 ? 0 : dot / d;
}
几条规则值得写进代码注释,而不是靠约定:
EMPTY和NEAR_ZERO要分开。上游偶发空数组,和模型对空文本仍返回「看起来合法、实际无方向」的向量,处理方式不同:前者应阻断嵌入调用,后者必须在嵌入之后拦。- 只扫描到第一个非有限值就可以失败。
NaN会污染后续距离,没有「修一个分量再入库」的空间。 - 指纹用量化后再哈希,而不是原始 float 直出。同一段文本用同一模型重算,末位噪声经常在 1e-6 量级,直接哈希会把重试当成全新向量;量化到 3 位小数,足以把「几乎同一条」收成同一指纹,又不必上 LSH。
指纹近重复与隔离区
检查通过还不该直接 upsert。主库按 (id, model) 保留历史版本;另外用指纹索引做近重复隔离。这里的「近重复」先限定两件能在写入路径上完成的事,不把语料级去重伪装成门禁:
- 同一
id+ 同一模型,新向量与当前版本余弦过高(例如 ≥ 0.995)且原文哈希未变:这是无信息量写入,跳过即可,不必进隔离区。 - 不同
id撞上同一量化指纹:更像重复内容或错误的文档主键,写入隔离区,避免用后到的记录覆盖先到的邻居结构。
失败一律进隔离区。隔离记录要能回答三个问题:哪条文档、为什么拒绝、当时用的哪套模型契约。主库则只收门禁通过的版本。下面把存储做成内存 Map,并接上完整的入库函数和自测。
const live = new Map(); // key: `${id}::${model}` -> versions[]
const fingerprints = new Map(); // fingerprint -> { id, model }
const quarantine = [];
export function ingest({
id,
text,
model,
expectedDim,
embedding,
nearDupCosine = 0.995,
}) {
const contentHash = crypto.createHash("sha256").update(text).digest("hex").slice(0, 16);
const inspected = inspectEmbedding(embedding, { expectedDim });
const base = {
id,
model,
expectedDim,
contentHash,
inspected,
textPreview: text.slice(0, 80),
at: new Date().toISOString(),
};
if (!inspected.ok) {
quarantine.push({ ...base, reason: inspected.issues });
return { status: "quarantine", reasons: inspected.issues };
}
const key = `${id}::${model}`;
const versions = live.get(key) || [];
const current = versions[versions.length - 1];
if (current && current.contentHash === contentHash) {
const sim = cosine(current.embedding, embedding);
if (sim >= nearDupCosine) {
return { status: "skip", cosine: sim, version: current.version };
}
}
const hit = fingerprints.get(inspected.fingerprint);
if (hit && hit.id !== id) {
quarantine.push({
...base,
reason: [{ code: "NEAR_DUP_FINGERPRINT", message: `与 ${hit.id} 指纹相同` }],
});
return { status: "quarantine", reasons: [{ code: "NEAR_DUP_FINGERPRINT" }] };
}
const version = (current?.version || 0) + 1;
const record = {
version,
embedding,
contentHash,
fingerprint: inspected.fingerprint,
norm: inspected.norm,
dim: inspected.dim,
};
versions.push(record);
live.set(key, versions);
fingerprints.set(inspected.fingerprint, { id, model });
return { status: "live", version };
}
function unit(dim, i) {
const v = Array(dim).fill(0);
v[i] = 1;
return v;
}
const dim = 8;
const model = "demo-emb-8d";
assert.equal(inspectEmbedding([], { expectedDim: dim }).issues[0].code, "EMPTY");
assert.equal(inspectEmbedding([1, NaN, 0], { expectedDim: 3 }).issues[0].code, "NON_FINITE");
assert.equal(
inspectEmbedding(unit(7, 0), { expectedDim: dim }).issues[0].code,
"DIM_MISMATCH",
);
assert.equal(
inspectEmbedding(Array(dim).fill(0), { expectedDim: dim }).issues[0].code,
"NEAR_ZERO",
);
const a = ingest({
id: "doc-1",
text: "向量入库要先做质检",
model,
expectedDim: dim,
embedding: unit(dim, 0),
});
assert.equal(a.status, "live");
const skip = ingest({
id: "doc-1",
text: "向量入库要先做质检",
model,
expectedDim: dim,
embedding: unit(dim, 0),
});
assert.equal(skip.status, "skip");
const dup = ingest({
id: "doc-2",
text: "另一篇文档,却得到同一条向量",
model,
expectedDim: dim,
embedding: unit(dim, 0),
});
assert.equal(dup.status, "quarantine");
const bad = ingest({
id: "doc-3",
text: "",
model,
expectedDim: dim,
embedding: Array(dim).fill(0),
});
assert.equal(bad.status, "quarantine");
const ok2 = ingest({
id: "doc-4",
text: "新文档,方向不同",
model,
expectedDim: dim,
embedding: unit(dim, 1),
});
assert.equal(ok2.status, "live");
assert.equal(live.get("doc-1::demo-emb-8d").length, 1);
assert.equal(quarantine.length, 2);
console.log("gate ok", {
liveDocs: [...live.keys()],
quarantine: quarantine.map((q) => ({ id: q.id, reason: q.reason[0].code })),
});
跑完应看到 doc-1、doc-4 在主库,doc-2 因指纹冲突、doc-3 因近零向量进入隔离区;doc-1 的重复写入被跳过,版本数仍是 1。这就是「失败不覆盖」:主索引的邻居关系不会被一次脏写改掉,隔离区里还能按 code 做回放。
量化指纹解决的是「几乎同一条向量」。它不是语义去重。两篇意思相近、向量夹角 0.9 的文档,不应在写入门禁里互斥,那是语料治理或检索去重的问题,硬塞进门禁会误杀合法邻居。余弦高阈值只拿来对比同一文档的相邻版本,范围要收得很窄。
和增量同步、版本化向量并存
门禁不该另起一套同步协议。把它插进现有增量任务里即可:读变更、调嵌入、inspect、通过则追加版本、失败则写隔离并留下可重试游标。几点实现上的约束比框架选择更重要。
模型与维度要和向量绑在同一条记录上。双写期间主库会同时存在 modelA/1536 与 modelB/3072,检索侧按请求选版本,写入侧按契约质检。不要把「当前默认模型」做成全局可变配置去改历史行。版本化向量的含义是:同一 id 可以有多条合法嵌入,非法的那次尝试从未成为最新版本。
隔离区要能重放。至少保存 id、原文或内容哈希、模型名、期望维度、原始向量(若已生成)、issues[].code。空文本导致的 NEAR_ZERO 应回到内容侧修复;DIM_MISMATCH 应修调用方后整批重放,不要在隔离区里把向量 pad/truncate 成「看起来能搜」。后者会让问题从写入时的明确失败,变成检索时的静默错位。
近重复策略要和增量语义对齐。内容哈希不变则跳过嵌入,能省费用,也能减少指纹抖动。内容变了但向量几乎没变(极短 diff、同义改写失败),可以记一条 skip 审计,不必升版本。只有契约检查失败或跨文档指纹冲突,才进隔离区。这样增量任务的「已处理位点」仍然前进,不会因为一条脏数据卡住整批,也不会把脏数据写进位点之后的主库。
如果你们已经在用双写做模型切换:两套写入各挂自己的 expectedDim 和指纹空间,隔离区按模型分桶。不要用同一指纹表跨模型去重,不同模型的量化结果没有可比性。混合召回同样如此——关键词索引和向量索引都可以引用同一 contentHash,但向量质检的结论不要拿去删文档正文;正文合法、嵌入失败时,关键词侧仍可服务,向量侧显示降级即可。
总结
检索策略解决的是匹配方式,写入口径解决的是库存质量。空向量、非有限值、维度错位和近重复一旦进入主索引,召回会静默变差,且缺少可对账的现场。把质检做成写入路径上的门禁:用模型契约校验维度,用范数拦住无方向和爆炸值,用量化指纹隔离无信息量的重复,失败写入隔离区而不是覆盖最新版本。它和增量同步、版本化向量、双写可以共用同一套 id + model + contentHash,并不要求先换向量库。先让脏数据进不去,再谈模型和召回路怎么演进,顺序别反。
评论