Free lesson · LLMOps Engineering
Implement pgvector index maintenance with VACUUM and reindexing schedules
You will build operational maintenance procedures for pgvector indexes. Implement scheduled VACUUM: create a CronJob that runs VACUUM ANALYZE on embedding tables during low-traffic windows to reclaim space and update statistics. Monitor index bloat: track pg_stat_user_tables.n_dead_tup and alert when dead tuple ratio exceeds 20%. Implement reindexing workflow: REINDEX INDEX CONCURRENTLY that rebuilds the HNSW index without blocking queries. Schedule reindexing when index quality degrades (detected by increasing query latency without data growth). Build index size monitoring: track pg_total_relation_size() for each index, project storage growth, and alert when approaching disk capacity. Track index_maintenance_duration_seconds{operation}, index_bloat_ratio, index_size_bytes.
Course: GenAI Operations · Chapter 39 · Vector Index Ops
Free to read — no subscription required.
Introduction
In production, pgvector indexes accumulate dead tuples and bloat as embeddings are updated or deleted, silently degrading nearest-neighbor recall without triggering obvious errors. IVFFlat and HNSW indexes cannot self-compact the way B-tree indexes do, so unaddressed bloat reduces search quality while your application keeps returning results—just progressively worse ones. By the end of this lesson, you will be able to query PostgreSQL system views to measure index health, schedule VACUUM operations based on dead-tuple thresholds, and trigger REINDEX CONCURRENTLY to restore recall when bloat exceeds safe limits.
Key Terminology
- Dead tuples — rows that have been updated or deleted in PostgreSQL but whose storage space has not yet been reclaimed; they accumulate in the heap and inflate the
n_dead_tupcounter inpg_stat_user_tables, whichPgvectorHealthMonitor.check_healthreads to computedead_tuple_ratio. - Dead-tuple ratio — the fraction of dead rows relative to live rows (
n_dead_tup / n_live_tup), exposed as theDEAD_TUPLE_RATIOPrometheus gauge and used as the primary threshold signal for scheduling VACUUM on an embeddings table; a value above roughly 0.20 indicates maintenance is overdue. - Index bloat — the accumulation of unused or stale space inside an IVFFlat or HNSW index caused by dead tuples that VACUUM cannot fully reclaim from the index structure itself; tracked by the
BLOAT_RATIOgauge and resolved only by a fullREINDEX CONCURRENTLY. - REINDEX CONCURRENTLY — a PostgreSQL DDL operation that rebuilds an index from scratch in the background without acquiring a table lock, allowing reads and writes to continue; triggered when
bloat_ratioexceeds safe limits and the index must be fully restored rather than incrementally cleaned. pg_stat_user_tables— a PostgreSQL system catalog view that exposes per-table tuple statistics includingn_live_tup,n_dead_tup,last_vacuum, andlast_autovacuum;PgvectorHealthMonitorqueries this view to populate thedead_tuple_ratioandseconds_since_vacuumfields ofIndexHealthReport.IndexHealthReport— a Python dataclass defined inpgvector_health.pythat bundles all per-table health measurements—live/dead tuple counts, dead-tuple ratio, index size, table size, and time since last VACUUM—into a single structure a maintenance scheduler can inspect and act on.
Concepts
Why pgvector indexes degrade silently
PostgreSQL's B-tree indexes can reuse pages in-place as rows are updated and deleted, so bloat is self-limiting in most transactional workloads. IVFFlat and HNSW indexes do not share this property. Both index types organize embedding vectors into fixed structures—inverted file lists or hierarchical navigable graphs—that do not automatically reclaim space from deleted or updated rows. Dead tuples accumulate in the underlying heap and inside the index, widening the gap between what the index "thinks" is there and what is actually live.
The failure mode is subtle: nearest-neighbor queries continue returning results, but recall degrades as the index scans dead-entry regions and misses live neighbors. There is no error, no warning, and no obvious metric spike unless you are explicitly measuring dead-tuple ratio and bloat. This is why the lesson centers on proactive measurement rather than reactive incident response.
Dead-tuple ratio as a scheduling signal
Because pgvector indexes cannot self-compact, maintenance must be driven by an observable threshold. The dead-tuple ratio—n_dead_tup / n_live_tup from pg_stat_user_tables—serves as that signal. A ratio near zero means the table is healthy; a ratio above roughly 0.20 on an embeddings table means dead rows are materially affecting index quality and VACUUM should run.
The DEAD_TUPLE_RATIO Prometheus gauge makes this threshold operationally visible: you can alert on it, graph it over time, and correlate spikes with write-heavy workloads or failed autovacuum runs. The companion LAST_VACUUM gauge surfaces a second failure mode—schedule drift, where the ratio stays moderate but VACUUM hasn't run in so long that a burst write event could push it over the threshold before the next scheduled window (see Code Walkthrough).
Two-tier maintenance: VACUUM versus REINDEX CONCURRENTLY
Not all bloat requires the same response. VACUUM reclaims dead heap rows and updates visibility maps, which allows the index to skip dead entries on subsequent scans. It is fast, non-blocking, and appropriate when dead-tuple ratio is elevated but the index structure itself is still sound.
When bloat has progressed to the point that the index contains significant stale structure—not just dead heap pointers but misallocated internal pages—VACUUM alone cannot restore recall. REINDEX CONCURRENTLY rebuilds the entire index from the current live heap, resetting bloat to zero, but at the cost of a longer runtime and additional I/O. The two operations form a hierarchy: schedule VACUUM frequently as a low-cost first response, and reserve REINDEX CONCURRENTLY for cases where the BLOAT_RATIO gauge exceeds a higher threshold.
Reading health from system catalogs
PgvectorHealthMonitor.check_health issues two parameterized queries. The first targets pg_stat_user_tables for tuple counts and vacuum timestamps; the second targets pg_index joined with pg_relation_size for index and table size in bytes. Splitting the queries keeps each one focused and avoids the join complexity that arises when combining tuple statistics with index metadata in a single pass.
The COALESCE(last_vacuum, last_autovacuum) expression in the first query is intentional: in production, most tables are maintained by PostgreSQL's autovacuum daemon rather than manually scheduled jobs, and treating a recent autovacuum as evidence of freshness keeps the seconds_since_vacuum field accurate regardless of which agent ran last. PgvectorHealthMonitor therefore surfaces a unified health picture that works whether your maintenance is automatic, scheduled, or mixed (see Code Walkthrough).
Code Walkthrough
Now that you understand dead-tuple ratio, index bloat, and the VACUUM/REINDEX CONCURRENTLY lifecycle, the implementation wires those concepts into two concrete components: a Prometheus-instrumented health reporter and an asyncpg-backed monitor that queries PostgreSQL system catalogs.
Health metrics and the IndexHealthReport dataclass
The block below defines four Prometheus Gauge metrics—tracking index size, dead-tuple ratio, bloat ratio, and time since last VACUUM—plus an IndexHealthReport dataclass that bundles all per-table measurements into a single structure the maintenance scheduler can act on:
Code snippetpython
1# src/vector_index_ops/pgvector_health.py 2from dataclasses import dataclass 3from typing import Optional 4import asyncpg 5from prometheus_client import Gauge 6 7INDEX_SIZE = Gauge( 8 "pgvector_index_size_bytes", 9 "Size of pgvector indexes in bytes", 10 ["index_name"], 11) 12DEAD_TUPLE_RATIO = Gauge( 13 "pgvector_dead_tuple_ratio", 14 "Ratio of dead tuples to live tuples", 15 ["table_name"], 16) 17BLOAT_RATIO = Gauge( 18 "pgvector_index_bloat_ratio", 19 "Estimated bloat ratio for pgvector indexes", 20 ["index_name"], 21) 22LAST_VACUUM = Gauge( 23 "pgvector_last_vacuum_seconds_ago", 24 "Seconds since last VACUUM on embedding tables", 25 ["table_name"], 26) 27 28@dataclass 29class IndexHealthReport: 30 table_name: str 31 index_name: str 32 live_tuples: int 33 dead_tuples: int 34 dead_tuple_ratio: float 35 index_size_bytes: int 36 table_size_bytes: int 37 last_vacuum_at: Optional[str] 38 seconds_since_vacuum: Optional[float]
DEAD_TUPLE_RATIO is the primary scheduling signal: once it exceeds roughly 0.20 on an embeddings table, VACUUM is overdue. LAST_VACUUM surfaces schedule drift—tables that haven't been vacuumed recently despite high write throughput need autovacuum tuning or explicit scheduling.
PgvectorHealthMonitor
The monitor wraps an asyncpg.Pool and reads from pg_stat_user_tables and pg_index to populate an IndexHealthReport:
Code snippetpython
1# src/vector_index_ops/pgvector_health.py (continued) 2class PgvectorHealthMonitor: 3 def __init__(self, pool: asyncpg.Pool) -> None: 4 self._pool = pool 5 6 async def check_health( 7 self, table_name: str = "embeddings" 8 ) -> IndexHealthReport: 9 stats = await self._pool.fetchrow( 10 """ 11 SELECT 12 n_live_tup, 13 n_dead_tup, 14 CASE WHEN n_live_tup > 0 15 THEN n_dead_tup::float / n_live_tup 16 ELSE 0 17 END AS dead_ratio, 18 last_vacuum, 19 last_autovacuum, 20 EXTRACT(EPOCH FROM ( 21 NOW() - COALESCE(last_vacuum, last_autovacuum) 22 )) AS seconds_since_vacuum 23 FROM pg_stat_user_tables 24 WHERE relname = $1 25 """, 26 table_name, 27 ) 28 index_info = await self._pool.fetchrow( 29 """ 30 SELECT 31 indexrelid::regclass AS index_name, 32 pg_relation_size(indexrelid) AS index_size, 33 pg_total_relation_size($1::regclass) AS table_size 34 FROM pg_index 35 WHERE indrelid = $1::regclass 36 AND indisvalid 37 ORDER BY pg_relation_size(indexrelid) DESC 38 LIMIT 1 39 """, 40 table_name, 41 ) 42 return IndexHealthReport( 43 table_name=table_name, 44 index_name=str(index_info["index_name"]), 45 live_tuples=stats["n_live_tup"], 46 dead_tuples=stats["n_dead_tup"], 47 dead_tuple_ratio=float(stats["dead_ratio"]), 48 index_size_bytes=index_info["index_size"], 49 table_size_bytes=index_info["table_size"], 50 last_vacuum_at=str( 51 stats["last_vacuum"] or stats["last_autovacuum"] 52 ), 53 seconds_since_vacuum=( 54 float(stats["seconds_since_vacuum"]) 55 if stats["seconds_since_vacuum"] is not None 56 else None 57 ), 58 )
check_health issues two parameterized queries—one for tuple statistics, one for index sizing—then combines them into the IndexHealthReport your scheduler will inspect. The COALESCE(last_vacuum, last_autovacuum) expression handles tables maintained by autovacuum rather than manual scheduling, keeping the time-since-vacuum metric accurate regardless of which agent performed the last cleanup.
Confirm that calling await monitor.check_health("embeddings") against a PostgreSQL instance with a pgvector-backed embeddings table returns an IndexHealthReport with a non-negative dead_tuple_ratio and a positive index_size_bytes.
Do's and Don'ts
Having walked through the material above, the following Do's and Don'ts distill it into practice.
Do's
- ✓Do use
dead_tuple_ratiofrompg_stat_user_tablesas your primary VACUUM trigger — IVFFlat and HNSW indexes cannot self-compact the way B-tree indexes do, so once the ratio crosses roughly 0.20 on an embeddings table, nearest-neighbor recall degrades silently while the application keeps returning results. - ✓Do use
COALESCE(last_vacuum, last_autovacuum)when computingseconds_since_vacuuminPgvectorHealthMonitor.check_health— tables maintained exclusively by autovacuum leavelast_vacuumNULL; omitting the COALESCE causes theLAST_VACUUMPrometheus gauge to reportNoneeven for recently cleaned tables, masking real schedule drift. - ✓Do expose
DEAD_TUPLE_RATIOandLAST_VACUUMas separate, table-labeled Prometheus Gauge metrics — keeping the dead-tuple ratio (the immediate VACUUM trigger) distinct from time-since-vacuum (the autovacuum-tuning signal) lets the maintenance scheduler distinguish between "run VACUUM now" and "the autovacuum schedule needs adjustment."
Don'ts
- ✗Don't treat a steadily rising
dead_tuple_ratioas benign because embeddings queries keep returning results — bloat in IVFFlat and HNSW indexes erodes recall without surfacing errors or slow-query alerts, so the only early signal is the ratio inpg_stat_user_tables, not application-level symptoms. - ✗Don't query
pg_indexalone to assess index health —pg_indexprovidesindex_size_bytesand theindisvalidflag needed to skip in-progress rebuilds, but it contains no tuple statistics; without the companionpg_stat_user_tablesquery,IndexHealthReport.dead_tuple_ratiois always zero and the scheduler has no signal on which to act. - ✗Don't run plain
REINDEXinstead ofREINDEX CONCURRENTLYon a production embeddings table — plain REINDEX acquires an exclusive lock that blocks all nearest-neighbor reads for the full rebuild duration;REINDEX CONCURRENTLYrebuilds the pgvector index without interrupting live query traffic.
This lesson is free to read. Its hands-on lab — real code, in a cloud IDE — is part of the LLMOps Engineering subscription.
From · cancel anytime · Already a subscriber? Sign in →
More free lessons in GenAI Operations
- Ch 34Deploy Redis Semantic Cache and Measure Hit Rate vs Cost Savings
- Ch 34Compare Provider Caching Strategies for OpenAI, Anthropic, and Google
- Ch 38Deploy pgvector and build embedding ingestion pipeline with operational monitoring
- Ch 39Implement pgvector index maintenance with VACUUM and reindexing schedulesYou are here
- Ch 39Deploy Qdrant and compare operational characteristics with pgvector
- Ch 41Compare retrieval quality across embedding models with Cohere Rerank
- Ch 43Build completeness checks for embedding coverage and knowledge graph gaps