Milvus not in 过滤性能爆炸:推荐系统已读去重架构改造复盘

发布时间:2026/10/28 13:18:12
Milvus not in 过滤性能爆炸:推荐系统已读去重架构改造复盘

先交代一下背景,我负责一个内容推荐服务的召回层,文档向量全量放在 Milvus 里,线上流量进来以后先做向量检索,再按用户状态做过滤。用户看完一篇内容就会被写进一个已读集合,下一次推荐时必须过滤掉。这个需求听起来人畜无害,我第一版实现也特别简单:把用户的已读 ID 列表直接拼进 Milvus 的搜索表达式,用not in排除。上线头两周一切正常,几百个已读 ID 过滤掉也就多花几毫秒。当用户已读量涨到几千、上万之后,我开始连续接到 p99 延迟告警:同一个查询,从 40 多毫秒一路涨到几百毫秒,queryNode 的 CPU 也跟着明显长高。这篇文章就是这个坑的完整复盘,包括 Milvus 为什么会在这种写法下变慢、我是如何一步步定位到根因、以及最终把架构改成什么样才算稳住。适合正在用 Milvus 做召回、又需要在搜索链路里做已读/去重这类个性化过滤的开发者参考。

1. 场景还原:一条 not in 是怎么塞进 Milvus 查询里的

1.1 业务背景:推荐流里的“已读去重”

我当时负责的是一个“猜你想读”类的内容推荐服务,内容库里大概有五百万篇图文和短视频,每篇内容向量化之后导入 Milvus,集合名就叫feed_content。用户的阅读行为是记录在 Redis 里的:看完一篇文章,就往user_seen:{uid}这个 Set 里加一个内容 ID。

用户刷新推荐流的时候,推荐服务的处理链路大概是这样的:

  1. 拿到用户当前上下文,生成 query 向量
  2. 去 Milvus 做向量相似度召回,拿到候选内容
  3. 把候选内容里“用户已经读过的”全部剔掉
  4. 剩下的内容进重排,最终返回给客户端

当时我的第一直觉是:向量检索和已读过滤都是“查询条件”,放在同一个搜索请求里一次性完成,既省一次网络往返,代码也简洁。于是很自然地写成了 Milvus 搜索时带expr=f"id not in {...}"。

这里有个前提要先说明白:我平时说的这类“查询”,其实是在collection.search()这种向量搜索接口里带expr参数,并不是 Milvus 那个纯标量过滤的query()接口。not in的语义在两种接口里都存在,但成本模型完全不同,后面章节会重点展开。

1.2 第一版代码和它埋下的雷

第一版代码写出来大概长这样:

from pymilvus import Collection collection = Collection("feed_content") def search_feed(user_id: str, query_vec: list, seen_ids: list, topk: int = 10): if seen_ids: expr = f"id not in {seen_ids}" else: expr = None res = collection.search( data=[query_vec], anns_field="embedding", param={"metric_type": "IP", "params": {"nprobe": 16}}, limit=topk, expr=expr, output_fields=["id"] ) return [hit.id for hit in res[0]]

代码看着人畜无害,现在回头看至少埋了三颗雷:

  • 第一,每次请求都要把用户的完整已读 ID 集合从 Redis 里SMEMBERS取出来,再拼成一个巨大的表达式字符串。几万 ID 的时候,光这条命令就好几毫秒,拼接、序列化也要时间。
  • 第二,几万个 ID 要跟着查询请求一起分发到 Milvus 的各个 queryNode,每个查询节点都要解析一遍这个大集合,序列化和传输开销都会随 ID 数量线性增长。
  • 第三,也是最重要的一点,Milvus 执行带表达式的向量搜索时,过滤动作发生在“向量召回出来的候选集合”之上,而不是像关系型数据库那样走全表索引扫描。not in几万个 ID,等于让每个候选都要和这个大集合做一次成员判断,CPU 开销直接爆炸。

我当时还犯了一个经典错误:seen_ids为空列表时没有单独处理,直接把空列表拼进了表达式。结果拼出来的id not in []在部分 Milvus 版本里要么解析失败,要么行为不符合预期。后面我改成空集合就回退到普通搜索,才算避免这个问题。

1.3 延迟是怎么一步步涨上去的

上线初期用户少,已读量也少,几百个 ID 的not in过滤几乎无感。等用户量起来以后,我统计了线上一个固定内容库规模下,不同已读集合大小对应的 p99 延迟,大致是这个走势:

已读 ID 数量p99 延迟观察到的现象
10038ms无明显影响
1,00055ms偶尔告警
5,00095ms延迟明显抬升
20,000260ms开始有用户反馈刷不出来新内容
50,000690msp99 告警不断,queryNode CPU 接近打满

一开始我怀疑是不是 Milvus 里的向量总量也在涨,导致向量索引本身变慢。后来我把内容库大小固定住,只调节已读集合大小做压测,发现真正在变的变量就是排除集规模。延迟随排除集增长不是线性关系,而是近似指数级的放大,这个现象已经不能简单用“集合大了多算几次”来解释了。

2. Milvus 的过滤机制决定了 not in 注定吃亏

要理解为什么not in几万 ID 会把查询拖垮,必须先搞清楚 Milvus 执行带过滤条件的向量搜索时,内部到底走了哪几步。

2.1 向量搜索和标量过滤的两段式执行

Milvus 底层的存储是按 segment 管理的,一个集合可以被切分成很多 segment,分布到多个 queryNode 上。执行一次带expr的向量搜索时,每个 segment 内部大体做这么几件事:

  1. 先用底层的向量索引做粗排,比如 HNSW 就在图里做游走,IVF 就访问一定数量的聚类中心,最后得到一批“相似度最高”的候选向量。
  2. 拿到这批候选以后,读取这些候选行对应的标量字段。
  3. 再拿标量字段的值去套表达式,把不满足条件的候选剔除。
  4. 对剩下的候选按相似度排序,返回 topK 条结果。

我用一个生活中常见的场景类比:海选选秀。你先凭着“长得像”从几万个人里粗筛出 1000 个候选人,然后再一个个查“黑名单”,把黑名单上的人剔掉。如果黑名单覆盖面特别大,1000 个人里 990 个都在黑名单上,那这轮复赛基本白选,只能回头再捞一批人重新查。

所以关键结论是:Milvus 的标量过滤是作用在向量粗排出来的候选集上的,不是全表过滤。过滤条件越严苛,候选集就要越大,才能保证过滤完以后还有足够的结果返回。

2.2 过滤越严格,ANN 就挖得越深

向量搜索里有几个参数直接决定候选集的大小:

  • topK:最终需要返回多少条结果。
  • nprobe:IVF 索引下访问多少个聚类中心,访问越多候选池越大。
  • ef:HNSW 索引下搜索队列的大小,控制图遍历的探索宽度。

正常情况下,如果过滤条件很轻,比如就过滤掉 1% 的候选,那搜索深度不用太大,随手一捞就够 topK 了。但如果过滤率很高,比如 90% 甚至 99%,要返回 10 条未读结果,就至少要搜索 100 到 1000 条候选。

更麻烦的是,在推荐场景里“已读内容”和“当前 query 相似的内容”往往高度重叠。用户会去读他喜欢的内容,推荐系统能召回回来的候选也恰恰是他喜欢的方向,所以候选集里的已读比例远高于全库的已读比例。举个例子:全库 50 万篇里用户读过 4 万篇,全局已读比例只有 8%,看起来不高。但向量召回回来的候选里,可能 40% 都是用户读过的,这时候not in过滤就把大部分候选都干掉了。

在 Milvus 2.4 之前的版本里,如果过滤后剩下的结果不足 topK,系统不会自动加搜,而是直接把不足的结果返回给客户端。业务上的表现就是用户发现“推荐刷两下就没了”。为了缓解这个问题,大家会手动调大nprobe或者ef,但这意味着单次搜索要遍历更多节点,延迟自然上去了。到了 2.4 之后,Milvus 引入了迭代搜索机制,过滤后结果不足时会自动按步长扩大搜索宽度继续找。这个机制能改善召回数量,却也把延迟抖动变得更大,过滤越严格,回溯得越深,延迟就越不稳定。

所以延迟随已读集合增长呈现指数级放大,本质上是“排除率高”和“ANN 回溯加深”在互相叠加。

2.3 为什么 not in 比 in 更吃亏

很多人会有一个疑问:既然 Milvus 支持表达式,那in和not in是不是一个量级的开销?实测下来不是。

标量字段如果建了倒排索引或者 bitmap 这类加速结构,对in和等值查询是非常友好的。in的本质是给定一个候选清单,倒排索引可以直接定位到包含这些值的文档列表,效率很高。但not in是反过来的语义——我要的是“全集减去排除集”,也就是一个低选择性的负向过滤。倒排索引的正向定位优势完全没有用武之地,只能把候选集里的每一条都拿去排除集合里查一遍。

Milvus 表达式执行引擎在处理NotIn算子时,通常会把排除集合组织成一个哈希集合,然后对每条候选进行成员判断。哈希集合的单次判断确实是 O(1) 理论复杂度,但前提是集合本身已经构建好、并且能命中缓存。几万个 ID 的哈希集合,单个就有几百 KB 甚至更大,遍历候选时基本是随机内存访问,缓存很难命中,实际开销比纸面复杂度高得多。

如果排除集合里装的是字符串 UUID,那就更疼。字符串哈希、比较、内存占用都比整数 ID 高一个量级,表达式字符串本身也会变得巨大,传输和解析成本直线上升。我后来把所有内容 ID 从 UUID 改成 int64 自增 ID,情况有明显好转,但并没有根治问题。

2.4 几万排除集的“乘法效应”

这里可以粗略算一笔账,看看几万排除集到底有多重。

假设排除集合有 2 万个 ID,某次向量搜索在一个 segment 内召回的候选有 1000 条,那么这个 segment 就要做 2000 万次成员判断。如果你的集合被拆成 20 个 segment,全链路就是 4 亿次。即便哈希查找本身很快,乘以这么多量级之后,CPU 时间也非常可观。

还有一层容易被忽略的成本:同样的表达式会分发到每个 queryNode,每个 segment 执行过滤时都要维护一份自己的哈希集合。几万个 ID 的表达式字符串组装、protobuf 序列化、网络分发、反序列化、哈希集合构建,这套流程在每台机器上都要完整走一遍。集群节点越多,这份重复开销就被放大得越多。

所以结论已经很清楚:让向量数据库在搜索请求里做“大集合负向排除”,本质上是把关系型数据库或者集合存储该干的活抢过来干了,成本高、收益低、还不好优化。我后来在 standalone 模式上压测也是一样的结论——过滤 CPU 瓶颈并不只是分布式分发带来的,单机一样扛不住。

3. 踩坑定位过程:我怎么确认瓶颈在过滤而非向量索引

发现问题以后,我并没有直接上手改架构,而是先做了一轮严格的对照实验,把瓶颈彻底钉死。因为只有确认了慢在哪一层,后续改造才有依据。

3.1 对照实验:去掉 expr、换 in、换空集合

我在同样的数据量、同样的索引参数下,只更改表达式,压测了这么几种情况:

场景表达式p99 延迟
无表达式不传 expr35ms
正向小集合id in [100 个 ID]38ms
负向小集合id not in [100 个 ID]40ms
负向大集合id not in [20000 个 ID]270ms
空集合id not in []部分版本直接报错或返回异常

这一组结果基本能得出两个结论:

  • 第一,小集合的not in和小集合的in差距不大,方向本身不是决定性因素。
  • 第二,集合规模上来以后,not in的延迟暴涨,说明代价主要来自大集合的成员判断,而不是not in这个语法本身。
  • 第三,我还验证了topK从 10 调到 100 时,无表达式场景延迟只增加 20ms 左右,而带 2 万排除集的场景延迟几乎翻倍。这说明除了过滤自身的 CPU 开销之外,为了凑够过滤后的结果,向量搜索的探索宽度也不得不加大。

3.2 用监控和 profiling 拆解延迟构成

对照实验只能说明“慢在expr”,还不能精确知道慢在表达式执行的哪一步。我又开了 Milvus 的监控指标和链路追踪,去看单次搜索请求内部的时间分布。主要看两个东西:

  • Prometheus 指标里 queryNode 的搜索耗时拆解,重点看向量检索阶段和过滤执行阶段各自占了多少。
  • 用 OpenTelemetry 的 trace 看单条请求的 span,能直接看到 filter 这个阶段花了多少时间。

在我这个 case 里,not in两万 ID 时,过滤阶段占到了搜索总耗时的 70% 以上。向量检索本身离群值并不大,这才是最有力的证据。我还顺手在 queryNode 上开了 pprof 抓 CPU profile,热点函数基本都落在表达式求值器和哈希集合查找相关的调用上,和现象完全对得上。

如果你们用的是托管版或者不方便开 pprof,也可以用个更笨但有效的办法:把同样的查询请求分别打到空闲节点和已满节点上观察,如果 CPU 已经打满,延迟往往会进一步恶化,因为过滤这种 CPU 密集型操作是没办法通过水平扩容完全解决的,每个节点都在做重复的集合构建。

3.3 确认根因:排除集越大,回溯越深

综合所有证据,最后的结论是:

  1. not in大集合本身带来巨量成员判断,CPU 开销高。
  2. 推荐场景里已读内容与向量召回结果高度重叠,导致过滤率异常高。
  3. 过滤率高以后,要么返回结果不足,要么触发迭代搜索自动扩大探索宽度,把延迟进一步推高。
  4. 三者叠加,就表现为“查询越来越慢”,而且是接近指数级的增长。

Milvus 本身没有 bug,是我把它用错了地方。向量数据库的核心优势是相似度召回,不是处理“几万个元素的负向集合运算”。

4. 解决思路:别让向量库做它不擅长的事

定位完根因以后,我开始尝试各种解决方案。有的是治标,有的是治本,最终我选了治本的那条。这里按我尝试的先后顺序整理出来,你可以根据自己的业务形态选择。

4.1 反转过滤方向:用“小集合”代替“大集合”

从集合论角度,id not in seen_ids和id in unseen_ids是等价的。如果未读集合很小,那完全可以直接用in去查未读集合,这样集合构建和成员判断成本都很低。

这个思路在什么场景下适用呢?典型的是新用户、新内容冷启动。比如内容库一共 1 万篇,新用户只读了 100 篇,那未读集合有 9900 个元素,直接反向列出来反而更大。但如果某个用户已经读了 9 万篇,未读只剩 1000 篇,用in查这 1000 个未读 ID 反而更划算。

所以判断标准很简单:用基数小的一侧做过滤。谁的集合小,谁就是你的过滤清单。

但问题是,推荐系统里绝大多数存量用户的已读集合都在几万量级,未读集合往往是几十万,根本列不出来。这个思路只能解决一小部分场景。我后来还尝试过在内容侧打标,给每条内容加一个“低选择率属性”,比如内容可推荐状态、地域限制、作者过滤,然后把not in换成in。这种做法对全局属性有效,但对“这个用户读过那篇”这种用户-内容交叉状态无效,因为每条内容上挂已读用户数组不现实。

4.2 核心改造:Milvus 只做粗召回,已读过滤放在 Redis

这是我最后真正采用并上线的方案:把“已读过滤”从 Milvus 表达式里彻底拿走,放到 Redis 里做。

改造后的链路长这样:

  1. 用户请求进入推荐服务。
  2. 推荐服务生成 query 向量后,直接调 Milvus 搜索,表达式中不做任何个性化状态过滤,最多保留少量低选择率的全局属性过滤,比如status in [1]。
  3. 搜索时故意把候选数量放大几倍,比如业务上需要 20 条结果,就先捞 100 条。
  4. 把 100 个候选 ID 一次性发给 Redis,用SISMEMBER批量判断哪些已经在用户的已读集合里。
  5. 过滤掉已读后,如果未读候选够 20 条,直接进入重排环节。
  6. 如果不够,就进入第二轮召回,把nprobe或者limit翻倍,再捞一批出来过滤,直到凑够结果或者达到最大轮次。

对应的伪代码大概是这样:

import redis from pymilvus import Collection r = redis.Redis(host="...", port=6379, decode_responses=True) collection = Collection("feed_content") def recall_unseen(user_id: str, query_vec: list, need: int = 20, max_rounds: int = 3): hits = [] nprobe = 16 limit = need * 5 # 先捞 5 倍候选 for _ in range(max_rounds): res = collection.search( data=[query_vec], anns_field="embedding", param={"metric_type": "IP", "params": {"nprobe": nprobe}}, limit=limit, output_fields=["id"] ) cand_ids = [hit.id for hit in res[0]] if not cand_ids: break # 批量判断候选 ID 是否已读 pipe = r.pipeline(transaction=False) for cid in cand_ids: pipe.sismember(f"user_seen:{user_id}", cid) seen_flags = pipe.execute() fresh = [cid for cid, flag in zip(cand_ids, seen_flags) if not flag] hits.extend(fresh) if len(hits) >= need: break # 下一轮扩大探索范围 nprobe *= 2 limit *= 2 return hits[:need]

这套思路好在哪里?好就好在把每一层的事都还给了真正擅长它的组件:

  • Milvus 只负责相似度粗召回,表达式保持简单,过滤开销几乎可以忽略。
  • Redis 的 Set 结构天生就是做成员判断的,SISMEMBER是 O(1),一次 pipeline 可以批量判断几千个 ID,往返也就 1 到 2 毫秒。
  • 推荐服务自己控制召回轮次,逻辑完全透明,不会出现 Milvus 内部不断回溯导致的不可控延迟。

有几个实施细节需要注意,都是我在线上踩过才明白的。第一,候选放大倍数和轮次上限要一起设置。我线上用need * 5起步,最多 3 轮,超过轮次就直接返回当前未读结果,宁可少推也不要用户无限等。第二,Redis 里的已读集合如果要设置过期时间,必须单独维护一个过期时间字段,Set 结构本身不能像 String 那样直接设置 TTL,否则会删掉用户阅读历史。第三,如果单个用户的已读集合异常庞大,比如几十万,可以考虑做两层过滤:先用布隆过滤器粗筛,再对布隆过滤器判定为“未读”的 ID 走一次精确SISMEMBER确认,减少大面积集合查询的压力。

4.3 辅助手段:内容属性预过滤和分区

在已读过滤被挪到业务层以后,Milvus 的表达式也不是完全不用了。我保留了一些“低选择率属性”的过滤,比如内容状态、品类、地域限制,这些集合通常只有几十到几百个元素,用in查询对延迟影响很小。

另外一个辅助手段是分区。把内容按时间窗口、产品线拆到不同 partition 里,搜索时用partition_names限定范围,可以显著减少需要扫描的 segment 数量。拿我们的场景举例,用户刷推荐通常只需要最近 30 天的高质量内容,那我就可以按周建分区,搜索时只进最近 4 个分区。注意,这里切分的是内容本身的属性,不是用户维度。如果你尝试按用户维度去建分区,分区数量会爆炸,完全不可行。

4.4 实在改不动代码时的止血参数

如果你的业务暂时不允许把过滤逻辑挪出 Milvus,在保住现有架构的前提下也有一些止血手段,虽然不治本但能争取时间:

  • 限制排除集合规模。产品上如果允许“偶尔重复推荐”,可以只排除最近 7 天或者最近 1000 条已读内容,而不是全部已读。实测下来,把排除集合从 5 万压到 1000,p99 能从 600ms 直接回到 60ms 左右。
  • 调大nprobe或者ef。搜索宽度加大以后,单次能从向量索引里捞出的候选变多,能缓解“过滤后结果不足”的问题。但这是用延迟换召回,过滤本身的 CPU 开销还在。
  • 关注 Milvus 新版本的迭代搜索特性。2.4 之后提供了迭代搜索,过滤后结果不足时自动扩大搜索深度,对结果数量有帮助,但延迟抖动会更明显。版本升级本身值得做,但不要指望它根治大集合not in。
  • 把字符串 ID 换成整型 ID。能显著降低表达式解析、哈希、比较和内存占用的成本,是成本最低的优化。

5. 实际改造后的效果与一些经验

5.1 改造后的查询路径长什么样

现在线上跑的通路已经是“Milvus 粗召回 + Redis 已读过滤 + 推荐服务兜底重试”的模式了。每次推荐请求的完整路径是:

  1. 用户点击或者刷新,推荐服务拿到上下文,生成 query 向量。
  2. 调 Milvus 搜索,表达式只带内容上下架、品类这种低选择率属性过滤,expr简单到一眼能看懂。
  3. 一次性取回需求数量的 5 倍候选,给到已读过滤模块。
  4. 已读过滤模块用 Redis pipeline 批量判断,返回未读候选。
  5. 未读候选数量足够,直接进重排;不够,则扩大nprobe和limit再来一轮,最多 3 轮。
  6. 如果 3 轮后还是不够,就拿“最近 7 天已读集合”做一次not in过滤作为兜底,宁可让用户看到少量很久以前读过的内容,也不要空屏。这个兜底逻辑线上很少触发,只是防极端情况。

5.2 延迟和资源的实测对比

改造前后的效果对比如下:

指标改造前改造后
p99 延迟690ms48ms
queryNode CPU 占用接近 80%35% 左右
结果不足率8%0.5% 以下
单次查询过滤阶段耗时占比70% 以上基本可忽略

延迟下降并不是因为我把机器加多了,而是因为 Milvus 侧的表达式从“几万个 ID 的not in”变成了“几乎没有过滤”,向量搜索的探索宽度稳定,不再出现迭代回溯。Redis 批量判断候选 ID 通常只要 1 到 2 毫秒,在整个链路里的占比非常小。

资源上的收益更明显。queryNode 的 CPU 降下来以后,同样节点数可以支撑更高的 QPS,不再需要为了一个本来就不该在向量库里做的操作去扩容。缩容下来一个月还能省不少机器成本,这在当时也算个额外惊喜。

5.3 几个值得长期记住的经验

这次踩坑以后,我把下面几条写进了团队的技术规范,也分享给你:

  • 向量数据库擅长的是“找相似”,不是“做集合运算”。把个性化状态过滤塞进 Milvus 表达式,等于让搜索引擎去干关系型数据库的活,成本模型完全不对。
  • 正向过滤和反向过滤的代价不对称。in小集合非常快,not in大集合非常慢。能用低基数集合表达的业务约束,就不要用高基数负向集合去描述。
  • 表达式里千万不要直接拼几万个 ID 的字符串。除了执行慢,光传输、序列化、解析就是一大笔开销,还会把查询计划拖复杂。如果实在绕不开过滤,至少控制在几千以内,并做空集合判断。
  • ID 最好用 int64 自增,不要用 UUID 字符串。字符串哈希和比较的成本在集合运算里会被放大很多倍。
  • 监控指标要能拆解过滤耗时。否则一旦延迟出问题,你分不清是向量索引退化还是表达式太重,排障会非常痛苦。Milvus 的 tracing span 里能看过滤阶段耗时,建议线上开着。

还有一点是关于产品和技术的取舍。我一开始追求的是“绝对不让用户看到任何已读内容”,这个目标本身没问题,但它把技术代价推到了极限。后来我们跟产品对齐了一下,确认少量重复推荐是可以接受的,于是把兜底策略、排除规模上限都做了分级。技术方案从“绝对精确”改成“高概率精确 + 低概率兜底”之后,性能和体验反而都更好了。

这次踩坑之后,我最大的体会是:一个推荐系统的架构,本质上是把“找相似”“查状态”“做融合”这三件事交给各自最擅长的组件,而不是把所有条件都塞进一个查询里。Milvus 负责粗召回,Redis 负责回答“用户到底看过什么”,推荐服务负责把它们拼装成一次顺畅的体验。每一层做自己擅长的事,系统才稳得住。

最后再分享一个小技巧。如果你在产品上能接受“短暂重复”,把“排除全部已读”降级为“排除最近 7 天已读”,延迟可以回到和不过滤几乎一样的水平。是否值得为了绝对不重复去承担几倍的延迟和成本,这个决策一定要拿到产品会上一起拍板,别自己闷头扛。