本文摘要:换 Embedding 模型却不重建索引,查询不报错但召回率悄悄跌至随机。通过 IVF_PQ 双层失配机制复现相似度趋同,给出灰度切换方案。适用于新旧模型维度相同的场景,维度变更时查询直接报错。
一、问题与结论
先说结论:Milvus 允许插入新向量后不重建索引直接查询,这是段模型的设计行为,不是 bug。但当新向量来自不同 Embedding 模型时,旧索引中的 PQ 码本和聚类质心无法表征新向量空间,cosine 相似度分布趋同于随机——召回率可以从 92% 跌到 7%,查询本身不报任何错。
一个典型场景:知识库第一天用text2vec-large-chinese导入 5 万条文档,建IVF_PQ索引,nlist=256、nprobe=16,top-10 召回率 92%。第 30 天新增 80 万条文档,Embedding 换成bge-large-zh——维度恰好也是 1024,索引参数没动。查询正常返回,HTTP 200,延迟不变甚至更快,但用户反馈"搜不到相关内容"。
根因是:两个模型输出维度相同但向量空间完全不同。不同模型学到的语义分布没有对齐,新旧向量混在同一空间后,ANN 检索返回的近邻与真实近邻几乎无关。这是一类"系统报告一切正常但结果已经错了"的静默降级。
二、排查与选择依据
排查分三层,每层都在加剧失配:
PQ 码本层:IVF_PQ的码本在 Day 1 基于旧 Embedding 分布训练。新向量落入码本未覆盖的区域,量化后的距离估计接近噪声。
IVF 聚类层:nlist=256的聚类中心固化在旧空间。新向量无法被正确分配到对应簇,nprobe=16命中的簇与真实近邻无关。
查询层:nprobe只探测 6.25% 的簇(16/256)。空间失配后,这 16 个簇覆盖的向量和真实近邻没有任何相关性。
三层叠加,检索退化为近似随机返回。更隐蔽的是段切换效应:新数据在 growing 段走暴力扫描能查准,flush 进入 sealed 段走 ANN 索引后开始不准(段模型详见下节)。用户误以为"刚插入的能搜到,过段时间搜不到了",实际是段切换暴露了空间失配。
替代方案与取舍
| 方案 | 做法 | 代价 | 不适用场景 |
|---|---|---|---|
| 双索引灰度切换 | 新模型向量写入独立 Collection,双路查询按比例切流 | 双倍存储 + 双路合并逻辑 | 维度变更时旧索引失效,无法并行 |
| 停机全量重建 | 导出旧数据 → 用新模型重新 Embedding → drop 重建索引 | 重建期间检索不可用,需停机窗口 | 实时性要求高,无法接受停机 |
| 分 Collection 隔离 | 不同模型向量存入不同 Collection,应用层多路查询后合并 | 管理复杂度高,需自定义合并去重 | 单一模型场景下多余 |
临时调高nprobe/ef | 每次查询增大搜索宽度 | 查询延迟上升,治标不治本 | 空间严重失配时调高也无效 |
本文方案的边界:新旧 Embedding 维度不同时,Milvus 查询会直接抛出维度不匹配错误,不存在静默失败——这种情况下必须重建。本文讨论的静默退化仅发生在维度恰好相同、但向量空间不同的场景。
三、关键原理
Milvus 将数据分存于两类段:Growing 段(未 flush)无索引,检索走暴力扫描精确匹配;Sealed 段(已 flush)按建索引时的参数构建 ANN 索引。索引参数(nlist、efConstruction、PQ 码本)是创建时快照,固化了构建时刻的数据分布。数据持续增长但参数不更新,等价于用过时的地图导航。
IVF_FLAT 与 IVF_PQ 的衰减模式有本质区别:FLAT 只有聚类层失真,召回率可能从 92% 降到 65% 左右;PQ 额外增加码本层失真,两层叠加才真正跌到接近随机水平。这也解释了为什么同样是 IVF 索引,FLAT 版本还能用,PQ 版本直接不能用了。
HNSW 的衰减模式不同:M和efConstruction在构建时决定图的连接密度。数据量大幅增长后若ef仍按小数据集设定,图中高层节点跳转精度下降。但 HNSW 不涉及码本,换模型后的衰减通常没有 IVF_PQ 剧烈。
四、可运行示例
环境要求:Python 3.10+,pymilvus[milvus_lite]==2.4.0、numpy、faiss-cpu。Milvus Lite 随 pymilvus 安装,无需 Docker。
pipinstall"pymilvus[milvus_lite]==2.4.0"numpy faiss-cpuMilvus 侧——Phase A 同模型建索引测基准,Phase B 混入不同分布向量后不改参数再测:
importnumpyasnpfrompymilvusimportMilvusClient,DataType DIM=128NLIST=100NPROBE=10TOP_K=10defgen_vectors(n,dim,seed,model_offset=0.0):rng=np.random.RandomState(seed)base=rng.randn(n,dim).astype("float32")+model_offsetreturnbase/np.linalg.norm(base,axis=1,keepdims=True)defsetup(client,name,vecs,ids,tags):ifclient.has_collection(name):client.drop_collection(name)schema=client.create_schema()schema.add_field("id",DataType.INT64,is_primary=True)schema.add_field("embedding",DataType.FLOAT_VECTOR,dim=DIM)schema.add_field("model_tag",DataType.INT64)client.create_collection(name,schema=schema)ip=client.prepare_index_params()ip.add_index(field_name="embedding",index_type="IVF_FLAT",metric_type="COSINE",params={"nlist":NLIST},)client.create_index(name,ip)data=[{"id":int(i),"embedding":v.tolist(),"model_tag":int(t)}fori,v,tinzip(ids,vecs,tags)]client.insert(name,data)client.flush(name)defmeasure(client,name,queries,gt):hits=0forq,ginzip(queries,gt):r=client.search(collection_name=name,data=[q],limit=TOP_K,output_fields=["id"],search_params={"metric_type":"COSINE","params":{"nprobe":NPROBE},},)hits+=len(set(x["id"]forxinr[0])&set(g))returnhits/(len(queries)*TOP_K)defgt_brute(queries,all_vecs,all_ids,k):return[set(all_ids[np.argsort(-np.dot(all_vecs,q))[:k]])forqinqueries]client=MilvusClient("./milvus_demo.db")# Phase A:同模型 10K 条vecs_a=gen_vectors(10_000,DIM,seed=42)ids_a=np.arange(10_000)setup(client,"recall_demo",vecs_a,ids_a,[0]*10_000)queries=vecs_a[:100]gt=gt_brute(queries,vecs_a,ids_a,TOP_K)recall_a=measure(client,"recall_demo",queries,gt)print(f"Phase A (10K, same model): recall ={recall_a:.4f}")# Phase B:混入不同模型分布的 90K 条,不改索引参数vecs_b=gen_vectors(90_000,DIM,seed=43,model_offset=0.5)ids_b=np.arange(10_000,100_000)data_b=[{"id":int(i),"embedding":v.tolist(),"model_tag":1}fori,vinzip(ids_b,vecs_b)]client.insert("recall_demo",data_b)client.flush("recall_demo")# flush 后 sealed 段自动构建索引,沿用 nlist=100recall_b=measure(client,"recall_demo",queries,gt)print(f"Phase B (100K, mixed model): recall ={recall_b:.4f}")client.drop_collection("recall_demo")FAISS 侧用同样思路验证这不是 Milvus 特有问题:
importfaissimportnumpyasnp DIM,NLIST,M=128,100,8# vecs_a、vecs_b 与上方脚本使用相同的 gen_vectors 生成vecs_a=...# 10K,seed=42,offset=0vecs_b=...# 90K,seed=43,offset=0.5quantizer=faiss.IndexFlatIP(DIM)# IP + 归一化 ≈ COSINEindex=faiss.IndexIVFPQ(quantizer,DIM,NLIST,M,8)index.train(vecs_a)# 码本只在 Phase A 上训练index.add(vecs_a)index.add(vecs_b)# 不 retrain、不重建# 用与 Phase A 相同的查询向量测 recall# FAISS 的 codebook 同样失配,recall 同样显著下降预期输出:Phase A 的 recall 显著高于 Phase B;混入向量越多、model_offset越大,下降越明显。实际输出须在本地运行后填入,此处不预设具体数值。
常见失败:Phase B 插入后如果没有显式调用flush,新数据仍在 growing 段走暴力扫描,recall 不会下降——这会误导你以为"没问题"。必须调用flush或等待自动 flush,让数据进入 sealed 段触发索引构建后再测。另外pymilvus2.4.x 的search方法搜索参数字段名为search_params,不同 minor 版本可能有差异,用前先查对应版本的 API 文档。
五、验证结果与边界
Phase B 测试的变量控制表:
| 条件 | 影响 | 说明 |
|---|---|---|
model_offset=0(同模型追加) | recall 基本不变 | 纯数据量增长,每簇变粗但方向未偏移 |
model_offset>0(换模型) | recall 随 offset 增大而下降 | offset 越大,新旧向量空间重叠越少 |
| IVF_FLAT vs IVF_PQ | PQ 衰减更剧烈 | PQ 码本层额外失真叠加 |
| growing 段 vs sealed 段 | growing 不衰减 | 暴力扫描不依赖索引参数 |
关于双索引灰度切换:新建 Collection 用新模型的索引,旧 Collection 保持不动。查询时同时打两个索引,按流量比例从新 Collection 拿结果,逐步提高比例直到全切。两边 top-k 重合度稳定后再下线旧 Collection。存储是双倍的,但不需要停机。
停机重建的耗时取决于数据量、Embedding 模型推理速度、索引类型和硬件配置。建议在排期前用目标数据量做一次 dry-run 估算,避免低估重建窗口。
思考
- 如果 Embedding 模型每季度升级一次,双索引灰度切换和停机重建哪个在业务上更容易被接受?
- 召回率衰减监控的抽样频率如何平衡检测及时性和 ground truth 计算成本?
参考资料
- Milvus 索引类型文档(2.4.x)
- FAISS IndexIVFPQ 官方说明