Detecting HNSW Index Bloat with pgstattuple
This page shows how to use the pgstattuple extension to measure physical bloat inside an HNSW vector index and decide, from concrete thresholds, whether a REINDEX INDEX CONCURRENTLY is warranted. It scopes the problem narrowly: given a large HNSW index whose size keeps growing after heavy UPDATE/DELETE churn, how to read dead_tuple_percent, free_percent, and leaf fragmentation without misreading the whole-relation scan that pgstattuple performs.
Up: Monitoring Vector Indexes with pg_stat Views
HNSW indexes accumulate wasted space in a way plain size metrics hide. Every UPDATE to a row’s embedding writes a new heap tuple and a new graph entry; the old graph node is only tombstoned, and its space is reclaimed lazily by VACUUM. Over a churn-heavy month the on-disk index can be far larger than the live data it serves, quietly raising memory pressure and cache misses. pgstattuple reads the actual page contents to tell you how much of that space is dead versus free, which is the evidence you need before scheduling the expensive rebuild described in zero-downtime index operations.
Prerequisites
- PostgreSQL 15+ and the ability to
CREATE EXTENSION(superuser or a role with the privilege). - The
pgvectorextension with at least one HNSW index under real churn; the build parameters are covered in step-by-step HNSW index creation for production workloads. - A maintenance window or a read replica:
pgstattupleandpgstatindextake anACCESS SHARElock and scan the entire relation, which is I/O-heavy on a multi-gigabyte index. - Baseline size numbers from your monitoring view so you can compare bloat measurements against a known-good build.
Ordered steps
Step-by-step procedure
1. Install the extension
pgstattuple ships in contrib and installs per-database.
CREATE EXTENSION IF NOT EXISTS pgstattuple;2. Measure heap-level bloat on the table
pgstattuple reports dead_tuple_percent and free_percent for the underlying table. Dead tuples here are the churned embedding rows that VACUUM has not yet reclaimed; they are the upstream cause of index bloat.
SELECT
table_len,
tuple_count,
dead_tuple_count,
round(dead_tuple_percent::numeric, 2) AS dead_pct,
round(free_percent::numeric, 2) AS free_pct
FROM pgstattuple('public.doc_chunks');A high dead_pct on the table points at autovacuum falling behind, which is tuned in autovacuum tuning for pgvector tables.
3. Measure index-level fragmentation with pgstatindex
For B-tree-structured indexes pgstatindex reports leaf_fragmentation and avg_leaf_density. HNSW is not a B-tree, so use pgstattuple directly on the index relation to get its free_percent; use pgstatindex on any accompanying B-tree indexes (for example a metadata btree on doc_id).
-- HNSW index: free space is the reclaimable bloat signal
SELECT round(free_percent::numeric, 2) AS free_pct,
pg_size_pretty(pg_relation_size('doc_chunks_embedding_hnsw')) AS idx_size
FROM pgstattuple('doc_chunks_embedding_hnsw');
-- B-tree companion index: leaf fragmentation is meaningful here
SELECT leaf_fragmentation, avg_leaf_density
FROM pgstatindex('doc_chunks_doc_id_btree');4. Compare against a fresh-build baseline
Bloat is relative. Record the size of the index right after a clean REINDEX and compare live size to it. A live index more than roughly 2x its fresh-build size, or with free_percent above 30%, has reclaimable space worth a rebuild.
SELECT
pg_size_pretty(pg_relation_size('doc_chunks_embedding_hnsw')) AS live_size,
round(
pg_relation_size('doc_chunks_embedding_hnsw')::numeric
/ nullif(:fresh_build_bytes, 0),
2) AS bloat_ratio;5. Reindex concurrently when thresholds are crossed
If the evidence says rebuild, do it without blocking writes. REINDEX INDEX CONCURRENTLY builds a replacement and swaps it in under a brief lock; the full scheduling pattern is in scheduling reindex concurrently without downtime.
REINDEX INDEX CONCURRENTLY doc_chunks_embedding_hnsw;Parameter reference
Thresholds that turn a raw measurement into a rebuild decision. Treat them as starting points and calibrate to your churn.
| Threshold | Type | Default reading | Production recommendation | Notes |
|---|---|---|---|---|
dead_tuple_percent (table) |
percent | reported by pgstattuple |
Investigate autovacuum above ~10% | High dead tuples upstream drive index bloat; fix vacuum first. |
free_percent (HNSW index) |
percent | reported by pgstattuple |
Rebuild above ~30% | Reclaimable space inside index pages; the primary HNSW bloat signal. |
leaf_fragmentation (B-tree) |
percent | reported by pgstatindex |
Rebuild above ~30% | Only meaningful for B-tree companion indexes, not HNSW itself. |
avg_leaf_density (B-tree) |
percent | reported by pgstatindex |
Healthy 70–90% | Low density means sparse leaves; complements fragmentation. |
bloat_ratio (vs fresh build) |
ratio | 1.0 at build time |
Rebuild above ~2.0 | Compare live pg_relation_size to a recorded post-REINDEX size. |
| scan lock | lock mode | ACCESS SHARE |
Run off-peak or on a replica | pgstattuple reads every page; heavy I/O on large indexes. |
Verification
After a REINDEX INDEX CONCURRENTLY, re-run the same measurements and confirm free_percent and size dropped back toward the baseline.
SELECT round(free_percent::numeric, 2) AS free_pct,
pg_size_pretty(pg_relation_size('doc_chunks_embedding_hnsw')) AS idx_size
FROM pgstattuple('doc_chunks_embedding_hnsw');
-- confirm no invalid index was left behind by a failed concurrent build
SELECT indexrelid::regclass AS index_name, indisvalid, indisready
FROM pg_index
WHERE indexrelid = 'doc_chunks_embedding_hnsw'::regclass;A low free_pct, a reduced size, and indisvalid = true together confirm the rebuild reclaimed the bloat cleanly.
Troubleshooting
pgstattupleruns for minutes and drives I/O wait up. That is expected: it scans the entire relation underACCESS SHARE. Move the call to a read replica or a low-traffic window, and never wire it into a per-scrape dashboard; run it on demand or on a slow cron.pgstatindexerrors on the HNSW index.pgstatindextargets B-tree structure. Usepgstattuple()on the HNSW relation for itsfree_percent, and reservepgstatindexfor B-tree companion indexes.free_percentis high but size never shrinks after VACUUM. PlainVACUUMmarks space reusable inside the index but does not return it to the OS. OnlyREINDEX(orVACUUM FULL, which locks) actually reduces the on-disk file; use the concurrent form to avoid blocking.- A concurrent reindex left a leftover invalid index. A failed
REINDEX INDEX CONCURRENTLYcan leave anindisvalid = falseshadow. Detect it with thepg_indexquery above and drop it withDROP INDEX CONCURRENTLYbefore retrying. - Bloat comes back within days. The rebuild treats the symptom, not the cause. Sustained high
dead_tuple_percentmeans autovacuum is not keeping up on this high-churn table; retune per-table autovacuum before the next rebuild cycle.
Related
- Autovacuum tuning for pgvector tables — fixing the dead-tuple source of the bloat
- Building a pg_stat_user_indexes dashboard for vector search — cheap size tracking that flags when to run pgstattuple
- Scheduling reindex concurrently without downtime — executing the rebuild safely
- Up: Monitoring Vector Indexes with pg_stat Views