换 Embedding 模型时,旧向量和新查询不在同一空间,不能拿文档增量同步那套流程硬套。全量删库重建会停服,也几乎没法回滚。本文用 Node.js 落地向量版本、双写、按版本灰度检索和在线回填,让模型切换变成一次可观测、可回退的发布,而不是赌离线重建。
换模型不是增量同步
检索系统里有两类更新,经常被混成一件事。
第一类是文档变了:标题改了、正文追加了、条目下线了。这时向量过期是因为内容变了,用同一模型重算即可。变更队列、按文档 id 覆盖写入、增量同步,都是在解决这件事。
第二类是模型变了:换供应商、换代际、改维度策略。这时向量过期是因为表示空间变了。旧向量和用新模型算出的查询向量,即便维度碰巧相同,也不能做余弦相似度。把它们排进同一张榜,分数没有可比性,召回问题还会被当成「模型效果不好」。
全量删库再重建看起来干脆,实际有三处硬伤。重建窗口里要么停检索,要么继续用旧空间接新模型的查询,结果是错的。重建失败或新模型不如预期时,旧向量已经没了,回滚只能再重建一次。重建期间写入仍在进来,还要处理快照和增量之间的缝。
所以模型切换应当成一次空间迁移:旧空间继续服务,新空间并行生长,检索按版本选择空间,确认后再切流量。它和 CDC、文档同步可以共用变更队列,但对账对象不同——同步关心「正文是否最新」,迁移关心「某版本向量是否与当前正文一致」。
把向量版本当成一等公民
不要靠维度或模型名去猜当前是哪套向量。维度相同也可能来自不同模型;同一模型若走了截断维度,也是另一个空间。显式存 embedding_version,写入、回填、检索、缓存和监控都带着它。
版本字符串应不可变,例如 openai:text-embedding-3-small:1536,不要用 latest。运行时只允许一个主版本,外加零或一个候选版本。单次请求只走一个版本,不要把两个空间的分数拼成一个 topK。
存储上也不要挤同一列。pgvector 里不同维度进不了同一个 vector(n);即便维度相同,索引和回填进度也宜按版本拆开。下面用官方 openai SDK 的 embeddings.create,在内存里按文档保存多版本向量,并给每个版本记下正文哈希——有向量不等于向量仍然对应这篇正文。需要 Node.js 18+、依赖 openai,以及环境变量 OPENAI_API_KEY。
import { createHash } from "node:crypto";
import OpenAI from "openai";
const openai = new OpenAI();
export const PRIMARY = "openai:text-embedding-3-small:1536";
export const CANDIDATE = "openai:text-embedding-3-large:3072";
const VERSIONS = {
[PRIMARY]: { model: "text-embedding-3-small" },
[CANDIDATE]: { model: "text-embedding-3-large" },
};
export function contentHash(content) {
return createHash("sha256").update(content, "utf8").digest("hex");
}
function cosine(a, b) {
if (a.length !== b.length) {
throw new Error("cannot compare vectors of different dimensions");
}
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 denom = Math.sqrt(na) * Math.sqrt(nb);
return denom === 0 ? 0 : dot / denom;
}
export async function embed(text, version) {
const cfg = VERSIONS[version];
if (!cfg) throw new Error(`unknown version: ${version}`);
const res = await openai.embeddings.create({
model: cfg.model,
input: text,
});
return res.data[0].embedding;
}
export class VersionedVectorStore {
constructor() {
this.docs = new Map();
}
isFresh(doc, version) {
const slot = doc.embeddings[version];
return Boolean(slot) && slot.hash === doc.contentHash;
}
async upsert({ id, content }, { requiredVersions, optionalVersions }) {
const existing = this.docs.get(id) || { embeddings: {} };
existing.content = content;
existing.contentHash = contentHash(content);
for (const version of requiredVersions) {
existing.embeddings[version] = {
vector: await embed(content, version),
hash: existing.contentHash,
};
}
this.docs.set(id, existing);
for (const version of optionalVersions) {
try {
existing.embeddings[version] = {
vector: await embed(content, version),
hash: existing.contentHash,
};
this.docs.set(id, existing);
} catch (err) {
console.error(`optional embed failed id=${id} version=${version}: ${err.message}`);
}
}
}
async search(query, version, topK = 5) {
const queryVec = await embed(query, version);
const ranked = [];
for (const [id, doc] of this.docs) {
if (!this.isFresh(doc, version)) continue;
ranked.push({
id,
content: doc.content,
score: cosine(queryVec, doc.embeddings[version].vector),
});
}
ranked.sort((a, b) => b.score - a.score);
return ranked.slice(0, topK);
}
staleIds(version) {
const ids = [];
for (const [id, doc] of this.docs) {
if (!this.isFresh(doc, version)) ids.push(id);
}
return ids;
}
coverage(version) {
if (this.docs.size === 0) return 1;
let hit = 0;
for (const doc of this.docs.values()) {
if (this.isFresh(doc, version)) hit += 1;
}
return hit / this.docs.size;
}
}
coverage 统计的是「该版本与当前正文一致」的比例,而不是「曾经算过这个模型」。文档被编辑后,候选向量若没跟上,应视为缺失,否则灰度阶段会出现偶发错召回,很难和模型质量分开。
双写:增量同时进入两个空间
回填解决存量,双写解决增量。候选版本一开始建设,新建和更新就要同时写入主版本和候选版本,否则回填永远追不上写入。更新时两个版本都要重算;只刷新主版本,候选空间会留下旧正文的向量。
双写必须接受部分失败。主版本成功、候选版本因限流失败时,文档仍应可被线上检索,并进入候选缺失队列。不要因为候选路径失败挡住主路径。两个空间往往不在同一列、甚至不在同一索引,不必强行塞进一个数据库事务;用「版本缺失或哈希不一致」做对账事实更稳。
import { PRIMARY, CANDIDATE, VersionedVectorStore } from "./store.mjs";
export async function ingest(store, doc) {
await store.upsert(doc, {
requiredVersions: [PRIMARY],
optionalVersions: [CANDIDATE],
});
}
const store = new VersionedVectorStore();
await ingest(store, {
id: "doc-1",
content: "换 embedding 模型后,旧向量不能和新查询直接比相似度",
});
线上 ingest 只把主版本放进 requiredVersions。候选版本放进 optionalVersions,失败留给回填。若候选路径持续失败,覆盖率会停住或下降,这是切流量的否决项,而不是靠重试把用户写入堵住。
Embedding API 的限额是双写和回填的共同预算。二者会叠加,需要批次、并发上限和失败重试;宁可回填慢,也不要把查询侧的 embed 打挂。具体限额随账号档位变化,不要写死在代码里当容量规划。
按版本检索与在线回填
检索入口只接收版本,不接收「自动选最新」。灰度用流量比例选定版本后,整次请求都钉死在这一空间:查询向量、扫索引、返回结果,全部同一版本。过滤、权限、业务打分可以留在向量检索之后,但不要跨版本混排。
查询缓存的 key 必须带 embedding_version。若只缓存 query 文本,切版本后会把旧空间结果当成新空间结果吐出去。
import { CANDIDATE, PRIMARY, VersionedVectorStore } from "./store.mjs";
export function pickVersion(candidatePercent) {
if (candidatePercent <= 0) return PRIMARY;
if (candidatePercent >= 100) return CANDIDATE;
return Math.random() * 100 < candidatePercent ? CANDIDATE : PRIMARY;
}
export async function backfill(store, version, { batchSize = 4 } = {}) {
const ids = store.staleIds(version);
for (let i = 0; i < ids.length; i += batchSize) {
const batch = ids.slice(i, i + batchSize);
await Promise.all(
batch.map(async (id) => {
const doc = store.docs.get(id);
await store.upsert(
{ id, content: doc.content },
{ requiredVersions: [], optionalVersions: [version] }
);
})
);
}
}
const store = new VersionedVectorStore();
await backfill(store, CANDIDATE);
console.log("candidate coverage", store.coverage(CANDIDATE));
const version = pickVersion(10);
const hits = await store.search("向量模型怎么不停机切换", version, 3);
console.log(version, hits);
在线回填是后台循环,不是发布窗口里的一次性脚本。只补缺失或哈希不一致的文档,不删旧向量——回滚需要物质基础。对账看覆盖率和缺失队列是否收敛,不要看「任务进程退出码为 0」。覆盖率没到就切流量,用户会在新空间里搜不到尚未回填的文档,表现为偶发召回空洞。
覆盖率足够后,可以开影子流量:同一查询同时打主版本和候选版本,只把主版本返回给用户。对比的是文档 id 集合和人工抽检。两个空间的相似度分数不可比,不要拿分数差当质量指标。
切流量与回滚的判断条件
切流量是改路由,不是改数据。按「影子 → 小流量 → 扩大 → 主备互换」走,每一步看同一组条件。下面没有统一数值,阈值要按召回空洞的业务代价自己定;存量很大时,覆盖率通常得接近全量,否则空洞比模型差异更显眼。
可以升流量
候选覆盖率达到事先约定的阈值,并且双写缺失队列持续接近空。候选路径的错误、超时没有明显恶化,也没有把主路径的 embed 配额挤占到查询失败。影子抽检里没有系统性错召回,例如某条噪声文档打满结果,或一批应出现的文档稳定消失。回填积压在下降,而不是越积越多。
应该停住或回滚
覆盖率停滞或下降,说明双写或回填已经坏了。候选路径错误、超时升高,已经影响可用性。业务侧出现明确的召回事故。回滚时把候选流量比例置回 0,主版本保持旧空间即可;旧向量还在,不需要再 embed 一遍。
主备互换之后,旧版本不要马上删。留观察窗口,确认没有迟到的回滚需求,再停双写、停旧索引。删旧空间是清理,不是切换的一部分。索引构建、回填赶工的时间取决于语料量和 API 限额,事先用一小批语料把流水线跑通,比在切换当天估一个「今晚能跑完」更有用。
总结
模型切换的本质是空间迁移,不是文档同步。给向量加不可变版本,增量双写,存量在线回填,检索按版本灰度,切流量只动路由。主路径和候选路径的失败要分开处理;分数跨版本不可比;正文更新后要用哈希判断向量是否仍新鲜;查询缓存必须带版本。旧空间在观察期结束前不要删。用覆盖率、错误率和抽检决定升流量还是回滚,而不是用重建任务的退出码。检索可以一直开着,切换也就从一次赌博变成一次普通的发布。
评论