KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
02 · 索引家族 — keel 龙骨
第 01 章把「什么算近」定义清楚了,这一章回答「怎么少算一些」。四个家族——IVF、PQ、HNSW、DiskANN——各自拿掉了检索里的不同环节。你需要建立的判断是:每个参数在拿掉什么、它的失效场景长什么样。参数配错的代价在本章都有实测数字。
第 01 章把「什么算近」定义清楚了,这一章回答「怎么少算一些」。四个家族——IVF、PQ、HNSW、DiskANN——各自拿掉了检索里的不同环节。你需要建立的判断是:每个参数在拿掉什么、它的失效场景长什么样。参数配错的代价在本章都有实测数字。
现场
你要给 100 万条向量选索引。文档给的选项是 IVF_FLAT、IVF_PQ、HNSW、DISKANN,每个下面一行参数。选错的表现通常不是「报错」,而是「召回悄悄低了 30%」或「内存比预期多一倍」——上线之后才发现,因为没人在选型时量过。
IVF:先分桶,再只扫相关的桶
IVF 的思路是把空间切成 nlist 块,查询只看离查询最近的 nprobe 块。它的结构分两部分:一个粗量化器(通常就是 k-means 得到的 nlist 个质心),和 nlist 个倒排列表(每个质心下挂一份向量 id 清单)。
flowchart TD
TRAIN["离线训练: k-means 求 nlist 个质心"] --> ASSIGN["入库: 每条向量分到最近质心<br/>写进对应倒排列表"]
ASSIGN --> Q["查询向量 q"]
Q --> COARSE["粗量化器: 在 nlist 个质心中<br/>找最近的 nprobe 个"]
COARSE --> SCAN["只扫这些列表里的向量<br/>算精确距离"]
SCAN --> MERGE["合并各列表结果 → top-k"]
COARSE -.->|"nprobe 太小,真邻居落在没探测的列表"| MISS["召回低 (03 章 nprobe=1 实测 2.5%)"]
TRAIN -.->|"训练点 < 39×nlist"| SKEW["列表大小倾斜 max=3100/median=4<br/>QPS 掉到 1/3"]
style TRAIN fill:#e3f2fd,color:#0d3b66
style ASSIGN fill:#e8f5e9,color:#1b5e20
style Q fill:#e3f2fd,color:#0d3b66
style COARSE fill:#fff3e0,color:#8a4b00
style SCAN fill:#f1f8e9,color:#33691e
style MERGE fill:#e8f5e9,color:#1b5e20
style MISS fill:#ffebee,color:#b71c1c
style SKEW fill:#ffebee,color:#b71c1c
两个参数各管一段:nlist 决定切多细,nprobe 决定每次看几块。nprobe 越大,扫得越多、召回越高、越慢。第 03 章会把这条曲线整张量出来。
训练数据量有个护栏。 faiss 在训练时会检查训练点数是否够,不够就打印警告。本机故意用小训练集触发(lab/evidence/vector-database-engineering/02b-train-short.txt):
WARNING clustering 5000 points to 1024 centroids: please provide at least 39936 training points
=== nlist=1024 ntrain=5000 (faiss 建议 >= 39936) ===
非空列表 = 1024/1024 列表总量 = 100000
列表大小: mean=97.7 median=4 max=3100
recall@10 = 0.3951 qps = 22035.0
=== nlist=1024 ntrain=40000 (faiss 建议 >= 39936) ===
列表大小: mean=97.7 median=106 max=239
recall@10 = 0.1701 qps = 61101.5
39936 = 1024 × 39,就是常说的「训练点至少是 39 × nlist」。看这三行列表统计:训练充分时(40000 条)列表大小中位数 106、最大 239,分布均匀;训练不足时(5000 条)中位数只有 4、最大却有 3100,严重倾斜。
这里有个反直觉的地方:训练不足那次 recall@10 = 0.3951,比训练充分的 0.1701 还高。别被骗了——看 QPS:22035 vs 61101,只有三分之一。原因是列表倾斜后,探测 16 个列表可能正好落进几个巨大的列表,实际扫的向量远多于预期,等于偷偷把 nprobe 调大了。所以这条经验值不是「召回保证」,是「别让粗量化器退化」的护栏;违反它的症状出现在延迟上,而不是召回上。
倒排列表里存了什么
理解 IVF 的内存账,要看倒排列表里到底存了什么。IndexIVFFlat 的每个倒排列表存的是原始向量 + id,所以内存约等于 N × d × 4 再加一点 id 和质心的开销——本机实测 IVF-Flat 是 521.3 字节/向量,和 Flat 的 512.0 几乎一样。IVF 不省内存,它省的是每次查询要扫的向量数。
IndexIVFPQ 的差别就在这里:倒排列表里存的不是原始向量,而是 PQ 编码。nbits=8 时每个向量占 m 字节,d=128 的 float32 原向量是 512 字节,所以 m=16 时内存掉到约 1/32(下一节 PQ 那组实测压缩比是 19.57 倍,差额来自质心和码本开销)。这是「IVF 省扫描量、PQ 省内存」这句话的物理来源——它们改的不是同一层。
PQ:把每条向量变小
PQ(乘积量化)解决的是内存。把 d 维向量切成 m 段,每段在各自的小码本里量化成一个编号(nbits 位)。存储从 d × 4 字节降到 m 字节(nbits=8 时)。IVF-PQ 则对残差(向量减去它所属质心)做 PQ,比直接量化更准。
代价是距离变成查表近似。本机实测(04-pq-compression.txt):
index | recall@10 | bytes | MB | vs flat
IVF-Flat(nprobe=16) | 0.2821 | 52133259 | 52.13 | 1.00x
IVF-PQ m=16 nbits=8 | 0.1321 | 2664372 | 2.66 | 19.57x (code=16B/vec)
IVF-PQ m=32 nbits=8 | 0.2434 | 4264372 | 4.26 | 12.23x (code=32B/vec)
m=16 省 19.57 倍内存,召回从 0.2821 掉到 0.1321(掉了 53%);m=32 省 12.23 倍,召回 0.2434(掉 14%)。这就是「用准确率换内存」的量级:压缩比越大,召回摔得越狠,而且不是线性的。
PQ 的召回还能靠加大探测范围找回来一些:
m | nprobe | recall@10
16 | 16 | 0.1321
16 | 64 | 0.1916
16 | 256 | 0.2224
32 | 16 | 0.2434
32 | 64 | 0.4186
32 | 256 | 0.5235
m=32 探满 256 个列表时召回 0.5235,已经超过 IVF-Flat 在 nprobe=16 时的 0.2821。所以 PQ + 高 nprobe 是「省内存 + 保召回」的一条路,代价是扫得更多。m 和 nbits 的选择要在内存预算下去试,没有一劳永逸的值。
HNSW:分层图
HNSW 是图索引。它在数据之上建一张多层近邻图:上层稀疏,用来做长距离跳跃;下层密集,用来精确定位。查询从顶层入口点开始,逐层贪心下降,到底层在一个大小为 efSearch 的候选池里继续搜索。
flowchart TD
EP["入口点 (顶层节点)"] --> TOP["第 L 层贪心走到局部最近"]
TOP --> DOWN{"还有更下一层?"}
DOWN -->|是| DESC["下潜一层,从当前点继续贪心"]
DESC --> DOWN
DOWN -->|否| BASE["第 0 层: 维护 efSearch 大的候选池<br/>取最近未访问点,扩展其邻居"]
BASE --> RET["候选池排序 → top-k"]
BASE -.->|"高维均匀 / 图局部断裂,贪心走不到真邻居"| LOST["召回低 (05 章 M=32 efSearch=10 实测 0.1686)"]
style EP fill:#e3f2fd,color:#0d3b66
style TOP fill:#f1f8e9,color:#33691e
style DOWN fill:#fff3e0,color:#8a4b00
style DESC fill:#f1f8e9,color:#33691e
style BASE fill:#e8f5e9,color:#1b5e20
style RET fill:#e8f5e9,color:#1b5e20
style LOST fill:#ffebee,color:#b71c1c
三个参数:M 控制每个节点连多少条边(图越密越准、越占内存),efConstruction 控制建图时的搜索宽度(越大图质量越好、建得越慢),efSearch 控制查询时的候选池大小。HNSW 不需要训练,这是它和 IVF 系最大的操作差异——不用准备训练集,但建图本身是重活。
内存上 HNSW 是四个家族里最贵的。本机实测(05-hnsw-efsearch.txt):
index | build_s | bytes | MB | B/vec
FlatL2 | 0.007 | 51200045 | 51.20 | 512.0
HNSW M=16 | 3.057 | 65633274 | 65.63 | 656.3
HNSW M=32 | 5.439 | 78420834 | 78.42 | 784.2
IVF-Flat nlist=256 | - | 52133259 | 52.13 | 521.3
M=16 总内存是 Flat 的 1.28 倍,M=32 是 1.53 倍。多出来的部分是图结构:M=16 时图占 144.3 字节/向量,M=32 时占 272.2 字节/向量。IVF-Flat 只有 521.3 字节/向量,和 Flat 几乎一样——省内存不是 IVF 的卖点,它省的是扫描量。
图的内存可以估
HNSW 多出来的那部分内存不是玄学,能估。第 0 层每个节点最多连 2M 条边(faiss 的约定是底层度数上限为 2M,上层为 M),每条边存一个 4 字节的 id,所以底层图约 2M × 4 = 8M 字节/向量。M=16 算出来 128 字节/向量,实测 144.3;M=32 算出来 256 字节/向量,实测 272.2——实测略高,多出来的是上层结构和每节点的元数据。两者都落在同一个量级,说明图内存基本由 2M × 4 决定。
这条估算的用处:你拿到一个「内存预算 30GB、数据 1000 万条 128 维」的需求,先算向量本身 1000万 × 512B = 4.77 GiB,再按 M=32 加上约 272 字节/向量(约 2.5 GiB),剩下的余量才用来放标量字段、id、副本和查询临时内存。很多人只算了向量本身,到部署时才发现内存不够。
DiskANN:图放磁盘
当数据大到内存装不下,一个方向是把图放磁盘、只把压缩后的 PQ 码放内存。Milvus 文档里的 DiskANN 就是这个思路(本节为官方文档陈述,非本机实验;来源 milvus.io/docs/diskann.md,检索日期 2026-10-05):Vamana 图存磁盘,PQ 码存内存做近似距离,检索时用 PQ 码筛出候选、再从磁盘读原始向量做精确校验。
它的参数和内存索引不是一套。构建参数(只能改 milvus.yaml):MaxDegree 默认 56,限制每个节点的最大边数;SearchListSize 默认 100,控制建图时候选池大小;PQCodeBudgetGBRatio 默认 0.125,控制 PQ 码占原始数据大小的比例;SearchCacheBudgetGBRatio 默认 0.10,控制缓存比例。查询参数:BeamWidthRatio 默认 4.0,Beam width = CPU 核数 × beam_width_ratio;search_list 默认 100。
关键判断:DiskANN 换的是内存,付的是磁盘随机读。文档明确建议数据目录挂 NVMe SSD,并且默认是关闭的(要 queryNode.enableDisk=true)。如果你的 QPS 需求高、延迟敏感,而数据量还没到内存装不下的程度,它不如直接用 HNSW。
参数与失效对照
| 索引 | 关键参数 | 主要成本 | 要不要训练 | 典型失效 |
|---|---|---|---|---|
| IVF-Flat | nlist / nprobe |
训练 + 每查询扫 nprobe 个列表 | 是 | nprobe 太小导致漏;训练点不足导致列表倾斜、QPS 崩 |
| IVF-PQ | nlist / nprobe / m / nbits |
训练慢(本机 m=32 训练 28.2s) | 是 | m 太小召回掉得厉害;nbits 低精度损失大 |
| HNSW | M / efConstruction / efSearch |
内存(图占 144~272 B/vec) | 否 | 高维均匀数据图导航失效;M 太大内存爆 |
| DiskANN | MaxDegree / SearchListSize / beam |
磁盘随机读 | 是(建图) | 慢盘导致延迟高;缓存比例调太小反复读盘 |
主动破坏:抽掉训练数据
把 02b_train_short.py 里 nlist=1024 的训练集从 100000 条砍到 5000 条(就是上面那组数据)。观察三件事同时发生:faiss 打印警告 please provide at least 39936 training points;列表大小从均匀(median 106)变成倾斜(median 4,max 3100);QPS 掉到三分之一。再把它砍到 1000 条,警告会更早出现,列表倾斜更极端。
这个破坏说明:训练不是「走个过场」。索引的参数(尤其 nlist)必须和你能提供的训练数据量一起定,否则你会得到一个「召回看着还行、但延迟解释不了」的索引。
生产边界
- 教学替身:faiss 单机内存索引。生产里 Milvus 的 IVF/HNSW 会分片(segment),参数语义一致但有一个额外的 segment 级别归并层(第 04 章)。
- 上线要盯的指标:
索引内存 / 原始数据内存(判断压缩有没有生效)、构建耗时、nprobe 或 efSearch 的实际分布(有没有被顺手调大)、列表大小分布(IVF 专有,倾斜是延迟隐患)。 - 失败策略:把索引参数和 embedding 模型版本绑定版本化。换模型必然重建索引;
nlist变更需要重新训练,nprobe/efSearch这类查询期参数可以热调,把它们分开管理。
动手
- 跑
04_pq.py,把m改成 8,看压缩比和召回各变成多少,验证「压缩比越大约召回越差」。 - 跑
05_hnsw.py,把M改成 64,记录内存涨了多少、最高召回涨了多少,算出「每多 1 倍内存换来多少召回」。 - 跑
02b_train_short.py,把nlist改成 256,重新找出「训练点不足」的临界值,验证它是不是39 × 256附近。
自测
- IVF 的
nlist和nprobe分别控制什么?为什么nprobe可以热调而nlist不能? - 训练点不足时召回反而升高(0.3951 > 0.1701),请从「列表大小分布」解释为什么这不是好事。
- IVF-PQ 的
m=16和m=32内存差了不到一倍,召回却差很多,为什么?m对应恒等式里的什么? - HNSW 不需要训练,但为什么它的建索引耗时(M=32 时 5.5s)比 Flat(0.007s)高三个数量级?
- 一个同事说「DiskANN 就是省内存的 HNSW」。这句话漏掉了 DiskANN 最核心的代价,是什么?
↓ 下一步:03 章 · 召回率-延迟曲线 —— 结构讲完了,接下来把这些参数真正调起来。