Rolling Index Parameter Changes Without Downtime
This page shows how to change an HNSW or IVFFlat index’s build parameters — m, ef_construction, or lists — on a live table without a maintenance outage, given that those parameters are baked in at build time and cannot be altered in place. It scopes the problem narrowly: building a second index with the new parameters concurrently, proving the planner uses it and that recall holds, then retiring the old index, so search never loses a usable index for even one query.
Up: Zero-Downtime Index Operations for pgvector
pgvector’s build parameters are immutable: m and ef_construction for HNSW, and lists for IVFFlat, are fixed the moment the index is created and are not settable by ALTER INDEX. The only way to raise m for better recall, or re-fit lists to a grown table, is to build a brand-new index with the new values and swap it in. Doing that with zero downtime means the new index is created concurrently so writes and reads never block, both indexes coexist briefly, and the old one is dropped only after the new one is proven valid and preferred by the planner. Get the sequence right and search quality changes over on a live system without a single failed query.
Prerequisites
- PostgreSQL 15+ with the
pgvectorextension 0.5+ (0.7+ if you buildhalfvecindexes).CREATE INDEX CONCURRENTLYandDROP INDEX CONCURRENTLYare both required. - The current index’s DDL and parameters recorded. Retrieve them from
pg_indexesso the new build differs only in the intended parameter. - Target parameters chosen deliberately, not guessed. The recall-versus-build-cost trade-off for HNSW lives in optimizing m and ef_construction parameters; the list-count math for IVFFlat is in tuning IVFFlat lists for high-throughput similarity search.
- Free disk to hold both indexes at once. For the overlap window the table carries two full vector indexes plus the extra write cost of maintaining both.
- A ground-truth query set to measure recall@k before and after, so the swap is validated on numbers rather than hope — the harness for this is recall regression testing in CI.
Step-by-step
1. Capture the current index definition
Read the existing DDL so the new index is identical except for the parameter you intend to change. This avoids silently altering the opclass or column alongside the parameter.
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'doc_chunks'
AND indexdef LIKE '%hnsw%';Record the current values — say m = 16, ef_construction = 64 on a vector_cosine_ops HNSW index named doc_chunks_embedding_hnsw.
2. Build the new index concurrently under a distinct name
Create the replacement with CREATE INDEX CONCURRENTLY and a temporary distinct name so both indexes exist side by side. CONCURRENTLY keeps writes and reads flowing during the build; the trade-off is a longer build and the two-snapshot wait described for any concurrent build.
-- raise HNSW connectivity from m=16 to m=32 for better recall
CREATE INDEX CONCURRENTLY doc_chunks_embedding_hnsw_new
ON doc_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 32, ef_construction = 128);For an IVFFlat list change the shape is the same — only the WITH clause differs:
-- re-fit IVFFlat lists to a table that has grown
CREATE INDEX CONCURRENTLY doc_chunks_embedding_ivf_new
ON doc_chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 2000);3. Confirm the new index built valid
A concurrent build that hit an error can finish INVALID. Verify before you trust it, because dropping the old index while the new one is invalid would leave the table with no usable vector index.
SELECT c.relname, i.indisvalid, i.indisready
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE c.relname IN ('doc_chunks_embedding_hnsw', 'doc_chunks_embedding_hnsw_new');Both indisvalid and indisready must be true for the new index before proceeding.
4. Validate recall and confirm the planner uses the new index
The whole point of the change is search quality, so measure it. Run your ground-truth recall@k against the new index and compare to the baseline. Because both indexes are valid, force the comparison by temporarily dropping the old one in a transaction you roll back, or by checking EXPLAIN shows the new index is chosen once it is the only one left. First confirm the planner will pick it:
EXPLAIN (ANALYZE, BUFFERS)
SELECT doc_id, embedding <=> $1 AS distance
FROM doc_chunks
ORDER BY embedding <=> $1
LIMIT 10;The plan should show an Index Scan using doc_chunks_embedding_hnsw_new. If recall@k on the new index does not meet the target, the new parameters were wrong — drop the new index and rebuild with different values rather than retiring the old one. Wire this measurement into recall regression testing in CI so the swap is gated on a number.
5. Retire the old index
Once the new index is valid, preferred by the planner, and passing recall, drop the old one without blocking traffic.
DROP INDEX CONCURRENTLY doc_chunks_embedding_hnsw;Optionally rename the new index to the canonical name so tooling and dashboards that reference it by name keep working:
ALTER INDEX doc_chunks_embedding_hnsw_new
RENAME TO doc_chunks_embedding_hnsw;ALTER INDEX ... RENAME takes a brief AccessExclusiveLock but completes in milliseconds since it only touches catalog metadata, not index data.
Parameter reference
| Parameter | Type | Old build | New build (example) | Notes |
|---|---|---|---|---|
m (HNSW) |
int | 16 |
32 |
Max edges per node; higher improves recall and raises build time, index size, and memory. Immutable — only changeable via rebuild. |
ef_construction (HNSW) |
int | 64 |
128 |
Candidate list size at build; higher improves graph quality and recall at the cost of build time. Immutable. |
lists (IVFFlat) |
int | 1000 |
2000 |
Number of k-means centroids; re-fit toward rows / 1000 as the table grows. Immutable. |
| Index name | identifier | ..._hnsw |
..._hnsw_new |
Build under a distinct name so both coexist, then optionally rename after the swap. |
| Build mode | DDL | CONCURRENTLY |
CONCURRENTLY |
Keeps reads and writes live during the build; required for a zero-downtime roll. |
maintenance_work_mem |
GUC (session) | 64MB |
1GB–2GB |
Raise per-session for the new build; too low slows or spills a large HNSW build. |
Verification
Confirm the end state: exactly one valid index on the column, carrying the new parameters, chosen by the planner. Read the stored WITH options back from the catalog to prove the new parameters actually took.
SELECT
c.relname,
x.indisvalid,
c.reloptions -- reloptions carry m / ef_construction / lists
FROM pg_index x
JOIN pg_class c ON c.oid = x.indexrelid
WHERE x.indrelid = 'doc_chunks'::regclass
AND c.relname LIKE 'doc_chunks_embedding%';reloptions should read {m=32,ef_construction=128} (or the new lists value), indisvalid should be true, and there should be only one such row. A final EXPLAIN on a representative query should still name that index, and a recall@k run should match the number you validated in step 4.
Troubleshooting
- Write throughput drops while both indexes exist. Every
INSERT,UPDATE, andDELETEmaintains both the old and new index during the coexistence window, doubling index write work. Diagnose from elevated write latency and WAL volume during the overlap; fix by keeping the window short — validate promptly and drop the old index as soon as the new one passes, rather than leaving both live for days. - The new index build ran the disk low. Both full indexes plus WAL from the build coexist. Diagnose by comparing
pg_relation_sizeof both indexes against free space; fix by ensuring free space of at least the combined index size before starting, and dropping anyINVALIDleftover from a failed build. - The planner still picks the old index. Statistics are stale or the old index looks cheaper. Diagnose with
EXPLAINnaming the chosen index; fix by runningANALYZE doc_chunksso the planner re-costs, and confirm the new index is valid — anINVALIDindex is invisible to the planner. Once the old index is dropped, the choice is forced. - Recall did not improve after raising
m. The bottleneck was query-timeef_search, not build-timem, or the parameter change was too small to matter. Diagnose by sweepingef_searchon the new index and re-measuring recall@k; fix by tuningef_searchand, if needed, rebuilding with a larger step inmper optimizing m and ef_construction parameters. - The new index finished
INVALID. The concurrent build hit an error or was interrupted. Diagnose with theindisvalid = falsecheck in step 3; fix withDROP INDEX CONCURRENTLYon the failed new index, then rebuild — never drop the old index until the new one is valid.
Related
- Zero-Downtime Index Operations for pgvector — the operations family this procedure belongs to
- Optimizing m and ef_construction parameters — choosing the HNSW values you are rolling to
- Tuning IVFFlat lists for high-throughput similarity search — sizing the new
listsvalue for a grown table - Recall regression testing in CI — gating the swap on a measured recall number
- Scheduling REINDEX INDEX CONCURRENTLY without downtime — when a same-parameter rebuild, not new parameters, is the fix
- Up: Zero-Downtime Index Operations for pgvector