来源:互联网 更新时间:2026-06-30 07:32
这是 Milvus 源码深度分析系列的第 3 篇。前两篇聊了数据隔离(多租户)和数据维护(Compaction),这一篇转到查询加速这条线,看看索引引擎在背后做了什么。
坦白说,写之前还以为 Milvus 的索引没什么可挖的——无非是用户指定一个 index_type,系统调对应算法库,建完存盘。翻了一圈源码才发现不是这么回事。
举一个很值得琢磨的疑问。翻 pkg/util/paramtable/ 目录时会注意到一个反直觉的现象:Milvus 的 AUTOINDEX 在 CPU 上默认用 HNSW_SQ,在 GPU 上默认用 GPU_CAGRA——这两个连索引家族都不一样。一个加了标量量化的图索引,一个是 NVIDIA 专用的 GPU 图索引。为什么不是同一个 HNSW 加不加量化的区别?更奇怪的是,autoindex_param.go 里还有 HNSW M:18 efConstruction:240 这个配置,跟前面两个都对不上。
带着这个疑问去读源码,牵出了一整套被忽略的设计取舍:三层参数填充管线、Go build tag 决定的硬件分流、保守到近乎偏执的版本聚合策略。这篇就一个一个拆。
先说第一个被神化的概念。AUTOINDEX 在文档里被描述成「自动帮你选索引」,听起来像有一套智能算法在根据数据特征做决策。实际看完源码,它的本质是一套基于配置前缀、分阶段、可叠加的默认参数填充机制。
它不改变索引构建算法,只是确保不管用户给了多少参数,最终下发给 IndexNode 的都是一个完整、可执行的参数集。这件事靠三层接力完成。
入口在 internal/proxy/task_index.go 的 createIndexTask.PreExecute。当 autoIndex.enable 打开、或者用户根本没指定索引类型时,系统会按字段的数据类型,从配置里取默认参数。分流表大致长这样:
| 数据类型 | 配置项 | 默认索引 |
|---|---|---|
| DenseFloat 向量 | autoIndex.params.build | HNSW_SQ(无 GPU)/ GPU_CAGRA(有 GPU) |
| Int8 向量 | autoIndex.params.int8.build | HNSW M:18 efConstruction:240 |
| 稀疏向量 | autoIndex.params.sparse.build | SPARSE_INVERTED_INDEX IP |
| 二值向量 | autoIndex.params.binary.build | BIN_IVF_FLAT HAMMING |
| 去重向量 | autoIndex.params.deduplicate.build | MINHASH_LSH MHJACCARD |
| 大 TopK 场景 | autoIndex.params.largeTopK.build | IVF_SQ8 |
看到这里,前面那个让人困惑的 HNSW M:18 efConstruction:240 就对上了——那是 Int8 向量的配置(autoindex_param.go 第 117-124 行),不是默认浮点向量的索引。把它当成浮点向量默认值是以讹传讹。
还有一个容易被忽略的细节。adjustAutoIndexParamsByDataType(第 85-120 行)会根据字段实际类型调整 refine_type:fp16 数据强制用 FP16 refine,bf16 数据强制用 BF16 refine。逻辑很朴素——低精度数据没必要用高精度 refine,那是浪费。但正是这种朴素的设计哲学贯穿了整个 AUTOINDEX:帮你选索引结构和构建参数,但不替你决定相似度度量。用户给的 metric_type 不会被覆盖。
这是最关键的接管层。在 internal/datacoord/index_inspector.go 的 createIndexForSegment(第 237-248 行)和 indexBuildTask.prepareJobRequest 里,有这么一段:
if isVectorIndex && Params.KnowhereConfig.Enable.GetAsBool() {
indexParams, err = Params.KnowhereConfig.UpdateIndexParams(indexType, paramtable.BuildStage, indexParams)
}
KnowhereConfig.UpdateIndexParams(knowhere_param.go 第 109-144 行)干了三件事:从配置读取 {indexType}.build.* 的默认参数,比如 DISKANN.build.max_degree;只填充用户没指定的参数——核心判断在 line 113:if GetKeyFromSlice(indexParams, key) == "";支持 override_index_type,如果指定,用目标类型的参数覆盖并替换 index_type。
第二条是整个机制的灵魂。它意味着用户哪怕只写了一句 index_type: DISKANN,系统也会自动补齐 max_degree: 56、pq_code_budget_gb_ratio: 0.125、search_list_size: 100 这些参数。用户不用记一长串配置,但又保留了对每个参数的覆盖权。
有意思的是,configs/milvus.yaml 的 knowhere: 段里只定义了 AISAQ 和 DISKANN 的 build/search 参数,没有 HNSW、HNSW_SQ、GPU_CAGRA。原因是后两者走的是第一层 Proxy 填充(硬编码在 autoindex_param*.go),根本不经过 Knowhere 接管层。Knowhere 配置段只管「用户需要感知磁盘/内存预算」的那类索引。
DISKANN 这种磁盘索引有额外的处理。indexparams.UpdateDiskIndexBuildParams(task_index.go 第 247-253 行)会追加磁盘索引特有的参数。更关键的是 GetRuntimeParameter(knowhere_param.go 第 99-107 行)。在 build 阶段,它会注入两个运行时参数:build_dram_budget_gb(当前可用内存)和 vec_field_size_gb(字段实际大小)。这两个值让索引算法能根据真实可用资源做自适应决策。换句话说,DISKANN 在构建时知道「我现在有多少内存可以用」,而不是盲目按配置走。
三层叠加下来,AUTOINDEX 的实质就很清楚了:一套分阶段的默认参数填充管线。每层都只做「填充用户没给的」,不覆盖用户给的。听起来很没技术含量,但这套机制让 AUTOINDEX 既能零配置可用,又能逐参数调优,兼顾了小白和高级用户。
三层填充管线示意
图 2:AUTOINDEX 三层填充管线——从用户参数到 IndexNode 完整参数集的接力
回到开头那个让人卡壳的问题:为什么无 GPU 是 HNSW_SQ、有 GPU 是 GPU_CAGRA?答案在两个文件里:
| 文件 | build tag | 默认浮点向量索引 |
|---|---|---|
autoindex_param_nocuda.go | !cuda(默认,无 GPU) | HNSW_SQ SQ4U refine:true refine_type:FP16 |
autoindex_param_cuda.go | cuda(有 GPU) | GPU_CAGRA |
注意是两个不同的 Go 文件,靠 build tag 在编译期决定,不是运行时检测硬件。编译 Milvus 时如果带 cuda tag(通常是 GPU 镜像),就编进去 autoindex_param_cuda.go;不带,就编进去 autoindex_param_nocuda.go。运行时根本没有「检测到 GPU 切换索引」这回事。
这个设计选择背后的逻辑很值得琢磨。无 GPU 场景下内存是瓶颈,所以选了 HNSW_SQ:纯内存图索引 + SQ4U 的 4bit 标量量化,把内存占用压到原来的 1/4,再用 FP16 refine 保证召回率。有 GPU 场景下显存带宽和并行度才是优势,所以选了 GPU_CAGRA——NVIDIA 专门为 GPU 设计的图索引,根本不需要量化压缩,直接吃显存带宽。
两者不是「同一个 HNSW 的变体」,而是面向完全不同的硬件资源模型的两套独立索引。把它们理解成「同一个索引加不加量化」是错的,那个流传的说法本身就是错的。
用 build tag 而不是运行时检测的好处是什么?一是二进制更小(不需要把另一套索引的参数表也编进去),二是行为可预测(不会出现「昨天还好好的今天突然换了索引」这种事)。坏处也很明显——同一个二进制没法同时支持 CPU 和 GPU 部署,要么编 CPU 版要么编 GPU 版。对运维来说,镜像选型要更小心。
搞清楚 AUTOINDEX 后,得回答另一个问题:那些索引算法到底在哪里跑?
答案是 Knowhere。Milvus 的官方设计文档(20211223-knowhere_design.md)对它的定义原话是:核心定位是向量搜索的执行引擎。它用 C++ 写的,把 faiss、hnswlib、NGT、annoy 这些底层索引库统一封装起来,对外暴露 BuildAll/Query/Serialize/Load 这套统一接口。
这就形成了一个很清晰的职责切割:
两层之间靠 CGO 桥接。Go 调 C++ 函数,C++ 把结果返回给 Go。
CGO 桥接里有一个设计很巧妙的地方。Go 层需要根据索引特性做资源分配决策——这个索引要不要 GPU?能不能 mmap?是不是磁盘索引?——但 Go 层又不能直接读 C++ 索引库的源码。
解决方案在 internal/util/vecindexmgr/vector_index_mgr.go。Go 层通过 CGO 调用 C.GetIndexFeatures(),从 Knowhere 拿到所有支持的索引类型及其特性标志(bit flags):
| Flag | 含义 |
|---|---|
NOTrainFlag | 无需训练(如 FLAT) |
KNNFlag | 精确搜索(100% 召回) |
GpuFlag | 需要 GPU |
MmapFlag | 支持 mmap |
MvFlag | 支持物化视图 |
DiskFlag | 需要磁盘 |
Go 层拿着这些 flag 决定资源策略。IsGPUVecIndex 为真的索引要分配 GPU 资源;IsMMapSupported 为真的可以用 mmap 降低内存。bit flags 是个看起来土但很实用的设计——一个 int 编码所有特性,位运算就能判断,不需要复杂的继承体系。
更「硬核」的设计在 index_attr_cache.go。不同索引的内存/磁盘占用比例,是 Go 层用硬编码常量决定的,不是运行时测量:
UsedDiskMemoryRatio = 4(内存 ≈ 索引大小 / 4)UsedDiskMemoryRatioAisaq = 64(内存 ≈ 索引大小 / 64)AISAQ 比 DISKANN 少用 16 倍内存,这个 16 倍不是跑测试测出来的,是源码里写死的经验值。这种做法的好处是行为可预测、调度器决策快;坏处是当实际工作负载偏离经验值时(比如数据分布特殊),调度可能不准。但对一个分布式系统来说,可预测性往往比精确性更重要——节点宕机、扩容缩容的决策都依赖这个比例。
分布式系统升级是噩梦,索引版本管理是其中的核心难题。Milvus 的解法保守到近乎偏执,但正因如此才支持滚动升级。
来自 20260313-scalar_index_version_management.md 设计文档:
| 版本 | 含义 |
|---|---|
MinimalIndexVersion | 能 build + search 的最低版本 |
CurrentIndexVersion | 硬编码的默认构建版本 |
MaximumIndexVersion | 能 build + search 的最高版本(非 beta) |
向量索引版本来自 Knowhere C++ 库;标量索引版本由 Milvus Go 层定义(common.go)。
每个 QueryNode 启动时会在 etcd 的 Session 里注册自己支持的版本范围。DataCoord 的 IndexEngineVersionManager 聚合所有节点的版本:
| 方法 | 聚合策略 | 用途 |
|---|---|---|
GetCurrent*Version() | MIN(所有 QN 的 Current) | 所有节点都能加载的最高版本(构建时用) |
GetMinimal*Version() | MAX(所有 QN 的 Minimal) | 任何节点都要求的最低版本 |
GetMaximum*Version() | MIN(所有 QN 的 Maximum) | 所有节点都能处理的最高上限(clamp 用) |
读仔细点。Current 取 MIN,Minimal 取 MAX,Maximum 又取 MIN。这个组合看起来反直觉,但逻辑很自洽——确保滚动升级期间新构建的索引一定能被最旧的节点加载。
举个具体场景。集群里有 3 个 QN:v1(老版本)、v2、v3(新版本)。新版本可能支持索引格式 v5,但 v1 只能读 v3。这时候 CurrentVersion 取 MIN 就是 v3,新建索引只会用 v3 格式,v1 也能加载。等 v1 升级完,CurrentVersion 自然上升到 v5,后续新索引才会用 v5。
这是可用性优先于先进性的工程取舍。代价是升级期间会用一段时间「次优」的索引格式,但换来的是零停机升级。
ResolveVecIndexVersion(line 298-330)的简化逻辑:
version := current
if target != -1 {
if forceRebuild {
version = target // 强制重建到指定版本
} else {
version = max(version, target) // 取集群值和目标值的较大者
}
}
return clampVersion(version, minimal, maximum, "targetVecIndexVersion")
三个配置参数的语义:
targetVecIndexVersion:临时灵活覆盖(-1 = 不激活)forceRebuildSegmentIndex:紧急措施,强制重建到指定版本(连已构建的也重建)autoUpgradeSegmentIndex:滚动升级后自动升级旧索引设计文档还专门处理了「老 QN 不报告 MaximumIndexVersion」的情况——GetMaximumIndexEngineVersion 跳过零值条目(line 147),如果所有 QN 都是老的,返回 math.MaxInt32(不做上限检查)。Session JSON 加字段的兼容性靠 Go 的 encoding/json:忽略未知字段、缺失字段反序列化为零值——双向兼容。
这套版本管理在 2026 年 3 月的 MEP 里被对称化到了标量索引上(新增 TargetScalarIndexVersion 和 ForceRebuildScalarSegmentIndex)。说明向量索引的版本管理是一套验证过的成熟模式,标量索引在向它对齐。
聊完向量索引,顺带说说标量索引的格式演进。Scalar Index V3 格式(20260209-scalar-index-unified-format.md)的核心目标是:单文件打包 + 并发 + 低内存 + 加密透明。
V3 的文件长这样:
[Magic "MVSIDXV3"] [Data Region] [Directory Table JSON] [Footer 32B]
从文件尾部 32 字节的 Footer 可以定位 Directory Table 和 Meta Entry,1-2 次 IO 就能获取全部元数据。这是个很务实的设计——远程存储(S3)按请求计费,少一次 IO 就少一份成本。
设计文档第 9.1 节明确对比了 Lance 格式。Lance 用列式存储编码索引,但有个绕不开的问题:任意图结构、树节点、哈希表必须展平成列,有编码/填充开销。V3 选择了另一条路——纯 KV 风格(key → 序列化 blob + metadata)。代价是牺牲了列式优化(不能只读某一列),换来的是对异构索引类型的最大兼容性。BITMAP、STL_SORT、INVERTED 这些完全不同结构的索引,都能塞进同一个文件格式。
这是个典型的工程取舍。向量数据库的标量索引不是为了做 OLAP 分析,更多是为了过滤和点查,列式优化的收益没那么大;但标量索引类型多、演进快(光是 2026 年就加了 JSON Path 多类型索引),用一个统一格式接住所有变体,比每个索引类型单独设计格式省心得多。
V3 还有个细节:加密边界是 Slice,不是 Entry。加密时每个 entry 切成 16MB slice 独立加密;不加密时 entry 是连续明文。这导致了两套写入路径:
IndexEntryDirectStreamWriter:直接写 RemoteOutputStream,后台并行上传 S3IndexEntryEncryptedLocalWriter:先写本地临时文件(密文大小不可预测),再上传为什么要分两套?因为加密后的密文大小不可预测,不能边算边直接流式上传到 S3。这种「为了一个特性牺牲一部分性能」的取舍(多一次本地写),在工程上很常见。
V3 还做了个反直觉的设计——不通过读文件 Magic 来判断版本。版本路由完全在调用方:
ScalarIndexCreator::Upload() 检查 SCALAR_INDEX_ENGINE_VERSION 配置SealedIndexTranslator 检查配置CurrentScalarIndexEngineVersion 常量控制为什么不读 Magic?避免「加载时才发现版本不对」的问题——版本在构建时就确定了,并在元数据里记录。Magic 还是会写(用于校验),但不参与版本决策。这是把「防御式检查」和「版本路由」解耦的干净做法。
最后聊聊 AISAQ,它是 2026 年 6 月新加进来的索引(knowhere v3.0.4,beta 2.6.4)。看官方文档(aisaq.md)的描述,AISAQ 是 DISKANN 的扩展,主打 Near-Zero DRAM。
DISKANN 已经是磁盘索引了,AISAQ 还要更进一步压内存。两条路线的对比:
| 模式 | PQ 数据位置 | 单节点访问 I/O | 磁盘占用 |
|---|---|---|---|
| DISKANN | 内存(DRAM) | 1 次(raw vector + edgelist) | 中等 |
| AISAQ-Performance | 磁盘(inline,冗余存储邻居 PQ) | 1 次(raw + edgelist + 邻居 PQ) | 大(冗余) |
| AISAQ-Scale | 磁盘(分离存储,rearrange 优化) | 小 |
这是个内存占用 vs IOPS vs 磁盘占用的三角权衡。DISKANN 把 PQ 放内存,省 IOPS 但吃内存;AISAQ-Performance 把 PQ 挪到磁盘,省内存但磁盘占用变大(冗余存储邻居 PQ);AISAQ-Scale 进一步优化存储布局,磁盘占用小但 IOPS 变多。
回到前面那个源码常量,DISKANN 的 UsedDiskMemoryRatio = 4,AISAQ 是 64——内存占用差 16 倍。AISAQ 的存在回答了一个问题:当数据规模超过 DRAM 容量时,用磁盘 IOPS 换 DRAM 是值得的。10 亿向量级别,DISKANN 可能要几十 GB 内存,AISAQ 只要 1-2 GB 就能跑起来。
这也是为什么 knowhere: 配置段里同时定义了 AISAQ 和 DISKANN 的参数——它们都是磁盘索引家族,但定位在光谱的不同位置。
DISKANN / AISAQ 在内存-IOPS-磁盘三角上的坐标
图 3:DISKANN、AISAQ-Performance、AISAQ-Scale 在内存-IOPS-磁盘三角上的不同坐标
写到这儿,一个被广泛误解的概念该澄清了。在 Milvus 里,「索引」不是一个东西,而是至少三类独立技术栈的统称。
图(HNSW 系列)、聚类(IVF 系列)、量化(SQ/PQ/RABITQ)——这是大家最熟悉的那类。技术栈来自 faiss、hnswlib、NGT、annoy,由 Knowhere 统一封装。
排序(STL_SORT)、位图(BITMAP)、倒排(INVERTED,基于 Tantivy)。标量 AUTOINDEX 按数据类型路由:int/varchar/float/json 默认 HYBRID(构建时按基数自动选 BITMAP 或 STL_SORT),bool 用 BITMAP,geometry 用 RTREE。
基数阈值 BitmapCardinalityLimit = 100 写在源码里——不同值数量 ≤ 100 用 BITMAP,否则用排序索引。HYBRID 是「用户不知道数据分布时的安全默认」。
这个最容易被忽略。主键索引用 BBhash(最小完美哈希)+ Value Array,跟向量索引和标量索引完全不是一套技术栈。来自 20250429-primarykey_index.md 设计文档。
它的目标是跨 segment 的 PK 快速定位(去重、点查、删除转发优化)。文档给的对比数据:PK 索引查询 ~200ns/次,10-20M QPS/节点;10000 个 Bloom Filter ~0.1ms/次,~1000 QPS/节点。差距大概 500 倍。内存效率也高:BBhash 2-4 bits/key + Value Array 4 bytes/key ≈ 4.5 bytes/key,10 亿 key ≈ 4.5GB。
三类索引各有独立的算法、格式、版本管理、演进节奏。向量索引在向量化召回上发力,标量索引在过滤和点查上发力,主键索引在精确去重上发力。把「索引」理解成单一概念是错的,理解成三类技术栈的统称,很多设计决策才说得通——比如为什么标量 V3 格式要独立设计,为什么标量索引的版本管理要单独做对称化。
三类索引技术栈的边界
图 4:向量索引、标量索引、主键索引三类技术栈的边界
把这篇拆的东西归拢一下:
HNSW_SQ 或 GPU_CAGRA。运行时没检测。有意思的是,Milvus 团队做的几个核心取舍都偏保守:build tag 编译期分流、版本 MIN/MAX 聚合、硬编码资源比例、KV 风格的标量格式。这些选择单独看都不「先进」,但放在一起构成了一个可预测、可滚动升级、行为稳定的系统。对一个分布式数据库来说,这比花哨更重要。
写完这一篇,查询加速这条线(索引引擎)就大致覆盖完了。跟前面两篇数据隔离(多租户)、数据维护(Compaction)合在一起,Milvus 的核心子系统也算是有了一个相对完整的源码级视角。后续如果再挖,可能会去聊聊 WAL 消息链或者 Segment 加载策略,看精力。
archiveofourown 实战指南:常见用法整理
币安Binance虚拟货币交易平台 币安官方APP安卓苹果下载入口
HBO 奇幻剧《龙之家族》第三季定档 6 月 22 日,最终预告片曝光喉道海战
客单价碾压宝马奥迪!极氪5月交付新车34377辆:连续4个月双增长
折后价近千元 澳洲一店主将真老鼠缝到内裤上当时尚单品卖
电视剧《小欢喜》剧情介绍
如何在夸克浏览器中开启网页视频的倍速播放功能?
作家助手如何上传自制封面 作家助手如何设置小说的封面
美好的简约网名男生(精选100个)
植物娘大战僵尸电脑端与手机端存档转移的方法
《梦幻西游》159五开五门怎么搭配-159五开五门常见搭配
欧易OKX官方网站直达入口 2026欧易官方App安卓版v7.1.0下载安装
腾讯元宝怎么用来分析股票基金的基本面信息?
wallpaper壁纸声音怎么开启
独家/李宰旭入伍前「登上孤岛服役」 惊见前辈裸体:忍不住笑了
国际贵金属走低,现货黄金价格跌0.49%
《金铲铲之战》高校赛夏季赛总决赛观赛指南来袭 还有铲铲教学一同献上
动漫《情色漫画老师OVA》剧情介绍
盖乐世社区怎么删除帖子?盖乐世社区个人发布内容撤回步骤
看韩漫的APP推荐 2026免费韩漫阅读软件大全
手机号码测吉凶
本站所有软件,都由网友上传,如有侵犯你的版权,请发邮件haolingcc@hotmail.com 联系删除。 版权所有 Copyright@2012-2013 haoling.cc