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)必须和你能提供的训练数据量一起定,否则你会得到一个「召回看着还行、但延迟解释不了」的索引。

生产边界

动手

  1. 跑 04_pq.py,把 m 改成 8,看压缩比和召回各变成多少,验证「压缩比越大约召回越差」。
  2. 跑 05_hnsw.py,把 M 改成 64,记录内存涨了多少、最高召回涨了多少,算出「每多 1 倍内存换来多少召回」。
  3. 跑 02b_train_short.py,把 nlist 改成 256,重新找出「训练点不足」的临界值,验证它是不是 39 × 256 附近。

自测

  1. IVF 的 nlist 和 nprobe 分别控制什么?为什么 nprobe 可以热调而 nlist 不能?
  2. 训练点不足时召回反而升高(0.3951 > 0.1701),请从「列表大小分布」解释为什么这不是好事。
  3. IVF-PQ 的 m=16 和 m=32 内存差了不到一倍,召回却差很多,为什么?m 对应恒等式里的什么?
  4. HNSW 不需要训练,但为什么它的建索引耗时(M=32 时 5.5s)比 Flat(0.007s)高三个数量级?
  5. 一个同事说「DiskANN 就是省内存的 HNSW」。这句话漏掉了 DiskANN 最核心的代价,是什么?

↓ 下一步:03 章 · 召回率-延迟曲线 —— 结构讲完了,接下来把这些参数真正调起来。

进入 keel 阅读