
🚩 2026 年「术哥无界」系列实战文档 X 篇原创计划 第 191 篇,Milvus 最佳实战「2026」系列第 28 篇
大家好,欢迎来到 术哥无界 | ShugeX | 运维有术。
我是术哥,一名专注于 AI 编程、AI 智能体、Agent Skills、MCP、云原生、AIOps、Milvus 向量数据库的技术实践者与开源布道者!
Talk is cheap, let's explore。无界探索,有术而行。

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 到底凭什么快,压缩量化把召回率损失在了哪条线上,以及为什么官方宁可让新算法默认关着。
先说背景。稀疏向量(比如 SPLADE、BGE-M3 这类 learned sparse embedding,或者 BM25 全文检索)的检索,本质是倒排索引上的最大内积搜索(MIPS)。2.x 时代的内核很简单:倒排列表按 doc 存,搜索时把查询里每个 term 的 posting 拿出来,和所有候选文档算内积,取 top-k。
这个朴素方案的问题,做过的人都知道:查询越复杂、文档集越大,无效计算越多。一个查询词可能命中几十万篇文档,但真正能挤进 top-k 的往往就几百个。绝大多数距离计算都白干了。
3.0 的思路是:既然哪些文档值得算这个决策本身有规律,那就把决策做成算法,让内核自己判断哪些候选可以跳过。于是 inverted_index_algo 一次性给了 6 个选项:TAAT_NAIVE、DAAT_WAND、DAAT_MAXSCORE、BLOCK_MAX_MAXSCORE、BLOCK_MAX_WAND、SINDI。
这 6 个名字看着像参数枚举,其实是一条演进路线上的 6 个节点:从完全不剪枝(TAAT_NAIVE),到 term 级上界剪枝(WAND / MaxScore),再到块级上界剪枝(Block-Max 族),最后到窗口级剪枝 + SIMD 的 SINDI。

先看它们共享的东西:所有算法都用同一个 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_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 这种协同遍历的模式才付得起。
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_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 源码为准。
前面几个算法(WAND / MaxScore / Block-Max)的共同点,是把 CPU 时间花在决策上:判断哪些 doc 该跳过、哪些块该跳过。这个决策本身是有开销的。
SINDI(Sparse Inverted Non-redundant Distance-calculation Index)换了个思路:不费劲做精细决策,把 posting 组织成紧凑窗口,用 SIMD 批量算。它的核心是三点:
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 宽度没有直接关系。qval * dim_max 降序排序,算 suffix_sum(最大贡献后缀和)。遍历窗口时,如果 curr_max_score + suffix_sum[ci] <= threshold,整个窗口跳过;否则窗口内 SIMD scatter 到窗口数组,批量乘加。为什么这套对 learned sparse 特别有效?官方博客的解释很直白:learned sparse embedding(如 SPLADE)的 posting list 比 BM25 更密,WAND/MaxScore 这类 pruning-heavy 算法把大量 CPU 花在决策跳过什么上。而 SINDI 改为紧凑窗口 + SIMD 友好的分数累加 + 无损窗口剪枝,跳过决策开销被大幅压缩。
看一遍 SINDI 的搜索流程就能体会这个设计。查询进来后:
qval * dim_max(值 × 维度最大贡献)降序排序,先处理贡献大的 termadvance_window(widx) 取窗口内 posting 段,SIMD kernel 把值 scatter 到窗口长度数组 wscores_finalcurr_max_score + suffix_sum[ci] <= threshold,整个窗口跳过,不进入 SIMD 计算;否则窗口内批量乘加,curr_max_score > threshold 才 batch_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。

索引小 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 上重建索引对比。

这是 3.0 稀疏索引最有意思的一个设计决策。
官方 Release Notes 第 101 行写得很明确:新索引版本是 opt-in。新引入的索引算法需要手动提升目标索引版本号(dataCoord.targetVecIndexVersion 到 10,dataCoord.targetScalarIndexVersion 到 4)才生效,后续版本才默认开启。
为什么?看版本路由的实现就明白了。IndexEngineVersionManager 聚合集群里所有 QueryNode 上报的索引引擎版本范围 [MinimalIndexVersion, CurrentIndexVersion],取当前版本是所有 QueryNode Current 的 MIN(滚动升级安全)。ResolveVecIndexVersion 的逻辑是:默认用 current;只有当 targetVecIndexVersion != -1 且 forceRebuildSegmentIndex=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_codec 和 block_max_block_size 传非法值,create_index 还是静默成功(#50257)。

最后看默认值: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 时,新索引会对齐集群当前的引擎版本。
把这 6 种算法放到一个决策框架里看,其实只有三条路:
DAAT_MAXSCORE,IP 用 SINDI,官方已经按 workload 帮你选好了DAAT_MAXSCORE 或 BLOCK_MAX_MAXSCORE。官方把高 k / 多 term 场景归给 DAAT_MAXSCORE;Block-Max 族的优势在块级元数据剪枝,是否更优需要在自己数据集上实测SINDI 是首选,窗口 + SIMD 的设计就是冲着这个场景去的但默认值只是起点,不是终点。官方 benchmark 的前提(硬件、k 值、recall 阈值)没有公开,所以任何迁移决策都应该在你自己数据集上重建索引、对比 recall 和 QPS 后做出。两个容易忽略的坑:一是 inverted_index_codec 和 block_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 删除。