Index Validation & Error Categorization for pgvector HNSW and IVFFlat

An index that builds without raising an error is not the same as an index that works. In production pgvector deployments the expensive failures are the quiet ones: a cosine query that silently falls back to a sequential scan because the operator class does not match, recall that drifts below your service-level target after a month of writes, or an ingestion job that inserts truncated float16 vectors that the planner still happily indexes. Without a deterministic validation workflow and a shared error taxonomy, these problems surface as latency pages and “search feels worse” tickets long after the root cause is buried. This page gives search platform engineers and Python data-pipeline builders a way to categorize every class of vector-index failure, the exact diagnostic SQL to confirm each one, and the remediation that actually fixes it — turning validation from a one-time checkpoint into a repeatable gate. It sits under the broader HNSW & IVFFlat index creation and tuning reference; if you have not yet chosen an index type, start with the HNSW vs IVFFlat algorithm selection framework first.

The four pgvector index error classes and their first-line mitigations Index validation fans out into four error classes. ERR_CONSTRUCTION (build-time failures) is remedied by raising maintenance_work_mem and chunking bulk inserts. ERR_RECALL_DRIFT (query-time degradation) is remedied by raising ef_search or ivfflat.probes. ERR_PIPELINE_DESYNC (embedding schema drift) is remedied by validating dimensions and dtype at ingestion. ERR_QUERY_TIMEOUT (planner and fragmentation) is remedied by REINDEX INDEX CONCURRENTLY. Index validation ERR_CONSTRUCTION build-time failures ERR_RECALL_DRIFT query-time degradation ERR_PIPELINE_DESYNC embedding schema drift ERR_QUERY_TIMEOUT planner · fragmentation Raise maintenance_work_mem; chunk bulk inserts Raise ef_search or ivfflat.probes Validate dims & dtype at ingestion REINDEX INDEX CONCURRENTLY

Architectural Divergence & Trade-offs

Validation is not a single check — it is three distinct layers that catch different failures at different times, and confusing them is why teams keep shipping broken indexes. Understanding where each layer sits, and its blind spots, is the core of a reliable error taxonomy.

Structural validation inspects the catalog and physical index before any query runs. It is cheap, deterministic, and runs in CI, but it can only prove that the index exists with the shape you asked for — not that it returns good answers. It catches operator-class mismatches, dimension mismatches, and missing indexes, and nothing else. HNSW and IVFFlat share this layer identically because both are ordinary PostgreSQL access methods exposed through pg_indexes and pg_stat_progress_create_index.

Recall validation measures answer quality by comparing the index’s top-K against exact brute-force results on a held-out query set. It is the only layer that catches silent recall collapse, but it is expensive (it needs ground truth) and its meaning diverges sharply between the two algorithms. For HNSW the recall dial is the query-time hnsw.ef_search, tuned against the build-time graph density set by m and ef_construction; for IVFFlat it is ivfflat.probes against the lists partitioning. A recall number is only interpretable next to the parameters that produced it, which is why this layer must record the knob values alongside the score.

Pipeline validation guards the boundary between the embedding job and the database. It is the layer most teams skip and the one that produces the most confusing incidents, because a desync failure looks like a recall problem — vectors that were normalized at training time but not at inference will index cleanly and quietly return garbage neighbours. This layer belongs upstream in Python, not in SQL, and it is where the normalizing embeddings before pgvector insertion contract is enforced.

The trade-off is coverage versus cost. Structural checks are nearly free and run on every deploy; recall checks are expensive and run on a schedule or against staging; pipeline checks run per batch at ingestion. A mature deployment runs all three, mapped to the four error classes below, so that every failure has exactly one owning layer.

Parameter Space & Diagnostic Workflow

Every validation decision turns on a handful of knobs whose defaults are tuned for correctness on tiny datasets, not for production recall or build throughput. The table below is the reference for what to check and what to move it to; treat the defaults as diagnostic tripwires rather than settings you keep.

Parameter Scope Default Production Recommendation Notes
maintenance_work_mem build 64MB 2GB8GB (session-local for the build) Undersizing forces the HNSW graph to spill to disk; the direct cause of most ERR_CONSTRUCTION.
max_parallel_maintenance_workers build 2 48 on a dedicated build window Caps HNSW build parallelism; check against available cores before blaming I/O.
hnsw.ef_search query 40 100400, tuned to recall SLA Primary HNSW recall dial; raise until recall clears target, then stop.
ivfflat.probes query 1 sqrt(lists) as a starting point Primary IVFFlat recall dial; 1 is almost always a bug in production.
hnsw.ef_construction build 64 128400 Build-time graph quality; frozen into the index, requires rebuild to change. See the calibration guide.
work_mem query 4MB 32MB128MB Too low and the top-K sort spills, inflating query latency and masking a healthy index scan.

The first-line diagnostic is always the same question: is the planner actually using the index? A cosine index the planner refuses to use is functionally identical to no index at all, and it produces no error. Confirm operator-class alignment before anything else:

SQL
SELECT i.indexname,
       am.amname       AS access_method,
       opc.opcname     AS operator_class
FROM pg_indexes i
JOIN pg_class c        ON c.relname = i.indexname
JOIN pg_index ix       ON ix.indexrelid = c.oid
JOIN pg_am am          ON am.oid = c.relam
JOIN pg_opclass opc    ON opc.oid = ANY (ix.indclass)
WHERE i.tablename = 'document_chunks'
  AND am.amname IN ('hnsw', 'ivfflat');

If the returned operator_class (vector_cosine_ops, vector_l2_ops, or vector_ip_ops) does not match the operator in your query (<=>, <->, <#> respectively), the planner will silently choose a sequential scan. Which operator to standardize on is a data-modeling decision covered in cosine vs L2 distance metrics; the validation rule is simply that query and index must agree.

Step-by-Step Implementation

Run these checks in order. Each step gates the next, so a failure short-circuits the workflow to the owning error class.

1. Confirm the index exists and is valid

A CREATE INDEX CONCURRENTLY that is interrupted leaves an INVALID index behind that the planner will not use. Catch it before it reaches traffic:

SQL
SELECT c.relname AS index_name,
       ix.indisvalid,
       ix.indisready
FROM pg_index ix
JOIN pg_class c ON c.oid = ix.indexrelid
JOIN pg_class t ON t.oid = ix.indrelid
WHERE t.relname = 'document_chunks';

Any row with indisvalid = false must be dropped and rebuilt; it is dead weight consuming disk and write amplification while serving zero queries.

2. Size the build environment

Before rebuilding, provision memory session-locally so you do not leave a global setting that starves query traffic:

SQL
SET maintenance_work_mem = '4GB';
SET max_parallel_maintenance_workers = 4;
ANALYZE document_chunks;   -- refresh stats so the planner costs the new index correctly

The ANALYZE is not optional. A stale reltuples estimate makes the planner misprice the index scan and fall back to a sequential scan even on a perfectly healthy index.

3. Track a live build to completion

Long HNSW builds should be observed, not guessed at. Poll the progress view so a stall (usually a memory spill) is visible immediately rather than as a missed maintenance window:

SQL
SELECT phase,
       blocks_done,
       blocks_total,
       round(100.0 * blocks_done / NULLIF(blocks_total, 0), 2) AS progress_pct,
       tuples_done
FROM pg_stat_progress_create_index;

If the build genuinely will not fit the window, move it off the critical path entirely using the asynchronous index build strategies patterns, and if the build times out outright, follow resolving pgvector index build timeout errors.

4. Validate the ingestion contract in Python

The database will accept any vector of the right dimension, so the dtype and normalization contract has to be enforced upstream before the batch ever reaches pgvector:

PYTHON
import numpy as np

EXPECTED_DIM = 1536
TOL = 1e-3

def validate_batch(vectors: np.ndarray) -> None:
    if vectors.dtype != np.float32:
        raise ValueError(f"ERR_PIPELINE_DESYNC: dtype {vectors.dtype}, expected float32")
    if vectors.shape[1] != EXPECTED_DIM:
        raise ValueError(f"ERR_PIPELINE_DESYNC: dim {vectors.shape[1]} != {EXPECTED_DIM}")
    norms = np.linalg.norm(vectors, axis=1)
    if np.any(np.abs(norms - 1.0) > TOL):
        raise ValueError("ERR_PIPELINE_DESYNC: vectors are not unit-normalized")

Rejecting a bad batch here is orders of magnitude cheaper than discovering it as recall drift weeks later.

Validation & Recall Testing

Structural checks prove the index is usable; only a recall test proves it is correct. The pattern is to serve a query set through the index at a chosen knob value, then compare against exact brute-force ground truth computed with the index disabled.

First confirm the planner is choosing an index scan and not a sequential scan hiding behind a low cost estimate:

SQL
SET LOCAL hnsw.ef_search = 100;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id
FROM document_chunks
ORDER BY embedding <=> '[...]'::vector
LIMIT 10;

The plan must contain an Index Scan using ... hnsw node. An unexpected Seq Scan means the operator class is wrong (step 1 above) or statistics are stale (step 2). Then measure recall against ground truth in Python:

PYTHON
import psycopg

def recall_at_k(conn, query_vecs, truth_ids, k=10, ef_search=100):
    hits = 0
    with conn.cursor() as cur:
        cur.execute("SET LOCAL hnsw.ef_search = %s", (ef_search,))
        for qv, truth in zip(query_vecs, truth_ids):
            cur.execute(
                "SELECT id FROM document_chunks "
                "ORDER BY embedding <=> %s::vector LIMIT %s",
                (qv, k),
            )
            got = {r[0] for r in cur.fetchall()}
            hits += len(got & set(truth[:k]))
    return hits / (len(query_vecs) * k)

Ground truth (truth_ids) is produced once by running the same queries with SET LOCAL enable_indexscan = off so the engine computes exact distances. Sweep ef_search (or probes for IVFFlat) upward and keep the smallest value that clears your recall target — the correlation between build-time graph density and the search budget you need is worked through in optimizing m and ef_construction parameters, and the IVFFlat equivalent in tuning IVFFlat lists for high-throughput similarity search.

Validation layers as swimlanes, each owning specific error classes Three validation layers run at different times and costs. The structural layer runs on every deploy in CI using catalog and statistics SQL (pg_index, pg_indexes, pg_stat_user_tables) and owns both ERR_CONSTRUCTION and ERR_QUERY_TIMEOUT. The recall layer runs on a schedule against staging using EXPLAIN ANALYZE and a Python Recall@10 harness, and owns ERR_RECALL_DRIFT. The pipeline layer runs per batch at ingestion using the Python validate_batch dtype, dimension and normalization check, and owns ERR_PIPELINE_DESYNC. Each failure class has exactly one owning validation layer Structural every deploy · CI catalog + stat reads indisvalid · pg_index operator class · pg_indexes dead_pct · pg_stat_user_tables ERR_CONSTRUCTION ERR_QUERY_TIMEOUT Recall scheduled · staging EXPLAIN ANALYZE + ground truth Recall@10 harness · Python ERR_RECALL_DRIFT Pipeline per batch · ingestion validate_batch() upstream dtype · dims · norm · Python ERR_PIPELINE_DESYNC

Failure Modes & Gotchas

Each of the four error classes has a characteristic signature, a confirming diagnostic, and a remediation. Memorizing the signatures is what makes triage fast.

ERR_CONSTRUCTION — build-time failures

Triggered by insufficient maintenance_work_mem, disk exhaustion, or a temp_file_limit that caps the spill during graph construction. The signature is a build that runs far longer than the storage math predicts, or that aborts partway. Confirm with pg_stat_progress_create_index (step 3) and cross-check the per-row footprint using pgvector storage overhead analysis. Mitigation: chunk bulk inserts into batches of 50k–200k vectors, raise maintenance_work_mem to 4–8GB for the build session, and confirm temp_file_limit is not throttling spill-to-disk.

ERR_RECALL_DRIFT — silent query-time degradation

The dangerous class, because it raises no error. Recall drops below target when runtime search parameters are too low for the current data topology, or when the dataset has shifted since the graph was built. The signature is “search quality complaints with green dashboards.” Confirm it with the recall harness above, not with logs. Mitigation: raise hnsw.ef_search or ivfflat.probes and re-measure the latency trade-off; if you cannot reach the target within the latency budget, the graph itself is under-built and needs a higher ef_construction.

ERR_PIPELINE_DESYNC — embedding schema drift

Dimension mismatches, dtype drift (float32 to float16), or a normalization step that runs at training but not at inference. pgvector enforces dimension but nothing else, so a desync indexes cleanly and returns wrong neighbours — it masquerades as ERR_RECALL_DRIFT. Confirm with the stored dtype and dimension:

SQL
SELECT vector_dims(embedding) AS dim,
       pg_column_size(embedding) AS bytes
FROM document_chunks
LIMIT 1;

Mitigation: enforce the Python contract from step 4 at the ingestion boundary and reject non-conforming batches before insert. The broader drift-handling patterns live in the ingestion pipeline reference; the type-width side of this decision is covered in vector data type selection.

ERR_QUERY_TIMEOUT — planner and fragmentation issues

Surfaces after heavy UPDATE/DELETE cycles. IVFFlat centroids stop representing the data distribution and HNSW graphs accumulate edge fragmentation and dead tuples. The signature is queries with a high buffer-hit ratio but rising execution time. Confirm via pg_stat_statements and the dead-tuple ratio in pg_stat_user_tables. Mitigation: run REINDEX INDEX CONCURRENTLY in a maintenance window to rebuild topology without blocking reads, and tighten autovacuum on the vector table so dead tuples do not accumulate between rebuilds.

A cross-cutting gotcha for all four: REINDEX and large CONCURRENTLY builds generate significant WAL. On a replicated cluster this can stall replicas or trip a replication-lag alert mid-rebuild, so schedule rebuilds against known WAL headroom rather than assuming a concurrent build is free.

Monitoring & Alerting Hooks

Validation that only runs by hand rots. Wire these signals into continuous monitoring so each error class trips an alert before users notice.

Track index-versus-sequential scan counts per table — a rising seq_scan on a vector table is the earliest sign of an operator-class or statistics regression:

SQL
SELECT relname,
       idx_scan,
       seq_scan,
       n_dead_tup,
       round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_pct
FROM pg_stat_user_tables
WHERE relname = 'document_chunks';

Export three families of metric to a Prometheus-compatible collector and alert on each: the seq_scan rate on vector tables (catches ERR_QUERY_TIMEOUT and operator drift), a scheduled Recall@10 gauge from the harness above (catches ERR_RECALL_DRIFT), and a per-batch desync counter from the ingestion validator (catches ERR_PIPELINE_DESYNC). Concrete SLOs worth encoding:

  • Recall@10 ≥ 0.95 at target latency (for example, p95 < 50 ms).
  • dead_pct < 15% before triggering an automated REINDEX CONCURRENTLY.
  • Zero ERR_PIPELINE_DESYNC events per 1M vectors ingested.
  • Index build time within 2× the storage-math baseline for the dataset size.

Because these queries read catalog and statistics views, restrict the exporter’s role to the minimum needed; the access-control patterns for vector-bearing tables are in security boundaries for vector data.

FAQ

Why does my index build succeed but queries still hit a sequential scan?

Almost always an operator-class or statistics mismatch, not a build failure. The index is bound to one operator class; if your query uses a different distance operator the planner cannot use it and silently falls back to a Seq Scan. Run the operator-class check, confirm the query operator matches, and run ANALYZE so the planner prices the index scan correctly.

How do I tell ERR_RECALL_DRIFT apart from ERR_PIPELINE_DESYNC? They look identical.

Both present as bad search results with no error. The discriminator is the ingestion contract: run the Python dtype/dimension/normalization validator on a sample of the stored vectors. If they fail the contract it is a desync (fix the pipeline); if they pass but recall is still low it is genuine drift (raise ef_search/probes or rebuild with a denser graph).

Can I validate recall without a labelled ground-truth dataset?

Yes — you generate the ground truth yourself. Run your representative query set with SET LOCAL enable_indexscan = off so PostgreSQL computes exact brute-force distances, treat those top-K as truth, then compare the index-served results against them. No human labels are required, only a representative sample of real query vectors.

Is REINDEX CONCURRENTLY safe to run on a live replicated cluster?

It does not block reads or writes on the primary, but it is not free: it rebuilds the whole index and emits substantial WAL, which can spike replication lag. Schedule it in a window with known WAL and replica headroom, and monitor lag during the rebuild rather than assuming “concurrently” means “invisible.”

How often should the recall check run in CI?

Structural checks (operator class, indisvalid, dimension) should run on every deploy because they are nearly free. The full recall harness is expensive, so run it nightly against staging and as a gate before any index parameter change reaches production, alerting if Recall@10 regresses below the SLO.

Up: HNSW & IVFFlat Index Creation & Tuning