背景问题:Embedding 不是普通字段,升级会改写召回空间
在大模型应用中,Embedding 索引通常被当成 RAG、语义搜索、推荐召回或知识库问答的底层基础设施。问题在于,embedding 模型一旦升级,变更的不是一个普通 API 参数,而是整个向量空间。
一次看似简单的升级,可能同时改变四件事:
| 变更维度 | 具体影响 |
|---|---|
| 向量维度 | text-embedding-3-small 默认 1536 维,text-embedding-3-large 默认 3072 维 |
| 距离度量 | Cosine、Euclidean、Dot Product 不可互换 |
| 文本截断策略 | 新模型可能采用不同 tokenizer 或截断逻辑 |
| 语义相似度分布 | 相同 query-doc pair 在新旧模型下得分显著不同 |
OpenAI 文档中,text-embedding-3-small 默认输出 1536 维,text-embedding-3-large 默认输出 3072 维,并支持通过 dimensions 参数缩短向量;Pinecone 创建 dense vector index 时也要求索引的 dimension 和相似度 metric 与 embedding 模型匹配。这说明索引结构与 embedding 模型是绑定关系,不应在生产环境中随意混用。
更隐蔽的风险是召回漂移。即使新模型离线 benchmark 更高,线上 query 的 top-k 结果也可能发生明显变化。对于客服知识库、合规问答、企业搜索这类场景,召回结果变化会直接影响答案稳定性。因此,Embedding 索引迁移的重点不是”重新跑一遍向量化脚本”,而是构建一套可验证、可切换、可回滚的发布流程。
核心原理:把索引迁移当成一次数据平面发布
生产级索引迁移可以拆成三个平面:
- 数据平面:源文档、chunk、metadata、embedding vector、索引分片和删除事件必须保持一致。只迁移向量而忽略文档版本,会导致向量指向过期文本。
- 查询平面:查询请求必须知道当前使用哪个 embedding 模型、哪个索引版本、哪个距离度量。否则查询向量来自新模型,检索却落到旧索引,结果会失真。
- 发布平面:迁移必须支持后台回填、增量双写、验证门禁、流量切换和快速回滚。
Qdrant 官方的 embedding model migration 文档给出了两种典型方式:一种是 blue-green migration,即新建并行 collection、双写新旧集合、后台重新 embedding,完成后切换搜索流量;另一种是在支持 named vectors 的集合中新增新模型向量字段,回填完成后切换查询的 using 参数,并保留旧向量以便回滚。
这两个方案背后的共同原则是:不要在旧索引上原地破坏性更新。迁移期间,新旧检索路径必须能并行存在。
推荐架构:双写、回填、别名切换、门禁验证
1. 建立索引版本号
不要只命名为 docs、faq 或 kb_vectors。生产环境应该显式记录索引版本:
index_profile:
logical_name: kb-search
physical_index: kb-search-v20260708-embed3-small
embedding_model: text-embedding-3-small
dimension: 1536
metric: cosine
chunker_version: chunker-v4
metadata_schema: kb-meta-v3
status: building
logical_name给业务调用,physical_index给向量库落地,chunker_version和metadata_schema用于排查召回变化。很多召回问题并不是 embedding 模型本身造成的,而是 chunk 规则、清洗规则或 metadata filter 同时变化导致的。
2. 新建索引,不原地覆盖
当新模型维度或 metric 变化时,新建物理索引是更稳妥的选择。Pinecone 的索引创建文档明确要求 dense vector index 指定 dimension 和 similarity metric,并与 embedding 模型匹配。Qdrant 的 collection 也要求同一 collection 中向量具备一致的维度和度量(除非使用 named vectors)。
如果向量库支持 collection alias 或 index alias,可以让业务始终访问逻辑名:
- Qdrant:collection alias 支持将查询从一个 collection 原子切换到另一个 collection
- Milvus:alias 用于把应用代码与具体物理 collection 解耦,支持无停机数据更新、A/B 测试和 blue-green deployment
3. 增量双写,避免回填窗口丢数据
迁移期间最容易出错的是增量数据。正确做法是让写入链路进入双写模式:
async function upsertDocument(doc: SourceDocument) {
const oldVector = await embed(doc.text, "old-embedding-model");
const newVector = await embed(doc.text, "new-embedding-model");
await Promise.all([
vectorStore.upsert("kb-search-v1", doc.id, oldVector, doc.metadata),
vectorStore.upsert("kb-search-v2", doc.id, newVector, doc.metadata),
]);
await ledger.record({
doc_id: doc.id,
old_index: "kb-search-v1",
new_index: "kb-search-v2",
source_hash: sha256(doc.text),
migrated_at: new Date().toISOString(),
});
}
双写不是简单地调用两次 upsert,还要记录请求账本:源文档 hash、chunk id、embedding model、索引版本、写入状态和失败原因。没有账本,后续只能靠抽样猜测哪些数据漏写。
4. 后台回填旧数据
对已有数据执行回填时,推荐流程:
扫描源数据 → 重新切 chunk → 生成新 embedding → 写入新索引 → 校验计数和 hash
不要从旧向量反推新向量,也不要只复制旧索引 payload 后直接宣称迁移完成。
回填过程需要支持断点续跑:
CREATE TABLE embedding_migration_task (
id BIGINT PRIMARY KEY,
doc_id VARCHAR(128) NOT NULL,
chunk_id VARCHAR(128) NOT NULL,
source_hash VARCHAR(64) NOT NULL,
target_index VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
last_error TEXT,
updated_at TIMESTAMP NOT NULL
);
这张表的核心目的是回答三个上线前问题:
- 还有多少 chunk 没迁移?
- 失败是否集中在某类文档?
- 是否存在同一个 doc 在新旧索引中的 chunk 数量不一致?
Recall 门禁:先验证索引,再验证业务
索引迁移至少需要两层门禁。
第一层:ANN Recall 门禁
Qdrant 的 ANN recall 文档把 recall@k 定义为 approximate nearest-neighbor 搜索与 exact kNN 搜索的接近程度,并建议在 CI 中用 exact-search 模式计算 recall@k,低于阈值时阻断发布。这一层主要检查:
- 索引构建参数(HNSW 的 M、efConstruction)
- 搜索参数(ef)
- 分片状态
- 向量写入质量
第二层:业务相关性门禁
这需要维护一组黄金查询集:用户常见问题、长尾问题、合规敏感问题、容易混淆的问题、历史线上失败问题。对每个 query 同时打到旧索引和新索引,比较:
- top-k 文档重叠率
- 目标文档命中率
- 答案引用是否变化
- 人工标注相关性是否下降
一个可执行的门禁配置:
release_gate:
ann_recall_at_10_min: 0.95
migrated_chunk_ratio_min: 0.999
failed_chunk_count_max: 0
golden_query_target_hit_rate_drop_max: 0.02
no_unknown_schema_version: true
rollback_ready: true
ANN recall 只能说明近似索引没有严重偏离精确搜索,但不能说明新 embedding 模型更懂业务意图。业务门禁仍然需要 query-level 评测。
切换策略:别名优先,配置兜底
当所有门禁通过后,可以进入小流量切换。更推荐把业务代码绑定到逻辑别名,而不是物理索引名。
以 Qdrant 为例,collection alias 可以让应用继续访问 production_collection,后台将 alias 从旧 collection 切换到新 collection —— alias actions 是原子执行,不会影响并发请求。Milvus 的 alias 也提供类似抽象。
切换时建议保留三类开关:
search_runtime:
active_index_alias: kb-search-prod
active_embedding_model: text-embedding-3-small
shadow_index: kb-search-v2
shadow_sample_rate: 0.05
rollback_index: kb-search-v1
灰度阶段,线上主结果仍来自旧索引,同时把一部分请求影子打到新索引并记录差异。确认差异可控后,再切换别名。切换后不要立即删除旧索引,至少保留一个观察窗口。
回滚策略:回滚的是查询路径,不是重新迁移
回滚必须在上线前准备好。常见错误是等到新索引出问题后才开始重建旧索引。正确做法是保留旧物理索引、旧 embedding 查询路径和旧配置版本。
回滚时优先做三件事:
- 将查询 alias 指回旧索引
- 恢复旧 embedding 模型用于 query embedding
- 停止新索引双写或改为异步补偿
只要旧索引在迁移期间仍保持增量同步,回滚就是一次配置切换,而不是一场数据抢修。
对于 named vectors 方案,回滚更直接:旧向量字段仍在同一个 collection 中,只需要把查询的 using 参数切回旧向量名即可。
适用场景
| 场景 | 说明 |
|---|---|
| Embedding 模型升级 | 从旧版本升级到新版本 |
| 向量维度变更 | 从 1536 切到 1024 或 3072 |
| Hybrid 搜索升级 | 从单一 dense vector 升级到 dense + sparse hybrid |
| Chunk 规则变化 | 分块策略调整需要重建索引 |
| 向量库迁移 | 从一个集群迁移到另一个集群 |
| 高召回稳定性要求 | 不能接受”更新后搜索质量随机波动” |
如果只是少量离线实验,或者索引没有线上流量,可以简化为一次性重建。但只要已经承载生产搜索,就应该按数据发布流程处理。
常见误区
误区一:只比较最终答案,不看召回
答案质量下降时,问题可能来自生成模型、prompt、reranker,也可能来自召回。索引迁移阶段必须先把召回层单独隔离出来验证,否则排障会变得非常困难。
误区二:新旧 embedding 混写到同一字段
同一个向量字段中混入不同模型生成的向量,通常是灾难性错误。它会让距离分数失去可解释性,也让回滚失去边界。
误区三:忽略删除和局部更新
Qdrant 的 blue-green 迁移说明也提醒,删除或局部更新在迁移期间需要暂停或增加额外处理逻辑 —— 因为后台回填可能把已经删除的数据重新写入新索引。
误区四:切换后立刻清理旧索引
旧索引是回滚保险。除非观察窗口结束、差异监控稳定、业务方确认新结果可接受,否则不要急于释放旧索引。
上线检查清单
上线前至少确认以下事项:
- 新索引维度和 metric 与新 embedding 模型一致
- 源文档、chunk、metadata、向量数量可对账
- 双写链路覆盖新增、更新和删除
- 回填任务支持断点续跑
- 黄金查询集已覆盖核心业务问题
- ANN recall@k 达到阈值
- 新旧 top-k 差异可解释
- alias 或配置切换已演练
- 旧索引仍可用
- 回滚步骤可以在分钟级完成