Chapter 03

MVCC 生命周期:建完不是终点

第 2 章把 HNSW 的图建了起来:每个向量是图里一个节点,节点之间连着导航边,查询沿边逼近最近邻。但那是一张静态图的故事。真实的 RAG 表会被 UPDATE、DELETE——文档重切、embedding 换模型、租户删数据。这一章讲那张图在 MVCC 下如何随写入腐烂:建好的图不会自动跟着堆表变,而第 1 章那条"ORDER BY 距离 ... LIMIT k 取够 k 个就停"的契约,会在墓碑堆积时让查询返回不足 k 行,甚至 0 行。

本章你将建立的 schema

  • MVCC 下 UPDATE = 删旧 + 插新,DELETE 只给堆元组打死亡标记;vacuum 之前死元组物理还在,HNSW 节点仍指着它。
  • 图节点不在 UPDATE/DELETE 时删除,而是 scan 时靠堆可见性事后过滤掉——这与第 4 章的 post-filter 欠返是同一个形状。
  • HNSW 的 vacuum 是定制的三趟例程:剥 TID → 重连邻居 → 标删清零;第二趟逐节点重算邻居,写重、慢。
  • 高 churn 表上召回会按天/周腐烂;vacuum 只"修补"不"重排",死元组比例到 10–15% 该上 REINDEX CONCURRENTLY。

沿用第 1 章的 doc_chunks 表。一个常见且无害的操作,是给某段 chunk 换一版 embedding(换了 embedding 模型,或文档被重新切分):

churn.sql SQL
-- 看似只是"改一列",MVCC 下却是删旧行 + 插新行
UPDATE doc_chunks
SET    embedding = $new_vec
WHERE  id = 12345;

-- 删除一个租户的全部文档:每行留下一个死元组,等 vacuum
DELETE FROM doc_chunks WHERE tenant_id = 42;

这两条 SQL 在堆表里留下的"尸体",正是这一章的主角。它们怎么影响那张已经建好的 HNSW 图,决定了几天后同一条检索查询还准不准。

3.1写入之后,图不动

UPDATE/DELETE 不会从 HNSW 图里摘掉对应节点——节点仍指着已死的堆行,靠 scan 时的可见性判定事后把它筛掉。

为什么需要它

MVCC 是 Postgres 并发的根基:写不阻塞读,靠的是"旧版本先留着、新版本另写一份、谁能看见谁由事务快照决定"。在堆表里这套很顺。但 HNSW 图是一份独立于堆表的结构——它没法像 B-tree 那样廉价地删一个 key。理解"图为什么不动",是理解后面 vacuum 为什么那么贵、召回为什么会腐烂的前提。

底层机制(比文档深一层):在 MVCC 里,一次 UPDATE 是删旧元组 + 插新元组,一次 DELETE 只是给旧元组打上死亡标记(事务结束后对新快照不可见)。无论哪种,旧的堆元组在 VACUUM 真正回收它之前都物理留在堆页里。HNSW 这边:图里那个老节点的元组保存着一个 heaptids[] 数组(源码常量 HNSW_HEAPTIDS = 10,单个 element 元组最多挂 10 个堆 TID),数组里那个指向老行的 TID 原封不动——节点没被摘掉,边也没断。

那查询为什么没被这些"幽灵节点"污染?因为过滤发生在扫描时,而不是写入时:

  • 检索时,HNSW 沿图游走,收集候选节点,把每个候选的堆 TID(xs_heaptid)逐个吐给执行器;
  • 执行器拿 TID 去堆表取行并做 MVCC 可见性判定——已删除 / 当前快照不可见的,直接丢弃;
  • 于是"老行还在图里"不影响正确性:死行会在可见性这一关被筛掉。图节点不在 UPDATE/DELETE 时删除,只是 scan 时被过滤。
陷阱 · issue #244:欠返,甚至返回 0 行

正确性没问题,完整性却会出问题。HNSW 的搜索在凑齐 ef_search(默认 40)个候选后就停止游走(这个 ef_search 就是第 2 章 §2.4 调过的搜索期参数)。可见性判定是在搜索停下来之后才逐个施加的——如果这 40 个候选里靠前的一批恰好都是死元组,它们被筛掉后,LIMIT 10 就只剩下不到 10 行,极端情况下 0 行,哪怕有效向量就排在搜索视野之外一点点。

IVFFlat 退化得更平缓:它按 probes 扫整张倒排列表,列表里的死元组被跳过后仍继续往下取,凑数的余地大得多。HNSW 的"固定预算先取满、可见性后过滤"才是这个 bug 的根因。

图游走,取满即停 ef_search = 40 个候选 候选含死元组 候选漏斗 40 个:有效 + 死亡混在一起 可见性判定 剔除死元组 LIMIT 10 线 只剩 4 行存活 < 10:欠返 有效向量在视野外 搜索已停,够不着
图 3.1"预算先取满,过滤在事后":搜索取够 40 个候选就停,可见性判定再从中剔除死元组,存活数跌破 LIMIT 10 线——欠返。注意:这个形状和第 4 章 带过滤的检索里 post-filter 的欠返一模一样——都是"固定条数先抓出来、条件后施加、最后不够数"。死元组是隐式过滤条件,WHERE 是显式过滤条件,根都在第 1 章那条 LIMIT k 契约。
想一想

一张 chunk 表昨天还好好的,今天一条 ... ORDER BY embedding <=> $1 LIMIT 10 突然只返回 3 行。表里明明有几十万条有效向量。中间只发生了一件事:凌晨批量 re-embedding 了一大批热门文档。问题出在哪?把 ef_search 调大到 200 能不能缓解?

展开答案(先停 10 秒)

批量 re-embedding = 大量 UPDATE = 大量死元组,且这些死元组集中在"热门文档"——也就是查询最容易命中的那片图区域。搜索取满 ef_search 个候选时,靠前的一批正是这些死节点;可见性判定把它们筛掉后就不够 10 行。

调大 ef_search(40 → 200)能缓解但不治本:候选池更大,存活的有效行更可能凑够 10,代价是每次查询更慢、更耗内存。真正的修法是让死元组消失——触发 vacuum(§3.2),或在死元组比例高时 REINDEX(§3.3)。pgvector 0.8.0 起还提供 hnsw.iterative_scan:候选不够时继续往下游走、而非停在 ef_search,正是为这个 bug 设计的兜底。

3.2vacuum 三趟修图

HNSW 的 vacuum 不是通用索引清理,而是定制的三趟例程:剥死 TID → 重连邻居 → 标删清零;第二趟逐节点重算邻居,是全程最贵的一步。

为什么需要它

§3.1 说明了"图不动"会导致欠返,那总得有人来收拾死节点、把断掉的连通性补回来。B-tree 删一个 key 是局部操作;HNSW 删一个节点却会让所有把它当邻居的节点少一条边——如果不补,图会越删越稀疏、越搜越偏。vacuum 就是那个"既要清死元组、又要保持图连通"的修复程序。理解它三趟干了什么,才能解释为什么"删除便宜、vacuum 死贵"。

底层机制(比文档深一层):pgvector 为 HNSW 实现了专门的 hnswbulkdelete,源码里清清楚楚分三趟(Pass 1 / Pass 2 / Pass 3),不是 Postgres 的通用索引 vacuum:

Pass 1 剥死 TID RemoveHeapTids() 从 heaptids[] 删掉 死行 TID · 便宜 Pass 2 · 最贵 重连邻居 RepairGraph() 逐节点 FindElement- Neighbors 重算边 Pass 3 标删 + 清零 MarkDeleted() 置 deleted 标记 清零向量数据 HNSW_UPDATE_LOCK / HNSW_SCAN_LOCK 全程串行化 并发的插入 / 扫描不会撞见"修了一半"的节点
图 3.2HNSW vacuum 的三趟。注意:朱红的 Pass 2 是性能瓶颈——它对每一个"邻居里有死节点"的节点都重跑一遍 HnswFindElementNeighbors()(和建索引时同一个邻居搜索)来算替补边。删除只是打标记,几乎免费;真正的代价全压在 vacuum 的 Pass 2 上,这就是"删得轻松、回收要命"的来源。
  • Pass 1 · 剥死 TID(RemoveHeapTids):遍历 element 元组,把 heaptids[] 里指向死堆行的 TID 剔除。轻量,主要是改数组、置无效标记。
  • Pass 2 · 重连邻居(RepairGraph,最贵的一趟):对每个"曾把某个被删 element 当邻居"的节点,调用 HnswFindElementNeighbors() 重新计算替补边并覆写邻居元组,把因删节点而断掉的连通性补回来。这一步逐节点重跑邻居搜索,是全程的写放大与耗时来源。
  • Pass 3 · 标删 + 清零(MarkDeleted):把已无有效 TID 的 element 标记为 deleted 并清零其向量数据,页空间留待复用。

三趟之间靠两把页级锁串行:HNSW_UPDATE_LOCK 把 vacuum 的重连和并发插入隔开,HNSW_SCAN_LOCK 把它和并发扫描隔开——保证正在跑的检索永远不会游走到一个"修了一半"的节点上(读邻居时还会校验 element 版本,防止读到已被替换的旧节点)。

洞察 · "修补"不等于"重排"

这是这一章最关键的一句话。Pass 2 只做一件事:把断掉的连通性补回来——让图重新连通。它不重新平衡整张图、不优化导航质量。打个比方:删掉路口后 vacuum 会修一条绕行小道把两头接上,但不会重新规划路网。绕道越修越多,图的平均跳数变长、搜索路径变绕,召回质量仍在缓慢下滑——只是连通性没断而已。这正是 §3.3"光靠 vacuum 救不回召回"的机制根源。

3.3churn → 召回腐烂 → REINDEX

高 churn 表上召回会按天/周腐烂;vacuum 只修补不重排,死元组比例到 10–15% 时,REINDEX CONCURRENTLY 重建一张干净图,比死等 vacuum 划算。

为什么需要它

§3.1 给了急性病(突然欠返),§3.2 给了应急药(vacuum 修补连通性)。但 RAG 的 embedding 表往往是慢性高 churn:天天有文档重切、模型升级触发整表 re-embedding。在这种负载下,召回不是某天崩,而是一个月里悄悄从 95% 滑到 80%,监控不报警、用户只觉得"最近搜得不太准"。这一节给的是运维决策:什么时候该停止打补丁、直接重建。

底层机制(比文档深一层):召回腐烂有两条叠加的路径,都能追回前两节——

  • 死节点占着候选预算(§3.1):vacuum 没跟上时,搜索游走仍会路过大量死节点,它们挤进 ef_search 候选池、又被可见性筛掉,等效于缩小了有效搜索宽度,召回向下掉(病态情况趋近 0%)。
  • 绕行边累积(§3.2):vacuum 跟上了,但它只修补不重排,反复删插让图攒下越来越多绕道,平均跳数变长、近邻越来越容易被错过——召回即使 vacuum 健康也仍在缓降。

所以运维上有两个动作,对应两条路径:

表 3.1 · 两种"治法"的分工
动作治什么代价触发时机
VACUUM / 调激进 autovacuum清死元组、补连通性(修补)Pass 2 写重、慢;治标持续后台跑;高 churn 表调低 scale_factor
REINDEX INDEX CONCURRENTLY从零重建一张干净、无绕道的图重付第 2 章的建图开销死元组比例 ≈ 10–15%,或召回掉到阈值以下
maintenance.sql SQL
-- 1) 高 churn 表:把 autovacuum 调激进,别等默认 20% 死元组才触发
ALTER TABLE doc_chunks SET (
    autovacuum_vacuum_scale_factor = 0.05,   -- 5% 死元组就 vacuum
    autovacuum_vacuum_cost_delay   = 0
);

-- 2) 看死元组比例:n_dead_tup / (n_live_tup + n_dead_tup)
SELECT relname, n_live_tup, n_dead_tup,
       round(100 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 1) AS dead_pct
FROM   pg_stat_user_tables
WHERE  relname = 'doc_chunks';

-- 3) 比例到 ~10–15%、或召回掉下来:重建干净图(不锁表)
REINDEX INDEX CONCURRENTLY doc_chunks_embedding_idx;
张力 · REINDEX 不是免费的

REINDEX ... CONCURRENTLY 重建图 = 重付第 2 章的建图代价(见 §2.3 的建图断崖:百万级向量建一次 HNSW 是分钟到小时级、吃满 maintenance_work_mem)。而且第 1 章挑战里埋的"一列建多个索引"(cosine + L2 两张图)在这里加倍咬人——每张图都要独立 vacuum、独立 REINDEX,churn 成本乘以索引个数。所以"要不要为 L2 再建一张图"这种决策,账要算到这一章的维护成本上,而不只看查询是否走索引。

§本章 self-check

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

  1. 一条 ... ORDER BY embedding <=> $1 LIMIT 10 在大批 UPDATE 后返回 3 行,表里有效向量却有几十万。用"取满 ef_search 后停 + 可见性事后过滤"两步,讲清为什么会欠返。
  2. 同样的 churn,为什么 IVFFlat 比 HNSW 欠返得轻?
  3. HNSW vacuum 三趟分别干什么?哪一趟最贵,贵在哪个函数调用上?
  4. 死元组都被 vacuum 清了、图也连通,为什么召回还会继续往下掉?这时该上什么操作?
答案(先做完再展开)
  1. UPDATE = 删旧 + 插新,老行留死元组、HNSW 节点仍指着它。搜索沿图取满 ef_search(默认 40)个候选就停;这批候选里靠前的一批是死节点,可见性判定在搜索停下之后才把它们筛掉,于是 LIMIT 10 只剩 3 行——有效向量排在搜索视野外、够不着。
  2. IVFFlat 按 probes 扫整张倒排列表,跳过死元组后继续往下取,凑够 k 的余地大;HNSW 是"取满固定预算就停、再过滤",停得早,欠返更狠。
  3. Pass 1 RemoveHeapTids 从 heaptids[] 剥掉死堆行 TID(便宜);Pass 2 RepairGraph 对每个邻居含死节点的节点调 HnswFindElementNeighbors() 重算替补边、补连通性(最贵,逐节点重跑邻居搜索);Pass 3 MarkDeleted 标删并清零向量数据。
  4. 因为 vacuum 只"修补"不"重排"——补的是绕行边,图攒下越来越多绕道,平均跳数变长、近邻易被错过,召回缓降。该上 REINDEX INDEX CONCURRENTLY(死元组比例 ≈ 10–15% 或召回破阈值),重建一张无绕道的干净图。
进阶挑战 · 刚好够不着

一张每天换掉 30% 向量的 chunk 表,怎么设计维护策略让召回稳住,又不让 REINDEX 把库压垮?

这张表 churn 极高(每日 ~30% 行被 re-embedding)。每天 REINDEX 一次又要分钟到小时级、吃满内存(§3.3 / §2.3);只靠 autovacuum 又只能修补、召回照样缓降。在不停服、不显著拉高查询延迟的前提下,怎么把"召回稳定"和"维护开销可控"这两件事同时拿住?

提示(卡住再展开)

分两层想。短期兜底:开 hnsw.iterative_scan(候选不够时继续游走而非停在 ef_search,§3.1 答案),让欠返先不发生;同时把 autovacuum 调到 5% 触发(§3.3)压住死元组。结构性解:高 churn 往往集中在"近期文档"——考虑按时间分区,热分区频繁 REINDEX、冷分区几乎不动,把全表重建摊成小分区重建。再想一层:如果 embedding 频繁换模型,索引是不是该跟着向量列一起做蓝绿切换(新列建好新图、原子切换),而不是原地 REINDEX?把"在线修补"和"离线重建"两条路按分区/版本拆开,是这类系统的常规打法。