news 2026/10/15 8:03:15

Milvus not in 过滤导致查询变慢的排查与优化方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Milvus not in 过滤导致查询变慢的排查与优化方案

"我在 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
空 filter28ms基线15%
not in 1000 ID35ms25%22%
not in 10000 ID96ms243%55%
not in 30000 ID182ms550%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万已读 ID96ms210ms55%排除集越大越慢
原方案:not in 3万已读 ID182ms420ms60%+基本不可用
新方案:召回300条,应用层过滤33ms58ms18%已读过滤在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 的工作搬走,性能自然就回来了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 8:02:58

Multi-YOLOv5实战:检测+分割一体化的工程化落地指南

1. 这不是“YOLOv5加个分割头”那么简单&#xff1a;Multi-YOLOv5的真实定位与工程陷阱你在网上搜“YOLOv5 实例分割”&#xff0c;十有八九会看到一堆拼凑的教程&#xff1a;改一改models/yolov5s-seg.yaml&#xff0c;加几行SegmentHead&#xff0c;跑通一个val.py就宣称“搞…

作者头像 李华
网站建设 2026/10/15 8:02:53

LED显示屏控制系统:单网口带载计算与连屏设计实战

简介&#xff1a;一份面向LED显示屏项目设计、调试与运维人员的诺瓦星云控制系统培训PDF资料&#xff0c;系统覆盖从LED模组、箱体基本结构到控制系统架构的完整知识链。资料从显示屏应用分类入手&#xff0c;比较室内外屏、固装屏与租赁屏的适用场景&#xff0c;并逐一解析视频…

作者头像 李华
网站建设 2026/10/15 8:02:40

AnyPS5跨平台适配框架:分层抽象与任务路由机制解析

1. 项目缘起与核心定位AnyPS5 这个名字第一次出现在我视野里的时候&#xff0c;我正被一堆跨平台脚本的兼容性问题折腾得够呛。当时手头有个小工具需要在几个不同的运行环境里跑起来&#xff0c;每个环境对文件路径、系统调用、编码格式的处理方式都不一样&#xff0c;改一处就…

作者头像 李华
网站建设 2026/10/15 8:02:28

不动产登记查询结果怎么看?一文读懂字段含义与核验方法

简介&#xff1a;这份PDF是东莞市不动产登记中心依据权利人容森煜查询申请出具的《不动产登记情况查询结果》&#xff0c;属于典型的个人住房证明文件。面向需办理落户、贷款、子女入学等事项的购房者及相关审批人员&#xff0c;用于核实名下房产登记状况。文件完整展示了权利人…

作者头像 李华
网站建设 2026/10/15 8:02:25

Chrome扩展监听AJAX请求的原理与实战指南

1. 为什么监听 AJAX 请求成了插件开发的“分水岭”能力很多刚写完弹窗、右键菜单、内容脚本的开发者&#xff0c;会误以为 Chrome 扩展开发已经“通关”了——直到某天&#xff0c;他想在电商页面自动高亮降价商品&#xff0c;却发现价格数据根本不在 HTML 源码里&#xff1b;或…

作者头像 李华
网站建设 2026/10/15 8:02:19

风储联合系统建模与DIgSILENT仿真实践

1. 项目背景与核心价值在新能源占比不断提升的现代电力系统中&#xff0c;电池储能系统&#xff08;BESS&#xff09;与风电场的协同运行已成为解决间歇性发电问题的关键技术方案。这个项目通过DIgSILENT PowerFactory平台&#xff0c;构建了完整的电池储能充放电控制模型&…

作者头像 李华