Contents
pg_recall_guard
Watch vector indexes for recall drift against a baseline you approved.
A degraded ANN index does not fail. It returns k plausible neighbours and never mentions that four of the ten good ones were left out. No error, no log entry, no alert — you find out when a user tells you the search “feels worse”, or when you happen to run a benchmark months later.
This extension measures the recall of any vector index against exact ground truth, records the number you accepted as good, and tells you when it drifts.
CREATE EXTENSION pg_recall_guard;
-- What can be measured? Discovered from the catalog, no configuration.
SELECT index_name, table_name, access_method, operator
FROM recall_guard.vector_indexes;
-- index_name | table_name | access_method | operator
-- ------------+------------+---------------+----------
-- items_hnsw | items | hnsw | <=>
-- docs_dann | docs | diskann | <=>
-- Record what you consider good.
SELECT recall_guard.approve('items_hnsw', p_k => 10, p_sample_size => 100);
-- Later — from pg_cron, from your monitoring, from a shell:
SELECT * FROM recall_guard.check();
-- index_name | baseline | current | drift | verdict
-- ------------+----------+---------+---------+---------
-- items_hnsw | 0.9800 | 0.8100 | -0.1700 | critico
Works with any vector index, because it never names one
Indexes are found through pg_amop.amoppurpose = 'o' — access methods that can
answer an ORDER BY on a distance operator. That is a PostgreSQL property, not an
extension’s. Today it covers hnsw and ivfflat (pgvector), diskann
(pgvectorscale), gist and spgist; tomorrow it covers whatever ships next,
with no change here.
Verified on PostgreSQL 19beta2 against pgvector 0.8.6 and pgvectorscale 0.9.0, over
38,352 vector(768) rows:
hnsw.ef_search |
measured recall |
|---|---|
| 1 | 0.0000 |
| 10 | 0.9000 |
| 40 (default) | 1.0000 |
It is built so it cannot flatter itself
Two failure modes would produce a reassuring 1.0000 that means nothing. Both are
checked rather than assumed:
- The indexed side must actually use the index. If the planner ignores it, the measurement compares the index against itself and always scores perfect. The plan is inspected and the function raises instead of returning a number.
- The ground truth must not use it. Same reasoning, other direction.
There is a third trap that is easy to miss. Sample queries are drawn from rows of
the table itself, and a stored vector always finds itself at distance zero — a
free hit in every single query, which with k = 10 means recall can never drop
below 0.1 no matter how broken the index is. pg_recall_guard asks for k+1
neighbours and discards the originating ctid from both sides. That is the
difference between measuring the index and measuring that a vector equals itself:
at ef_search = 1 the naive version reports 0.1000, this one reports 0.0000.
Cost
check() runs one exact sequential scan per sampled query, so it is not free and
it is not meant to run on every request. It is meant to run on a schedule, on a
sample, the way you would run ANALYZE — the degradation it looks for is gradual
and silent, so the only moment it gets caught is when something bothers to look.
Start with p_sample_size => 30 and raise it if the numbers move around between
runs.
Install
make install # or: make PG_CONFIG=/path/to/pg_config install
Requires the tsm_system_rows extension (ships with PostgreSQL contrib) for
sampling.
Prior art, and where this differs
VectorChord 0.5 added vchordrq_evaluate_query_recall and query sampling — a good
design, and this extension borrows the shape of it. But by their own documentation
it is “not supported by vchordg”: it works for their IVF+RaBitQ index only. That
is a rational choice for a vendor and a gap for everyone else, since each vector
extension has an incentive to measure its own index and none has an incentive to
measure the others'.
pg_recall_guard is the neutral version: it belongs to no index, so it can watch
all of them.
Maturity
0.1.0, released as testing. The mechanism is verified and the numbers above
are real, but they come from one machine, one dataset of 38,352 vectors, two index
types and a single PostgreSQL version (19beta2). Nobody has run this against a
production workload yet, and the sampling strategy — drawing query vectors from
rows of the table — is a reasonable proxy for real traffic, not real traffic.
Treat the recall numbers it reports as trustworthy and its coverage as unproven. Reports from other datasets, dimensions and index parameters are the most useful thing anyone could send.
License
PostgreSQL License.