Extensions
- pg_recall_guard 0.2.0
- Measure and watch recall of any PostgreSQL vector index
README
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
pgxn install pg_recall_guard
Or from source:
make install # or: make PG_CONFIG=/path/to/pg_config install
Then, in the database you want to watch:
CREATE EXTENSION pg_recall_guard CASCADE;
The CASCADE pulls in tsm_system_rows (PostgreSQL contrib), which measure()
uses to sample query vectors. Without it, CREATE EXTENSION fails immediately and
tells you so — rather than installing cleanly and failing at the first
measurement.
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.
Tests
make installcheck # no vector extension required
make installcheck-vector # end-to-end, requires pgvector
They are split on purpose. installcheck covers what must hold on any PostgreSQL
— the extension loads, discovery invents nothing, and pointing it at a plain btree
fails with a message that says where to look. installcheck-vector builds a real
HNSW index and asserts the thing that matters: that recall collapses when the
index is throttled. If it did not, the tool would not be measuring anything and
every other number it prints would be decorative.
Assertions are ranges, not exact values, because measure() samples queries at
random — a test demanding 0.9000 would fail every few runs, and a test that
fails for reasons that are not failures gets ignored.
installcheck is green on PostgreSQL 18.6 and 19beta2, with the same expected
output on both. Earlier versions are not tested: META.json claims 13 as the
minimum because nothing in the SQL is newer than 9.5 (TABLESAMPLE,
tsm_system_rows, ON CONFLICT, EXPLAIN (FORMAT JSON), format() with
%I/%L), but that is reasoning from the source, not a passing test. If you run
it on 13–17, a report either way is welcome.
Maturity
0.2.0, released as stable — the test suite is what earned the label, not
time in the field. What that means concretely: the mechanism is tested, including
the assertion that recall collapses when an index is throttled, and it is green
on two PostgreSQL versions with identical expected output.
What it does not mean, and is worth knowing before you rely on it:
- The performance figures above come from one machine and one dataset (38,352
vectors, 768 dimensions), on
hnswanddiskann. Other dimensions,mandef_searchvalues are untested. - PostgreSQL 13–17 are untested. Green on 18.6 and 19beta2.
- Nobody has run this against a production workload. The 0.1.0 release was
testing, which meantpgxn installskipped it by default and nobody could find it — so field reports had no way of arriving. That is the main reason this release isstable. - The sampling strategy — drawing query vectors from rows of the table — is a reasonable proxy for real traffic, not real traffic. Capturing actual queries needs an executor hook in C, which this does not have.
Treat the recall numbers it reports as trustworthy and its coverage as narrow. Reports from other datasets, dimensions and index parameters are the single most useful thing anyone could send.
License
PostgreSQL License.