
先交代一下背景我负责一个内容推荐服务的召回层文档向量全量放在 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。用户刷新推荐流的时候推荐服务的处理链路大概是这样的拿到用户当前上下文生成 query 向量去 Milvus 做向量相似度召回拿到候选内容把候选内容里“用户已经读过的”全部剔掉剩下的内容进重排最终返回给客户端当时我的第一直觉是向量检索和已读过滤都是“查询条件”放在同一个搜索请求里一次性完成既省一次网络往返代码也简洁。于是很自然地写成了 Milvus 搜索时带exprfid 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 fid not in {seen_ids} else: expr None res collection.search( data[query_vec], anns_fieldembedding, param{metric_type: IP, params: {nprobe: 16}}, limittopk, exprexpr, 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 内部大体做这么几件事先用底层的向量索引做粗排比如 HNSW 就在图里做游走IVF 就访问一定数量的聚类中心最后得到一批“相似度最高”的候选向量。拿到这批候选以后读取这些候选行对应的标量字段。再拿标量字段的值去套表达式把不满足条件的候选剔除。对剩下的候选按相似度排序返回 topK 条结果。我用一个生活中常见的场景类比海选选秀。你先凭着“长得像”从几万个人里粗筛出 1000 个候选人然后再一个个查“黑名单”把黑名单上的人剔掉。如果黑名单覆盖面特别大1000 个人里 990 个都在黑名单上那这轮复赛基本白选只能回头再捞一批人重新查。所以关键结论是Milvus 的标量过滤是作用在向量粗排出来的候选集上的不是全表过滤。过滤条件越严苛候选集就要越大才能保证过滤完以后还有足够的结果返回。2.2 过滤越严格ANN 就挖得越深向量搜索里有几个参数直接决定候选集的大小topK最终需要返回多少条结果。nprobeIVF 索引下访问多少个聚类中心访问越多候选池越大。efHNSW 索引下搜索队列的大小控制图遍历的探索宽度。正常情况下如果过滤条件很轻比如就过滤掉 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 确认根因排除集越大回溯越深综合所有证据最后的结论是not in大集合本身带来巨量成员判断CPU 开销高。推荐场景里已读内容与向量召回结果高度重叠导致过滤率异常高。过滤率高以后要么返回结果不足要么触发迭代搜索自动扩大探索宽度把延迟进一步推高。三者叠加就表现为“查询越来越慢”而且是接近指数级的增长。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 里做。改造后的链路长这样用户请求进入推荐服务。推荐服务生成 query 向量后直接调 Milvus 搜索表达式中不做任何个性化状态过滤最多保留少量低选择率的全局属性过滤比如status in [1]。搜索时故意把候选数量放大几倍比如业务上需要 20 条结果就先捞 100 条。把 100 个候选 ID 一次性发给 Redis用SISMEMBER批量判断哪些已经在用户的已读集合里。过滤掉已读后如果未读候选够 20 条直接进入重排环节。如果不够就进入第二轮召回把nprobe或者limit翻倍再捞一批出来过滤直到凑够结果或者达到最大轮次。对应的伪代码大概是这样import redis from pymilvus import Collection r redis.Redis(host..., port6379, decode_responsesTrue) 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_fieldembedding, param{metric_type: IP, params: {nprobe: nprobe}}, limitlimit, output_fields[id] ) cand_ids [hit.id for hit in res[0]] if not cand_ids: break # 批量判断候选 ID 是否已读 pipe r.pipeline(transactionFalse) for cid in cand_ids: pipe.sismember(fuser_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 万压到 1000p99 能从 600ms 直接回到 60ms 左右。调大nprobe或者ef。搜索宽度加大以后单次能从向量索引里捞出的候选变多能缓解“过滤后结果不足”的问题。但这是用延迟换召回过滤本身的 CPU 开销还在。关注 Milvus 新版本的迭代搜索特性。2.4 之后提供了迭代搜索过滤后结果不足时自动扩大搜索深度对结果数量有帮助但延迟抖动会更明显。版本升级本身值得做但不要指望它根治大集合not in。把字符串 ID 换成整型 ID。能显著降低表达式解析、哈希、比较和内存占用的成本是成本最低的优化。5. 实际改造后的效果与一些经验5.1 改造后的查询路径长什么样现在线上跑的通路已经是“Milvus 粗召回 Redis 已读过滤 推荐服务兜底重试”的模式了。每次推荐请求的完整路径是用户点击或者刷新推荐服务拿到上下文生成 query 向量。调 Milvus 搜索表达式只带内容上下架、品类这种低选择率属性过滤expr简单到一眼能看懂。一次性取回需求数量的 5 倍候选给到已读过滤模块。已读过滤模块用 Redis pipeline 批量判断返回未读候选。未读候选数量足够直接进重排不够则扩大nprobe和limit再来一轮最多 3 轮。如果 3 轮后还是不够就拿“最近 7 天已读集合”做一次not in过滤作为兜底宁可让用户看到少量很久以前读过的内容也不要空屏。这个兜底逻辑线上很少触发只是防极端情况。5.2 延迟和资源的实测对比改造前后的效果对比如下指标改造前改造后p99 延迟690ms48msqueryNode 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 天已读”延迟可以回到和不过滤几乎一样的水平。是否值得为了绝对不重复去承担几倍的延迟和成本这个决策一定要拿到产品会上一起拍板别自己闷头扛。