How small can your embeddings get before search gets worse? shrinkvec measures every compression option on your vectors, builds the real indexes in your Postgres, and tells you the smallest one that keeps your recall, with the SQL to switch.
Float32 embeddings are big: 6 KB per row at 1536 dimensions, so 100 million rows is 600 GB before
any index. pgvector's halfvec and binary_quantize, VectorChord's RaBitQ and the new TurboQuant
extensions all promise to shrink that. How much recall each one costs depends on your embedding
model, and published numbers are mostly measured by the vendors on their own data. shrinkvec
answers it on your data, in a few minutes, with exact ground truth.
docker run --rm -p 8080:8080 ghcr.io/ahmad-codex/shrinkvec:demoIt starts Postgres with pgvector and VectorChord, loads 25,000 real OpenAI embeddings, measures 13 compression options and 5 index types, and serves the report at http://localhost:8080. It takes about three minutes on a laptop.
pip install 'shrinkvec[pg]'
export SHRINKVEC_DSN=postgresql://user:password@host:5432/db
shrinkvec run --table public.documents --column embedding --target 0.95It samples 100,000 rows (-n to change), holds out 1,000 of them as queries, computes the exact
neighbours, and then:
- Codec lab: the recall of every compression option, scanning every code exactly, so the numbers measure the compression and not an index. No extension needed.
- Real indexes: builds pgvector HNSW on
vector,halfvecandbinary_quantizewith rerank, and VectorChordvchordrq(with and without the vectors in the index) when it is installed, in its ownshrinkvecschema. It never writes to your tables. For each index it sweeps the search setting and records recall, p50/p95 latency, size on disk and build time. - Recommendation: the smallest index that keeps recall@10 at or above
--target, and the exactCREATE INDEX,SETand query to use it.
Vectors in a file work too, without a database: shrinkvec lab --file vectors.npy (also .fvecs
and .parquet).
The same 13 options on three datasets, scanning every code (no index), with 1,000 held-out queries and exact ground truth. Codecs with a random rotation ran with 3 seeds; ± is the standard deviation.
| Option | dbpedia-openai-1536 recall@10 |
glove-100 recall@10 |
sift-128 recall@10 |
Bytes/vector searched (1536 d) |
|---|---|---|---|---|
float32 |
1.000 | 1.000 | 1.000 | 6,144 |
halfvec |
0.999 | 0.999 | 1.000 | 3,072 |
int8 |
0.994 | 0.981 | 0.990 | 1,536 |
tq-mse-4 |
0.953 ± 0.001 | 0.855 ± 0.003 | 0.814 ± 0.001 | 772 |
tq-prod-4 |
0.817 ± 0.003 | 0.631 ± 0.003 | 0.339 ± 0.005 | 772 |
tq-mse-3 |
0.916 ± 0.002 | 0.737 ± 0.002 | 0.671 ± 0.003 | 580 |
tq-mse-2 |
0.850 ± 0.000 | 0.539 ± 0.003 | 0.456 ± 0.005 | 388 |
rabitq-4 |
0.972 ± 0.001 | 0.878 ± 0.001 | 0.858 ± 0.001 | 776 |
rabitq-1 |
0.834 ± 0.003 | 0.329 ± 0.003 | 0.252 ± 0.002 | 200 |
binary |
0.707 | 0.136 | 0.022 | 192 |
tq-mse-3>tq-mse-8 |
0.996 ± 0.000 | 0.986 ± 0.000 | 0.983 ± 0.001 | 580 |
rabitq-1>float32 |
0.999 ± 0.000 | 0.736 ± 0.001 | 0.704 ± 0.006 | 200 |
binary>float32 |
0.992 | 0.348 | 0.055 | 192 |
| Index | Size on disk | First setting with recall@10 ≥ 0.95 | p50 there | Best recall@10 |
|---|---|---|---|---|
hnsw-vector |
781 MiB | hnsw.ef_search=80 (0.959) | 2.34 ms | 0.982 |
hnsw-halfvec |
391 MiB | hnsw.ef_search=80 (0.957) | 2.31 ms | 0.978 |
hnsw-binary-rerank |
48 MiB | candidates=100 (0.963) | 2.83 ms | 0.994 |
vchordrq |
812 MiB | vchordrq.probes=40 (0.981) | 2.58 ms | 0.981 |
vchordrq-codes |
31 MiB | vchordrq.probes=20 (0.957) | 1.69 ms | 0.985 |
| Index | Setting | recall@10 | p50 ms | p95 ms | Index size | Build |
|---|---|---|---|---|---|---|
hnsw-vector |
hnsw.ef_search=40 | 0.941 | 1.74 | 2.23 | 781 MiB | 83 s |
hnsw-vector |
hnsw.ef_search=80 | 0.959 | 2.34 | 3.07 | 781 MiB | 83 s |
hnsw-vector |
hnsw.ef_search=160 | 0.972 | 3.55 | 4.51 | 781 MiB | 83 s |
hnsw-vector |
hnsw.ef_search=320 | 0.982 | 5.83 | 8.60 | 781 MiB | 83 s |
hnsw-halfvec |
hnsw.ef_search=40 | 0.940 | 1.73 | 2.27 | 391 MiB | 71 s |
hnsw-halfvec |
hnsw.ef_search=80 | 0.957 | 2.31 | 3.89 | 391 MiB | 71 s |
hnsw-halfvec |
hnsw.ef_search=160 | 0.969 | 3.71 | 5.41 | 391 MiB | 71 s |
hnsw-halfvec |
hnsw.ef_search=320 | 0.978 | 5.35 | 7.10 | 391 MiB | 71 s |
hnsw-binary-rerank |
candidates=100 | 0.963 | 2.83 | 4.97 | 48 MiB | 33 s |
hnsw-binary-rerank |
candidates=200 | 0.977 | 4.98 | 7.07 | 48 MiB | 33 s |
hnsw-binary-rerank |
candidates=400 | 0.986 | 7.65 | 9.80 | 48 MiB | 33 s |
hnsw-binary-rerank |
candidates=800 | 0.994 | 13.69 | 16.52 | 48 MiB | 33 s |
vchordrq |
vchordrq.probes=4 | 0.854 | 1.04 | 1.42 | 812 MiB | 14 s |
vchordrq |
vchordrq.probes=10 | 0.916 | 1.31 | 1.77 | 812 MiB | 14 s |
vchordrq |
vchordrq.probes=20 | 0.949 | 1.71 | 2.00 | 812 MiB | 14 s |
vchordrq |
vchordrq.probes=40 | 0.981 | 2.58 | 3.27 | 812 MiB | 14 s |
vchordrq-codes |
vchordrq.probes=4 | 0.853 | 1.07 | 1.26 | 31 MiB | 11 s |
vchordrq-codes |
vchordrq.probes=10 | 0.920 | 1.30 | 1.51 | 31 MiB | 11 s |
vchordrq-codes |
vchordrq.probes=20 | 0.957 | 1.69 | 2.01 | 31 MiB | 11 s |
vchordrq-codes |
vchordrq.probes=40 | 0.985 | 2.43 | 2.87 | 31 MiB | 11 s |
Table (the vectors themselves): 795 MiB. PostgreSQL 18.3, vector 0.8.2, vchord 1.1.1. Windows AMD64, 12 threads, Python 3.12.13.
Bytes are for 1536 dimensions. Full results, with the reports' charts as Markdown: DBpedia OpenAI · GloVe-100 · SIFT-128.
What the numbers say:
- The same compression does not transfer between models. pgvector's
binary_quantizewith a rerank keeps 0.992 of the true top 10 on OpenAI embeddings, 0.348 on GloVe and 0.055 on SIFT, whose values are all positive, so a sign bit carries almost nothing. Vendor numbers on someone else's embeddings do not tell you what yours will do. - RaBitQ beats a plain sign bit at the same size. 0.834 against 0.707 on OpenAI embeddings for 8 more bytes per row, and at 4 bits it beats TurboQuant on all three datasets.
- For search, spend TurboQuant's bits on the codes. The paper's unbiased variant (
tq-prod) gives up one code bit for a correction sketch; at the same 4 bits it loses 14 to 48 points of recall to the plain codes (tq-mse). Unbiased inner products do not make a better ranking. - Search small, rerank precise. 3-bit codes reranked with 8-bit codes keep 0.983 or more on every dataset while searching 10.6 times less data than float32.
DBpedia OpenAI embeddings, 100,000 rows, 1,000 queries one at a time from one client, each index built with its defaults and then swept over its search setting. Postgres 18.3 with pgvector 0.8.2 and VectorChord 1.1.1 in Docker on a 6-core laptop (Intel i7-10750H).
The table itself (the float32 vectors) is 795 MiB. VectorChord keeps a second copy of every vector
inside its index by default; with rerank_in_table = true the index holds only RaBitQ codes and
reranks from the table, which here made it both the smallest and the fastest index that reaches
0.95. VectorChord warns that this mode costs latency when the table has to come from disk: in this
run the whole table fits in shared_buffers, so measure it on your own data size. Every setting, with
p95 and build times, is in results.md.
Every result is recall@10 against exact search in float64 over held-out queries. Every byte a codec stores per row is counted, including norms, correction factors and padding.
The TurboQuant and RaBitQ codecs are written from their papers and checked against them in the test suite: TurboQuant's distortion at 1 to 4 bits matches the paper (0.363, 0.117, 0.0345, 0.0095), its prod estimator is unbiased, RaBitQ's estimator is unbiased and its error bound holds at the rate the theory predicts. Recall is also cross-checked against the reference libraries on the same vectors: within 0.008 of the official rabitqlib at 1, 4 and 7 bits, and within 0.003 of turbovec at 2 and 3 bits (0.014 higher at 4 bits). The details, and what the numbers do not cover, are in docs/method.md.
| Codec | Paper | In Postgres |
|---|---|---|
halfvec |
pgvector | |
binary (+ rerank) |
pgvector binary_quantize |
|
int8 |
not in pgvector | |
rabitq-1, rabitq-<b> |
RaBitQ (SIGMOD 2024), extended (SIGMOD 2025) | VectorChord |
tq-mse-<b>, tq-prod-<b> |
TurboQuant (ICLR 2026) | pg_turbovec |
Pipelines combine them: rabitq-1>float32 searches the 1-bit codes and reranks the best 100 with
the full vectors; tq-mse-3>tq-mse-8:200 reranks 200 with 8-bit codes.
Fail a build when a model or codec change drops recall below a floor:
shrinkvec lab --file eval/embeddings.npy --pipelines 'halfvec,rabitq-1>float32' --min-recall 0.97The exit code is 1 when any listed option falls below the floor, and the report is written either way.
| Command | What it does |
|---|---|
shrinkvec lab |
Codec lab only, no database |
shrinkvec pg |
Real indexes only |
shrinkvec run |
Both, when --dsn is set, plus the recommendation |
shrinkvec datasets |
The public datasets |
Common options: --dataset, --file, --table/--column, -n, --queries, -k, --target,
--min-recall, --pipelines, --indexes, --repeats, --seed, --out. shrinkvec <command> --help lists them all.
Every run writes report.html (self-contained, charts, light and dark), results.md and
results.json.
From Python or a notebook, run_lab, make_codec and run_pg take NumPy arrays directly: see
docs/python.md.
uv sync
uv run pytest -m "not crosscheck"
uv run --group crosscheck pytest -m crosscheckSee CONTRIBUTING.md and ROADMAP.md. Next up: dimension truncation for Matryoshka models, recall under filters, IVFFlat, pgvectorscale and pg_turbovec. Results on other embedding models are welcome through the "Share a result" issue form.