news 2026/10/28 21:41:19

Milvus not in 大列表查询慢?已读ID过滤性能优化实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Milvus not in 大列表查询慢?已读ID过滤性能优化实战

1. 从一次线上告警说起:几万条已读 ID 是怎么把查询拖垮的

事情的起点很朴素。我们做的是一个内容推荐流的场景,用户刷过的内容要标记成"已读",下次请求时把已读的 ID 从候选池里剔除掉,避免重复推送。早期用户量小,已读列表撑死几百条,not in写起来顺手,查询也是毫秒级返回,谁也没多想。

后来日活涨上来,重度用户一天能刷几千条内容,累积几周之后,单个用户的已读 ID 列表轻松破万,运营侧甚至有几万条的极端 case。然后监控开始报警:推荐接口的 P99 从 30ms 一路爬到 800ms,个别请求直接超时。排查下来,慢查询全部集中在同一个模式上——带not in大列表的向量检索。

这篇文章就把这次排查和优化的完整链路摊开讲。核心关键词是Milvus、not in、filter、查询慢、ID。如果你也在用 Milvus 做向量检索,并且习惯用not in或者类似的否定式过滤来排除一批 ID,那这篇基本就是写给你的。不管你是刚接触 Milvus 的新手,还是已经跑了一段时间 standalone 模式的老手,下面这些坑和结论都能直接拿去用。

先说结论,免得你看到一半才发现方向不对:在 Milvus 里用超大not in列表做过滤,性能会随着列表长度和集合规模双重恶化,这不是配置问题,是过滤执行机制决定的。正确的做法是把"排除"逻辑从查询期挪到写入期或者用位图/标记位的方式重构。

下面我按"现象—原理—复现—方案—验证"的顺序,把每一步都讲透。

2. not in 在 Milvus 里到底发生了什么:过滤执行机制拆解

2.1 标量过滤和向量检索是两套东西在配合

很多人对 Milvus 的过滤有个误解,以为not in就是简单地"把结果里符合条件的删掉"。实际上 Milvus 的查询流程大致是这样的:先根据标量条件做一轮过滤,得到一个满足条件的候选 ID 集合(或者叫位图),然后在这个候选集合上做向量相似度计算,最后返回 TopK。

关键在于,标量过滤这一轮的成本,和过滤条件的形态强相关。等值过滤(id == 123)可以走索引,直接定位;范围过滤(age > 18)也能借助索引做区间扫描;但not in这种否定式、集合式的条件,处理起来完全是另一回事。

2.2 否定式过滤为什么天然吃亏

用一个生活化的类比:你有一本一万页的电话簿,要找"除了张三、李四、王五……这几万个人之外的所有人"。正向查找是"翻到张三是第几页",否定式查找是"把这几万个名字逐个在电话簿里划掉,剩下的才算数"。

Milvus 处理not in时,本质上要做的是:对候选集合里的每一个元素,判断它是否落在你给的那几万个 ID 里。如果这个判断是逐个比对,那复杂度就是候选数量 × 排除列表长度。几万乘几万,量级直接爆炸。

当然 Milvus 不会傻到纯线性比对,它内部会用哈希集合来做成员判断,单次判断是 O(1)。但问题在于,这个判断要对候选集合里的每个元素都执行一遍,而且候选集合本身可能非常大。集合里有一千万条向量,你就要做一千万次哈希查找,再加上构建那个几万元素的哈希集合本身的开销,累积起来就很可观了。

2.3 过滤和索引的交互:为什么加了索引还是慢

有人会说,我给标量字段建了索引啊,怎么还慢?这里有个认知差:标量索引加速的是"定位",不是"排除"。

对于in这种正向条件,索引能帮你快速圈定一个小的候选集,后续向量计算量就小。但对于not in,索引帮不上太多忙——因为你要的是"补集",补集的范围几乎等于全集,索引没法把全集变小。结果就是过滤阶段筛出来的候选集依然巨大,后面的向量计算照样要跑满。

更麻烦的是,如果过滤条件写在向量检索的expr里,Milvus 需要先算出满足条件的 ID 位图,再拿这个位图去和向量索引做交集。位图本身如果很大,构建和传递都是开销。这就是为什么你会观察到:排除列表越长,查询越慢,而且不是线性变慢,是带着常数项和内存压力一起恶化。

2.4 一个容易被忽略的点:表达式解析和序列化成本

还有个隐蔽的成本:那几万个 ID 是要通过网络传到 Milvus 的。表达式字符串动辄几百 KB 甚至上 MB,解析这个表达式、构建内部数据结构,本身就要花时间。如果你的客户端每次都重新拼这个超长表达式,光是字符串拼接和网络传输就能吃掉几十毫秒。这部分成本在列表短的时候看不出来,列表一长就原形毕露。

3. 复现问题:我是怎么把"越来越慢"量化出来的

3.1 测试环境与数据构造

光说慢没有说服力,我搭了个最小复现环境。Milvus 用的是 standalone 模式(单机部署,适合验证和中小规模场景),集合规模 100 万条向量,维度 768,索引用 HNSW,标量字段doc_id建了倒排索引。

排除列表从 100 条开始,逐步加到 1000、10000、50000,每次测 100 次查询取 P50 和 P99。

3.2 实测数据:慢得很有规律

排除列表长度P50 延迟P99 延迟备注
10012ms28ms基本无感
100035ms90ms开始有感知
10000180ms520ms明显变慢
50000620ms1800ms接近不可用

这张表基本印证了前面的判断:延迟随排除列表长度增长,而且 P99 的恶化比 P50 更严重,说明存在长尾——某些查询恰好命中了最坏情况,比如候选集特别大、位图构建特别慢。

3.3 对比实验:换成 in 会怎样

为了确认问题确实出在"否定式"上,我做了个对照:把not in换成in,排除列表长度同样是 50000,但这次是"只查这 50000 个 ID 里的内容"。

结果in的 P99 只有 60ms 左右。差距接近 30 倍。

这个对比很关键,它说明问题不在"列表长",而在"否定式过滤"这个语义本身。正向条件能把候选集缩小,否定式条件几乎不缩小候选集,反而额外增加了一轮全量判断。

3.4 慢查询日志里能看到什么

Milvus 的慢查询日志(如果你开了的话)会记录查询耗时、表达式、返回条数。我翻日志发现,慢查询的表达式字段长度普遍在 300KB 以上,而且search阶段的耗时占比超过 80%。这进一步确认:瓶颈在过滤和候选集处理,不在网络返回。

提示:如果你还没开慢查询日志,建议在配置里把慢查询阈值调低一点(比如 100ms),先看清楚慢在哪,再动手优化。盲目调参是最容易走弯路的。

4. 为什么"把已读 ID 塞进 not in"是个结构性错误

4.1 已读状态是高频变更的数据,不该塞进查询条件

已读 ID 的本质是什么?是用户维度的状态数据,而且变更极其频繁——用户每刷一条内容,这个列表就长一条。把它塞进查询表达式,等于每次查询都要重新传输、重新解析、重新构建一遍这个巨大的状态。

这就像你每次去超市买东西,都要把家里冰箱里所有东西的清单背一遍给收银员听,告诉他"除了这些我都要"。荒谬且低效。

正确的思路是:状态数据应该有状态数据的存法,查询条件应该只承载"筛选意图",而不是"全量状态"。

4.2 否定式过滤的候选集几乎不缩小

前面已经讲过,not in的补集范围约等于全集。这意味着无论你怎么优化,过滤后的候选集依然很大,向量计算量下不去。这是语义层面的天花板,不是工程优化能突破的。

4.3 列表长度和集合规模是乘法关系

最要命的是,这个成本是排除列表长度 × 候选集规模的乘法关系。用户越活跃,已读越多;内容库越大,候选越多。两个方向同时增长,延迟就是平方级恶化。这就是为什么"越来越慢"——它不是某个阈值突然触发,而是随着业务增长持续劣化。

5. 重构方案:把排除逻辑从查询期挪走

5.1 方案一:写入期打标记,查询期用等值过滤

最直接的思路:给每条向量加一个"是否已读"的标量字段,但这个字段是用户维度的,不能直接存在向量上(因为同一条内容对不同用户已读状态不同)。

所以更实际的做法是:为每个用户维护一个独立的"已读集合",查询时先用向量检索召回一个较大的候选集(比如 TopK 的 3-5 倍),然后在应用层用已读集合做一次过滤。

这个方案的好处是:Milvus 只负责向量召回,不背过滤的锅;已读集合放在 Redis 或者内存里,用哈希结构做 O(1) 判断,几万条也就几 MB 内存,判断几万次是微秒级的事。

代价是召回数量要放大,因为过滤会砍掉一部分。但实测下来,召回放大 3 倍带来的向量计算增量,远小于not in带来的过滤开销。

5.2 方案二:用分区或者多集合隔离

如果已读状态可以接受一定的延迟同步,可以考虑按"已读/未读"做分区。但用户维度太细,分区数量会爆炸,这个方案只适合粗粒度场景,比如"全局黑名单"这种。

对于"全局排除一批 ID"的场景(比如下架内容、违规内容),分区或者单独的排除集合是可行的。查询时先查主集合,再在应用层剔除排除集合里的结果。

5.3 方案三:位图标记 + 定期重建

如果一定要在 Milvus 侧做过滤,可以用位图字段。Milvus 支持BINARY类型的向量或者用标量数组模拟位图,但用户维度的位图没法直接存。

折中方案是:把"已读"这个状态做成一个独立的标量字段,但用分片的方式——比如按用户 ID 哈希到不同的集合,每个集合里存该分片用户的已读标记。这样单次查询的过滤范围就小了。不过这个方案工程复杂度高,除非规模真的很大,否则不推荐。

5.4 方案选型对比

方案改动成本性能收益适用场景
应用层过滤低高已读、个性化排除
分区/多集合中中全局黑名单
位图标记高中高大规模、状态稳定
维持 not in无无列表很短(<1000)

我的选择是方案一,因为它改动最小、收益最直接,而且符合"状态归状态、查询归查询"的架构原则。

6. 落地细节:应用层过滤怎么写才不踩坑

6.1 召回放大倍数的确定

召回放大不是拍脑袋定的。我的做法是:统计已读列表长度和候选集规模的比例,取一个覆盖 P95 的倍数。比如已读 1 万条、候选池 100 万条,已读占比 1%,那召回放大 1.5 倍基本够用。但如果已读占比到 10%,就得放大到 3 倍以上。

公式大致是:召回数 = TopK / (1 - 已读占比) × 安全系数。安全系数取 1.2-1.5。

6.2 已读集合的存储选型

Redis 的 Set 结构最顺手,SISMEMBER是 O(1)。但几万条的 Set 在高并发下也有网络往返成本。如果 QPS 很高,可以考虑把已读集合缓存在应用进程内存里,用本地哈希表,定期从 Redis 同步。

我用的是本地缓存 + Redis 兜底:热用户的已读集合常驻内存,冷用户走 Redis。内存占用控制在几百 MB 以内。

6.3 过滤后的补召回逻辑

应用层过滤有个副作用:如果过滤太狠,返回条数可能不够。这时候需要补召回——再查一次 Milvus,把上次的 offset 往后挪,继续过滤,直到凑够条数或者达到最大重试次数。

这里要注意设置最大重试次数,否则极端情况下(比如用户已读了 99% 的内容)会陷入无限补召回。我的做法是重试 3 次,还不够就返回实际条数,前端做兜底展示。

6.4 一个容易忽略的坑:过滤顺序

应用层过滤时,先过滤再排序还是先排序再过滤,结果完全不同。正确做法是:Milvus 返回的是按相似度排序的结果,你在应用层过滤时,要保持这个顺序,过滤掉已读的,剩下的自然还是有序的。千万别过滤完又重新排序,那会打乱相似度顺序。

7. 优化后的实测对比与长期观察

7.1 延迟对比

方案P50P99备注
not in 50000620ms1800ms优化前
应用层过滤18ms45ms优化后
应用层过滤 + 补召回22ms70ms含补召回

P99 从 1800ms 降到 70ms,提升超过 25 倍。而且这个数字不随已读列表增长而恶化,因为已读集合的判断是 O(1),列表长度只影响内存占用,不影响单次判断耗时。

7.2 长期观察:稳定性才是关键

优化上线后我盯了两周。最明显的感受是:延迟曲线变平了。以前随着用户活跃度上升,P99 会缓慢爬升,现在基本是一条水平线。这说明我们消除了那个"随业务增长而劣化"的结构性隐患。

另一个收益是 Milvus 的负载下降了。以前大量 CPU 花在过滤和位图构建上,现在这些算力都省下来了,同样的硬件能扛更高的 QPS。

7.3 什么情况下 not in 还是可以用的

不是说not in一无是处。如果排除列表很短(比如几百条以内),或者查询频率很低(离线任务),用not in完全没问题,代码还简单。判断标准是:排除列表长度 × 查询频率 × 候选集规模,这三个乘起来如果不大,就别过度设计。

我给自己定的红线是:排除列表超过 1000 条,或者查询 QPS 超过 100,就考虑重构。

8. 几个相关的坑和延伸思考

8.1 别用 not in 做"软删除"

有人用not in来排除已删除的内容 ID。这个场景其实更适合用标量字段is_deleted做等值过滤,因为删除状态是内容维度的,可以存在向量上。用not in排除删除 ID,等于把删除列表也塞进查询条件,同样会踩坑。

8.2 表达式长度是有上限的

Milvus 对表达式长度有隐式限制,太长的表达式可能直接被拒绝或者解析超时。我遇到过表达式超过 1MB 时查询直接失败的情况。所以就算你要用not in,也得控制列表长度,别指望它能扛几万条。

8.3 索引不是万能的

这次排查最大的收获是:索引解决的是"查找"问题,解决不了"排除"问题。否定式语义天然和索引不友好,这是信息检索领域的老问题,不是 Milvus 的锅。理解这一点,很多性能问题就能提前避开。

8.4 监控要盯"表达式长度"这个指标

常规监控盯 QPS、延迟、错误率,但这次问题暴露后,我加了一个自定义指标:查询表达式的平均长度。这个指标一旦上涨,就说明有人在往查询里塞大列表,能提前预警。这个经验分享给做类似系统的朋友,挺有用的。

最后说个个人体会:性能问题往往不是某个参数没调好,而是数据模型和查询语义不匹配。已读状态是状态,不是查询条件,把它放对位置,问题自然就消失了。与其在 Milvus 的参数里反复折腾,不如先想清楚"这个数据到底该存在哪、该在哪个阶段参与计算"。这个思路,比任何调参技巧都值钱。

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

API式数字营销解决方案:提升媒介对接效率与精准度

1. 项目概述&#xff1a;API式宣发的行业痛点与Infoseek解决方案在数字营销领域&#xff0c;媒介对接长期存在"三高"难题&#xff1a;高沟通成本&#xff08;平均每个项目需对接7.2个渠道&#xff09;、高时间损耗&#xff08;人工协调占项目周期38%&#xff09;、高…

作者头像 李华
网站建设 2026/10/28 21:37:55

基于Cloudflare Workers的边缘AI应用框架:pnpm与wrangler工程化实践

1. 从"cloudflare-os"这个标题说起&#xff1a;它到底想解决什么问题第一次看到"cloudflare-os"这个标题&#xff0c;我脑子里冒出来的第一个念头是&#xff1a;这又是一个把 Cloudflare 全家桶包装成"操作系统"概念的项目。但仔细琢磨了一下&am…

作者头像 李华
网站建设 2026/10/28 21:35:04

学透impeccable:从零瑕疵到无可挑剔的英语用词指南

1. 先搞清楚 impeccable 到底在说什么1.1 核心语义&#xff1a;不只是“完美”那么简单impeccable 这个词&#xff0c;其实在雅思、托福、专八这些高阶英语考试里出现的频率很高&#xff0c;但在日常交流中&#xff0c;很多英语学习者对它要么只认识“完美的”这个中文释义&…

作者头像 李华
网站建设 2026/10/28 21:35:03

2026论文写作AI工具场景分榜:从开题、初稿到降AIGC,哪款适合你现在用?

很多本科生、研究生问“AI写作免费一键生成工具哪家好用”&#xff0c;但真正写论文时&#xff0c;很难有一款工具能从头包到尾。你要交付的不是一段文案&#xff0c;而是“开题报告—文献综述—论文初稿—数据/图表—定稿检测—答辩准备”这一整套成果。 所以这篇不做“全网大…

作者头像 李华
网站建设 2026/10/28 21:33:45

6S模型大气校正实战:表观反射率拆解与查找表加速

简介&#xff1a;这份6S模型操作说明以PDF手册形式围绕大气传输过程展开&#xff0c;面向从事遥感影像大气校正、定量遥感分析与6S模型使用的科研人员和学生。内容从太阳—目标—传感器路径出发&#xff0c;讲解大气气体吸收与气溶胶散射对可见光、近红外辐射的影响&#xff0c…

作者头像 李华
网站建设 2026/10/28 21:32:39

m4-1.4.19 源码编译、多版本共存与宏展开避坑

简介&#xff1a;面向Linux文本处理与宏扩展学习者的m4-1.4.19源码包&#xff0c;适合需要理解GNU m4实现、编译安装流程及autoconf底层机制的中级开发者与运维人员。包内共1670个文件&#xff0c;以558个C源码、334个m4测试用例与脚本、268个头文件为主&#xff0c;另有72个Sh…

作者头像 李华