Development

pgvector at Scale: Vacuum, Reindexing and Index Rebuilds

Antonio Evans9 min read

A database administrator's desk: an open laptop shows a terminal with a query plan and an index size growing over time, next to a printed chart of recall versus row count with a red threshold line.
Key points

Key points

  1. 1pgvector HNSW and IVFFlat indexes do not update in place, so recall drifts silently as rows are inserted, updated and deleted.
  2. 2Regular VACUUM controls dead tuples and index bloat, but it does not repair graph or list structure, so a rebuild is the only real fix.
  3. 3Measure recall against a fixed ground-truth set of exact nearest neighbors, and rebuild when it drops by a few percentage points.
  4. 4Use REINDEX INDEX CONCURRENTLY or build a shadow index and swap names inside a transaction to rebuild without blocking reads.

Why pgvector indexes go stale as rows change

An approximate nearest neighbor index is a physical data structure, not a logical view. When a row's vector changes or a row disappears, the extension either writes a new entry and leaves the old one in place, or marks the old entry dead. Search still works, which is exactly why the problem is easy to miss. A degraded index returns ten plausible neighbors and never reports that four of the correct ones were omitted; there is no error, log line or alert when that happens (pg_recall_guard docs).

The two index types degrade differently.

HNSW builds a layered proximity graph. New vectors become new nodes connected to nearby nodes at insert time. Deleted vectors leave tombstones: the node stays in the graph, still consuming memory and traversal time, until vacuum removes it. Over time the graph's connectivity reflects a history of inserts rather than the current distribution of data, so a query path can wander through nodes that no longer correspond to live rows. HNSW is also the more expensive of the two to build: it is slower to construct and uses more memory than IVFFlat (Crunchy Data).

IVFFlat partitions vectors into lists around centroids computed at build time. Each list records which rows belong to which centroid. The centroids are fixed when the index is created, so as rows are added and deleted, lists drift out of balance: some grow far beyond their original share and others shrink. A query probes only the nearest lists centroids, and if the true nearest neighbors have migrated into an under-probed list, they are missed. This is why IVFFlat should be built after the table already holds a representative sample of data, and why a growing table eventually needs a new centroid set (Tiger Data).

A comparison of what maintenance can and cannot fix:

SymptomVACUUM helpsREINDEX needed
Dead tuples from deleted rowsYes, reclaims spaceOnly if tombstones remain in the graph
Index size far above live row countPartiallyYes, rebuild compacts the structure
HNSW graph connectivity skewed by historyNoYes
IVFFlat lists out of balance after growthNoYes, or rebuild with new list count
Recall drop against ground truthNoYes

There is a third effect: insert cost grows. HNSW insertion means finding neighbors and linking, which gets slower as the graph densifies. If builds become unmanageable, the pgvector maintainer recommends smaller types such as halfvec or binary quantization with re-ranking to shrink index size, and dropping ef_construction back toward its default to build faster (pgvector issue #822).

Vacuum, autovacuum and the settings that matter for vector tables

VACUUM removes dead tuples and makes their space reusable, but it does not shrink files on disk. For a vector table, that means the heap portion of the index can be cleaned while the graph or list structures retain entries pointing at dead rows. The practical consequence is that vacuum is necessary and regular, but it is not sufficient.

Autovacuum's defaults are tuned for small, low-churn tables. A vector table that receives re-embeddings or re-ingestion of documents is a high-churn table, and the relevant knobs are well documented in the PostgreSQL performance community (Scaling Postgres). A workable starting configuration for a table with an HNSW index:

ALTER TABLE documents SET (
  autovacuum_vacuum_scale_factor = 0.02,
  autovacuum_vacuum_threshold = 1000,
  autovacuum_analyze_scale_factor = 0.01
);

Scale factors of 0.02 mean autovacuum triggers after 2 percent of the table changes, instead of the default 20 percent. On a table of 10 million rows, that is roughly 200,000 dead tuples at default versus 200,000 at… the smaller setting fires after about 200,000 instead of 2 million. Lower thresholds cost more background I/O but keep bloat in a narrow band.

For long index builds, the memory settings matter more than anything else. The issue thread that tracked a stuck HNSW build shows the diagnostic: NOTICE: hnsw graph no longer fits into maintenance_work_mem after 570905 tuples. The fix was to raise maintenance_work_mem and note that SET LOCAL does not apply to CREATE INDEX CONCURRENTLY because the statement runs outside the transaction (pgvector issue #822). Use a session-level SET:

SET maintenance_work_mem = '8GB';
SET max_parallel_maintenance_workers = 4;
CREATE INDEX CONCURRENTLY documents_embedding_hnsw_new
  ON documents USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

A rule of thumb for sizing: HNSW's in-memory build working set scales with m and vector width. Watch the progress view during the build; if it sits at a fixed percentage for a long time, the graph has spilled to disk and the build is now many times slower:

SELECT phase,
       round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS pct
FROM pg_stat_progress_create_index;

Detecting recall drift with a ground-truth set

Recall is the fraction of true nearest neighbors that the index returns. You cannot measure it without knowing the true answers, so the first maintenance task is to build a small, stable evaluation set.

  1. Sample queries. Take 1,000 random documents from the corpus and use their vectors as query vectors. Store the query vector, not the text, so the test does not depend on the embedding service being up.
  2. Compute the exact answers once, with the index disabled for the measurement session:
    SET LOCAL enable_indexscan = off;
    SET LOCAL enable_bitmapscan = off;
    
    Run the nearest-neighbor query for each sampled vector and store the top 10 ids in a ground_truth table keyed by query id.
  3. Recompute recall on a schedule by running the same queries with the index enabled and comparing sets:
    WITH hits AS (
      SELECT gt.query_id,
             count(*) AS matched
      FROM ground_truth gt
      JOIN LATERAL (
        SELECT id
        FROM documents
        ORDER BY embedding <=> gt.query_vector
        LIMIT 10
      ) ann ON ann.id = gt.id
      GROUP BY gt.query_id
    )
    SELECT avg(matched) / 10.0 AS recall_at_10 FROM hits;
    

The result is a single number between 0 and 1. Record it at build time as the baseline you approved. If you prefer a packaged tool, pg_recall_guard does the same job across HNSW, IVFFlat and other index types by discovering indexes through the catalog rather than naming extensions (pg_recall_guard docs).

Decision rule, which is the part most teams lack:

  • Recall within 2 percentage points of baseline: no action, keep monitoring weekly.
  • Recall 2 to 5 points below baseline: schedule a rebuild in the next maintenance window; investigate whether a large backfill or deletion batch caused it.
  • Recall more than 5 points below baseline: rebuild now, raise hnsw.ef_search or ivfflat.probes as an interim mitigation, and check whether a queued job is rewriting vectors.

The interim mitigation deserves a mention because it is often mistaken for a fix. Raising ef_search widens the candidate set at query time and recovers some of the lost neighbors, but it cannot recover nodes that are no longer reachable in the graph or rows in an unprobed list. It trades latency for a partial recovery, and the drift keeps getting worse underneath.

When a full REINDEX is necessary versus a lighter repair

Not every recall dip needs a rebuild, and not every rebuild needs the same scope.

When lighter repair is enough: a bulk delete that removed 30 percent of rows, followed by vacuum, often restores acceptable behavior, because the remaining graph still reflects the live data. If recall only recovered partway, the tombstones are the issue and a rebuild is due. Similarly, a one-time re-embedding of a small percentage of a corpus can be handled by updating rows and reindexing affected entries, then watching recall.

When a full REINDEX is required: any structural change to the index definition (a new operator class, a change from IVFFlat to HNSW, a different lists value), sustained churn over many weeks, or recall that stays below the 5-point threshold after vacuum.

Check whether a rebuild is due before committing to one:

SELECT pg_size_pretty(pg_relation_size('documents_embedding_hnsw')) AS index_size,
       (SELECT count(*) FROM documents) AS live_rows;

If the index is more than roughly 1.3 times the size you would expect from live rows and vector width, the structure is carrying dead weight that only a rebuild reclaims.

Partial repairs to consider first: if a table is partitioned by tenant or time, rebuild only the partition that received the heavy churn. Partitioning the vector table is itself the strongest maintenance lever, because it bounds the scope of every future rebuild (pgvector issue #822 describes a partitioned setup with about 40 million rows per partition). Reindexing one partition at a time also keeps each build short enough to fit a maintenance window.

The parameter changes matter for cost. Rebuilding with ef_construction at 64 builds much faster than the higher values used for a careful initial build, at some cost in graph quality, which is a reasonable trade for an index that will be rebuilt again after the next churn cycle (Crunchy Data).

Scheduling rebuilds without downtime

PostgreSQL gives two paths, and the right one depends on your version and on whether you can tolerate a brief lock.

Path 1: REINDEX INDEX CONCURRENTLY. On PostgreSQL 12 and later, this rebuilds the index while reads and writes proceed. It is the simplest correct option:

REINDEX INDEX CONCURRENTLY documents_embedding_hnsw;

It requires more total work and disk space than a plain rebuild (the old and new copies exist side by side), and it cannot run inside an explicit transaction block. Run it from a job that can retry, because a failed concurrent reindex leaves an invalid index behind that must be dropped before retrying.

Path 2: shadow index and rename. This gives you control over when the switch happens, and it works on older versions:

BEGIN;
CREATE INDEX CONCURRENTLY documents_embedding_hnsw_shadow
  ON documents USING hnsw (embedding vector_cosine_ops);
-- verify recall against the shadow index here
DROP INDEX documents_embedding_hnsw;
ALTER INDEX documents_embedding_hnsw_shadow
  RENAME TO documents_embedding_hnsw;
COMMIT;

One caveat: CREATE INDEX CONCURRENTLY cannot run inside a transaction block, so split the script: build the shadow index outside a transaction, validate it, then run the drop and rename in a short transaction. The rename itself takes an exclusive lock only for an instant. Note that while the shadow index exists, writes are maintained in both indexes, which slows inserts during the overlap and doubles the disk requirement.

A scheduling checklist:

StepCheckTarget
TimingRebuild during low write volumeFewer concurrent index writes means faster shadow catch-up
DiskFree space at least 2x the index sizeConcurrent build keeps both copies
Memorymaintenance_work_mem sized for the graphAvoid the spill notice in the build log
ValidationRecall on sampled queries before swapWithin 2 points of the original baseline
RollbackOld index name known and droppableRecovery path if validation fails

The worked example: a 10 million row table with 1,536-dimensional vectors, a 3.5 GB HNSW index, and 15 percent of rows deleted over a quarter. Index size reads 4.6 GB against roughly 3.0 GB of live data, and recall against 1,000 ground-truth queries has fallen from 0.96 to 0.87. The gap is above the 5-point threshold, so rebuild. With maintenance_work_mem at 8 GB and ef_construction at 64, the concurrent build finishes without spill notices, validation shows recall back at 0.95, and the swap takes a second.

Automation is where this becomes sustainable. If your pipeline already calls embedding models to generate the vectors being indexed, putting the embedding call and the maintenance job behind one interface reduces the number of moving parts: Quanta Inference exposes text, image, video, audio and embedding models through one OpenAI-compatible API and prices in US dollars, which keeps re-embedding costs predictable when you plan a rebuild that includes re-vectorization (https://quantaintelligence.ai/inference).

For teams that want the retrieval stack, the maintenance jobs and the swap automation built together rather than scripted by hand, a specialist can pick up the design and work through it with you (https://quantaintelligence.ai/).

A monthly maintenance routine that holds up

A repeatable routine, ordered from cheapest to most disruptive:

  1. Weekly: run the recall query above and write the result to a metrics table with a timestamp. Alert on a drop greater than 2 points.
  2. Weekly: check bloat. Compare index size to live row count and alert when the ratio exceeds 1.3.
  3. Monthly: review autovacuum logs for the vector tables. If vacuum runs rarely and bloat climbs, lower the scale factors again.
  4. Quarterly, or on the recall threshold: run the shadow-index rebuild with validation, or REINDEX INDEX CONCURRENTLY if a brief lock is acceptable.
  5. After any bulk operation: a large delete, a bulk re-embedding or a schema change, run recall immediately rather than waiting for the weekly job.

Two habits prevent most incidents. First, never run a bulk update that rewrites every vector in a large table without planning the rebuild that follows; the update leaves a graph built from the old vectors plus new nodes, which is exactly the state that degrades recall fastest. Second, keep the ground-truth table small and versioned. A thousand queries and their exact neighbors is a few megabytes and gives you a number that is comparable across rebuilds. Without that baseline, you are managing recall by feel.

The broader lesson from vector search practice is that indexing is a maintenance commitment, not a one-time setup step. Embedding-based retrieval captures semantic similarity by placing related items close in vector space, which makes the index structure carry real meaning; that structure deserves the same operational care as any other index whose freshness affects correctness (Elastic, Weaviate).

FAQ

FAQ

How often should I rebuild a pgvector HNSW index?

Do not rebuild on a fixed calendar. Rebuild when recall against a fixed ground-truth set falls more than a few percentage points below the baseline you recorded at build time, or when index size exceeds roughly 1.3 times the live row count. A table that churns 1 percent of rows a month may go a year; a table that re-embeds everything nightly may need a rebuild every few weeks.

Does VACUUM fix a degraded pgvector index?

Partly. VACUUM removes dead tuples and keeps bloat in check, and it is essential for a high-churn vector table. But it does not reconnect an HNSW graph or rebalance IVFFlat lists, so recall lost to structural drift stays lost until the index is rebuilt. Treat vacuum as prevention and reindex as repair.

Does REINDEX INDEX CONCURRENTLY block queries?

No. It rebuilds the index while normal reads and writes continue, which is why it is the default choice on PostgreSQL 12 and later. It costs more disk space and total work because both index copies exist during the build, and it cannot run inside a transaction block, so schedule it with headroom on disk and a retry path.

What is a realistic ground-truth set for measuring recall?

About 1,000 sampled query vectors with their exact top 10 nearest neighbors, computed with index scans disabled. That is enough to detect a 2-point recall shift reliably, small enough to recompute in minutes, and stable across rebuilds as long as you keep the same query vectors.

Can I rebuild only part of a vector index?

Not within a single index, but partitioning the table by tenant or time makes each partition's index independent, so you can rebuild one partition while the rest keep serving. For a table that receives churn concentrated in a few tenants or recent time ranges, that is usually a better operational design than trying to repair one very large index in place.

Sources

Sources

  1. pg_recall_guard 0.2.4: Watch vector indexes for recall drift ...
  2. HNSW Indexes with Postgres and pgvector | Crunchy Data Blog
  3. Nearest Neighbor Indexes: What Are IVFFlat Indexes in Pgvector and ...
  4. HNSW index creation is stuck on dozens millions entries #822 - GitHub
  5. HNSW Indexes, Vacuuming Bloat, Watch Me Now, Connections
  6. What is vector search? Better search with ML - Elastic
  7. Vector Search Explained - Weaviate
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles