未记录事实核验 · 状态:维护中
记录仅说明核验时间与维护状态,不保证适用于所有环境;使用前请留意正文中的版本、适用条件与风险提示。
warning 适用边界与维护说明
本文适合学习 FLAT、IVF、HNSW 与 DiskANN 的原理、形成候选选型方案,并在隔离测试环境中练习数据生成、单类索引创建及参数扫描;创建集合函数会删除同名集合,勿用于已有业务数据。当前 benchmark_index 仅在同一 Collection 上改变搜索参数,没有切换实际索引,不能据其输出认定取得了 FLAT 精确真值或有效的 FLAT/HNSW 性能对比;批量耗时除以查询数也不等于请求延迟。性能表、规模阈值和推荐参数只能作为待测试起点,HNSW 的固定优势倍数及“97% Recall 生产可用”不能作为通用结论。PQ 表已明确仅计算编码载荷,但 HNSW 图载荷的 ID 字节口径及综合内存表仍未统一,不能据此估算 Milvus 总内存。DiskANN 配置、SSD 节点位置、训练样本报错门槛与 mmap 设置尚未明确对应版本、部署形态和接口,生产应用前须确认实现并实测;明确列为前置的核心概念材料目前仅保留名称,未提供唯一定位路径。本次仅作全文结构审阅,未运行代码或进行外部事实核验。 已取消无法确定目标的可点击引用「#IVF_FLAT:城市分区搜索」,保留原提及;Missing anchor: #IVF_FLAT:城市分区搜索 已取消无法确定目标的可点击引用「#IVF_SQ8:压缩存储的 IVF」,保留原提及;Missing anchor: #IVF_SQ8:压缩存储的 IVF 已取消无法确定目标的可点击引用「#IVF_PQ:极致压缩的乘积量化」,保留原提及;Missing anchor: #IVF_PQ:极致压缩的乘积量化 已取消无法确定目标的可点击引用「03-核心概念详解」,保留原提及;Ambiguous link target: 03-核心概念详解 已取消无法确定目标的可点击引用「09-生产运维与性能调优」,保留原提及;Ambiguous link target: 09-生产运维与性能调优 已取消无法确定目标的可点击引用「08-查询与搜索详解」,保留原提及;Ambiguous link target: 08-查询与搜索详解 GPU选型标题已改为结合显存、负载与成本评估,同时将目录自锚点改为实际最终标题。
Milvus 索引类型与选型指南
abstract 本篇核心收益
- 彻底理解为什么向量搜索是一个工程难题,而不只是"查个数据库"
- 深度掌握 FLAT、IVF 系列、HNSW、DiskANN 的底层原理
- 能在真实场景下做出有依据的索引选型决策
- 掌握关键参数的调优逻辑,而不是凭感觉猜
- 预计时长:90 分钟精读 + 30 分钟代码实践
这篇文档是整个 Milvus 系列里技术密度最高的一篇。向量索引是 Milvus 的核心引擎——选对了,系统飞速响应;选错了,钱烧完了效果还很差。本篇会把每种索引讲到原理级别,但同时用生活类比让你不需要数学博士学位也能理解。
目录
- 一、为什么向量索引是一个难题
- 二、ANN 的本质:精度与速度的 Pareto 前沿
- 三、Milvus 支持的索引类型逐一解析
- FLAT:最诚实的暴力搜索
- #IVF_FLAT:城市分区搜索
- #IVF_SQ8:压缩存储的 IVF
- #IVF_PQ:极致压缩的乘积量化
- HNSW:最强的图索引
- DiskANN:磁盘原生的超大规模索引
- GPU 索引:结合显存、负载与成本评估
- 四、索引选型决策框架
- 五、关键参数调优指南
- 六、完整实战代码
- 七、常见误区与陷阱
一、为什么向量索引是一个难题
在理解各种索引之前,先要搞清楚这个问题本身有多难。很多人第一次接触向量搜索时会想:"向量不就是个数组吗?排个序不就完了?"这个直觉在小规模数据下是对的,但在工程规模下会撞上一堵巨大的性能墙。
暴力搜索的代价
最朴素的向量搜索方式叫暴力搜索(Brute Force),逻辑极其简单:
- 拿到查询向量
q - 计算
q和数据库里每一条向量的距离 - 返回距离最小的 K 个
这在逻辑上无懈可击,精度是 100%。问题在于计算量。
复杂度分析:
假设数据库里有 $N$ 条向量,每条向量的维度是 $D$。计算一对向量的内积或欧氏距离需要 $O(D)$ 次浮点运算,计算所有向量的距离就是 $O(N \times D)$。
来一个具体的数字:
| 参数 | 数值 | 说明 |
|---|---|---|
| 向量数量 N | 1 亿 | 中等规模 RAG 知识库 |
| 向量维度 D | 1536 | OpenAI text-embedding-3-small |
| 单次查询浮点运算 | 1536 亿次 | N × D |
| 现代 CPU 浮点性能 | ~100 GFLOPS | 单核峰值 |
| 理论查询延迟 | ~1.5 秒 | 1536亿 / 1000亿 |
1.5 秒。这还是理论峰值,实际上还有内存带宽限制、缓存压力等因素,实际延迟很可能是 5-10 秒。
对于一个需要实时响应的搜索系统,这完全不可接受。互联网产品的用户体验阈值是 200ms,AI 对话的检索环节要求往往更严苛。
warning 内存带宽才是真正瓶颈
1 亿个 1536 维 float32 向量,原始内存占用是:
1亿 × 1536 × 4 bytes = 614 GB
别说计算了,光把数据从内存读出来,按现代服务器 200 GB/s 的内存带宽,就需要 3 秒。这才是暴力搜索真正的性能天花板——内存带宽,而不是 CPU 算力。
高维空间的诅咒
向量索引面临的第二个困难是维度灾难(Curse of Dimensionality)。
在低维空间(比如 2D 平面),你可以轻松地用树形结构(KD-tree)把空间分区,找最近邻效率极高。但维度升高后,这种方法彻底失效。
直觉解释:在 2D 空间,一个圆圈能覆盖它外接正方形 78% 的面积。在 1536 维空间,一个超球体只能覆盖对应超立方体的 10^{-3000} 的体积——几乎为零。这意味着在高维空间,所有的点看起来都"差不多远",基于空间分区的索引结构失去了区分度。
这就是为什么向量索引必须用专门设计的算法,而不能简单套用关系型数据库的 B-tree 或 KD-tree。
二、ANN 的本质:精度与速度的 Pareto 前沿
既然精确搜索代价太高,工程界选择了一条妥协路线:近似最近邻搜索(Approximate Nearest Neighbor,ANN)。
Recall@K 的定义
ANN 的精度用 Recall@K 衡量。定义如下:
note Recall@K 公式
$$\text{Recall@K} = \frac{|\text{ANN返回的K个结果} \cap \text{真实最近邻K个结果}|}{K}$$
简单说:在 ANN 找到的 K 个结果里,有多少个是"真正的"最近邻?
举例:搜索 Top-10,ANN 返回了 [id1, id3, id5, id7, id9, id2, id4, id6, id8, id10],其中真正的最近邻是 [id1, id3, id5, id7, id9, id11, id13, id15, id17, id19],两者交集有 5 个,那么 Recall@10 = 5/10 = 50%。
精度-速度的 Pareto 前沿
ANN 索引本质上在以下两个维度做权衡:
%%{init: {"theme":"base","themeVariables":{"fontFamily":"PingFang SC, -apple-system, Source Han Serif SC, serif","fontSize":"16px","background":"#FDFBF5","primaryColor":"#FDFBF5","primaryTextColor":"#2D2A26","primaryBorderColor":"#6E6A60","secondaryColor":"#E8EFE8","secondaryBorderColor":"#8FA890","secondaryTextColor":"#2D2A26","tertiaryColor":"#FDFBF5","tertiaryBorderColor":"#9A9488","tertiaryTextColor":"#6E6A60","lineColor":"#6E6A60","textColor":"#2D2A26","mainBkg":"#FDFBF5","nodeBorder":"#2D5A3D","clusterBkg":"#F3EEDF","clusterBorder":"#9A9488","edgeLabelBackground":"#FDFBF5"},"flowchart":{"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":60,"rankSpacing":70,"useMaxWidth":false},"themeCSS":".node rect, .node polygon, .node circle { stroke-width: 1.5px; } .edgePath .path { stroke-width: 1.5px; } .cluster-label .nodeLabel, .cluster-label foreignObject { color: #2D2A26 !important; } .edgeLabel, .edgeLabel foreignObject { color: #2D2A26 !important; background-color: #FDFBF5; } .edgeLabel rect { fill: #FDFBF5; }"}}%%
xychart-beta
title "ANN 索引的精度-速度权衡(示意)"
x-axis ["FLAT (100%)", "HNSW-高精度", "IVF_FLAT", "HNSW-平衡", "IVF_SQ8", "IVF_PQ"]
y-axis "查询延迟 (ms, 越低越好)" 0 --> 1500
bar [1500, 8, 25, 5, 22, 15]
tip 如何判断 99% 平均 Recall 是否满足任务要求
这个问题值得仔细想想。考虑一个 RAG 场景:用户问了一个问题,系统需要从 100 万个文档片段里找最相关的 10 个。
- 100% Recall:找到了完全正确的 Top-10
- 99% Recall:若这是多次 Top-10 查询的平均 Recall,则平均每次命中 9.9 个真实最近邻;单次查询的命中数只能是整数,且漏检片段对答案的影响取决于任务。
作为示例假设,若漏检片段不包含回答所需的唯一关键证据,99% 平均 Recall 可能仍满足该 RAG 任务要求;若漏掉关键证据,答案质量可能明显下降。也可假设某个测试负载下近似检索比精确检索快 100 倍,但本文未提供支持该倍数的测试依据,实际效果需同时评估检索性能与下游答案质量。
换句话说:工程上的最优解从来不是数学上的最优解。
三、Milvus 支持的索引类型逐一解析
Milvus 支持 10+ 种索引,但核心选手就是以下几类。理解它们的原理,选型就水到渠成。
%%{init: {"theme":"base","themeVariables":{"fontFamily":"PingFang SC, -apple-system, Source Han Serif SC, serif","fontSize":"16px","background":"#FDFBF5","primaryColor":"#FDFBF5","primaryTextColor":"#2D2A26","primaryBorderColor":"#6E6A60","secondaryColor":"#E8EFE8","secondaryBorderColor":"#8FA890","secondaryTextColor":"#2D2A26","tertiaryColor":"#FDFBF5","tertiaryBorderColor":"#9A9488","tertiaryTextColor":"#6E6A60","lineColor":"#6E6A60","textColor":"#2D2A26","mainBkg":"#FDFBF5","nodeBorder":"#2D5A3D","clusterBkg":"#F3EEDF","clusterBorder":"#9A9488","edgeLabelBackground":"#FDFBF5"},"flowchart":{"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":60,"rankSpacing":70,"useMaxWidth":false},"themeCSS":".node rect, .node polygon, .node circle { stroke-width: 1.5px; } .edgePath .path { stroke-width: 1.5px; } .cluster-label .nodeLabel, .cluster-label foreignObject { color: #2D2A26 !important; } .edgeLabel, .edgeLabel foreignObject { color: #2D2A26 !important; background-color: #FDFBF5; } .edgeLabel rect { fill: #FDFBF5; }"}}%%
mindmap
root((Milvus 索引家族))
暴力搜索
FLAT
IVF 系列
IVF_FLAT
IVF_SQ8
IVF_PQ
图索引
HNSW
NSW
磁盘索引
DiskANN
GPU 索引
GPU_IVF_FLAT
GPU_IVF_PQ
GPU_CAGRA
二进制索引
BIN_FLAT
BIN_IVF_FLAT
稀疏索引
SPARSE_INVERTED_INDEX
FLAT:最诚实的暴力搜索
原理
FLAT 就是第一节里说的暴力搜索。没有任何技巧,没有任何近似,就是把查询向量和所有存储向量逐一计算距离,返回最小的 K 个。
在相同数据快照、过滤条件、距离度量及一致的并列结果处理规则下,FLAT 可作为精确最近邻基准,Recall@K = 1.0;这不保证返回内容的业务相关性或下游答案正确性。
什么时候用 FLAT
check 适用场景
- 数据量 小于 100 万(超过这个规模,延迟会超出容忍范围)
- 精度要求极高,不能接受任何近似误差(比如金融合规、医疗诊断)
- 用作 基准测试:评估其他索引的 Recall 时,需要 FLAT 的结果作为真值
- 开发调试阶段:先用 FLAT 跑通逻辑,再换生产索引
参数
FLAT 没有构建参数。查询时支持标准的 metric_type(L2、IP、COSINE)。
内存与性能
| 指标 | 说明 |
|---|---|
| 内存占用 | 100% 原始向量数据(float32 精度) |
| 构建速度 | 极快(几乎无需构建) |
| 查询速度 | O(N×D),数据越多越慢 |
| 精度 | 100% |
| 支持动态删除 | 是 |
IVF_FLAT:城市分区搜索
IVF(Inverted File Index,倒排文件索引)是向量数据库里最经典的索引结构,理解它是理解所有 IVF 变体的基础。
原理深度解析
类比:假设你在一座陌生城市想找离你最近的餐厅。
- 暴力搜索:跑遍整座城市,逐一测量每家餐厅到你的距离。
- IVF:城市被分成若干个行政区(Voronoi 区域),每个区有一个区域中心。你先找到离你最近的几个区域中心,然后只在这几个区域内搜索餐厅。
这个类比精确地对应了 IVF 的两个阶段:
构建阶段(训练):
- 对所有向量跑 K-Means 聚类,把向量空间划分为
nlist个 Voronoi 区域 - 每个区域有一个质心(centroid)
- 每条向量被分配到离它最近的质心所在的区域
- 建立倒排索引:对每个质心,记录属于该区域的所有向量 ID
%%{init: {"theme":"base","themeVariables":{"fontFamily":"PingFang SC, -apple-system, Source Han Serif SC, serif","fontSize":"16px","background":"#FDFBF5","primaryColor":"#FDFBF5","primaryTextColor":"#2D2A26","primaryBorderColor":"#6E6A60","secondaryColor":"#E8EFE8","secondaryBorderColor":"#8FA890","secondaryTextColor":"#2D2A26","tertiaryColor":"#FDFBF5","tertiaryBorderColor":"#9A9488","tertiaryTextColor":"#6E6A60","lineColor":"#6E6A60","textColor":"#2D2A26","mainBkg":"#FDFBF5","nodeBorder":"#2D5A3D","clusterBkg":"#F3EEDF","clusterBorder":"#9A9488","edgeLabelBackground":"#FDFBF5"},"flowchart":{"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":60,"rankSpacing":70,"useMaxWidth":false},"themeCSS":".node rect, .node polygon, .node circle { stroke-width: 1.5px; } .edgePath .path { stroke-width: 1.5px; } .cluster-label .nodeLabel, .cluster-label foreignObject { color: #2D2A26 !important; } .edgeLabel, .edgeLabel foreignObject { color: #2D2A26 !important; background-color: #FDFBF5; } .edgeLabel rect { fill: #FDFBF5; }"}}%%
flowchart LR
classDef default fill:#FDFBF5,stroke:#6E6A60,color:#2D2A26,stroke-width:1.5px
classDef startend fill:#2D2A26,stroke:#2D2A26,color:#FDFBF5,font-weight:bold
classDef decision fill:#F6EAD8,stroke:#C08A1A,color:#2D2A26,font-weight:bold
classDef hub fill:#3B5A80,stroke:#3B5A80,color:#FDFBF5,font-weight:bold
classDef io fill:#E5EAF0,stroke:#3B5A80,color:#2D2A26
classDef accent fill:#A6432C,stroke:#A6432C,color:#FDFBF5,font-weight:bold
classDef storage fill:#F3EEDF,stroke:#9A9488,color:#2D2A26
subgraph "训练数据(100万向量)"
V["全部向量"]
end
subgraph "K-Means 聚类(nlist=1000)"
C1["质心 1<br/>覆盖 1000 条向量"]
C2["质心 2<br/>覆盖 980 条向量"]
C3["质心 3<br/>覆盖 1020 条向量"]
CN["... 质心 N<br/>覆盖 N 条向量"]
end
subgraph "倒排索引"
I1["1 → [vec_id_1, vec_id_7, ...]"]
I2["2 → [vec_id_3, vec_id_9, ...]"]
I3["3 → [vec_id_2, vec_id_5, ...]"]
end
V --> C1
V --> C2
V --> C3
V --> CN
C1 --> I1
C2 --> I2
C3 --> I3
class V,C1,C2,C3,CN,I1,I2,I3 default
查询阶段:
- 计算查询向量
q到所有nlist个质心的距离 - 选出最近的
nprobe个质心 - 仅在这
nprobe个区域内的向量中,做暴力搜索 - 合并结果,返回 Top-K
info nprobe 的物理意义
nprobe 决定了"搜索的行政区数量"。如果 nlist=1000,nprobe=10,那么每次查询只搜索全库的 1/100。这就是 IVF 获得速度提升的核心机制——用区域化搜索代替全局搜索。
核心参数
构建参数 nlist:
nlist 是聚类数,即把向量空间划成多少个区域。
| nlist 取值 | 效果 |
|---|---|
| 太小(如 64) | 每个区域内向量太多,搜索慢,但召回高 |
| 适中(sqrt(N)) | 经验最优点,平衡速度与质量 |
| 太大(如 65536) | 每个区域向量少,搜索快,但边界效应导致召回下降 |
经验公式:$\text{nlist} \approx \sqrt{N}$,即对 1 亿条数据,nlist ≈ 10000
warning 训练数据要求
IVF 的 K-Means 训练要求:训练数据量 >= nlist × 39。 如果 nlist=1000,你需要至少 39000 条训练向量。 Milvus 会用实际数据训练,数据不足时会报错。
查询参数 nprobe:
nprobe 是查询时探测的区域数。它是最重要的查询时调优旋钮。
| nprobe / nlist | 召回率 | 查询速度 | 适用场景 |
|---|---|---|---|
| 1/128 (~0.8%) | ~60-70% | 极快 | 对精度不敏感的推荐 |
| 1/32 (~3%) | ~80-85% | 很快 | 一般推荐场景 |
| 1/8 (~12%) | ~92-95% | 中等 | 大多数搜索场景 |
| 1/4 (~25%) | ~97-99% | 慢 | 高精度要求 |
| 1/1 (100%) | ~100% | 等同FLAT | 退化为暴力搜索 |
tip nprobe 调优策略
从 nlist / 8 开始,逐步增大,观察 Recall 和延迟的变化曲线,找到业务可接受的最低 nprobe 值。通常 nprobe = nlist / 8 到 nlist / 4 之间能覆盖大多数场景。
边界效应问题
IVF 的最大局限是边界效应:如果查询向量 q 的真实最近邻恰好位于相邻区域,而不是 q 所在区域,IVF 就会错过它。
区域边界
|
| ★ q(查询向量)
| ○ 真实最近邻(在隔壁区域!)
|
区域 A 的质心 区域 B 的质心
增大 nprobe 可以缓解边界效应,但代价是速度变慢。这是仅探测部分分区时的漏检风险。对于保留原始向量的 IVF_FLAT,令 nprobe = nlist 并遍历全部分区,可在一致的距离度量和并列结果处理规则下得到精确最近邻;IVF_SQ8、IVF_PQ 则仍可能存在量化误差。
IVF_SQ8:压缩存储的 IVF
原理
IVF_SQ8 在 IVF_FLAT 的基础上,对存储的向量做了标量量化(Scalar Quantization,SQ):
- IVF_FLAT:每个向量分量用 32-bit 浮点数(float32)存储
- IVF_SQ8:每个向量分量用 8-bit 整数(uint8)存储
具体做法:对每个维度,找到该维度在训练集上的最大值和最小值,然后把 [min, max] 范围均匀映射到 [0, 255] 的整数区间。
$$\text{quantized}[i] = \text{round}\left(\frac{x[i] - \min_i}{\max_i - \min_i} \times 255\right)$$效果对比
| 对比维度 | IVF_FLAT | IVF_SQ8 | 差异 |
|---|---|---|---|
| 内存占用 | 100% | ~25% | 节省 75% |
| 精度损失 | 0% | ~1-2% | 可接受 |
| 查询速度 | 中等 | 略快于FLAT | 因内存减少,缓存命中提升 |
| 参数 | 同IVF_FLAT | 同IVF_FLAT | 无额外参数 |
什么场景值得用 IVF_SQ8
check IVF_SQ8 的最佳场景
- 内存资源受限,但数据量又在 IVF 的适用范围(百万到亿级)
- 能接受 1-2% 的精度损失
- 使用余弦相似度或内积(SQ8 的量化误差对这两种度量影响更小)
一个典型例子:你有 1 亿条 768 维向量,IVF_FLAT 需要 ~288 GB 内存,而你的服务器只有 96 GB。IVF_SQ8 只需要 ~72 GB,刚好能放得下。
IVF_PQ:极致压缩的乘积量化
IVF_PQ 是 IVF 系列里压缩率最高的,背后的**乘积量化(Product Quantization,PQ)**技术是向量压缩领域的经典算法。
PQ 原理深度解析
类比:把一首歌压缩成 MP3。
一首歌有鼓声、吉他声、人声三个声部。朴素的压缩方式是把整首歌当成一个整体压缩,这样会丢失很多细节。更好的方式是:把三个声部分开,每个声部独立压缩,最后合并。每个声部都有自己的"声学模式库",用索引代替实际波形。
PQ 就是这个思路应用到向量上:
第一步:切割子空间
把 D 维向量切成 m 段,每段是 D/m 维的子向量。
原始向量(1536维)
[d1, d2, ..., d192 | d193, ..., d384 | ... | d1345, ..., d1536]
子空间 1 子空间 2 子空间 8
第二步:每个子空间独立量化
对每个子空间,在训练集上跑 K-Means,得到 $2^{\text{nbits}}$ 个质心(通常 nbits=8,即 256 个质心)。
每个子向量用距离最近的质心的索引(0-255)来表示。
第三步:用编码代替原始向量
一个 1536 维的 float32 向量,原本需要 1536 × 4 = 6144 bytes。经过 PQ 编码(m=8, nbits=8)后,只需要 8 × 1 byte = 8 bytes。
压缩比:768:1。
距离计算:查找表加速
PQ 的聪明之处在于距离计算的加速:
- 预计算查询向量每个子空间到所有质心的距离,存入"查找表"
- 对每条候选向量,把各子空间的编码索引在查找表里查距离并求和
- 这样一次距离计算只需要
m次表查询,极其快速
%%{init: {"theme":"base","themeVariables":{"fontFamily":"PingFang SC, -apple-system, Source Han Serif SC, serif","fontSize":"16px","background":"#FDFBF5","primaryColor":"#FDFBF5","primaryTextColor":"#2D2A26","primaryBorderColor":"#6E6A60","secondaryColor":"#E8EFE8","secondaryBorderColor":"#8FA890","secondaryTextColor":"#2D2A26","tertiaryColor":"#FDFBF5","tertiaryBorderColor":"#9A9488","tertiaryTextColor":"#6E6A60","lineColor":"#6E6A60","textColor":"#2D2A26","mainBkg":"#FDFBF5","nodeBorder":"#2D5A3D","clusterBkg":"#F3EEDF","clusterBorder":"#9A9488","edgeLabelBackground":"#FDFBF5"},"flowchart":{"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":60,"rankSpacing":70,"useMaxWidth":false},"themeCSS":".node rect, .node polygon, .node circle { stroke-width: 1.5px; } .edgePath .path { stroke-width: 1.5px; } .cluster-label .nodeLabel, .cluster-label foreignObject { color: #2D2A26 !important; } .edgeLabel, .edgeLabel foreignObject { color: #2D2A26 !important; background-color: #FDFBF5; } .edgeLabel rect { fill: #FDFBF5; }"}}%%
flowchart TB
classDef default fill:#FDFBF5,stroke:#6E6A60,color:#2D2A26,stroke-width:1.5px
classDef startend fill:#2D2A26,stroke:#2D2A26,color:#FDFBF5,font-weight:bold
classDef decision fill:#F6EAD8,stroke:#C08A1A,color:#2D2A26,font-weight:bold
classDef hub fill:#3B5A80,stroke:#3B5A80,color:#FDFBF5,font-weight:bold
classDef io fill:#E5EAF0,stroke:#3B5A80,color:#2D2A26
classDef accent fill:#A6432C,stroke:#A6432C,color:#FDFBF5,font-weight:bold
classDef storage fill:#F3EEDF,stroke:#9A9488,color:#2D2A26
subgraph "查询时预计算查找表"
Q["查询向量 q(1536维)"]
Q --> S1["子空间1距离<br/> q[0:192] → 质心[0..255] 的距离<br/> → 查找表 LT1[256]"]
Q --> S2["子空间2距离<br/> q[192:384] → 质心[0..255] 的距离<br/> → 查找表 LT2[256]"]
Q --> SM["...(共 m 个查找表)"]
end
subgraph "对每条候选向量快速计算距离"
VEC["候选向量 PQ编码<br/>[code1=47, code2=183, ..., code8=12]"]
VEC --> LOOKUP["LT1[47] + LT2[183] + ... + LT8[12]<br/>= 近似距离"]
end
S1 --> LOOKUP
S2 --> LOOKUP
SM --> LOOKUP
class Q,S1,q,LT1,S2,LT2,SM,VEC,LOOKUP,LT8 default
IVF_PQ 参数
| 参数 | 含义 | 推荐值 |
|---|---|---|
nlist |
聚类数(同IVF_FLAT) | sqrt(N) |
m |
子空间切割数 | D/32 到 D/8 之间,必须能整除 D |
nbits |
每段编码位数 | 通常 8(即 256 个质心/子空间) |
m 的选择对内存的影响(D=1536 的向量,N=1亿):
| m 值 | 压缩比 | 内存占用 | 精度损失 |
|---|---|---|---|
| 192(D/8) | 32x | 19.2 GB(仅编码) | 需实测 |
| 96(D/16) | 64x | 9.6 GB(仅编码) | 需实测 |
| 48(D/32) | 128x | 4.8 GB(仅编码) | 需实测 |
| 16(D/96) | 384x | 1.6 GB(仅编码) | 需实测 |
以上按 nbits=8、十进制 GB 计算,不包含 ID、码本、分区结构和运行时开销。
warning PQ 的精度代价
PQ 的量化误差比 SQ8 大得多。当 m 值很小时,精度损失可能超过 5-10%。在实际应用中,IVF_PQ 通常用于第一阶段粗排:先用 PQ 快速召回候选集,再用精确距离重排。这种"两阶段检索"是大规模系统的常见架构。
HNSW:最强的图索引
HNSW(Hierarchical Navigable Small World,分层可导航小世界图)是常用的图型 ANN 索引,可作为高召回、低延迟内存检索的候选方案;是否优于其他索引取决于数据分布、内存预算、更新方式及测试负载。理解 HNSW 需要先理解它的前身 NSW。
NSW:小世界图的直觉
"六度分隔"理论说:世界上任意两个人,最多通过 6 个中间人就能相互认识。这是因为社交网络里既有本地连接(邻居、同事),也有长程连接(不同城市的朋友、大 V)。
NSW 把这个思想应用到向量空间:
- 每个向量是一个节点
- 每个节点连接一些近邻(距离近的向量)和一些远邻(距离远但能快速跳跃到远处的向量)
- 搜索时,从任意节点出发,沿着"贪心"方向跳跃,快速接近目标
问题:NSW 是单层图,在大规模数据下,"找入口点"本身就很慢。
HNSW:加上分层,彻底解决入口问题
HNSW 在 NSW 基础上引入了分层结构,类比于地图的缩放级别:
%%{init: {"theme":"base","themeVariables":{"fontFamily":"PingFang SC, -apple-system, Source Han Serif SC, serif","fontSize":"16px","background":"#FDFBF5","primaryColor":"#FDFBF5","primaryTextColor":"#2D2A26","primaryBorderColor":"#6E6A60","secondaryColor":"#E8EFE8","secondaryBorderColor":"#8FA890","secondaryTextColor":"#2D2A26","tertiaryColor":"#FDFBF5","tertiaryBorderColor":"#9A9488","tertiaryTextColor":"#6E6A60","lineColor":"#6E6A60","textColor":"#2D2A26","mainBkg":"#FDFBF5","nodeBorder":"#2D5A3D","clusterBkg":"#F3EEDF","clusterBorder":"#9A9488","edgeLabelBackground":"#FDFBF5"},"flowchart":{"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":60,"rankSpacing":70,"useMaxWidth":false},"themeCSS":".node rect, .node polygon, .node circle { stroke-width: 1.5px; } .edgePath .path { stroke-width: 1.5px; } .cluster-label .nodeLabel, .cluster-label foreignObject { color: #2D2A26 !important; } .edgeLabel, .edgeLabel foreignObject { color: #2D2A26 !important; background-color: #FDFBF5; } .edgeLabel rect { fill: #FDFBF5; }"}}%%
flowchart TB
classDef default fill:#FDFBF5,stroke:#6E6A60,color:#2D2A26,stroke-width:1.5px
classDef startend fill:#2D2A26,stroke:#2D2A26,color:#FDFBF5,font-weight:bold
classDef decision fill:#F6EAD8,stroke:#C08A1A,color:#2D2A26,font-weight:bold
classDef hub fill:#3B5A80,stroke:#3B5A80,color:#FDFBF5,font-weight:bold
classDef io fill:#E5EAF0,stroke:#3B5A80,color:#2D2A26
classDef accent fill:#A6432C,stroke:#A6432C,color:#FDFBF5,font-weight:bold
classDef storage fill:#F3EEDF,stroke:#9A9488,color:#2D2A26
subgraph "第 3 层(最稀疏)"
L3_A["节点A"] <--> L3_C["节点C"]
L3_A <--> L3_F["节点F"]
end
subgraph "第 2 层"
L2_A["节点A"] <--> L2_B["节点B"]
L2_A <--> L2_C["节点C"]
L2_B <--> L2_D["节点D"]
L2_C <--> L2_F["节点F"]
end
subgraph "第 1 层(最密集)"
L1_A["节点A"] <--> L1_B["节点B"]
L1_B <--> L1_C["节点C"]
L1_C <--> L1_D["节点D"]
L1_D <--> L1_E["节点E"]
L1_E <--> L1_F["节点F"]
L1_A <--> L1_D["节点D"]
L1_B <--> L1_E["节点E"]
end
L3_A -.->|"跨层指针"| L2_A
L3_C -.-> L2_C
L3_F -.-> L2_F
L2_A -.-> L1_A
L2_B -.-> L1_B
L2_C -.-> L1_C
L2_D -.-> L1_D
L2_F -.-> L1_F
class L3_A,L3_C,L3_F,L2_A,L2_B,L2_C,L2_D,L2_F,L1_A,L1_B,L1_C,L1_D,L1_E,L1_F default
分层的含义:
- 顶层(稀疏):只有少量节点,节点之间的连接跨越很大的距离,像"高速公路"
- 底层(密集):所有节点都在,连接距离很近,像"小街巷"
- 跨层指针:每个节点在不同层都有对应,通过指针连接
HNSW 查询过程
类比导航软件:从北京找到上海的某个咖啡馆。
- 先在全国地图(高层)上定位到上海(粗粒度跳跃)
- 切换到上海地图(中层),定位到目标区域
- 切换到街道地图(底层),精确找到咖啡馆
HNSW 搜索过程完全类似:
- 从顶层的入口节点出发,在当前层做贪心搜索(每步跳到距目标最近的邻居)
- 找到当前层的局部最优点,下移一层,从该点对应的下层节点继续搜索
- 重复直到底层,在底层做精确搜索(在 ef 范围内的候选集里)
- 返回 Top-K 结果
%%{init: {"theme":"base","themeVariables":{"fontFamily":"PingFang SC, -apple-system, Source Han Serif SC, serif","fontSize":"16px","background":"#FDFBF5","primaryColor":"#FDFBF5","primaryTextColor":"#2D2A26","primaryBorderColor":"#6E6A60","secondaryColor":"#E8EFE8","secondaryBorderColor":"#8FA890","secondaryTextColor":"#2D2A26","tertiaryColor":"#FDFBF5","tertiaryBorderColor":"#9A9488","tertiaryTextColor":"#6E6A60","lineColor":"#6E6A60","textColor":"#2D2A26","mainBkg":"#FDFBF5","nodeBorder":"#2D5A3D","clusterBkg":"#F3EEDF","clusterBorder":"#9A9488","edgeLabelBackground":"#FDFBF5"},"flowchart":{"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":60,"rankSpacing":70,"useMaxWidth":false},"themeCSS":".node rect, .node polygon, .node circle { stroke-width: 1.5px; } .edgePath .path { stroke-width: 1.5px; } .cluster-label .nodeLabel, .cluster-label foreignObject { color: #2D2A26 !important; } .edgeLabel, .edgeLabel foreignObject { color: #2D2A26 !important; background-color: #FDFBF5; } .edgeLabel rect { fill: #FDFBF5; }"}}%%
flowchart LR
classDef default fill:#FDFBF5,stroke:#6E6A60,color:#2D2A26,stroke-width:1.5px
classDef startend fill:#2D2A26,stroke:#2D2A26,color:#FDFBF5,font-weight:bold
classDef decision fill:#F6EAD8,stroke:#C08A1A,color:#2D2A26,font-weight:bold
classDef hub fill:#3B5A80,stroke:#3B5A80,color:#FDFBF5,font-weight:bold
classDef io fill:#E5EAF0,stroke:#3B5A80,color:#2D2A26
classDef accent fill:#A6432C,stroke:#A6432C,color:#FDFBF5,font-weight:bold
classDef storage fill:#F3EEDF,stroke:#9A9488,color:#2D2A26
subgraph "查询流程"
direction TB
ENTER["入口节点(顶层)"]
GREEDY3["顶层贪心搜索<br/>快速跳跃,找粗略方向"]
GREEDY2["中层贪心搜索<br/>继续收窄范围"]
DENSE1["底层精确搜索<br/>在 ef 候选集内排序"]
RESULT["返回 Top-K 结果"]
ENTER --> GREEDY3 --> GREEDY2 --> DENSE1 --> RESULT
end
class ENTER,GREEDY3,GREEDY2,DENSE1,RESULT default
HNSW 插入过程
插入一个新节点 q 时:
- 随机决定层级:用指数分布随机决定
q最高出现在第几层(大多数节点只在底层,少数节点出现在多层,形成稀疏的高层) - 从顶层开始搜索:找到
q在每一层的ef_construction个最近邻 - 连接最近邻:在每一层,把
q和其最近邻相连,并可能"修剪"一些旧连接以维持图的性质
ef_construction 越大,构建时找到的邻居质量越高,图质量越好,但构建越慢。
HNSW 核心参数详解
构建参数:
| 参数 | 含义 | 默认值 | 调优建议 |
|---|---|---|---|
M |
每个节点在每层的最大连接数 | 16 | 通用场景 16,高精度场景 32-64 |
ef_construction |
构建时的动态候选集大小 | 200 | 通常 100-500,越大质量越好 |
M 值的影响(以 1 亿向量为例):
| M 值 | Recall@10 | 查询延迟 | 额外内存开销 | 适用场景 |
|---|---|---|---|---|
| 8 | ~96% | 最快 | 最小 | 对延迟极度敏感 |
| 16(默认) | ~98% | 快 | 中等 | 大多数场景 |
| 32 | ~99% | 中等 | 较大 | 高精度要求 |
| 64 | ~99.5% | 较慢 | 很大 | 极高精度要求 |
每增加一倍的 M,额外内存开销大约增加 N × M × 2 × 4 bytes(每条边两个方向各需存储 ID)。
查询参数:
| 参数 | 含义 | 默认值 | 调优建议 |
|---|---|---|---|
ef |
查询时的动态候选集大小 | 64 | 必须 >= topK,通常 topK × 2 到 topK × 4 |
ef 决定了在底层搜索时维护的候选集大小。ef 越大,结果越精确,延迟越高。
warning ef 和 ef_construction 的关系
ef_construction:构建时参数,决定图的结构质量,一旦建完就固定了ef:查询时参数,决定搜索时探索的范围,可以随时调整
即使你用 ef_construction=100 建了一个"一般质量"的图,也可以通过增大 ef 来提高查询精度(但受限于图本身的质量上限)。
规则:ef 必须 >= topK,否则直接报错。
HNSW 的内存开销
HNSW 是内存密集型索引,除了原始向量数据,还需要存储图的边信息:
$$\text{额外内存} \approx N \times M \times 2 \times 8 \text{ bytes}$$(M 是每层连接数,2 是双向边,8 bytes 是 int64 ID 的大小)
以 1 亿向量、M=16 为例,按上述简化假设计算的图载荷为:1亿 × 16 × 2 × 8 = 25.6 GB。若每条向量为1536维float32,原始向量载荷为614.4 GB,两者相加约640 GB,即原始向量载荷的104.2%;这不包含其他层级、元数据、预留容量和运行时开销,也不是Milvus总内存估算。
success HNSW 的核心优势总结
- 查询速度极快:对数级复杂度,比 IVF 系列快 3-5 倍
- 精度极高:在同等延迟下,Recall 比 IVF 系列高 2-3%
- 无需训练阶段:IVF 需要 K-Means 训练,HNSW 插入即索引
- 支持实时插入:HNSW 的图结构天然支持动态插入,不需要重建
- 查询参数简单:只有一个
ef旋钮
DiskANN:磁盘原生的超大规模索引
解决的核心问题
HNSW 很好,但有一个致命弱点:需要把整个图结构加载到内存。当数据量超过 10 亿时,内存成本高得令人望而却步。
DiskANN(基于 Vamana 图算法)的核心思想是:把向量和图结构存在 SSD 上,内存只放一部分关键数据。
架构原理
DiskANN 的设计分两个层面:
磁盘层:完整的 Vamana 图(所有向量 + 边的连接关系)存储在 SSD 上。Vamana 图的设计针对磁盘访问做了优化——每次磁盘读取时,把一个节点和它的邻居数据压缩在同一个磁盘块里,减少随机 IO。
内存层:对每条向量保留一个压缩版本(通常是 PQ 编码),用于快速过滤候选集,减少不必要的磁盘访问。
查询流程:
- 用内存中的 PQ 编码初步筛选出 BeamSearch 候选集
- 从 SSD 加载候选节点的完整向量和邻居信息
- 在内存中精确计算距离,更新候选集
- 沿图继续搜索,直到收敛
- 返回 Top-K 结果
关键参数
| 参数 | 含义 | 推荐值 |
|---|---|---|
index_file_size |
每个分段索引文件的大小(MB) | 1024 |
search_list |
搜索时的候选集大小 | max(topK × 2, topK + 20) |
pq_code_budget_gb |
内存中 PQ 编码的内存预算(GB) | 数据量 × 压缩比 |
性能特征
| 指标 | 数值 | 说明 |
|---|---|---|
| 内存占用 | 5-20% 原始数据 | 只存 PQ 编码 |
| SSD 占用 | ~100% 原始数据 + 图结构 | 通常是原始数据的 1.2-1.5 倍 |
| 查询延迟 | 10-50ms | 比 HNSW 慢,但可接受 |
| 精度 | ~95-98% Recall | 受 PQ 压缩影响 |
| 适用规模 | 十亿级以上 | 小于 1 亿用 HNSW 即可 |
warning DiskANN 的使用限制
- 需要高性能 NVMe SSD:机械硬盘的随机 IO 完全无法满足要求,延迟会飙升到秒级
- 不支持实时插入:DiskANN 需要重建索引来合并新数据(Milvus 通过 Segment 机制部分缓解了这个问题)
- Milvus 版本要求:DiskANN 在 Milvus 2.2+ 开始稳定支持
- DataNode 配置:需要挂载持久化 SSD 存储,云环境要注意存储类的选择
GPU 索引:结合显存、负载与成本评估
Milvus 支持多种 GPU 加速索引,核心是利用 GPU 的大规模并行计算能力。
GPU_CAGRA:面向 GPU 并行计算的图索引
CAGRA(Cuda Anns Graph-based Refined Approach)是 NVIDIA RAFT 库的图索引,专为 GPU 并行计算设计。作为待验证的示例假设,可设定某个GPU测试负载相比CPU HNSW有5-20倍性能提升;本文未提供支持该倍数的测试依据,不能将其视为有GPU时的通用结果。
性能基准(A100 GPU vs 32 核 CPU)
| 场景 | CPU HNSW | GPU_CAGRA | 提升倍数 |
|---|---|---|---|
| 构建速度(1000万向量) | ~30 分钟 | ~3 分钟 | 10x |
| QPS(Recall=99%) | ~5000 | ~50000-100000 | 10-20x |
| 延迟(p99) | ~5ms | ~1ms | 5x |
适用场景
check GPU 索引的适用条件
- 拥有 NVIDIA GPU(A100/V100/RTX 4090 以上效果最好)
- 需要极高 QPS(>10000 QPS)
- 数据量在内存可接受范围(GPU 显存通常 80 GB 以内)
- 实时推荐、广告检索等延迟极度敏感场景
四、索引选型决策框架
决策树
%%{init: {"theme":"base","themeVariables":{"fontFamily":"PingFang SC, -apple-system, Source Han Serif SC, serif","fontSize":"16px","background":"#FDFBF5","primaryColor":"#FDFBF5","primaryTextColor":"#2D2A26","primaryBorderColor":"#6E6A60","secondaryColor":"#E8EFE8","secondaryBorderColor":"#8FA890","secondaryTextColor":"#2D2A26","tertiaryColor":"#FDFBF5","tertiaryBorderColor":"#9A9488","tertiaryTextColor":"#6E6A60","lineColor":"#6E6A60","textColor":"#2D2A26","mainBkg":"#FDFBF5","nodeBorder":"#2D5A3D","clusterBkg":"#F3EEDF","clusterBorder":"#9A9488","edgeLabelBackground":"#FDFBF5"},"flowchart":{"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":60,"rankSpacing":70,"useMaxWidth":false},"themeCSS":".node rect, .node polygon, .node circle { stroke-width: 1.5px; } .edgePath .path { stroke-width: 1.5px; } .cluster-label .nodeLabel, .cluster-label foreignObject { color: #2D2A26 !important; } .edgeLabel, .edgeLabel foreignObject { color: #2D2A26 !important; background-color: #FDFBF5; } .edgeLabel rect { fill: #FDFBF5; }"}}%%
flowchart TD
START["开始选型"] --> Q1{"数据量?"}
Q1 -->|"< 100万"| FLAT["FLAT<br/>100%精度,无需调参<br/>简单粗暴"]
Q1 -->|"100万 - 5亿"| Q2{"内存是否充足?<br/>(有 N × D × 4 bytes)"}
Q2 -->|"内存充足"| Q3{"有 GPU 吗?"}
Q3 -->|"有 GPU,且需要高 QPS"| GPU["GPU_CAGRA<br/>/ GPU_IVF_FLAT<br/>极高性能"]
Q3 -->|"无 GPU"| Q4{"优先级?"}
Q4 -->|"查询速度第一"| HNSW["HNSW<br/>M=16, ef_construction=200<br/>综合最佳"]
Q4 -->|"写入速度第一<br/>或动态更新频繁"| IVF_FLAT["IVF_FLAT<br/>nlist=sqrt(N)"]
Q2 -->|"内存不足"| Q5{"能接受多少精度损失?"}
Q5 -->|"< 2% 损失"| IVF_SQ8["IVF_SQ8<br/>省 75% 内存<br/>精度损失极小"]
Q5 -->|"2-5% 损失<br/>或内存极度紧张"| IVF_PQ["IVF_PQ<br/>压缩到原始 1/16<br/>适合两阶段检索"]
Q1 -->|"> 5亿(超大规模)"| Q6{"内存能否放下?"}
Q6 -->|"内存放不下"| DISKANN["DiskANN<br/>需要高性能 NVMe SSD<br/>十亿级规模利器"]
Q6 -->|"内存勉强放下"| IVF_SQ8_2["IVF_SQ8<br/>或 IVF_PQ<br/>搭配两阶段重排"]
style FLAT fill:#dbeafe,stroke:#3b82f6
style HNSW fill:#dcfce7,stroke:#16a34a
style IVF_FLAT fill:#dcfce7,stroke:#16a34a
style IVF_SQ8 fill:#fef9c3,stroke:#ca8a04
style IVF_SQ8_2 fill:#fef9c3,stroke:#ca8a04
style IVF_PQ fill:#fed7aa,stroke:#ea580c
style DISKANN fill:#fce7f3,stroke:#db2777
style GPU fill:#ede9fe,stroke:#7c3aed
style GPU_CAGRA fill:#ede9fe,stroke:#7c3aed
综合对比表
| 索引 | 构建速度 | 查询速度 | 内存占用 | 精度 | 适用规模 | 是否需要训练 | 支持动态插入 |
|---|---|---|---|---|---|---|---|
| FLAT | 极快 | 极慢 | 100% | 100% | <100万 | 否 | 是 |
| IVF_FLAT | 快 | 中等 | 100% | 高 (~98%) | 100万-1亿 | 是 | 是 |
| IVF_SQ8 | 快 | 中等 | 25% | 高 (~97%) | 1亿+ | 是 | 是 |
| IVF_PQ | 中等 | 较快 | 极小 (<10%) | 中 (~90-95%) | 内存受限 | 是 | 是 |
| HNSW | 慢 | 极快 | ~150% | 极高 (~99%) | 通用推荐 | 否 | 是 |
| DiskANN | 极慢 | 中等 | 极小 | 高 (~96%) | 十亿级 | 否 | 受限 |
| GPU_CAGRA | 快(GPU) | 极极快 | GPU 显存 | 极高 | 高 QPS 场景 | 否 | 是 |
五、关键参数调优指南
nlist 设置原则
经验公式:
$$\text{nlist} = \sqrt{N} \quad \text{(N 为向量总数)}$$实际应用参考:
| 数据量 | 推荐 nlist | 原因 |
|---|---|---|
| 100 万 | 1,000 | sqrt(1M) = 1000 |
| 1,000 万 | 3,162 | sqrt(10M) ≈ 3162,通常取整为 4096 |
| 1 亿 | 10,000 | sqrt(100M) = 10000 |
| 10 亿 | 31,623 | 通常取 32768(2 的幂) |
tip 使用 2 的幂次
取 2 的幂次方(如 1024、4096、8192)是个好习惯——K-Means 在这些值上的数值稳定性更好,同时方便后续通过 nprobe 精确控制搜索比例。
nlist 上限约束:
- 训练数据量必须 >=
nlist × 39 - 如果你的数据量是 100 万,nlist 最大不超过约 25,000
nprobe 调优策略
nprobe 的调优本质是找业务可接受的 Recall 下限,然后用最小的 nprobe 满足它。
调优流程:
Step 1: 用 FLAT 跑 1000 条测试查询,得到真实最近邻(Ground Truth)
Step 2: 用不同 nprobe 值跑相同查询,计算 Recall@K
Step 3: 画出 Recall vs 延迟 曲线
Step 4: 找到 Recall 达标的最小 nprobe
典型的 nprobe Recall 曲线(nlist=4096,1000万向量):
| nprobe | Recall@10 | 查询延迟(ms) |
|---|---|---|
| 1 | 62% | 0.3 |
| 4 | 78% | 0.8 |
| 16 | 88% | 2.5 |
| 64 | 95% | 8.0 |
| 128 | 97% | 15 |
| 256 | 99% | 28 |
| 512 | 99.5% | 55 |
| 4096(全部) | ~100% | 220 |
note 通用建议
- 推荐系统(精度要求不高):nprobe = nlist/64 到 nlist/32
- RAG/语义搜索(精度要求中等):nprobe = nlist/16 到 nlist/8
- 高精度场景:nprobe = nlist/4 到 nlist/2
HNSW M 值选择
| 场景 | 推荐 M | 说明 |
|---|---|---|
| 资源受限,速度第一 | 8-12 | 精度稍有牺牲 |
| 通用生产场景 | 16(默认) | 大多数情况的最优点 |
| 高精度要求(如人脸识别) | 32-48 | 相比M=16,简化图连接载荷约增至2-3倍,不代表总内存同比增长 |
| 极高精度(金融风控) | 64 | 仅在必要时使用 |
ef_construction 和 ef 的关系
这两个参数经常被混淆,用一个表格厘清:
| 参数 | 阶段 | 作用 | 改变后是否需要重建索引 |
|---|---|---|---|
ef_construction |
构建时 | 决定图的内在质量(边的连接质量) | 是(必须重建) |
ef |
查询时 | 决定搜索时探索的范围 | 否(随时可调) |
经验值:
ef_construction = max(ef, M × 2),且 >= 64ef = topK × 2到topK × 4(精度要求越高,倍数越大)ef必须 >=topK
warning ef_construction 不是越大越好
ef_construction = 500 比 ef_construction = 200 的构建时间可能多 2-3 倍,但 Recall 的提升往往不到 1%。在构建时间受限的场景,ef_construction = 100-200 已经足够。
六、完整实战代码
连接与基础设置
from pymilvus import (
connections,
Collection,
CollectionSchema,
FieldSchema,
DataType,
utility
)
import numpy as np
# 连接 Milvus
connections.connect(
alias="default",
host="localhost",
port="19530"
)
# 数据规模设置(可根据实际情况调整)
NUM_VECTORS = 100_000 # 10 万向量用于演示
DIM = 1536 # OpenAI text-embedding-3-small 维度
COLLECTION_NAME = "index_demo"
# 创建 Schema(通用,后续所有索引共用)
def create_collection(collection_name: str) -> Collection:
"""创建测试用 Collection"""
# 如果已存在则删除
if utility.has_collection(collection_name):
utility.drop_collection(collection_name)
fields = [
FieldSchema(
name="id",
dtype=DataType.INT64,
is_primary=True,
auto_id=True # 自动生成主键
),
FieldSchema(
name="embedding",
dtype=DataType.FLOAT_VECTOR,
dim=DIM
),
FieldSchema(
name="doc_id",
dtype=DataType.VARCHAR,
max_length=100
)
]
schema = CollectionSchema(
fields=fields,
description="索引类型演示集合",
enable_dynamic_field=True # 允许动态字段
)
collection = Collection(
name=collection_name,
schema=schema
)
return collection
# 生成测试数据
def generate_data(n: int, dim: int):
"""生成随机测试向量(实际应用中替换为真实 embedding)"""
# 归一化向量(cosine 相似度场景的标准做法)
vectors = np.random.randn(n, dim).astype(np.float32)
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
vectors = vectors / norms
doc_ids = [f"doc_{i:08d}" for i in range(n)]
return vectors, doc_ids
# 插入数据
def insert_data(collection: Collection, vectors: np.ndarray, doc_ids: list):
"""批量插入数据,推荐批次大小 1000-5000"""
batch_size = 2000
total = len(vectors)
for start in range(0, total, batch_size):
end = min(start + batch_size, total)
batch_data = [
vectors[start:end].tolist(), # embedding 字段
doc_ids[start:end] # doc_id 字段
]
collection.insert(batch_data)
# 刷新确保数据持久化
collection.flush()
print(f"插入完成:{total} 条向量")
FLAT 索引
def create_flat_index(collection: Collection):
"""
FLAT 索引:暴力搜索,精度 100%
适用场景:
- 数据量 < 100 万
- 精度要求 100%(作为基准测试的标准答案)
- 开发调试阶段
"""
index_params = {
"metric_type": "COSINE", # 余弦相似度(语义搜索首选)
"index_type": "FLAT",
"params": {} # FLAT 无需任何参数
}
collection.create_index(
field_name="embedding",
index_params=index_params
)
print("FLAT 索引创建完成(无训练过程,几乎即时)")
def search_with_flat(collection: Collection, query_vectors: np.ndarray, top_k: int = 10):
"""FLAT 搜索:在一致数据、过滤条件、度量及并列结果处理规则下可作为精确最近邻基准,不保证业务相关性。"""
collection.load()
search_params = {
"metric_type": "COSINE",
"params": {} # FLAT 搜索时同样无参数
}
results = collection.search(
data=query_vectors.tolist(),
anns_field="embedding",
param=search_params,
limit=top_k,
output_fields=["doc_id"]
)
return results
IVF_FLAT 索引
import math
def create_ivf_flat_index(collection: Collection, num_vectors: int):
"""
IVF_FLAT 索引:倒排文件 + 暴力搜索
核心参数:
- nlist:聚类数,推荐 sqrt(N)
适用场景:
- 数据量 100万 - 1亿
- 内存充足
- 写入频繁(IVF 不需要重建索引即可添加新数据)
"""
# 经验公式:nlist = sqrt(N),取整到最近的整数
nlist = int(math.sqrt(num_vectors))
# 对齐到 1024 的倍数(可选,便于 nprobe 的整除计算)
nlist = max(1024, round(nlist / 1024) * 1024)
print(f"设置 nlist = {nlist}(数据量 {num_vectors} 的 sqrt 约为 {int(math.sqrt(num_vectors))})")
index_params = {
"metric_type": "COSINE",
"index_type": "IVF_FLAT",
"params": {
"nlist": nlist # 聚类数,影响构建速度和查询精度
}
}
# 注意:IVF 类索引需要训练,当数据量 < nlist*39 时会报错
collection.create_index(
field_name="embedding",
index_params=index_params
)
print(f"IVF_FLAT 索引创建完成,nlist={nlist}")
def search_with_ivf_flat(
collection: Collection,
query_vectors: np.ndarray,
top_k: int = 10,
nprobe: int = None,
nlist: int = 1024
):
"""
IVF_FLAT 搜索
nprobe 是最关键的查询参数:
- 越大召回越高,延迟越高
- 推荐从 nlist/8 开始,根据 Recall 评估结果调整
"""
collection.load()
# 如果未指定 nprobe,使用经验起始值
if nprobe is None:
nprobe = max(1, nlist // 8)
search_params = {
"metric_type": "COSINE",
"params": {
"nprobe": nprobe # 探测的 Voronoi 区域数
}
}
results = collection.search(
data=query_vectors.tolist(),
anns_field="embedding",
param=search_params,
limit=top_k,
output_fields=["doc_id"]
)
print(f"IVF_FLAT 搜索完成,nprobe={nprobe}(搜索了 {nprobe}/{nlist} = {nprobe/nlist:.1%} 的区域)")
return results
IVF_SQ8 索引
def create_ivf_sq8_index(collection: Collection, num_vectors: int):
"""
IVF_SQ8 索引:IVF + 8-bit 标量量化
内存节省:相比 IVF_FLAT 节省 ~75%
精度损失:~1-2%
最佳场景:
- 服务器内存不够放 IVF_FLAT
- 能接受轻微精度损失
- 数据规模 1亿 以上
"""
nlist = int(math.sqrt(num_vectors))
nlist = max(1024, round(nlist / 1024) * 1024)
index_params = {
"metric_type": "COSINE",
"index_type": "IVF_SQ8",
"params": {
"nlist": nlist
# SQ8 无额外参数,量化过程自动进行
}
}
collection.create_index(
field_name="embedding",
index_params=index_params
)
# 内存估算
original_memory_gb = num_vectors * DIM * 4 / (1024 ** 3)
sq8_memory_gb = num_vectors * DIM * 1 / (1024 ** 3) # 8-bit = 1 byte
print(f"IVF_SQ8 索引创建完成")
print(f" 原始内存估算:{original_memory_gb:.1f} GB")
print(f" SQ8 内存估算:{sq8_memory_gb:.1f} GB(节省 {(1 - sq8_memory_gb/original_memory_gb)*100:.0f}%)")
IVF_PQ 索引
def create_ivf_pq_index(collection: Collection, num_vectors: int, dim: int):
"""
IVF_PQ 索引:IVF + 乘积量化
极致压缩,适合内存极度受限场景
参数选择原则:
- m:子空间数,必须能整除 dim,推荐 dim/32 到 dim/8
- nbits:每段编码位数,通常固定为 8(256个质心)
"""
nlist = int(math.sqrt(num_vectors))
nlist = max(1024, round(nlist / 1024) * 1024)
# m 的选择:dim 的因子,且能被 dim 整除
# dim=1536 的合理选项:8, 12, 16, 24, 32, 48, 64, 96, 128, 192, 256, 384
# 这里选 m=96,每个子空间维度 = 1536/96 = 16
m = dim // 16 # dim/16 是一个平衡点
# 确保 dim 可以被 m 整除
while dim % m != 0:
m -= 1
nbits = 8 # 256 个质心,通常不需要修改
index_params = {
"metric_type": "COSINE",
"index_type": "IVF_PQ",
"params": {
"nlist": nlist,
"m": m, # 子空间切割数
"nbits": nbits # 每段编码位数(质心数 = 2^nbits)
}
}
collection.create_index(
field_name="embedding",
index_params=index_params
)
# 内存估算
original_bytes = num_vectors * dim * 4
pq_bytes = num_vectors * m * (nbits / 8) # 每个向量的 PQ 编码大小
compression_ratio = original_bytes / pq_bytes
print(f"IVF_PQ 索引创建完成")
print(f" m={m}, nbits={nbits}")
print(f" 每个向量编码:{m * nbits // 8} bytes(原始 {dim * 4} bytes)")
print(f" 压缩比:约 {compression_ratio:.0f}x")
print(f" 内存估算:{pq_bytes / (1024**3):.2f} GB(原始 {original_bytes / (1024**3):.1f} GB)")
HNSW 索引
def create_hnsw_index(
collection: Collection,
m: int = 16,
ef_construction: int = 200,
scenario: str = "general"
):
"""
HNSW 索引:分层可导航小世界图
这是大多数场景的首选索引
参数说明:
- M:每个节点的最大连接数
- 8:速度优先,适合延迟敏感的推荐系统
- 16(默认):大多数场景最优
- 32:高精度要求,如语义搜索、RAG
- 64:极高精度,内存代价大
- ef_construction:构建时的候选集大小
- 越大构建越慢,图质量越好
- 推荐 100-500,通常 200 够用
"""
# 根据场景自动选择参数
scenario_configs = {
"speed_first": {"M": 8, "ef_construction": 100},
"general": {"M": 16, "ef_construction": 200},
"high_recall": {"M": 32, "ef_construction": 300},
"ultra_recall": {"M": 64, "ef_construction": 400}
}
if scenario in scenario_configs:
config = scenario_configs[scenario]
m = config["M"]
ef_construction = config["ef_construction"]
print(f"使用预设场景 '{scenario}':M={m}, ef_construction={ef_construction}")
index_params = {
"metric_type": "COSINE",
"index_type": "HNSW",
"params": {
"M": m, # 图的连接数,影响内存和精度
"efConstruction": ef_construction # 构建质量参数
}
}
collection.create_index(
field_name="embedding",
index_params=index_params
)
print(f"HNSW 索引创建完成:M={m}, efConstruction={ef_construction}")
print(f"预计额外内存开销:~{NUM_VECTORS * m * 2 * 8 / (1024**3):.1f} GB(图结构)")
def search_with_hnsw(
collection: Collection,
query_vectors: np.ndarray,
top_k: int = 10,
ef: int = None
):
"""
HNSW 搜索
ef 是唯一需要调整的查询参数:
- 必须 >= top_k
- 推荐 top_k * 2 到 top_k * 4
- 精度要求越高,ef 越大
"""
collection.load()
if ef is None:
ef = max(top_k * 2, 64) # 至少 top_k 的 2 倍,且不低于 64
# 安全检查
if ef < top_k:
raise ValueError(f"ef ({ef}) 必须 >= top_k ({top_k})")
search_params = {
"metric_type": "COSINE",
"params": {
"ef": ef # 查询时的动态候选集大小,越大越精确
}
}
results = collection.search(
data=query_vectors.tolist(),
anns_field="embedding",
param=search_params,
limit=top_k,
output_fields=["doc_id"]
)
print(f"HNSW 搜索完成,ef={ef},top_k={top_k}")
return results
DiskANN 索引
def create_diskann_index(collection: Collection):
"""
DiskANN 索引:磁盘原生超大规模索引
使用前提:
1. Milvus 2.2+ 版本
2. 挂载了高性能 NVMe SSD
3. 数据量通常 > 5亿(否则 HNSW 更优)
参数说明:
- index_file_size:每个索引分片文件的大小(MB),默认 1024
越小内存使用更平滑,越大 IO 效率更高
- search_list:查询时的候选集,类似 HNSW 的 ef
- pq_code_budget_gb:内存中缓存的 PQ 编码大小
这是 DiskANN 能放在内存的压缩向量,越大越好,通常设为可用内存的 70-80%
"""
index_params = {
"metric_type": "L2", # DiskANN 当前主要支持 L2(部分版本支持 COSINE)
"index_type": "DISKANN",
"params": {
"index_file_size": 1024, # 每个索引文件 1GB,根据 SSD 性能调整
# 注意:pq_code_budget_gb 在 Milvus 中通过集群配置指定,不在这里设置
}
}
collection.create_index(
field_name="embedding",
index_params=index_params
)
print("DiskANN 索引创建完成(构建时间较长,请耐心等待)")
print("提示:确保 DataNode 的挂载路径是 NVMe SSD,否则查询延迟会很高")
def search_with_diskann(
collection: Collection,
query_vectors: np.ndarray,
top_k: int = 10,
search_list: int = None
):
"""
DiskANN 搜索
search_list 类似 HNSW 的 ef,必须 >= top_k
推荐值:max(top_k * 2, top_k + 20)
"""
collection.load()
if search_list is None:
search_list = max(top_k * 2, top_k + 20)
search_params = {
"metric_type": "L2",
"params": {
"search_list": search_list
}
}
results = collection.search(
data=query_vectors.tolist(),
anns_field="embedding",
param=search_params,
limit=top_k,
output_fields=["doc_id"]
)
return results
Recall 评估工具
def evaluate_recall(
collection: Collection,
query_vectors: np.ndarray,
ground_truth_ids: list, # 来自 FLAT 搜索的真实最近邻 ID 列表
search_params: dict,
top_k: int = 10
) -> float:
"""
计算 Recall@K
ground_truth_ids: 每个查询对应的真实 Top-K ID 列表
通常用 FLAT 索引搜索结果作为真值
返回:平均 Recall@K
"""
collection.load()
results = collection.search(
data=query_vectors.tolist(),
anns_field="embedding",
param=search_params,
limit=top_k
)
total_recall = 0.0
num_queries = len(query_vectors)
for i, result in enumerate(results):
returned_ids = set(hit.id for hit in result)
true_ids = set(ground_truth_ids[i])
intersection = len(returned_ids & true_ids)
recall = intersection / min(top_k, len(true_ids))
total_recall += recall
avg_recall = total_recall / num_queries
return avg_recall
# 使用示例:完整的 Recall 评估流程
def benchmark_index(collection_name: str, num_test_queries: int = 100):
"""对某个索引做完整的 Recall + 延迟基准测试"""
import time
collection = Collection(collection_name)
collection.load()
# 生成测试查询
query_vectors, _ = generate_data(num_test_queries, DIM)
# Step 1: 用 FLAT 获取 Ground Truth
print("Step 1: 用 FLAT 获取 Ground Truth...")
flat_params = {"metric_type": "COSINE", "params": {}}
start = time.time()
flat_results = collection.search(
data=query_vectors.tolist(),
anns_field="embedding",
param=flat_params,
limit=10
)
flat_latency = (time.time() - start) * 1000 / num_test_queries
ground_truth = [[hit.id for hit in result] for result in flat_results]
# Step 2: 测试目标索引(以 HNSW ef=64 为例)
print("Step 2: 测试 HNSW 索引...")
hnsw_params = {"metric_type": "COSINE", "params": {"ef": 64}}
start = time.time()
recall = evaluate_recall(
collection, query_vectors, ground_truth, hnsw_params, top_k=10
)
hnsw_latency = (time.time() - start) * 1000 / num_test_queries
print(f"\n基准测试结果({num_test_queries} 条查询):")
print(f" FLAT 延迟:{flat_latency:.1f} ms/query")
print(f" HNSW 延迟:{hnsw_latency:.1f} ms/query Recall@10:{recall:.1%}")
print(f" 速度提升:{flat_latency/hnsw_latency:.1f}x")
七、常见误区与陷阱
warning 误区一:HNSW 构建很慢,能不能用 IVF_FLAT 代替?
HNSW 确实比 IVF_FLAT 构建慢(因为图的构建是 O(N log N)),但这个代价只付一次。除非你的数据更新极其频繁(分钟级全量重建),否则 HNSW 在查询性能上的优势远超一次性构建成本。
如果你需要频繁批量更新:考虑 Milvus 的 Segment 机制——新数据会先以 Growing Segment 形式快速插入,后台自动触发索引构建,不会阻塞查询。
warning 误区二:nprobe 越大越好
nprobe 增大到 nlist/4 以上时,Recall 的提升往往已经很边际(从 97% 到 98%),但延迟可能翻倍。在大多数业务场景,97% 的 Recall 已经是生产可用的水平。
真正需要 99%+ Recall 的场景,应该考虑换 HNSW,而不是无限增大 nprobe。
warning 误区三:向量维度越高,索引越难做
这是对的,但程度有限。Milvus 的 HNSW 在 1536 维和 128 维的性能差异并不像理论上的维度灾难那么严重,因为实际数据的分布远比随机均匀分布稠密(真实 embedding 通常集中在低维流形上)。如果遇到高维向量的性能问题,优先考虑降维(PCA 到 256 维)或者减少 embedding 模型维度,而不是换索引。
warning 误区四:collection.load() 会把所有向量都加载进内存
对于 IVF 类索引,load() 确实会把所有向量加载进内存(这是 IVF_FLAT 内存开销等于原始数据的原因)。但 DiskANN 是例外——它只把 PQ 编码部分加载进内存,向量本体留在 SSD。
内存不足时,考虑使用 load(replica_number=1) 并配合 mmap_enabled=True(Milvus 2.4+),允许向量数据用内存映射方式访问磁盘,以时间换空间。
warning 误区五:IVF 的 nlist 一旦设定不能改
正确,nlist 是索引构建时的参数,构建完成后无法修改。但 nprobe 是查询时参数,每次搜索都可以动态指定不同的 nprobe 值。如果发现 nlist 设置不合理,需要 drop_index() 后重建。
总结与下一步
核心要点回顾
| 概念 | 关键记忆点 |
|---|---|
| 暴力搜索的代价 | O(N×D),1亿×1536维 = 约 1.5 秒延迟,内存带宽是瓶颈 |
| Recall@K | ANN 找到的 K 个结果中,真实最近邻的比例;99%平均Recall是否够用取决于任务与漏检影响 |
| FLAT | 暴力搜索,精度 100%,仅适合 <100 万数据 |
| IVF_FLAT | K-Means 分区 + 局部暴力搜索,nlist=sqrt(N),nprobe 是关键旋钮 |
| IVF_SQ8 | IVF 基础上 8-bit 量化,省 75% 内存,损失 ~1-2% 精度 |
| IVF_PQ | 乘积量化,极致压缩(可达 100x),适合两阶段检索 |
| HNSW | 分层小世界图,可作为高召回、低延迟检索的候选方案;性能与总内存需结合维度、参数及负载实测 |
| DiskANN | 磁盘原生,十亿级利器,需要 NVMe SSD |
| GPU_CAGRA | GPU加速;10-20x仅作为本文待验证的性能假设,是否选用需评估显存、兼容性、负载与成本 |
推荐的默认选择
如果你不知道选什么,按以下规则走:
- 数据 < 100万:FLAT
- 数据 100万-5亿,内存充足:HNSW(M=16, ef_construction=200)
- 数据 100万-5亿,内存不足:IVF_SQ8(nlist=sqrt(N))
- 数据 > 5亿:DiskANN(需要 NVMe SSD)
- 有 GPU:将GPU_CAGRA列为候选,确认实现兼容性与显存预算后,按其自身构建和搜索参数进行基准测试,不直接套用HNSW参数
相关文档
- 03-核心概念详解 — 理解 Collection、Segment、Index 的关系,是本篇的前置知识
- 02-Milvus架构深度解析 — 理解 QueryNode、IndexNode 如何协作构建和服务索引
- 09-生产运维与性能调优 — 本篇讲"选什么索引",第 9 篇讲"如何调优到最佳状态"
- 08-查询与搜索详解 — 索引选好之后,如何写出高效的查询语句(混合搜索、过滤查询)
创建时间:2026-03-11 所属系列:Milvus 向量数据库完整指南 第 6 篇 技术深度:进阶
#Milvus #向量数据库 #ANN #HNSW #IVF #DiskANN #向量索引 #索引选型