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 pgvector extension 0.5+ (0.7+ if you build halfvec indexes). CREATE INDEX CONCURRENTLY and DROP INDEX CONCURRENTLY are both required.
  • The current index’s DDL and parameters recorded. Retrieve them from pg_indexes so 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.
Rolling an immutable-parameter change by building a second index and swapping A left-to-right sequence of four phase boxes. Phase one, the old index with parameter m equals 16, is live and serving. Phase two builds a new index concurrently with the new parameter m equals 32. Phase three is a coexistence window where both indexes are valid and the write path maintains both; here recall is measured and EXPLAIN confirms the planner picks the new index. Phase four drops the old index concurrently, leaving only the new index. A bracket spans phases two and three labelled "both indexes live: extra space and write cost". Rolling parameter change — build new, validate, drop old LIVE Old index m = 16 BUILD New index CONCURRENTLY m = 32 VALIDATE Recall + EXPLAIN planner picks new RETIRE DROP old index CONCURRENTLY both indexes live: extra space and write amplification
Because build parameters are immutable, the change rolls forward as a second index that is validated before the original is retired.

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.

SQL
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.

SQL
-- 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:

SQL
-- 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.

SQL
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:

SQL
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.

SQL
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:

SQL
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 1GB2GB 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.

SQL
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, and DELETE maintains 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_size of both indexes against free space; fix by ensuring free space of at least the combined index size before starting, and dropping any INVALID leftover from a failed build.
  • The planner still picks the old index. Statistics are stale or the old index looks cheaper. Diagnose with EXPLAIN naming the chosen index; fix by running ANALYZE doc_chunks so the planner re-costs, and confirm the new index is valid — an INVALID index 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-time ef_search, not build-time m, or the parameter change was too small to matter. Diagnose by sweeping ef_search on the new index and re-measuring recall@k; fix by tuning ef_search and, if needed, rebuilding with a larger step in m per optimizing m and ef_construction parameters.
  • The new index finished INVALID. The concurrent build hit an error or was interrupted. Diagnose with the indisvalid = false check in step 3; fix with DROP INDEX CONCURRENTLY on the failed new index, then rebuild — never drop the old index until the new one is valid.