首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Milvus 3.0 稀疏索引重写:索引小 3 倍、快 10 倍,新算法却默认不开

Milvus 3.0 稀疏索引重写:索引小 3 倍、快 10 倍,新算法却默认不开

原创
作者头像
术哥
发布2026-08-10 21:28:13
发布2026-08-10 21:28:13
1690
举报
文章被收录于专栏:运维有术运维有术

🚩 2026 年「术哥无界」系列实战文档 X 篇原创计划 第 191 篇,Milvus 最佳实战「2026」系列第 28

大家好,欢迎来到 术哥无界 | ShugeX | 运维有术

我是术哥,一名专注于 AI 编程、AI 智能体、Agent Skills、MCP、云原生、AIOps、Milvus 向量数据库的技术实践者与开源布道者

Talk is cheap, let's explore。无界探索,有术而行。

Milvus 3.0 稀疏索引重构信息图封面
Milvus 3.0 稀疏索引重构信息图封面

Milvus 3.0 发布时,官方给出了一组很抓眼球的口径:压缩后的 BM25 稀疏索引比 2.6 小约 3 倍(recall 相当),SINDI 在 learned sparse embedding 上 QPS 能达到 MaxScore 的约 10 倍(worst-case 也有 5 倍)。

但等你真正升级完、打开配置文档一看,会发现一个略显反直觉的事实:这些新算法默认并不生效inverted_index_algo 枚举里躺着 6 个名字,默认值只落在 DAAT_MAXSCORE(BM25)和 SINDI(IP)上,而 SINDI、Block-Max WAND、Block-Max MaxScore 这几个新面孔,要手动把 dataCoord.targetVecIndexVersion 抬到 10 才真正启用。

这篇是 Milvus 3.0 系列的第 10 篇,不聊怎么建索引、怎么调参,只拆内核:6 种算法各自在怎么遍历、怎么剪枝,SINDI 到底凭什么快,压缩量化把召回率损失在了哪条线上,以及为什么官方宁可让新算法默认关着。

1. 从暴力倒排到剪枝算法:为什么一次给 6 个选择

先说背景。稀疏向量(比如 SPLADE、BGE-M3 这类 learned sparse embedding,或者 BM25 全文检索)的检索,本质是倒排索引上的最大内积搜索(MIPS)。2.x 时代的内核很简单:倒排列表按 doc 存,搜索时把查询里每个 term 的 posting 拿出来,和所有候选文档算内积,取 top-k。

这个朴素方案的问题,做过的人都知道:查询越复杂、文档集越大,无效计算越多。一个查询词可能命中几十万篇文档,但真正能挤进 top-k 的往往就几百个。绝大多数距离计算都白干了。

3.0 的思路是:既然哪些文档值得算这个决策本身有规律,那就把决策做成算法,让内核自己判断哪些候选可以跳过。于是 inverted_index_algo 一次性给了 6 个选项:TAAT_NAIVEDAAT_WANDDAAT_MAXSCOREBLOCK_MAX_MAXSCOREBLOCK_MAX_WANDSINDI

这 6 个名字看着像参数枚举,其实是一条演进路线上的 6 个节点:从完全不剪枝(TAAT_NAIVE),到 term 级上界剪枝(WAND / MaxScore),再到块级上界剪枝(Block-Max 族),最后到窗口级剪枝 + SIMD 的 SINDI。

六种稀疏索引算法演进路线示意图
六种稀疏索引算法演进路线示意图

2. 六种算法,差在怎么决定跳过什么

先看它们共享的东西:所有算法都用同一个 SparseHeap(最小堆维护 top-k 阈值)和 FilterBounds(bitset 过滤边界)。差别全在候选集生成和上界剪枝这两步。

算法

遍历方式

上界粒度

剪枝思路

TAAT_NAIVE

Term-at-a-Time

不剪枝,全量累加

DAAT_WAND

DAAT

term 级 max_score

累计上界找 pivot,非 pivot 整段跳过

DAAT_MAXSCORE

DAAT

term 级 max

分 essential/non-essential,后者只探测

BLOCK_MAX_WAND

DAAT

块级 max

块上界进不了 top-k 就整块跳过

BLOCK_MAX_MAXSCORE

DAAT

块级 max

essential 划分 + 块级上界双重剪枝

SINDI

窗口迭代

窗口级 suffix_sum

窗口贡献不够就整体跳过,窗口内 SIMD 全算

TAAT vs DAAT:一个内存换时间,一个时间换内存

TAAT_NAIVE 是 Term-at-a-Time:一次处理一个 term 的整个倒排列表,把得分累加到一张全量距离数组 std::vector<float> distances(max_vec_id, 0.0f) 上。内存 O(N),没有任何剪枝,纯粹是拿来当 baseline 的。

DAAT 是 Document-at-a-Time:一次处理一个 doc,所有 term 的 cursor 协同推进。代价是管理 cursor 数组,收益是可以在推进过程中做上界剪枝。因为 DAAT 状态下,你能同时看到多个 term 对当前 doc 的贡献潜力。

理解这两者的区别,就理解了 3.0 稀疏索引一半的设计取舍:剪枝是有成本的,而这个成本只有 DAAT 这种协同遍历的模式才付得起。

WAND vs MaxScore:两种剪枝哲学

DAAT 家族里最值得对比的是 WAND 和 MaxScore。用一个具体场景感受一下差别:假设查询有 3 个 term,每个 term 的 posting list 都按 doc-id 排好了序。

WAND 的思路是找 pivot:从最小的 doc 开始,逐个累加各个 term 的 max_score,直到累计上界达到能进入 top-k 的阈值 WouldEnter(upper_bound),这个位置就是 pivot。pivot 之前的文档,因为上界不够,直接跳过,用 next_geq(pivot_id) 一步跳到位。注意这里用的是 term 级 max_score(整个 term 的全局上界,很粗,但跳得快)。

MaxScore 的哲学不一样:它先按 max_score 把 term 降序排列,用后缀和算出上界,把低于阈值的 term 归为 non-essential。essential 的 term 逐文档认真打分,non-essential 的 term 只用来探测。如果 essential 部分已经够进 top-k 了,non-essential 就不贡献分数。

一句话总结:WAND 靠累计上界找 pivot 做整体跳过,MaxScore 靠 essential/non-essential 划分做激进剪枝,但 MaxScore 更依赖 top-k 阈值收敛。所以适用场景也不同:WAND 适合小 topK / 短查询,MaxScore 适合高 k / 多 term 的查询。

Block-Max:把上界细化到块

Block-Max 家族(BLOCK_MAX_WAND / BLOCK_MAX_MAXSCORE)和前面两个的区别,在于多维护了一份块级元数据BlockMaxData:块 id、块内 max_score、块偏移)。posting list 按块组织,每块预存块内最大得分。

这带来一个很实在的好处:跳过粒度从文档级细化到了块级。WAND 的 pivot 判断基于 term 级 max_score,那是整个 term 的全局上界,很粗;Block-Max 可以精确到这一批文档的 max_score 是多少,块上界进不了 top-k,整块直接跳过。

代价是构建时要额外算并存储块级 max,搜索时要多一次分块判断。这是典型的用存储换剪枝精度

另外有个搜索参数值得提:dim_max_score_ratio。它把每维的 max_score 乘一个比例再参与剪枝判断,相当于给上界打了折扣。默认值在 knowhere 侧,作用是让剪枝更保守:上界打得越高,越不容易误杀真命天子,但剪枝效率也会下降。这个参数是通用搜索参数,不限定只影响 DAAT 族——SINDI 的窗口级上界同样基于 qval * dim_max,具体影响范围以 knowhere 源码为准。

3. SINDI:不靠决定跳过什么来快

前面几个算法(WAND / MaxScore / Block-Max)的共同点,是把 CPU 时间花在决策上:判断哪些 doc 该跳过、哪些块该跳过。这个决策本身是有开销的。

SINDI(Sparse Inverted Non-redundant Distance-calculation Index)换了个思路:不费劲做精细决策,把 posting 组织成紧凑窗口,用 SIMD 批量算。它的核心是三点:

  1. 固定 doc-id 窗口:doc-id 空间切成固定 size 的窗口(window_size_ 范围 1024, 65535),posting 里存的是窗口内局部 id(uint16_t local_id = global_vecid % window_size_),每窗口的 nnz 用稀疏/稠密两种格式编码,选型逻辑很朴素:cnt_nonempty*4 < nr_windows*2 时用稀疏,否则用稠密,每个 dim 用一个 bit 标记格式。窗口大小是缓存友好的权衡:论文实验最优 λ≈100K,太小会增加子列表缓存驱逐,太大则增加距离数组的随机访问,与 SIMD 宽度没有直接关系。
  2. 窗口级剪枝:每个查询 term 按 qval * dim_max 降序排序,算 suffix_sum(最大贡献后缀和)。遍历窗口时,如果 curr_max_score + suffix_sum[ci] <= threshold,整个窗口跳过;否则窗口内 SIMD scatter 到窗口数组,批量乘加。
  3. SIMD 批量乘加:窗口内不再逐 doc 判断,直接 SIMD kernel 累加,把内积计算变成批量操作。

为什么这套对 learned sparse 特别有效?官方博客的解释很直白:learned sparse embedding(如 SPLADE)的 posting list 比 BM25 更,WAND/MaxScore 这类 pruning-heavy 算法把大量 CPU 花在决策跳过什么上。而 SINDI 改为紧凑窗口 + SIMD 友好的分数累加 + 无损窗口剪枝,跳过决策开销被大幅压缩

看一遍 SINDI 的搜索流程就能体会这个设计。查询进来后:

  1. 查询 term 按 qval * dim_max(值 × 维度最大贡献)降序排序,先处理贡献大的 term
  2. 算 suffix_sum,即最大贡献的后缀和,作为窗口级上界
  3. 逐窗口迭代:advance_window(widx) 取窗口内 posting 段,SIMD kernel 把值 scatter 到窗口长度数组 wscores_final
  4. 关键判断在这里:如果 curr_max_score + suffix_sum[ci] <= threshold整个窗口跳过,不进入 SIMD 计算;否则窗口内批量乘加,curr_max_score > thresholdbatch_insert 进 top-k

注意最后一步的对比:WAND/MaxScore 是逐 doc 判断要不要算,SINDI 是逐窗口判断要不要算。窗口内的计算是 SIMD 批量做的,单次判断的成本被摊薄了。这就是它和 MaxScore 拉开差距的来源:不是剪枝更聪明,而是把剪枝粒度从 doc 提升到窗口,把计算从标量变成 SIMD

注意:SINDI 论文(arXiv 2509.08395,ICDE 2026)是华东师大/同济/港科大/蚂蚁等团队做的,已集成到蚂蚁 VSAG 库。Milvus 博客说的 10x vs MaxScore 是官方内部 benchmark 口径,论文本身对比的是 Seismic、PyANNS 这类系统,不直接对比 MaxScore。

SINDI 窗口机制与 SIMD 批量计算示意图
SINDI 窗口机制与 SIMD 批量计算示意图

4. 压缩 3 倍:量化、gap 编码、自适应编码

索引小 3 倍这件事,拆开看是三层压缩的叠加。

第一层:doc-id 的 gap 编码 + 前缀和。 2.6 的 FlattenInvertedIndex 直接用 uint32 存 doc-id(raw_index_ids_.size() * sizeof(uint32_t)),这是压缩 3 倍的对比基准。新格式把 doc-id 转成 gap(delta)编码,解码用 SIMD 前缀和(simd_prefix_sum::integrate_doc_id_gaps)。

第二层:自适应整数编码。 AdaptiveBlockCodec 按块内容选编码方式:PFor(带异常值)、StreamVByte-0124、全等值、32 位直接打包,完整 128 值块用垂直 SIMD kernel。这里有个容易忽略的细节:单个 posting 的短列表有 singleton 短格式,不存 doc-id 载荷,把唯一的 doc-id 塞进块 max-id 槽位,省掉一整段编码开销。

第三层:值量化。 这是最容易理解、也最值得警惕的一层。IP 场景的 posting 值用 fp16(半精度)存,BM25 场景的 TF 用 uint16_t 存。量化是类型级硬编码的,SINDI 的 QuantType 静态断言为 knowhere::fp16(IP)或 uint16_t(BM25),编译期就定死了。

召回率损失就发生在第三层:值从 float 量化到 fp16/uint16,精度必然有损。为什么还能保持 recall 相当?因为 IP 场景半精度损失很小,BM25 场景 TF 饱和后对得分影响有限((k1+1)*tf/(tf+k1*(1-b+b*dl/avgdl)) 这个公式里,TF 大到一定程度后贡献趋缓)。再加上 drop_ratio_build(构建时丢弃最小比例的值)和 drop_ratio_search(查询时丢弃最小比例的值)控制噪声,整体召回率基本持平。

但这里必须说清楚:小 3 倍、recall 相当是官方内部 benchmark 口径,官方只说了 one set of internal BM25 benchmarks,硬件环境、k 值、recall 具体数值都没公开。论文侧的数据(Xeon 8269CY + AVX-512)是论文的实验室环境,不是 Milvus 内部 benchmark 环境。所以这个 3x 可以当作官方声称的优化幅度来理解,不能当普遍真理。想验证,只能在自己的 workload 上重建索引对比。

索引压缩量化三层结构示意图
索引压缩量化三层结构示意图

5. 为什么新算法默认不开

这是 3.0 稀疏索引最有意思的一个设计决策。

官方 Release Notes 第 101 行写得很明确:新索引版本是 opt-in。新引入的索引算法需要手动提升目标索引版本号(dataCoord.targetVecIndexVersion 到 10,dataCoord.targetScalarIndexVersion 到 4)才生效,后续版本才默认开启。

为什么?看版本路由的实现就明白了。IndexEngineVersionManager 聚合集群里所有 QueryNode 上报的索引引擎版本范围 [MinimalIndexVersion, CurrentIndexVersion],取当前版本是所有 QueryNode Current 的 MIN(滚动升级安全)。ResolveVecIndexVersion 的逻辑是:默认用 current;只有当 targetVecIndexVersion != -1forceRebuildSegmentIndex=true 时,才强制对齐 target;否则 version = max(version, target),最后 clampVersion 夹在 [minimal, maximum] 区间内。

这个设计回答了一个很现实的问题:滚动升级过程中,新老节点混跑,如果新算法直接在旧节点上生效,行为就不一致了。所以新算法宁可先关着,等集群所有节点都升到能跑的版本、再手动抬 target 版本号,才统一切换。

而且这不是杞人忧天。社区里已经有人踩过升级的坑:3.0 升上去之后再回滚到 2.6,索引存储路径版本不兼容,集合直接不可用(GitHub issue #51788)。版本通道的存在,本质是给索引格式变更留了一条可控的灰度路径:先升代码,再手动抬版本号重建索引,避免一升级就全量重建的爆炸半径。

顺带说一句,inverted_index_algo 的参数校验也做了迁移:校验从 C++ 移到了 Go 端。knowhere 源码里的注释说得直白:参数校验在 Go 端做,因为 knowhere 已经不再校验这个参数(removed in knowhere fd532fb)。所以你现在传一个不认识的算法名,是 Go 端直接拒绝,而不是内核报错。但校验覆盖并不完整,社区提过 issue:inverted_index_codecblock_max_block_size 传非法值,create_index 还是静默成功(#50257)。

新算法默认不开启的版本解析流程图
新算法默认不开启的版本解析流程图

6. BM25 和 IP,默认值为什么不一样

最后看默认值:DAAT_MAXSCORE(BM25)/ SINDI(IP)。这个差异不是拍脑袋,是 workload 决定的。

BM25 场景(metric=BM25)的 posting list 相对稀疏,TF 饱和后对得分影响小,适合 MaxScore 这种 essential/non-essential 划分的剪枝。而 IP 场景(learned sparse embedding)的 posting list 更密,pruning-heavy 算法把大量 CPU 花在决策上,反而是 SINDI 的窗口 + SIMD 批量乘加更划算。

顺带澄清一个 2.x 遗留的困惑:老版本的 SPARSE_WAND 索引类型从 2.5.4 起就被官方弃用了,建议用 inverted_index_algo=DAAT_WAND 等价替代。所以你在 3.0 的枚举里看不到 SPARSE_WAND 的名字,不是它消失了,而是它被并进了 DAAT_WAND 这个更完整的算法体系里,同一个内核,统一了命名

所以结论也很直接:如果你在 IP 场景,默认值已经帮你选了 SINDI,直接验证效果即可;如果你在 BM25 场景想试 Block-Max 族或 SINDI,需要显式配置 inverted_index_algo,同时把目标索引版本号抬到 10,并且确认 forceRebuildSegmentIndex 的语义符合你的预期。不设 target 时,新索引会对齐集群当前的引擎版本。

7. 落地建议:怎么选、怎么验证

把这 6 种算法放到一个决策框架里看,其实只有三条路:

  • 不知道数据长什么样:用默认值。BM25 用 DAAT_MAXSCORE,IP 用 SINDI,官方已经按 workload 帮你选好了
  • 数据是典型的 BM25 全文检索DAAT_MAXSCOREBLOCK_MAX_MAXSCORE。官方把高 k / 多 term 场景归给 DAAT_MAXSCORE;Block-Max 族的优势在块级元数据剪枝,是否更优需要在自己数据集上实测
  • 数据是 learned sparse(SPLADE / BGE-M3)SINDI 是首选,窗口 + SIMD 的设计就是冲着这个场景去的

但默认值只是起点,不是终点。官方 benchmark 的前提(硬件、k 值、recall 阈值)没有公开,所以任何迁移决策都应该在你自己数据集上重建索引、对比 recall 和 QPS 后做出。两个容易忽略的坑:一是 inverted_index_codecblock_max_block_size 传非法值会静默成功(#50257),配置前先确认参数合法;二是升级到 3.0 后想回滚到 2.6,索引存储路径版本不兼容,集合可能直接不可用(#51788),升级前备份好数据。

总结

Milvus 3.0 的稀疏索引重构,本质是把怎么跳过无效计算从拍脑袋变成了可配置的算法:从 TAAT 暴力累加,到 WAND/MaxScore 的文档级剪枝,到 Block-Max 的块级剪枝,再到 SINDI 的窗口+SIMD。压缩 3 倍来自 gap 编码、自适应编码和 fp16/uint16 量化三层叠加,召回率损失很大程度落在量化精度上。

但别忘了两件事:3x 和 10x 是官方内部 benchmark 口径,方法论没公开;新算法默认不开,不是 bug,是滚动升级安全的设计。真要在生产上用,先手动对齐 target 版本号,再自己跑一遍你的 workload 验证。

说到底,稀疏索引从 2.6 到 3.0 的变化,不是多了一个参数,而是把索引内核从只关注存储,变成了存储格式 + 搜索算法 + 版本管理三件事一起设计的产物。这个思路,比具体的 3x/10x 数字更值得关注。

说明:本文内容基于 Milvus 3.0.0 源码(zilliztech/milvus 与 knowhere commit 16a71a3d)、官方文档、Release Notes 及 SINDI 论文(arXiv 2509.08395)分析整理而成,源码分析基于笔者本地仓库版本,尚未在生产环境中完成全场景验证。文中的配置模板和参数建议仅供参考,实际效果请以你的业务数据和环境测试结果为准。如果有实际使用经验,欢迎在评论区分享交流。

好啦,谢谢你观看我的文章,如果喜欢可以点赞转发给需要的朋友,我们下一期再见!敬请期待!

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 1. 从暴力倒排到剪枝算法:为什么一次给 6 个选择
  • 2. 六种算法,差在怎么决定跳过什么
    • TAAT vs DAAT:一个内存换时间,一个时间换内存
    • WAND vs MaxScore:两种剪枝哲学
    • Block-Max:把上界细化到块
  • 3. SINDI:不靠决定跳过什么来快
  • 4. 压缩 3 倍:量化、gap 编码、自适应编码
  • 5. 为什么新算法默认不开
  • 6. BM25 和 IP,默认值为什么不一样
  • 7. 落地建议:怎么选、怎么验证
  • 总结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档