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 里的构建参数:

index_methods.sql SQL
-- 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 都随行数走:

表 2.1 · IVFFlat 参数经验值(按行数 N)
参数阶段经验取值含义
listsbuildN ≤ 1M 取 N/1000;N > 1M 取 sqrt(N)把空间切成几个单元
probesquerysqrt(lists) 起步查询时探查几个单元(默认 1)
表 2.2 · 两种访问方法的工程取舍
维度HNSWIVFFlat
建在空表上可以(增量建图)不行(质心需已有数据)
构建速度慢快
内存占用高低
召回 / 延迟更好较低
定位0.5.0 起的默认选择legacy,仅低内存 / 快构建场景
洞察 · 默认选 HNSW,IVFFlat 是退路

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,每次检索都从这里出发。

元数据页 0 entry point (block, offset) 入口 索引页 A element tuple 向量值 + heaptids[] level + 邻居指针 ──┐ heaptids → 堆表数据行 随机 I/O 索引页 B(任意一张) neighbor tuple (level+2)×m 个邻居 TID 下一个 element 又在别的页 → 再跳 图 > 内存 每一跳撞 page fault → 延迟成数量级恶化 "装进内存"是第一杠杆
图 2.1HNSW 一次遍历的物理轨迹:从元数据页的入口点出发,读 element tuple,顺着邻居指针跳到另一张任意页上的 neighbor tuple,再顺着每个邻居 TID 跳向下一个 element——每一跳都是一次随机页访问。注意:这些页摊在哪里不可控,所以检索是随机 I/O 密集型;一旦图的总体积超过可用内存,每一跳都得从磁盘补页,延迟从毫秒级塌到几十上百毫秒。这就是"HNSW 必须装进内存"的物理根源。

把机制推到后果:检索沿图遍历,每一跳都要解引用一个落在任意页上的邻居 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 在日志里打出一行:

postgres.log LOG
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 上限 内存快路径(整图在内存) 落盘慢路径 逐元组读写,慢 10–50× NOTICE: graph no longer fits → 切到 on-disk 路径 本应如此
图 2.2构建耗时随插入元组数的两条路径:阈值左侧整图在 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。两个参数配套调,才能既吃满并行、又稳稳走在内存快路径上。

build_tuning.sql SQL
-- 为这次构建临时放大内存与并行度(按图体积,百万行级常配多个 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 两端,调高都换来更高召回、更慢速度:

表 2.3 · HNSW / IVFFlat 的 build 与 query 参数
索引build 参数query 参数(默认)调高的效果
HNSWm(16) · ef_construction(64)hnsw.ef_search(40)召回↑、构建/查询变慢
IVFFlatlists(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:

measure_recall.sql SQL
-- 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)。单条查询不算数,要在一批有代表性的查询向量上取平均。这是验收一个向量索引唯一可信的方式——延迟能用秒表测,召回率只能这么算出来。

查询向量 $1 同一个,分两路 精确暴力(真值) enable_indexscan = off 走 HNSW(近似) hnsw.ef_search = 40 recall@k 交集 / k 不达标 → 抬高 ef_search 重测
图 2.3召回率测量回路:同一个查询向量分两路——关索引那路给出精确 top-k 真值,走索引那路给出近似 top-k,交集除以 k 就是 recall@k。注意:达不到目标时不是去改索引,而是回到"走索引"那一路抬高 ef_search / probes 重测(朱红回环)——这是 query 参数能在线调、build 参数不能调的直接体现。
洞察 · ef_search 这根弦,后两章还要拨

ef_search 不止管召回率,它是后面两章的共同前置。第 3 章会讲:HNSW 检索时拿到的候选要回堆表做可见性 recheck(MVCC),而这个 recheck 只在 ef_search 圈定的候选集里做、不会为了凑数额外扩大——删改一多,被过滤掉的死元组会让实际返回数缩水,见 03 章 §3.1。第 4 章会讲:带 WHERE 过滤时,ef_search 圈的候选要先满足过滤条件才算数,所以它直接决定过滤后还剩几条的预算,见 04 章 过滤检索。这一节把 ef_search 当召回旋钮,后两章把同一个旋钮接到可见性和过滤上。

§本章 self-check

先合上教程,把答案写在纸上或编辑器里,再点开对照。直接展开等于把这一节当再读一遍。

  1. 给一张空表建索引,HNSW 成立、IVFFlat 几乎无用。说出这个差别的根因。
  2. HNSW 检索为什么是"随机 I/O 密集型"?从 element tuple / neighbor tuple 的存储布局说一句话,并由此推出"索引必须装进内存"。
  3. 构建悬崖是哪个参数、跨过哪条线触发的?为什么这个参数偏小是"双重惩罚"?
  4. 用默认 ef_search = 40 建好索引、查询返回 10 条、速度也快。凭什么说召回率不一定够?要拿到 ground truth,那一行关键的 SET 是什么?
答案(先做完再展开)
  1. HNSW 是增量可生长的图,空图也合法,每次 INSERT 把节点接进去即可。IVFFlat 的 lists 个质心要对已有行跑 k-means——空表没有行可聚类,质心退化、倒排结构形同虚设。所以 IVFFlat 必须先灌数据再建。
  2. 图被拆成 element tuple(向量 + heaptids[] + 邻居指针)和 neighbor tuple,散落在不同 8KB 页上;遍历每一跳都解引用一个落在任意页的邻居 TID,访问模式是随机的。一次检索几十上百跳,图若超出内存,未缓存的跳就逐个缺页 → 延迟成数量级恶化,所以"装进内存"是头号杠杆。
  3. maintenance_work_mem;当正在生长的图体积超过它时触发,pgvector 打出 graph no longer fits 的 NOTICE 并切到逐元组落盘路径,慢 10–50 倍。双重惩罚:偏小既让 0.6.0 的并行共享内存段装不下图、提前关掉并行,又触发这条落盘慢路径。
  4. 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 内存占用"的三方取舍,而不是一个能在线拧的旋钮。