☰
Milvus 向量检索中 not in 大列表过滤的性能优化实践
2026/10/28 14:07:08 网站建设 项目流程

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(name="id", dtype=DataType.INT64, is_primary=True), FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768), FieldSchema(name="category", dtype=DataType.VARCHAR, max_length=64), FieldSchema(name="is_read", dtype=DataType.BOOL), ] schema = CollectionSchema(fields=fields)

如果集合已经存在,Milvus 2.4 之后支持add_field动态加字段,但加完之后历史数据的is_read默认值需要确认。我建议新建集合再迁移数据,避免字段默认值带来的语义混乱。

4.3 第三步:给 is_read 建标量索引

布尔字段建倒排索引或者位图索引都可以。位图索引对低基数字段(只有 true/false 两个值)特别友好,查询时可以直接做位运算。建索引的命令:

collection.create_index( field_name="is_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 = f"id not in [{','.join(map(str, read_ids))}]" results = collection.search( data=[query_vector], anns_field="embedding", param={"metric_type": "COSINE", "params": {"ef": 64}}, limit=50, expr=expr )

改造后:

expr = "is_read == false" results = collection.search( data=[query_vector], anns_field="embedding", param={"metric_type": "COSINE", "params": {"ef": 64}}, limit=50, expr=expr )

如果还需要排除少量近期已读但字段还没更新过来的数据,可以再加一个短列表:

expr = f"is_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 倍即可。比如 limit=50,ef 取 128 到 200。我实测 ef=64 时召回率已经够用,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,查询时指定 partition,Milvus 只会在该用户的分区里检索,范围大幅缩小。但 partition key 的基数不能太高,Milvus 对 partition 数量有建议上限,用户量特别大时要评估。

第四个技巧是定期 compaction。频繁 upsert 会产生大量删除标记和小 segment,查询时要合并这些 segment,耗时增加。Milvus 有自动 compaction,但触发时机不一定及时。可以在业务低峰期手动触发 compaction,把 segment 合并整理,查询性能会明显回升。我一般每周跑一次手动 compaction,P99 能稳定在低位。

最后说一个监控上的经验:不要只看平均耗时,要看 P99 和 P999。not in大列表的问题往往在平均耗时上不明显,因为大部分查询的排除列表可能还不长,但少数重度用户的排除列表特别长,拖高了长尾。我当时的平均耗时只有 80ms,但 P99 到了 1.8s,如果只看平均值根本发现不了问题。把长尾监控加上之后,才定位到是重度用户的已读列表膨胀导致的。这个内容后续还可以往“按用户分层做不同过滤策略”的方向扩展,比如轻度用户直接用 not in,重度用户走正向字段,兼顾成本和性能。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询