Chapter 02
索引构建与存储
第 1 章定下排序契约:向量索引只服务 ORDER BY 距离 ... LIMIT k,HNSW 和 IVFFlat 是它的两个实现。这一章把这两个名字落成 Postgres 里真实的索引对象——它们的 DDL 和默认参数、图怎么躺在 8KB 索引页里、构建时为什么会在某个内存阈值上突然慢 10–50 倍,以及一个没人会警告你的事实:默认参数的召回率并不高。
本章你将建立的 schema
- HNSW 和 IVFFlat 都注册成 PG 的索引访问方法;HNSW 能建在空表上,IVFFlat 必须等数据灌完再建。
- HNSW 的图被拆成 element tuple 和 neighbor tuple,散落在不同的 8KB 页上——检索是随机 I/O 密集型,图一旦超出内存延迟就崩。
- 构建在内存里跑,直到
maintenance_work_mem装满,越过这道坎就掉进逐元组落盘的慢路径——这就是"构建悬崖"。 - build 参数(
m/ef_construction/lists)和 query 参数(ef_search/probes)各管一头;默认值给的召回率很普通,且没有任何提示。
沿用第 1 章的多租户文档块表 doc_chunks(embedding vector(1536))。第 1 章建索引时只写了 opclass、没碰参数;这一章把 WITH (...) 里的每个数字摊开,看它们如何决定构建时间、索引体积和召回率。
2.1HNSW 与 IVFFlat:两种 PG 访问方法
HNSW 和 IVFFlat 都是 Postgres 的索引访问方法(access method),同一条 CREATE INDEX 语法、不同的 USING 和参数;它们的本质区别是能不能建在空表上。
第 1 章说"HNSW / IVFFlat 是排序索引",但没说怎么把它们造出来。落到工程上,第一个要回答的问题不是算法细节,而是:这两个东西在 Postgres 里是什么对象、怎么建、建之前要不要有数据。选错顺序,IVFFlat 索引会建出一个几乎没用的结构。
两者的 DDL 形状一致,差别在 USING 后面的方法名和 WITH 里的构建参数:
-- HNSW:可以建在空表上,之后每次 INSERT 增量把节点接进图
CREATE INDEX ON doc_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64); -- m 默认 16,ef_construction 默认 64
-- IVFFlat:必须先灌数据再建,lists 个质心来自对已有行做 k-means
CREATE INDEX ON doc_chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100); -- lists 默认 100
底层机制(比文档深一层):这条"建之前要不要有数据"的差别,根子在两种索引的结构怎么来。HNSW 是一张增量可生长的图——空图也是合法的图,每来一行就把它的节点连进去,所以建在空表上、再慢慢 INSERT 完全成立。IVFFlat 不是图,它先把向量空间切成 lists 个单元(cell),每个单元一个质心(centroid),而质心是对当前表里已有的行跑 k-means 算出来的。表是空的,就没有行可聚类,质心退化成随机点,整个倒排结构形同虚设。所以 IVFFlat 的铁律是:先把数据灌进去,再建索引;数据分布大改之后还得重建。算法层面 HNSW 的贪婪搜索、IVF 的聚类怎么工作,见隔壁 vector-database 教程 02;这一章只把它们当 Postgres 对象看。
参数怎么取,有经验公式。IVFFlat 的 lists 和 query 端的 probes 都随行数走:
| 参数 | 阶段 | 经验取值 | 含义 |
|---|---|---|---|
lists | build | N ≤ 1M 取 N/1000;N > 1M 取 sqrt(N) | 把空间切成几个单元 |
probes | query | sqrt(lists) 起步 | 查询时探查几个单元(默认 1) |
| 维度 | HNSW | IVFFlat |
|---|---|---|
| 建在空表上 | 可以(增量建图) | 不行(质心需已有数据) |
| 构建速度 | 慢 | 快 |
| 内存占用 | 高 | 低 |
| 召回 / 延迟 | 更好 | 较低 |
| 定位 | 0.5.0 起的默认选择 | legacy,仅低内存 / 快构建场景 |
pgvector 0.5.0 引入 HNSW 后,它就成了默认选择:同样召回率下延迟更低,且支持增量写入。IVFFlat 没有被删,但定位变成 legacy——只有在内存紧张、要求构建极快、且能接受较低召回的窄场景才选它。换句话说,新项目默认 HNSW,除非有明确理由退回 IVFFlat。本章余下三节全部围绕 HNSW 展开,因为它是绝大多数 RAG 检索层的实际选择,也是构建和存储问题真正复杂的那一个。
2.2图在索引页里怎么躺
HNSW 的图被拆成两种 tuple、散落在多张 8KB 页上;检索靠 TID 指针在页之间跳,所以它是随机 I/O 密集型——图装不进内存,延迟就崩。
"HNSW 索引要放进内存"是所有调优指南的第一条,但很少有人说为什么。答案不在算法里,在存储布局里:图是怎么切成 tuple、摊到页上的,决定了检索的 I/O 模式。看懂这一层,"装进内存"就从一句口诀变成一个能推出来的结论——也解释了第 3 章为什么删改向量会让图越来越碎。
底层机制(比文档深一层):HNSW 在索引里存两种 tuple。element tuple(HnswElementTuple)装一个节点:向量原值、一个 heaptids[] 数组(最多约 10 个堆表 TID,指向这个向量对应的实际数据行)、节点所在的层级(level),以及一个指向独立的 neighbor tuple 的指针。neighbor tuple 单独存这个节点的邻居列表,大小约 (level+2) × m 个邻居槽。这两种 tuple 都按普通堆页规则塞进一张张 8KB 页里,谁和谁同页是不确定的。索引的 0 号元数据页(metadata page)记着图的入口点(entry point)——它的 block 号和页内 offset,每次检索都从这里出发。
把机制推到后果:检索沿图遍历,每一跳都要解引用一个落在任意页上的邻居 TID。当整张图能装进 shared_buffers / 操作系统页缓存时,这些跳大多命中内存,延迟是毫秒级。一旦图的体积超过可用内存,落在未缓存页上的那些跳就各自触发一次缺页(page fault),要从磁盘把那张页读上来——一次 ANN 检索动辄几十上百跳,延迟随之从毫秒塌到几十甚至上百毫秒。这就是为什么"索引能不能装进内存"是 HNSW 性能的头号杠杆,压倒一切参数微调。
HNSW 索引体积远大于"行数 × 向量字节数",因为每个节点还要存 (level+2) × m 个邻居槽。1536 维、百万行的 HNSW 索引常到几个 GB;这个数才是你要拿去和 shared_buffers + 可用内存比的数。建完用 SELECT pg_size_pretty(pg_relation_size('索引名')); 量它的真实大小,别用表大小估。
2.3构建悬崖:maintenance_work_mem
HNSW 构建在内存里跑,直到 maintenance_work_mem 装满;越过这道坎,构建掉进逐元组落盘的慢路径,同样的数据、同一个阈值,构建时间炸开 10–50 倍。
构建一个百万行的 HNSW 索引,有人 10 分钟建完,有人同样的机器跑 3 小时还没完。差别往往不在 CPU、不在磁盘,而在一个内存参数有没有覆盖住图。这一节解释那道看不见的坎在哪、越过去会发生什么,以及为什么参数没调对会同时关掉并行、又触发慢路径——双重惩罚。
底层机制(比文档深一层):HNSW 构建时,pgvector 先尝试把整张正在生长的图放进 maintenance_work_mem 里建——这条内存路径快。一旦图的体积超过这个上限,pgvector 在日志里打出一行:
NOTICE: hnsw graph no longer fits into maintenance_work_mem after 524288 tuples
HINT: Building will take significantly more time.
这行 NOTICE 一出,构建就从"整图在内存"切到逐元组落盘(on-disk, one tuple at a time)的路径:每接一个新节点都要在磁盘上的图结构里读写,慢 10–50 倍。同一批数据,只因为跨过 maintenance_work_mem 这一个阈值,构建时间就从线性增长变成断崖式爆炸。这就是构建悬崖。
maintenance_work_mem 里,曲线平缓;越过阈值,pgvector 打出 NOTICE 并切到逐元组落盘的慢路径,曲线急转直上。注意:拐点不是渐变而是断崖——同一份数据,参数够与不够,构建时间差一个数量级。虚线是"内存够时本该走的延长线",和实线朱红段的落差就是这 10–50 倍。并行构建:又一个被 maintenance_work_mem 掐住的开关
pgvector 0.6.0(2024-01)给 HNSW 加了并行构建。它的做法是把正在生长的图放进一段共享内存(内部叫 hnswarea),多个 worker 同时往里接节点。两个工程细节决定了它的边界:
- 用相对偏移指针:共享内存段在每个 worker 进程里映射到的虚拟地址不同,所以图内的指针不能存绝对地址,必须存相对段起点的偏移,每个 worker 解引用时各自加上自己的基址——这样大家看到的是同一张图。
- 段不能扩容、按元素加 LWLock:每个 element 用一把轻量锁(LWLock)保护并发修改;但这段共享内存一旦分配就不能再长大。它的大小同样由
maintenance_work_mem决定。
把两节的机制叠起来,就看出 maintenance_work_mem 偏小是双重惩罚:它既让共享内存段装不下图、提前关掉并行(worker 无段可用),又触发 §2.3 的逐元组落盘慢路径。一个参数没给够,并行和快路径一起没了。控制并行度的是 max_parallel_maintenance_workers,默认 2(外加 leader 进程本身)。
有过真实报告:1700 万行 × 1536 维的 HNSW 构建,即便 maintenance_work_mem 给到 48GB,参数配比不当时仍会在约 2 小时后失败。结论不是"内存越大越好",而是 maintenance_work_mem 要按图的实际体积来配(百万行级就是多个 GB),并同步抬高 max_parallel_maintenance_workers。两个参数配套调,才能既吃满并行、又稳稳走在内存快路径上。
-- 为这次构建临时放大内存与并行度(按图体积,百万行级常配多个 GB)
SET maintenance_work_mem = '8GB';
SET max_parallel_maintenance_workers = 7; -- 默认 2;加上 leader 共 8 路
CREATE INDEX ON doc_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- 建完核对索引真实体积,确认它能被内存覆盖
SELECT pg_size_pretty(pg_relation_size('doc_chunks_embedding_idx'));
给一台 64GB 内存的机器建 HNSW 索引,构建日志里没有出现 graph no longer fits 那行 NOTICE,构建也很快。但上线后单条检索 P99 高达 200ms。maintenance_work_mem 在检索阶段还起作用吗?问题更该往哪里查?
展开答案(先停 10 秒)
maintenance_work_mem 只在构建/维护阶段生效,检索期它一点用都没有——构建快、没触发悬崖,只说明建得顺,不代表查得快。
P99 200ms 的嫌疑落在 §2.2:检索期决定延迟的是图能不能驻留在 shared_buffers + 操作系统页缓存里。构建用的 8GB maintenance_work_mem 和检索期的缓存是两笔预算。如果 shared_buffers 配得小、或同机别的负载把页缓存挤掉,遍历每跳都缺页,延迟自然塌。构建参数和检索内存要分别核算——这正是把 §2.2 的存储布局和 §2.3 的构建路径分开理解的价值。
2.4参数与"怎么真正量召回"
build 参数定下索引的"上限",query 参数定下每次查询花多少力气;默认 ef_search = 40 / probes = 1 给的召回率很普通,而没有任何东西会警告你——召回率必须自己量。
ANN 是近似检索,它会漏掉一些真正的最近邻。漏多少,由参数决定。最危险的地方在于:用默认参数建好索引、查询返回 10 条、看起来一切正常——但这 10 条里往往只有 7、8 条落在真正的 top-10 里,数据库不会报错、不会变慢、不给任何信号。要知道召回率,只有一条路:自己测。
先把四个参数归位。它们分成 build 和 query 两端,调高都换来更高召回、更慢速度:
| 索引 | build 参数 | query 参数(默认) | 调高的效果 |
|---|---|---|---|
| HNSW | m(16) · ef_construction(64) | hnsw.ef_search(40) | 召回↑、构建/查询变慢 |
| IVFFlat | lists(100) | ivfflat.probes(1) | 召回↑、查询变慢 |
分工要分清:build 参数(m / ef_construction / lists)在建索引时一次性定下结构质量的上限,建完不可调,要变只能重建;query 参数(ef_search / probes)每条查询都能用 SET 调,控制这一次"搜得多狠"。一条硬规则:ef_search 必须 ≥ 你要取的 k——要取 top-10 却把 ef_search 设成 5,索引连 10 个候选都凑不齐。
底层机制(比文档深一层):默认 ef_search = 40 意味着 HNSW 遍历时维护一个大小 40 的候选集,probes = 1 意味着 IVFFlat 只探查 1 个单元。这两个默认值偏向速度,给出的召回率在很多数据集上只有八九成、甚至更低,取决于数据分布。pgvector 没有内置的召回率监控——索引照常返回 k 条、查询照常很快,召回率低这件事完全不可见。唯一的办法是拿精确暴力检索的结果当 ground truth,去比对索引返回的结果。
怎么逼 Postgres 跑一次精确暴力检索?在一个事务里关掉索引扫描,让 planner 退化成顺序扫描 + 精确排序,同一条 ORDER BY ... LIMIT k 就成了 ground truth:
-- 1) ground truth:关掉索引扫描,强制精确暴力 top-10
BEGIN;
SET LOCAL enable_indexscan = off;
SET LOCAL enable_indexonlyscan = off;
SELECT id FROM doc_chunks ORDER BY embedding <=> $1 LIMIT 10; -- 真值
COMMIT;
-- 2) 索引结果:正常走 HNSW,先定 ef_search
SET hnsw.ef_search = 40; -- 默认值
SELECT id FROM doc_chunks ORDER BY embedding <=> $1 LIMIT 10; -- 近似
-- 3) recall@10 = 两个 id 集合的交集大小 / 10
-- 多取一批查询向量求平均,才是这套参数的真实召回率
-- 4) 召回不达标就抬高 ef_search(48 → 64 → 100 ...)重测,直到达标
流程固定:关索引取真值 → 走索引取近似 → 算 overlap@k / k → 不达标就抬 ef_search / probes 重测,直到召回率压过你的目标(比如 0.95)。单条查询不算数,要在一批有代表性的查询向量上取平均。这是验收一个向量索引唯一可信的方式——延迟能用秒表测,召回率只能这么算出来。
recall@k。注意:达不到目标时不是去改索引,而是回到"走索引"那一路抬高 ef_search / probes 重测(朱红回环)——这是 query 参数能在线调、build 参数不能调的直接体现。ef_search 不止管召回率,它是后面两章的共同前置。第 3 章会讲:HNSW 检索时拿到的候选要回堆表做可见性 recheck(MVCC),而这个 recheck 只在 ef_search 圈定的候选集里做、不会为了凑数额外扩大——删改一多,被过滤掉的死元组会让实际返回数缩水,见 03 章 §3.1。第 4 章会讲:带 WHERE 过滤时,ef_search 圈的候选要先满足过滤条件才算数,所以它直接决定过滤后还剩几条的预算,见 04 章 过滤检索。这一节把 ef_search 当召回旋钮,后两章把同一个旋钮接到可见性和过滤上。
§本章 self-check
先合上教程,把答案写在纸上或编辑器里,再点开对照。直接展开等于把这一节当再读一遍。
- 给一张空表建索引,HNSW 成立、IVFFlat 几乎无用。说出这个差别的根因。
- HNSW 检索为什么是"随机 I/O 密集型"?从 element tuple / neighbor tuple 的存储布局说一句话,并由此推出"索引必须装进内存"。
- 构建悬崖是哪个参数、跨过哪条线触发的?为什么这个参数偏小是"双重惩罚"?
- 用默认
ef_search = 40建好索引、查询返回 10 条、速度也快。凭什么说召回率不一定够?要拿到 ground truth,那一行关键的SET是什么?
答案(先做完再展开)
- HNSW 是增量可生长的图,空图也合法,每次
INSERT把节点接进去即可。IVFFlat 的lists个质心要对已有行跑 k-means——空表没有行可聚类,质心退化、倒排结构形同虚设。所以 IVFFlat 必须先灌数据再建。 - 图被拆成 element tuple(向量 + heaptids[] + 邻居指针)和 neighbor tuple,散落在不同 8KB 页上;遍历每一跳都解引用一个落在任意页的邻居 TID,访问模式是随机的。一次检索几十上百跳,图若超出内存,未缓存的跳就逐个缺页 → 延迟成数量级恶化,所以"装进内存"是头号杠杆。
maintenance_work_mem;当正在生长的图体积超过它时触发,pgvector 打出graph no longer fits的NOTICE并切到逐元组落盘路径,慢 10–50 倍。双重惩罚:偏小既让 0.6.0 的并行共享内存段装不下图、提前关掉并行,又触发这条落盘慢路径。- ANN 是近似检索、本就会漏最近邻,而 pgvector 不监控召回率——返回 k 条、速度快,都不代表这 k 条就是真 top-k,低召回完全不可见。ground truth 靠在事务里
SET LOCAL enable_indexscan = off;强制精确暴力扫描得到,再算overlap@k / k,不达标抬高ef_search重测。
构建快、索引也"装得下",为什么召回率还是上不去?
你把 maintenance_work_mem 配得很足,构建没触发悬崖、很快建完,索引体积也小于内存,检索延迟漂亮。但无论怎么抬 ef_search,recall@10 始终卡在 0.88 上不去。问题出在哪一类参数?在不接受"召回封顶"的前提下,唯一的出路是什么、代价是什么?
提示(卡住再展开)
把参数分成两端想(§2.4):ef_search 是 query 参数,它只能在"索引这张图已经定下的结构上限"之内找更多候选。召回率封顶,说明瓶颈不在查得够不够狠,而在图本身的连通质量——那是 build 参数 m 和 ef_construction 决定的,建完不可调。出路:用更大的 m(每个节点更多邻居)和更大的 ef_construction(建图时每个节点搜更多候选邻居)重建索引。代价回到 §2.2 和 §2.3:m 变大 → 每个节点的 neighbor tuple 更大 → 索引体积涨、对内存的要求更高;ef_construction 变大 → 构建更慢、更容易撞上构建悬崖。所以这是一道"召回率 vs 构建成本 vs 内存占用"的三方取舍,而不是一个能在线拧的旋钮。