资讯详情

Milvus 向量检索中 not in 大列表过滤的性能优化实践

📅 2026/10/28 14:07:10 | 华诺云谱 👁 阅读
Milvus 向量检索中 not in 大列表过滤的性能优化实践
1. 问题现场还原几万条已读 ID 是怎么把查询拖垮的先把场景摆出来。推荐流或者消息流系统里用户每次刷新都要过滤掉已经看过的内容。最直觉的做法就是把该用户所有已读内容的 ID 拉出来拼成一个not in [...]的过滤条件丢给 Milvus 做向量检索。刚开始几百条已读记录的时候查询响应稳定在几十毫秒完全感觉不到问题。等到单个用户的已读 ID 累积到几万条查询耗时开始肉眼可见地往上走从几十毫秒涨到几百毫秒再涨到一两秒最后甚至出现超时。这个现象不是 Milvus 的 bug而是过滤表达式在向量检索链路里的执行方式决定的。Milvus 的查询流程大致分三步先根据过滤条件做标量筛选得到一批候选主键再对这些候选主键做向量距离计算最后排序返回 TopK。not in这种否定式过滤在标量筛选阶段几乎无法利用索引做有效剪枝它需要把整个集合里符合条件的主键都过一遍再逐个判断是否在排除列表里。几万个 ID 的排除列表意味着每个候选主键都要跟这几万个值做比对这个开销是线性叠加的。更麻烦的是Milvus 对过滤表达式的解析和执行有长度限制与性能拐点。表达式字符串本身太长解析器要花时间not in的集合越大标量引擎的内存占用和比对次数就越高。我实测过一个集合大概两百万条向量单用户已读 ID 到三万条时查询 P99 从 40ms 涨到 1.8s翻了四十多倍。这个涨幅不是线性的而是过了某个阈值之后突然变陡因为标量筛选的结果集已经大到把向量计算阶段的优势完全吃掉了。所以这个问题的本质是用否定式大列表过滤等于让 Milvus 在每个查询里做一次全集合级别的排除运算。它跟“查询越来越慢”是因果关系不是偶发。下面我会把背后的原理、可选的替代方案、具体的改造步骤以及我踩过的坑一条条拆开讲。2. 为什么 not in 大列表会拖慢查询原理层面的拆解2.1 Milvus 过滤表达式的执行顺序Milvus 处理一个带过滤的向量查询时内部并不是“先算向量再过滤”而是先过滤再算向量。这个顺序很关键。标量过滤阶段会扫描集合的标量字段找出满足条件的实体主键列表然后把这个列表交给向量检索阶段去算距离。过滤阶段越快、结果集越小后面的向量计算就越省。not in的问题在于它属于否定条件。对于肯定条件比如category news如果 category 字段建了倒排索引Milvus 可以直接定位到对应的主键集合速度很快。但not in要求的是“除了这些 ID 之外的所有 ID”标量引擎没法用一个正向索引直接跳过去它只能先取全量主键再逐个剔除排除列表里的值。排除列表越大剔除操作的比对次数就越多。2.2 表达式长度与解析开销Milvus 的过滤表达式是字符串形式传入的。几万个 ID 拼成的not in [1,2,3,...]字符串长度可能达到几百 KB 甚至上 MB。这个字符串在网络传输、服务端解析、语法树构建上都要花时间。我抓过一次慢查询日志发现其中将近 200ms 是花在表达式解析上真正做向量计算的时间反而只占一小部分。表达式越长解析开销越不可忽视。2.3 标量引擎的内存与比对成本标量筛选阶段Milvus 需要把排除列表加载到内存里做集合判断。几万个 int64 的 ID内存占用其实不大但问题在于比对次数。假设集合有 200 万条实体排除列表有 3 万个 ID最坏情况下要做 200 万 × 3 万次比对判断虽然实际实现会用哈希集合优化到 O(n)但构建这个哈希集合本身、以及遍历全量主键的过程仍然是 O(n) 级别的开销。集合越大、排除列表越长这个阶段就越慢。2.4 查询计划无法有效剪枝向量检索本身有索引加速比如 HNSW、IVF 系列可以在亚线性时间内找到近邻。但过滤条件如果选择性很差即过滤后剩下的实体仍然很多向量索引的优势就被稀释了。not in几万条已读 ID如果这些 ID 只占集合的很小比例过滤后剩下的实体依然很多向量计算阶段要处理的候选量并没有明显减少整体耗时自然下不来。核心结论not in大列表的慢主要慢在标量筛选阶段的否定式全量比对以及表达式解析的固定开销而不是慢在向量距离计算本身。3. 替代方案选型从 not in 到更合理的过滤策略3.1 方案一改用正向标记字段最彻底的改法是把“已读”变成实体上的一个标量字段。给每条内容加一个is_read或者read_status字段用户已读之后更新这个字段查询时用is_read false来过滤。这样过滤条件从否定式大列表变成了肯定式单值判断标量引擎可以直接用倒排索引或者位图索引快速定位性能提升非常明显。这个方案的代价是需要维护字段更新。Milvus 支持标量字段的 upsert但要注意 upsert 在 Milvus 里是“删除加插入”的语义频繁更新会影响写入性能也可能产生大量小段文件需要配合 compaction。如果已读状态更新很频繁这个方案要评估写入放大问题。3.2 方案二用分区或者分区键隔离如果已读和未读的数据可以物理隔离可以考虑用 Milvus 的 partition 或者 partition key。把未读内容放在一个分区已读内容放在另一个分区查询时只查未读分区。这样过滤条件直接消失查询范围也缩小了。但 partition 数量不宜过多Milvus 对分区数有建议上限通常几百个以内比较稳妥。如果按用户分区用户量一大就不适用了。3.3 方案三缩小排除列表做分层过滤如果业务上允许可以把排除列表拆成两层近期已读用not in小列表过滤历史已读用其他机制处理。比如只排除最近 7 天已读的几百条更早的已读通过召回后的业务层再过滤。这样not in的列表长度控制在几百条以内性能就不会明显退化。代价是召回结果里可能混入少量历史已读内容需要在应用层做二次过滤。3.4 方案四用 bitset 或者布隆过滤器做预过滤在应用层维护一个已读 ID 的布隆过滤器或者位图查询前先做一轮粗筛把明显已读的候选排除掉再交给 Milvus 做精细检索。这个方案适合已读 ID 量极大、但允许少量误判的场景。布隆过滤器有假阳性可能把少量未读内容误判为已读需要业务上能接受这种损失。3.5 方案对比方案改动成本查询性能写入影响适用场景正向标记字段中高有写入放大已读更新不频繁分区隔离中高高需重建分区数据可物理隔离分层过滤低中高无允许应用层二次过滤布隆过滤器预筛中中无可接受少量误判我最终选的是正向标记字段加分层过滤的组合热数据用is_read false直接过滤冷数据用短列表not in兜底。这样既避免了长表达式又控制了字段更新的频率。4. 实操改造把 not in 大列表换成可落地的方案4.1 第一步确认当前查询的瓶颈位置改造之前先量化。打开 Milvus 的慢查询日志找到那条耗时最高的查询看它的表达式长度、返回结果数、以及各阶段耗时。Milvus 的日志里通常会记录查询的 plan 和耗时分布。如果看到表达式解析占了很大比例那优化表达式长度就是第一优先级。如果看到标量筛选阶段耗时最长那就要考虑换过滤方式。我当时的做法是在应用层加了一个计时埋点把“拼表达式”“发请求”“等响应”三段分开统计。结果发现拼表达式只占几毫秒等响应占了 95% 以上说明瓶颈在服务端。再结合慢查询日志确认是标量筛选阶段的问题。4.2 第二步给集合加 is_read 标量字段假设原来的集合 schema 里只有id、embedding、category这几个字段现在要加一个is_read布尔字段。Milvus 支持动态字段但为了性能和索引效果建议显式定义。建集合时加上from pymilvus import CollectionSchema, FieldSchema, DataType fields [ FieldSchema(nameid, dtypeDataType.INT64, is_primaryTrue), FieldSchema(nameembedding, dtypeDataType.FLOAT_VECTOR, dim768), FieldSchema(namecategory, dtypeDataType.VARCHAR, max_length64), FieldSchema(nameis_read, dtypeDataType.BOOL), ] schema CollectionSchema(fieldsfields)如果集合已经存在Milvus 2.4 之后支持add_field动态加字段但加完之后历史数据的is_read默认值需要确认。我建议新建集合再迁移数据避免字段默认值带来的语义混乱。4.3 第三步给 is_read 建标量索引布尔字段建倒排索引或者位图索引都可以。位图索引对低基数字段只有 true/false 两个值特别友好查询时可以直接做位运算。建索引的命令collection.create_index( field_nameis_read, index_params{index_type: BITMAP} )建完索引后is_read false的过滤会走索引标量筛选阶段从全量扫描变成索引查找耗时大幅下降。我实测同一个集合过滤条件从not in [三万条ID]换成is_read false之后P99 从 1.8s 降到 60ms 左右。4.4 第四步改造写入链路维护 is_read 状态用户标记已读时不再往排除列表里追加 ID而是对对应的实体做 upsert把is_read改成 true。这里有个坑Milvus 的 upsert 需要提供完整的实体数据包括向量字段。如果只更新标量字段向量字段也要原样带上否则会丢失。所以应用层需要先查出原实体的向量再整体 upsert 回去。如果已读操作非常频繁可以考虑批量合并更新比如攒够 100 条或者每隔 5 秒批量 upsert 一次减少写入次数。Milvus 的写入是追加式的频繁小批量写入会产生很多小 segment影响查询效率需要靠 compaction 合并。4.5 第五步查询语句改造改造前的查询大概长这样expr fid not in [{,.join(map(str, read_ids))}] results collection.search( data[query_vector], anns_fieldembedding, param{metric_type: COSINE, params: {ef: 64}}, limit50, exprexpr )改造后expr is_read false results collection.search( data[query_vector], anns_fieldembedding, param{metric_type: COSINE, params: {ef: 64}}, limit50, exprexpr )如果还需要排除少量近期已读但字段还没更新过来的数据可以再加一个短列表expr fis_read false and id not in [{,.join(map(str, recent_read_ids))}]这里的recent_read_ids控制在几百条以内性能影响可以忽略。4.6 参数选择与计算过程向量检索的ef参数影响召回率和耗时。ef越大召回越全但越慢。对于推荐场景TopK 取 50 到 100 比较常见ef取limit的 2 到 4 倍即可。比如 limit50ef 取 128 到 200。我实测 ef64 时召回率已经够用ef 再往上加耗时增长比召回提升更明显。COSINE 距离适合文本向量因为文本向量通常做过归一化余弦值能反映语义相似度。如果向量没归一化用 L2 也可以但要注意距离阈值的选择。Milvus 的 COSINE 返回的是相似度越大越相似排序时按降序取 TopK。5. 常见问题与排查技巧实录5.1 加了 is_read 字段后查询还是慢先确认索引有没有建成功。用collection.indexes查看索引列表确认is_read字段的索引类型是 BITMAP 或者 INVERTED。如果索引没建过滤还是会走全量扫描。另外确认查询时表达式确实用到了is_read有时候代码里条件拼错了实际还是走的旧逻辑。还有一种情况是集合里is_read false的实体占比太高比如 95% 都是未读那过滤后的候选集依然很大向量计算阶段还是慢。这时候要考虑结合其他过滤条件缩小范围比如加上时间范围或者类目限制。5.2 upsert 之后查询结果没变化Milvus 的 upsert 是异步可见的写入后需要等一段时间才能被查询到。如果业务要求强一致需要在 upsert 后调用 flush但 flush 是重操作不建议频繁调用。更合理的做法是接受最终一致在应用层做短暂缓存或者延迟。另外确认 upsert 时主键是否正确。如果主键对不上会变成插入新实体而不是更新旧实体导致数据重复。5.3 表达式解析超时如果暂时没法改字段还是得用not in那至少要控制表达式长度。可以把排除列表拆成多个小批次分批查询再合并结果。比如三万条 ID 拆成 30 批每批 1000 条分别查询后取并集。这样单次表达式短解析快但查询次数变多总体耗时需要权衡。我试过拆成 10 批每批 3000 条总耗时比单次长表达式还低一些因为单次长表达式的解析开销太大了。5.4 慢查询日志怎么看Milvus 的慢查询日志会记录查询的表达式、耗时、返回行数等信息。重点关注expr字段的长度和latency的分布。如果发现某条查询的 latency 突然跳高对比它的 expr 长度和之前的查询基本就能定位到是表达式膨胀导致的。建议在应用层也记录每次查询的表达式长度方便做趋势监控。5.5 常见问题速查表现象可能原因排查方法解决方向查询逐渐变慢not in 列表增长看表达式长度趋势改正向字段加字段后仍慢索引未建或未命中查索引列表和查询计划补建索引upsert 后查不到异步可见或主键错查主键和 flush 状态等待或修正主键表达式解析超时字符串过长看慢查询日志 expr 长度拆批或改字段召回结果混入已读过滤不彻底检查过滤条件加应用层二次过滤实操心得改字段之前一定要先量化瓶颈不要凭感觉优化。我见过有人直接把 not in 换成多个 or 条件结果表达式更长查询更慢。方向比努力重要。6. 我踩过的坑和最后分享的几个技巧第一个坑是布尔字段的默认值。新建集合时如果没指定默认值历史数据的is_read可能是 null 而不是 false导致is_read false过滤不到这些数据。解决办法是在迁移数据时显式写入 false或者在查询时用is_read ! true来兼容 null。但!又是否定条件性能不如所以最好还是把数据补齐。第二个坑是批量 upsert 的主键顺序。Milvus 的 upsert 要求所有字段的数组长度一致且主键和向量要一一对应。如果批量更新时主键顺序和向量顺序错位会把 A 的向量写到 B 的主键上造成数据污染。我当时的做法是在应用层用字典按主键聚合确保顺序一致后再提交。第三个技巧是用 partition key 做用户级隔离。如果业务允许按用户分区可以把 partition key 设为 user_id查询时指定 partitionMilvus 只会在该用户的分区里检索范围大幅缩小。但 partition key 的基数不能太高Milvus 对 partition 数量有建议上限用户量特别大时要评估。第四个技巧是定期 compaction。频繁 upsert 会产生大量删除标记和小 segment查询时要合并这些 segment耗时增加。Milvus 有自动 compaction但触发时机不一定及时。可以在业务低峰期手动触发 compaction把 segment 合并整理查询性能会明显回升。我一般每周跑一次手动 compactionP99 能稳定在低位。最后说一个监控上的经验不要只看平均耗时要看 P99 和 P999。not in大列表的问题往往在平均耗时上不明显因为大部分查询的排除列表可能还不长但少数重度用户的排除列表特别长拖高了长尾。我当时的平均耗时只有 80ms但 P99 到了 1.8s如果只看平均值根本发现不了问题。把长尾监控加上之后才定位到是重度用户的已读列表膨胀导致的。这个内容后续还可以往“按用户分层做不同过滤策略”的方向扩展比如轻度用户直接用 not in重度用户走正向字段兼顾成本和性能。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑