第 18 篇决策树把「必须留在 SQL 同进程」标成
sqlGate,但停在选型口头禅。同一条业务行上的
vector(768),在 PostgreSQL 里怎么进堆、怎么进
HNSW 索引页、过滤发生在扫描前还是扫描后——这些才是和 Milvus
Growing/Sealed + Knowhere
差在哪一层的可核对答案。
本文是「向量检索引擎」系列第 19 篇(共 19 篇)。→ 系列目录
文章 分工 01 全景 / 18 选型 四类库与 sqlGate03 / 05 / 08 / 11 Milvus 生命周期与 bitset 15 / 16 另两极对照 本文 pgvector v0.8.0:Index AM + 8KB 页 + post-filter PG 页面 / 扩展 8KB 页与 AM 共性 storage/39 ANN 通识入口 版本锚定:pgvector v0.8.0;Milvus 对照 v2.6.21。本机无 Postgres:页装箱为宏公式复现,不伪造
pg_relation_size。
一、最小故事:同一行向量,两套系统怎么落地
业务表要保存文档元数据与 768 维嵌入,并且「插入成功后同一事务里就能按相似度查出刚写入的行」。
pgvector 路径(同进程):
BEGIN;
INSERT INTO documents (id, tenant_id, embedding)
VALUES (42, 7, '[...]'::vector(768));
-- 同事务内可见(MVCC);近似索引路径见下文 Index AM
SELECT id FROM documents
ORDER BY embedding <-> $1 LIMIT 10;
COMMIT;堆元组与 HNSW 索引页变更进入 同一套
WAL;提交后对本快照可见。代价是:建索、ANN
距离计算、OLTP 查询、autovacuum 抢 同一实例
的 CPU 与 shared_buffers。
Milvus 路径(本系列主线):客户端
insert 经 Proxy → Streaming Node → WAL →
Growing;高质量 Sealed 索引要等 flush / Data Node 建索 /
handoff(03、05、10)。可见性由一致性级别裁剪(12),与业务库事务通常跨系统,靠业务主键对齐。
差分不在「会不会 HNSW」,而在:页模型是否绑死 8KB Index AM、索引生命周期能否与在线查询物理分流、过滤是否进图内 bitset。
flowchart TB
subgraph pg ["PostgreSQL process"]
sql["SQL / Planner / Executor"]
heap["Heap + TOAST"]
idx["pgvector HNSW index pages"]
buf["shared_buffers"]
sql --> heap
sql --> idx
heap --> buf
idx --> buf
end
subgraph milvus ["Milvus disaggregated path"]
proxy["Proxy"]
stream["Streaming + WAL"]
growing["Growing segment"]
sealed["Sealed + Knowhere"]
proxy --> stream --> growing
stream --> sealed
end
二、谱系、争论与工程映射
| 阶段 | 代表 work | 与本文关系 |
|---|---|---|
| 图 ANN | Malkov & Yashunin, HNSW, IEEE TPAMI 42(4), 2020(arXiv:1603.09320) | 与 Knowhere/Hnswlib 同源算法族 |
| 专用向量系统 | Wang et al., Milvus, SIGMOD 2021 | 「动态向量需要独立数据管理」 |
| PG 扩展面 | Index Access Method;20 扩展 | 插入点是 IndexAmRoutine |
| 同进程落地 | pgvector v0.8.0 | vector +
hnsw/ivfflat;iterative index
scan |
争论(双方可核对):
| 立场 | 主张 | 代表论据 |
|---|---|---|
| A:向量应成通用 DB 一等类型 | 事务、JOIN、备份同库 | pgvector README;第 18 篇 sqlGate |
| B:与 OLTP 页模型不兼容 | 持续写入、建索、过滤、对象存储应分流 | Wang et al., SIGMOD 2021;本系列 03–08、10 |
下文只钉可核对差分:页上装得下什么、过滤发生在哪、与谁争 Buffer。
三、接入点:IndexAmRoutine
HNSW 入口 hnswhandler 向 Postgres 交标准索引
AM(摘录保留与正文相关的字段):
// pgvector v0.8.0 — src/hnsw.c
Datum
hnswhandler(PG_FUNCTION_ARGS)
{
IndexAmRoutine *amroutine = makeNode(IndexAmRoutine);
amroutine->amcanorder = false;
amroutine->amcanorderbyop = true; /* ORDER BY dist_op */
amroutine->amcanparallel = false;
amroutine->ambuild = hnswbuild;
amroutine->aminsert = hnswinsert;
amroutine->ambulkdelete = hnswbulkdelete;
amroutine->amvacuumcleanup = hnswvacuumcleanup;
amroutine->amcostestimate = hnswcostestimate;
amroutine->ambeginscan = hnswbeginscan;
amroutine->amgettuple = hnswgettuple;
amroutine->amendscan = hnswendscan;
PG_RETURN_POINTER(amroutine);
}ORDER BY embedding <-> $1走 order-by-operator(amcanorderbyop = true),不是 Knowhere 式独立 search RPC。- 构建 / 插入 / vacuum / 代价估计挂在 Postgres 生命周期上
→ 继承
shared_buffers、autovacuum、WAL、planner 误估。 - IVFFlat 对称:
src/ivfflat.c的ivfflathandler。下文以 HNSW 钉页布局。
四、堆上的
vector:varlena + TOAST
// pgvector v0.8.0 — src/vector.h
#define VECTOR_MAX_DIM 16000
#define VECTOR_SIZE(_dim) (offsetof(Vector, x) + sizeof(float)*(_dim))
typedef struct Vector {
int32 vl_len_;
int16 dim;
int16 unused;
float x[FLEXIBLE_ARRAY_MEMBER];
} Vector;
#define DatumGetVector(x) ((Vector *) PG_DETOAST_DATUM(x))\(d\) 维 float32 载荷约 \(8+4d\) 字节(另加 HeapTuple 头)。\(d=768\) 约 3080 字节,常仍可进单页;更高维或宽行更易 TOAST。pgvector README 写明 planner 不把 out-of-line 计入 seqscan 代价,可能让串行扫看起来偏便宜——同进程特有的「优化器 vs 物理布局」错位。
HNSW 索引页内再存一份向量副本(下节
data),故「表 + HNSW」存储接近
两份稠密向量 + 邻接 tid。
五、HNSW 落在 8KB 页上
索引页同样是 8KB(02 页面)。block 0 为元页,其后为图页:
// pgvector v0.8.0 — src/hnsw.h
/* Make graph robust against non-HOT updates */
#define HNSW_HEAPTIDS 10
typedef struct HnswMetaPageData {
uint32 magicNumber; uint32 version; uint32 dimensions;
uint16 m; uint16 efConstruction;
BlockNumber entryBlkno; OffsetNumber entryOffno;
int16 entryLevel; BlockNumber insertPage;
} HnswMetaPageData;
typedef struct HnswElementTupleData {
uint8 type; /* HNSW_ELEMENT_TUPLE_TYPE = 1 */
uint8 level; uint8 deleted; uint8 version;
ItemPointerData heaptids[HNSW_HEAPTIDS];
ItemPointerData neighbortid;
uint16 unused;
Vector data;
} HnswElementTupleData;
typedef struct HnswNeighborTupleData {
uint8 type; /* HNSW_NEIGHBOR_TUPLE_TYPE = 2 */
uint8 version; uint16 count;
ItemPointerData indextids[FLEXIBLE_ARRAY_MEMBER];
} HnswNeighborTupleData;\[ \begin{aligned} \texttt{HNSW\_ELEMENT\_TUPLE\_SIZE}(s) &= \mathrm{MAXALIGN}\bigl(\mathrm{offsetof}(\texttt{data}) + s\bigr), \\ \texttt{HNSW\_NEIGHBOR\_TUPLE\_SIZE}(\ell, m) &= \mathrm{MAXALIGN}\bigl(\mathrm{offsetof}(\texttt{indextids}) + (\ell+2)\,m\cdot |\texttt{ItemPointerData}|\bigr). \end{aligned} \]
hnswbuild.c 注释与逻辑要求:尽量把 element
与 neighbor 放同一页,降低遍历随机读。
heaptids[10] 落点:非 HOT
更新可能产生新堆元组。hnswinsert.c 在已有
element 上找空槽追加 heap
TID;槽满(i == HNSW_HEAPTIDS)或正在删除则放弃本次合并:
// pgvector v0.8.0 — src/hnswinsert.c(摘录)
for (i = 0; i < HNSW_HEAPTIDS; i++) {
if (!ItemPointerIsValid(&etup->heaptids[i]))
break;
}
/* Either being deleted or we lost our chance to another backend */
if (i == 0 || i == HNSW_HEAPTIDS)
return false;
etup->heaptids[i] = element->heaptids[0];宏旁注释 Make graph robust against non-HOT updates 与此对应:不是「无限抗更新」,而是 有上限的多 TID 槽。
5.1 本机可复现:页装箱
写作机无 PostgreSQL。用 v0.8.0 宏估算 level 0、\(m=16\) 时每页大约几对 element+neighbor(exp_19_page_packing.py,本机已跑通):
| \(d\) | VECTOR_SIZE |
etup | ntup | 约 pairs/page |
|---|---|---|---|---|
| 128 | 520 | 592 | 200 | 10 |
| 384 | 1544 | 1616 | 200 | 4 |
| 768 | 3080 | 3152 | 200 | 2 |
| 1536 | 6152 | 6224 | 200 | 1 |
与 pgvector#690 维护者说明同构。这是 稠密向量 × 8KB 页 的几何事实,不是「HNSW 算法慢」。Milvus Sealed 上 Knowhere 按段文件/mmap 布局,不受此装箱约束(06、08)。
python3 post/db/vector-engine/reproduce/exp_19_page_packing.py六、写路径与生命周期对照
| 步骤 | pgvector | Milvus 2.6.x |
|---|---|---|
| 持久化 | 堆 + 索引页 → 同一 WAL | Streaming → Woodpecker/WAL → Growing |
| 事务 | 可与业务表同事务(MVCC) | 通常跨系统;业务 id 对齐 |
| 可搜时机 | 提交后对本快照可见 | Growing 可搜;Sealed+Index 需 handoff |
| 建索隔离 | 与查询争同一实例 | Data Node 可独立池 |
| 删除 | hnswbulkdelete +
vacuum;deleted 标记 |
Delete/Upsert 与 segment 版本(13) |
hnswgettuple 要求 MVCC
snapshot(非 MVCC
直接报错),搜索前对扫描锁页加共享锁,以便 vacuum 在无
in-flight scan
时标记删除(src/hnswscan.c)——图索引住在 Buffer
Manager 里的直接后果。
七、过滤:默认 post-filter 与 iterative scan
README(v0.8.0):近似索引下过滤在
索引扫描之后。谓词命中 10% 行、默认
hnsw.ef_search=40 时,平均大约 4 行通过过滤。与
11 /
frontier/09
的 post-filter 失效同构;专用引擎常先编 bitset 再搜图。
0.8.0 起可用 iterative scan 扩扫,直至凑够结果或触达
hnsw.max_scan_tuples / 内存上限:
SET hnsw.iterative_scan = strict_order; -- 或 relaxed_order实现落在 hnswgettuple:候选空且未关闭
iterative 时,从 discarded 恢复或
ResumeScanItems:
// pgvector v0.8.0 — src/hnswscan.c(摘录)
if (list_length(so->w) == 0) {
if (hnsw_iterative_scan == HNSW_ITERATIVE_SCAN_OFF)
break;
if (so->tuples >= hnsw_max_scan_tuples
|| MemoryContextMemAllocated(so->tmpCtx, false) > so->maxMemory) {
/* return remaining from discarded heap */
so->w = lappend(so->w,
HnswGetSearchCandidate(w_node, pairingheap_remove_first(so->discarded)));
} else {
LockPage(scan->indexRelation, HNSW_SCAN_LOCK, ShareLock);
so->w = ResumeScanItems(scan);
UnlockPage(scan->indexRelation, HNSW_SCAN_LOCK, ShareLock);
}
}这是对召回塌陷的工程补丁,不是 ACORN 式图内过滤。README 仍建议:过滤列 B-Tree/GIN、低基数 partial index、高基数分区——Postgres 既有工具,不是 Knowhere 旋钮。
八、资源边界与可观测入口
同进程意味着 ANN 与 OLTP
共享核、shared_buffers、复制流与扩展模型(分区
/ Citus 等,而非 Channel+Worker)。第 18 篇「阶段一→迁出」是
sqlGate 与争用同时翻转,不是品味。
本机无 Postgres,下列为 排障入口(工程清单,非实测数字):
| 怀疑 | 先看 |
|---|---|
| 缓冲争用 | pg_stat_io /
pg_buffercache(若装);比较向量索引关系与热
OLTP 表的 hit 形态 |
| 建索打满 | pg_stat_progress_create_index;避开高峰
CREATE INDEX |
| 复制放大 | 大表建索/重建期间的 pg_stat_replication
延迟 |
| 过滤召回不足 | EXPLAIN (ANALYZE, BUFFERS);提高
hnsw.ef_search 或打开
iterative_scan,并给过滤列建索引 |
有数字再说快慢;未标注硬件与 QPS 的「pgvector 一定更慢」本站不写。
九、工程间隙
- 算法同源,外壳不同:HNSW 论文假设与 ann-benchmarks 多为静态集;pgvector 把图塞进 OLTP 页与 MVCC,Milvus 把图挂在段文件上——比较 QPS 前先对齐外壳。
- 文档「同进程即简单」掩盖装箱:\(d=768\) 约 2
节点/页时,随机遍历对 buffer
更敏感;半精度/
halfvec改变的是装箱,不是换成另一套引擎。 - iterative_scan ≠ mixed-filter 索引:扩扫提高召回上限,仍可能放大延迟与锁持有时间;ACORN 等见 frontier/09。
- TOAST ≠ 对象存储段:仍在同一备份域与实例内,无独立 Data Node 建索池。
十、常见误解
- 「pgvector 的 HNSW 和 Milvus 不是一种东西」 —— 算法谱系同源;差分在 Index AM / 8KB 页 vs Segcore/Knowhere/对象存储。
- 「有了 iterative_scan 就等于图内过滤」 —— 仍是扩扫 + 事后过滤。
- 「进了 TOAST 就等于进了对象存储」 —— 备份域与调度模型都不同。
- 「同进程一定更慢」 —— 小规模少一跳网络往往更简单;大规模迁的是隔离与生命周期。
十一、开放问题
- 页装箱与量化:
halfvec/ binary 能否在不破坏 AM 语义下显著提高每页节点数? - 过滤下推深度:iterative scan + partial index/分区,能否覆盖专用引擎 bitset 的多数生产谓词?缺同 workload 公开对照。
- 分布式 Postgres 上的图分割:Citus 等跨分片 top-k 的召回责任边界仍薄。
- 双写协同:热 pgvector + 冷 Lance/Milvus 的一致性,产品案例多、形式化少。
十二、三句话小结
- pgvector 是 PostgreSQL Index AM 上的
HNSW/IVFFlat:向量为 varlena,图落在 8KB 页,共享
WAL/Buffer/VACUUM。
- 相对本系列 Milvus,它强在 同进程事务与
SQL,弱在 生命周期分流、页装箱、默认
post-filter。
- 选型回到第 18 篇
sqlGate与资源争用,而不是比较「谁更会做 ANN」。
参考资料
规范 / 官方文档
- PostgreSQL Documentation — Index Access Method;TOAST;MVCC。
- pgvector v0.8.0 README — Filtering、Iterative index scans。
源码
pgvector/pgvectorv0.8.0:src/hnsw.c(hnswhandler)、src/hnsw.h、src/hnswbuild.c、src/hnswinsert.c(heaptids)、src/hnswscan.c(iterative scan)、src/vector.h。
核心论文
- Malkov, Y. A., & Yashunin, D. A. (2020). Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs. IEEE TPAMI, 42(4), 824–836. (arXiv:1603.09320)
- Wang et al. (2021). Milvus: A Purpose-Built Vector Data Management System. SIGMOD.
实验 / 线索
- reproduce/exp_19_page_packing.py
- pgvector#690(B 级,与宏交叉验证)
站内交叉阅读
- 01 · 18 · 03 · 08 · 11
- PG 页面 · storage/39 · frontier/08
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【向量检索引擎】ANN 算法工程接口:从 HNSW/IVF 到 Knowhere 契约
把 HNSW、IVF、DiskANN、Flat 收成引擎侧 Train/Build/Load/Search 契约与构建期/查询期参数面;用生命周期图与召回–QPS–内存三角说明索引如何贴着 Segment,并与 db-frontier/08、第 8 篇 Knowhere 分工。
【向量检索引擎】Knowhere:向量索引执行引擎与插件契约
按官方 Knowhere 文档说明其在 Milvus 中的位置、相对 Faiss 的扩展(bitset、SIMD 选择、二进制度量)、VecIndex 类层次与 IDMAP/IVF/HNSW 等类型,用插件注册、CPU/GPU 分发与 bitset 进查询三张图钉住工程契约,并与 db-frontier/08 的算法细节分工。
【向量检索引擎】Qdrant 对照:单库路径、payload 过滤与分片副本
对照 Qdrant 与本系列 Milvus 主线:Segment 内的向量/payload/id mapper 三件套、WAL+序号版本化、后台 optimizer 四类任务,以及 Raft 只管拓扑不管点操作的分布式模型;说明何时单进程/小集群更合适。
【向量检索引擎】选型与阅读地图:决策树、RAG 回链与开放问题
扩展选型决策树:从单机原型到十亿级多租户,逐层加入湖上格式、SQL 同进程、存算分离运维、多一致性级别四个判断轴;用一个团队规模演进的最小故事串起决策点,并回链 llm-infra RAG 与本系列全部核心论文谱系。