Skip to content
dbexplore

The pgvector index nobody measured

Vector search in Postgres degrades quietly. The index crosses RAM, the build runs on disk, recall decays with churn, and none of it raises an alert.

Vector search in Postgres has a failure mode that almost nothing else in the database shares. When a query plan goes bad, the query gets slow and somebody notices. When a vector index goes bad, the query stays fast and starts returning worse answers.

Nobody gets paged for worse answers. There is no metric for it in any standard dashboard, and the application cannot tell the difference between the nearest neighbour and the fourth nearest. It just quietly serves slightly wrong results until somebody complains that search has felt off for a while.

The build that silently falls off a cliff

Start with the part people hit first.

Building an HNSW index requires the graph under construction to be resident in memory. The amount of memory available for that is governed by maintenance_work_mem, and the default is small, historically tens of megabytes, sized for building B-trees on ordinary tables.

If the graph does not fit, the build does not fail. It spills and continues, using the disk as working space, and it finishes correctly. It simply takes somewhere between ten and fifty times longer.

This produces one of the more frustrating conversations in operations, where someone reports that an index build has been running for eleven hours and is asked whether they raised the memory setting, and the answer is that nobody knew there was a setting. The build was not misconfigured in a way that announced itself. It was configured with a default intended for a different kind of index.

The index that outgrows the machine

The second problem is slower to arrive and worse when it does.

An HNSW index is not small. At a few million rows and typical embedding dimensions you are into tens of gigabytes; at ten million rows and larger embeddings you can be well past a hundred. That is the index alone, before the table it indexes.

While the index fits comfortably in the page cache, queries are fast and consistent. As it approaches the size of available memory, the tail starts moving, because some proportion of graph traversals now touch disk. Past the crossover, the tail collapses, and the median often still looks fine, which is why the first symptom is usually a complaint about occasional slowness rather than a chart that has obviously moved.

The crossover is a property of index size against available memory. Both are knowable in advance. Almost nobody tracks either, because index size is not something conventional database monitoring treats as interesting.

Recall is a correctness metric, and it drifts

The third problem is the one that has no equivalent elsewhere in the database.

Approximate nearest neighbour search trades exactness for speed, and how much exactness you trade is tunable. The measure is recall: of the true nearest neighbours, what fraction did the index actually return.

Recall is not stable. On an IVFFlat index it decays as the data drifts away from the centroids chosen when the index was built, so a table under steady insert and update pressure gets quietly worse over weeks until it is rebuilt. On HNSW it moves with the search-time effort parameter, which people lower when they want latency and then forget about. Filtering makes it worse again: applying a predicate after the vector search rather than during it can cut the returned set down to very little, and the query still returns quickly with fewer good results.

None of these raise an error. None of them change the shape of the plan. The query is fast and the answers are worse.

What to actually do

The measurements that matter here are not the ones a database monitor usually collects.

  • Project index size against available memory and alert on the approach, not the crossing. By the time the tail moves you are already past it.
  • Size the build memory deliberately before a large index build, and treat a build that takes far longer than the estimate as a signal rather than as patience.
  • Sample recall on a schedule. Take a fixed set of query vectors, run exact nearest neighbour search against the table, compare it with what the index returns, and record the fraction. This is the only way to see decay, and it costs a few expensive queries at a quiet hour.
  • Alert on the recall trend, not a threshold. What matters is that it is falling, and the absolute number that is acceptable depends entirely on the application.

A note on the alternatives

The field has moved. Extensions built on disk-optimised graph structures now beat the original pgvector implementation on recall at a given throughput, particularly in exactly the regime described above, where the index no longer fits in memory. Others rework how vectors are stored and updated to hold recall steadier under write pressure.

That does not make pgvector the wrong choice. It makes it a choice, where previously it was the only option, and the deciding factor is usually whether your working set fits in memory and how much churn the table takes.

The general version of that accounting, for ordinary indexes as much as vector ones, is in unused and missing indexes.

What has not changed is that all of them share the same operational blind spot. Whichever you pick, the index has a size, the build has a memory requirement, and the recall has a trend. If nothing is watching those three things, the degradation is invisible until a user tells you that search has not been very good lately.

Keep reading

· 5 min read

PostgreSQL 18 moved the WAL I/O counters

pg_stat_wal lost its write and sync columns to pg_stat_io in 18. Nothing errors. The numbers just stop arriving, and most monitoring never notices.

Read the post →

· 5 min read

75% of our query-plan storage was duplicate

Thousands of stored plans turned out to be a few hundred distinct shapes. What a structural fingerprint keeps, what it strips, and why regressions depend on it.

Read the post →

· 5 min read

Aurora's writer endpoint can lie to you

CloudWatch measures instances. Your application talks to endpoints. For the minute those disagree, every instance metric looks healthy while writes fail.

Read the post →

Run this on your own fleet.

DBExplore watches every Postgres you run and acts only inside the guardrails you set. Early access is open in small batches.