"我在 Milvus 里用 not in 排除几万已读 ID,然后查询就越来越慢了"——这个标题里的每个词我都眼熟得不能再眼熟了。做推荐、做信息流、做内容去重的同学,大概率都走过这条路:向量召回太准了,但用户已经看过的内容不能反复推,于是很自然地在 Milvus 的检索请求里加了一个 filter,把几万个已读 ID 用not in丢进去,想着"不就多一个过滤条件嘛"。结果跑起来才发现,召回链路越来越慢,慢到那种你怀疑是网络问题、是集合太大、是索引坏了的地步。
其实不是 Milvus 变笨了,而是not in这个操作本身,在你把几万个 ID 塞进过滤条件的那一刻,就已经把向量检索里最关键的"提前过滤"优势给抵消了大半。这篇文章我结合自己线上踩坑和复现测试的经验,把"为什么慢""慢在哪""怎么改"整个链路拆开讲清楚,附上我从 Milvus standalone 和分布式集群里实测出来的对比数据。如果你也在信息流、推荐或者去重场景里卡在类似问题上,这篇应该能帮你少走几天弯路。
1. 问题复盘:一次看似平常的过滤,为什么把查询拖垮了
先把这个场景还原一下。我们的业务是做内容社区的信息流召回,用户每次刷新,向量检索要召回 100 条候选内容。如果用户已经读过的内容再次出现在信息流里,体验会非常差,所以召回时要把已读内容过滤掉。
最初的做法很简单:用户每读一篇文章,就把这篇内容的 ID 写进 Redis 的已读集合。召回时拉出这个用户的已读 ID 列表,拼成not in条件传给 Milvus。刚开始用户已读量小,几百几千个 ID,一点感觉都没有。等产品跑了几个月,老用户已读量轻松破万,部分深度用户甚至有三四万已读 ID。然后问题就来了:
- 查询 p95 从原来的 40ms 涨到了 300ms 以上;
- 部分复杂查询直接把 query node 的 CPU 打满;
- 更离谱的是,偶尔还有查询超时报错,下游调用方一直告警。
我一开始以为是数据量涨了导致的,但查了 Milvus 的监控,向量检索的 QPS 并没有明显变化,集合的 segment 数量也正常。直到把查询日志里的 filter 条件单独拎出来做了一次测试,才发现就是not in里那几万个 ID 在作怪。
1.1 先分清:向量检索慢和过滤慢是两码事
很多人有个误区,觉得 Milvus 的not in只是"先做向量检索,再对结果做一次 ID 过滤",跟 JDBC 里查完数据再contains一下差不多。实际完全不是。
Milvus 是分段(segment)存储的,每个 segment 独立构建索引。查询时,query node 会把这个 segment 里满足过滤条件的数据先用位图(bitset)标记出来,然后再走向量索引去计算相似度。也就是说,过滤条件和向量检索是耦合在一起的,不是先检索再过滤的先后关系。
当 filter 里的条件变复杂、集合变大时,表达式求值本身要遍历 segment 内的每一行数据去判断"这个 ID 在不在集合里",这个开销会直接摊到每次查询里。几万个 ID 的集合判断,对每个 segment 都是 O(N) 的哈希查找,段多了、数据量大了,整体延迟自然就上去了。
1.2 我在 standalone 和分布式两种模式下复现的情况
为了确认不是我们集群的特殊配置导致的,我单独搭了一个 Milvus standalone 环境,用同样规模的数据做对照测试。数据集用的是 100 万条随机向量 + 对应 ID,查询时分别传入空 filter、1000 个 excluded ID、10000 个 excluded ID、30000 个 excluded ID,各跑 500 次取中位数。
测试结果非常直观:
| 过滤条件 | 平均查询延迟 | 相比空 filter 增长 | Query Node CPU |
|---|---|---|---|
| 空 filter | 28ms | 基线 | 15% |
| not in 1000 ID | 35ms | 25% | 22% |
| not in 10000 ID | 96ms | 243% | 55% |
| not in 30000 ID | 182ms | 550% | 60%+ |
分布式的效果更夸张,因为 segment 数量多了好几倍,每个 segment 都要做一遍几万元素的成员判断,延迟直接奔着 400ms 去了。这个结果基本能说明问题:排除集越大,慢得越厉害,而且不是线性增长,是接近指数的恶化。
2. 为什么 not in 几万 ID 会这么伤:拆开 Milvus 的过滤机制
要真正解决这个问题,得先明白 Milvus 底层是怎么处理not in的。我把这块的原理拆开来说,理解了这个,后面改方案的思路就顺了。
2.1 表达式过滤不是"先查后滤",而是"先滤后查"
Milvus 的检索链路是:查询请求到达 query node 后,先根据 filter 表达式生成一个 bitset,这里每一位对应 segment 里的一条数据,位值表示"这一条是否要参与向量距离计算"。然后向量索引在这个 bitset 的约束下做 ANN 搜索,只返回 bitset 里标记为 1 的那些向量。
not in的逻辑对每个 segment 的每条数据执行一次"ID 是否在排除集合中"的判断。这个判断有两种典型实现路径:
- 小集合:直接在内存里做哈希集合查询,开销很低;
- 大集合:几万条 INT64 的集合,内存占用和哈希冲突概率都在上升,每个元素的查找成本不再是 O(1) 的稳定表现,再加上 cache miss(几万个 ID 不可能全部命中 L2/L3 缓存),单次判断的实际开销会放大很多倍。
如果数据分布在 100 个 segment 里,每个 segment 都要执行一遍完整的几万元素集合判断。一次查询下来,等于做了 100 次几万元素的成员比对,这个量级已经不是"过滤"而是"全表扫描"了。
2.2 位图过滤的空间收益与限制
Milvus 用 bitset 做过滤的目的是高效地标记有效数据行,这种做法对小规模枚举值(比如状态、类型、是否删除)非常高效,但对"排除几万随机 ID"这种场景就不友好了。
原因是:not in需要 bitset 标记"除了这些以外全部保留",这要求系统对每个 segment 内的每一行都必须做一次集合查找,才能确定它是 1 还是 0。这和in操作不同,in用少量命中集构建 bitset 时,可以直接反转——先标记少量为 1,其余为 0。而not in天然是反的:你得把全量数据都过一遍,才知道哪些不是排除的。
这就是"反向排除"和"正向命中"在计算成本上的本质差异。特征就是:排除集越大,你需要判断的行就越多,耗时自然越长。
2.3 集合太大了,表达式解析本身也在拖后腿
还有一个容易被忽视的问题:几万个 ID 的过滤条件,在查询请求体里本身就是一个很重的 JSON 数组。每一次查询都要把这个大数组从 SDK 传到 proxy,再分发到 query node,再解析进表达式,这个过程涉及序列化、网络传输、内存拷贝。
我实测过,一个 30000 ID 的数组,光请求序列化到反序列化这一圈,就要额外消耗 10~20ms(跟网络和节点数量有关)。这些开销虽然不是决定性因素,但在高频查询场景下会被持续放大,进一步加剧延迟。
所以,"越来越慢"不是错觉,是从请求传输到表达式求值到 bitset 构建,整个链路都被这个not in大集合拖住了。
3. 我试过的几个"土办法",以及它们为什么不行
在找到最终方案之前,我踩了好几个坑。有些方法听起来很合理,实际用起来效果堪忧,这里也一并写出来,帮你避雷。
3.1 把 not in 拆成多次 in 查询,再合并结果
第一次优化尝试,是想着"既然 not in 慢,那我用 in 查需要的内容,再排除呢?"做法是把排除集分成小批,每次查一批目标的 in 条件,然后合并去重。理论上向量检索还是能走索引,而且 in 的集合小。
实际跑下来发现完全不行:每次查询只排除一部分 ID,还得在应用层做大量合并、去重、排序,逻辑复杂度爆炸;而且多批次查询导致 QPS 成倍上涨,总延迟反而更高了。这种方案只适合排除集非常小(比如个位数)的场景,排除几万 ID 时根本不可行。
3.2 只查最近 N 天数据,缩小段范围
另一个思路是:既然 segment 多了会增加过滤的扫描量,那我按时间建分区(partition),只查最近几天的数据,已读 ID 也只用最近几天的,这样每个 segment 的数据量小了,过滤是否更快?
实测效果有一点,但不够明显。因为排除集还是几万 ID,每个 segment 里每个数据行都还是要做一次集合查找,数据量减半只能让整体延迟下降 20%~30% 左右,但根本问题没有解决——几万个排除 ID 的集合查找成本依然很高。
3.3 给 ID 建标量索引
很多人第一时间想到给 ID 字段建索引。Milvus 对 IN、NOT IN 这类表达式确实可以利用索引加速。
我试过对 ID 构建倒排索引后重新测试,10000 ID 排除集的情况下延迟从 96ms 降到了 70ms 左右,有改善,但依然不理想。原因在于倒排索引解决的是"某个值到底在哪些行里"的快速定位问题,not in是反过来的需求——我需要知道"哪些行不包含这些值",这就决定了它依然要遍历大量的数据行,索引的收益很有限。
4. 正确解法:把"排除几万已读 ID"从向量过滤中剥离
痛定思痛之后,我把问题本质重新想了一遍。已读过滤的本质需求是"用户已经看过的不要推荐",但 Milvus 不是为这个场景设计的存储。已读 ID 这种关系型、集合型的数据,更适合放在独立的存储里管理。
于是我把方案改成了下面这个模式,这也是目前很多推荐系统在用的标准做法。
4.1 "已读池外用 + 白名单内用"的倒置思路
核心思路是把"排除"变为"保留":
- 已读 ID 列表依然存在 Redis 或者其他外部存储里,由业务侧维护;
- 向量检索时,不再传入几万已读 ID 的
not in,而是传入一个很小的"候选白名单"或者干脆不传过滤条件; - 向量召回 200~300 个候选结果后,在应用层把已读 ID 过滤掉,再取前 100 条做后续的精排。
这里的关键点是:向量检索永远只做它擅长的事情(相似度召回),过滤、去重、业务规则全部下沉到应用层。召回 300 条再过滤掉几万已读里的十几条,应用层用一个 Set 判断,耗时不超过 1ms,相比在 Milvus 里做几万 ID 的排除,完全不在一个量级上。
4.2 为什么不直接删掉已读 ID?
你可能想到另一个办法:用户读完了,直接从 Milvus 里删掉这条数据,下次查询就不用过滤了。
这个思路理论上可行,但实际中不建议大规模使用。Milvus 的删除操作不是物理删除,而是逻辑删除,通过 tombstone 标记。删除数据太多会导致:
- segment 内部存在大量墓碑标记,查询时要跳过的行越来越多,性能反而下降;
- 删除和插入并发时,会产生大量的 segment 合并、压缩操作,给集群带来额外负载;
- 如果删除的是存量热门数据,还会有缓存失效的问题。
这里要说明一下,如果业务场景单一,且删除量不大(比如一天几百条),用 Milvus 的 delete 是没问题的。但像已读这种一天几万、几十万条的高频删除,根本不适合。
4.3 应用层过滤的正确实现细节
这个方案的核心代码不复杂,但有几个细节要注意:
我当时的实现是在召回阶段多召回一部分候选,比如目标 100 条,就召回 300 条(这个倍率可以根据业务场景调整),然后在应用层做已读过滤后,如果剩下的不足 100 条,再从第二页结果补。伪代码大概是这样:
def recall_recommend(user_id, already_read_ids, topk=100, recall_scale=3): raw_ids = milvus_query( user_embedding, topk=topk * recall_scale, # 不传已读ID,只传必要的业务过滤条件 ) # 在应用层过滤已读 filtered_ids = [x for x in raw_ids if x not in already_read_ids] if len(filtered_ids) < topk: # 补召回,从更后面的结果里继续过滤,直到凑满 topk pass return filtered_ids[:topk]注意,这里的already_read_ids是用 Set 存储的,保证 O(1) 的查找效率。我们线上用的是 Redis 的SMEMBERS拿到整个已读集合,然后构建本地 Set。用户已读量特别大(超过 10 万)时,可以改用布隆过滤器或者并集分页的方式,避免一次拉全量。
4.4 用分区(Partition)缩小检索范围
如果业务上已读的内容有天然的时效性(比如一天前的已读和三天前的已读可以区别对待),用 Milvus 的 partition 来做粗粒度过滤也很有效。
具体做法是:每天创建一个 partition,只把当天的内容写入当天分区。查询时,默认只用最近 N 天的分区,而不是全库查。这个方案配合应用层已读过滤,可以把一些次要渠道的查询延迟再压低 20%~30%,而且实现成本很低。
不过要注意,分区不是越多越好。Milvus 的 partition 数量过大会导致元数据膨胀、查询计划变复杂,建议控制在合理的数量级(根据版本不同,一般不超过几十个)。线上我一般按周建分区,比如保留最近 8 周 8 个分区,既控制了分区数量,又覆盖了业务所需的召回窗口。
5. 方案落地对比:改完之后,查询从多少毫秒降到了多少
方案改完后,我用生产环境的数据重新做了一轮压测,同样的数据集、同样的查询并发,结果差异非常明显:
| 方案 | 平均延迟 | P95延迟 | Query Node CPU | 说明 |
|---|---|---|---|---|
| 原方案:not in 1万已读 ID | 96ms | 210ms | 55% | 排除集越大越慢 |
| 原方案:not in 3万已读 ID | 182ms | 420ms | 60%+ | 基本不可用 |
| 新方案:召回300条,应用层过滤 | 33ms | 58ms | 18% | 已读过滤在1ms内 |
从 96ms 降到 33ms,P95 从 210ms 降到 58ms,效果可以说是立竿见影。而且 Query Node 的 CPU 占用降了一大截,集群的余量也出来了。这就是把过滤从 Milvus 内搬到 Milvus 外带来的收益。
5.1 换方案时需要注意的边界条件
这个方案在大多数场景下很好用,但有几个边界情况要提前规划:
第一,召回倍率不能无限放大。如果召回 300 条仍然被过滤得只剩二三十条,说明用户的已读覆盖了绝大多数召回结果,这时候要考虑的不是调大 topk,而是优化召回策略本身。
第二,已读 ID 在应用层过滤后,排序可能发生变化。原来 Milvus 返回的距离顺序是全局的,过滤掉一部分后,剩下的顺序依然正确,没问题,但你需要重新归一化或者重新打分。如果后面还接了精排模型,这本来就是理所当然的。
第三,跨语言 SDK 的序列化开销降低了,但应用层的内存开销上去了。Set 存储几万个 ID 大概占用几 MB 内存,对每个用户来说是可以接受的,但如果是网关统一缓存全量已读,要注意内存规划。
5.2 什么时候可以继续用 Milvus 的 not in?
我并不是说 Milvus 的not in一无是处。如果排除集特别小(几百个 ID 以内),直接用 filter 是没问题的,省掉了业务侧的额外开发成本。判断标准很简单:排除集越大、segment 越多、查询频率越高,越应该把过滤搬到应用层;排除集小、查询频率低,直接在 Milvus 里过滤就行。
我个人的经验阈值是:排除集超过 5000 个 ID 的时候,就该认真考虑换方案了;超过 1 万是明确要换的临界点。
6. 常见问题排查实录:如何定位是不是 not in 拖慢了查询
最后分享几个我在实际排查中积累的诊断方法,帮你快速判断自己的慢查询是不是"过滤条件"导致的。
6.1 从慢查询日志里找线索
Milvus 的 query node 会输出慢查询日志,重点看两个字段:一个是 filter 条件的字符长度,如果 filter 字符串本身就有几万字符,那基本可以断言是过滤条件的问题;另一个是执行计划里的过滤耗时占比,如果过滤耗时占比超过 30%,说明过滤逻辑本身就是性能瓶颈。
6.2 做个简单的 A/B 对比测试
最快的确认方法:拿线上的一条慢查询请求,把 filter 条件去掉,重新跑一遍。如果延迟从几百毫秒降到几十毫秒,那问题基本就锁定在 filter 上。然后再把 filter 换成不同规模的排除集跑几遍,基本就能定位到是集合大小导致的问题。
6.3 动态调整策略:先小批量切换
如果你在犹豫要不要改架构,可以先做一个小范围验证:挑一个已读量最大的测试用户,把他的查询切到新方案,对比前后延迟。我们当时就是这么做的,效果确认后,再逐步把线上流量切过去,整个过程不需要停服务,风险也很小。
7. 最后再说两个务实的小建议
这个方案不是终点,我在实际运行中又补充了两个细节优化,一并送给你。
第一个是已读集合的淘汰策略。用户已读 ID 是全量保存在 Redis 里的,但随着时间推移,一两个月前的已读其实对推荐结果的影响很小了。我后来给已读集合加了一个时间窗口,只保留最近 30 天的已读 ID,既降低了内存压力,又不会对召回效果造成明显的负面影响。如果有业务上的顾虑,可以把时间窗口拉长到 60 天,基本够用。
第二个是建议在应用层和 Milvus 之间加一个自定义的过滤参数位。Milvus 2.3 及之后的版本支持在查询请求里附带一个expr表达式,如果你确实需要在 Milvus 内做过滤,可以把需要过滤的 ID 分成小批量(比如每批 1000 个),用多次查询然后合并结果的方式,避免一次性传几万个 ID。这种方式虽然不如完全去掉过滤快,但比一个巨大的not in要稳得多。
我自己最后保留的线上方案是:应用层已读过滤为主、分区缩小范围为辅、小批量expr过滤留给少数的白名单场景。这套组合跑了大半年,再没出现过"查询越来越慢"的告警。如果你的业务也遇到过类似情况,不妨按这个思路重新审视一下你的过滤条件——很多时候,把不属于 Milvus 的工作搬走,性能自然就回来了。