换模型、双写和混合召回解决的是检索策略,管不了库里已经脏掉的向量。空向量、NaN、维度错位和近重复一旦入库,召回会静默变差,事后很难对账。把质检前移到写入路径:校验维度与范数、拦截异常嵌入、用指纹隔离近重复,失败进隔离区而不是默默覆盖。

脏向量为什么比差策略更难查

检索变差时,团队习惯先动策略:换 Embedding 模型、新旧向量双写、关键词加向量做混合召回。这些手段都合理,前提却是库里的向量本身可信。策略调的是「怎么找」;脏数据改的是「能找到什么」。后者更隐蔽。

典型事故并不戏剧。一次模型升级漏改了 expectedDim,新向量按 1536 写入,旧索引按 3072 读,客户端有的直接抛错,有的在驱动层截断或填零。一次批量任务里上游文本是空字符串,模型仍返回数组,但范数接近 0,这些点在空间里挤成一团,谁靠近谁都像相关。一次重试把半截响应写进去,中间出现 NaN,ANN 索引不一定拒绝,查询时距离变成无意义的数。还有更常见的:同一段文档被增量任务重复嵌入,近重复向量把 Top-K 名额占满,真正该露出的邻居被挤掉。

这些都不会在仪表盘上显示成 5xx。召回指标慢慢往下掉,你却说不清是模型、分块,还是某天的一次失败写入。对账也难:向量库往往只留最终那条,覆盖发生后,当时的原文、模型版本、失败原因都不在同一条链上。所以质检不该做成「检索前再滤一遍」的补丁,而应做成写入路径上的门禁:不合格的记录根本不要进主索引。

门禁放在写入路径,而不是检索侧补丁

一套能落地的入库门禁,只做四件事:

  1. 校验形状:类型、长度、维度必须与当前模型契约一致。
  2. 校验数值:每个分量有限,L2 范数落在合理区间,拒绝全零和爆炸值。
  3. 用量化指纹识别近重复,避免无信息量的覆盖。
  4. 失败写入隔离区,带上原因、原文摘要、模型与维度,供重试和对账;成功才进主库,并保留版本,而不是原地覆盖。

门禁是写入链路的纯函数加两侧存储,不依赖某个向量数据库的专有插件。下面的示例只用 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,并不要求先换向量库。先让脏数据进不去,再谈模型和召回路怎么演进,顺序别反。