Rule of thumb: index halfvec whenever you can — on our data it cost -0.2 recall points for a 50% smaller index. It's also how you index 2,001–4,000 dimensions. Binary quantization is a further step for very large tables, and only works with a re-ranking query.
The options at a glance#
| Representation | Bytes per 1,536-dim value | Index limit | HNSW index, 100K rows | Recall@10 (ef 40) |
|---|---|---|---|---|
vector (float32) | 6,152 | 2,000 dims | 781 MB | 0.947 |
halfvec (float16) | 3,080 | 4,000 dims | 391 MB | 0.949 |
bit via binary_quantize | 200 | 64,000 dims | 48 MB | 0.699 alone · 0.985 re-ranked |
HNSW index size by representation
100,000 × 1,536 dims. Binary quantization indexes 1 bit per dimension; the full vectors stay in the table for re-ranking.
| Representation | Table size | Index size | Index build |
|---|---|---|---|
| vector(1536) | 795 MB | 781 MB | 3.4 min |
| halfvec(1536) | 400 MB | 391 MB | 2.9 min |
| bit(1536) via binary_quantize | (uses vector table) | 48 MB | 39 s |
Recall vs latency: vector, halfvec and binary quantization
100K × 1,536 dims, HNSW m 16 / ef_construction 64. Binary points vary the re-rank candidate count (20 → 640).
- vector (float32)
- halfvec (float16)
- binary + re-rank
| Variant | Setting | Recall@10 | p50 |
|---|---|---|---|
| vector | ef 10 | 0.815 | 1.4 ms |
| vector | ef 20 | 0.897 | 1.9 ms |
| vector | ef 40 | 0.947 | 2.9 ms |
| vector | ef 64 | 0.970 | 3.9 ms |
| vector | ef 100 | 0.983 | 5.3 ms |
| vector | ef 200 | 0.990 | 9.1 ms |
| vector | ef 400 | 0.995 | 16 ms |
| halfvec | ef 10 | 0.819 | 1.4 ms |
| halfvec | ef 20 | 0.896 | 1.8 ms |
| halfvec | ef 40 | 0.949 | 2.5 ms |
| halfvec | ef 64 | 0.968 | 3.5 ms |
| halfvec | ef 100 | 0.982 | 4.8 ms |
| halfvec | ef 200 | 0.991 | 7.8 ms |
| halfvec | ef 400 | 0.995 | 14 ms |
| binary + re-rank | 20 candidates | 0.854 | 1.8 ms |
| binary + re-rank | 40 candidates | 0.918 | 2.3 ms |
| binary + re-rank | 80 candidates | 0.966 | 3.5 ms |
| binary + re-rank | 160 candidates | 0.985 | 5.7 ms |
| binary + re-rank | 320 candidates | 0.993 | 10 ms |
| binary + re-rank | 640 candidates | 0.997 | 20 ms |
halfvec: half the bytes, almost all the recall#
A halfvec stores each element as a 16-bit float — about three significant digits, range ±65,504. Normalised embeddings sit far inside that range, so the rounding barely moves distances. You can either store halfvec columns, or keep full precision in the table and index a cast:
demo=# CREATE INDEX ON items USING hnsw ((embedding::halfvec(64)) halfvec_cosine_ops);
CREATE INDEX
demo=# EXPLAIN (COSTS OFF) SELECT id FROM items ORDER BY embedding::halfvec(64) <=> (SELECT embedding FROM items WHERE id = 1)::halfvec(64) LIMIT 10;
QUERY PLAN
-----------------------------------------------------------------------------------
Limit
InitPlan 1
-> Index Scan using items_pkey on items items_1
Index Cond: (id = 1)
-> Index Scan using items_embedding_idx3 on items
Order By: ((embedding)::halfvec(64) <=> ((InitPlan 1).col1)::halfvec(64))
(6 rows)| Approach | Table | Index | Use when |
|---|---|---|---|
halfvec column | 400 MB (100K × 1,536) | 391 MB | You never need full precision |
vector column + halfvec expression index | 795 MB | 391 MB | You want exact re-ranking or full-precision exports |
Recall and latency for the two were the same within noise: the expression index scored 0.949 at ef_search = 40 (3.0 ms median). The halfvec index also built faster — 2.9 min versus 3.4 min — because two elements fit on each 8 KB page instead of one.
Indexing more than 2,000 dimensions#
OpenAI's text-embedding-3-large returns 3,072 dimensions by default, and pgvector refuses to index it as vector:
column cannot have more than 2000 dimensions for hnsw index
column cannot have more than 2000 dimensions for ivfflat indexA halfvec expression index works up to 4,000 dimensions. On 99,000 3,072-dim DBpedia vectors:
| ef_search | Recall@10 | p50 | p95 |
|---|---|---|---|
| 20 | 0.889 | 2.9 ms | 4.5 ms |
| 40 | 0.937 | 4.1 ms | 7.0 ms |
| 100 | 0.979 | 7.1 ms | 12 ms |
| 200 | 0.989 | 12 ms | 17 ms |
Index: 773 MB, built in 5.4 min. Exact search over the same table took 716 ms per query. Above 4,000 dimensions you'll see column cannot have more than 4000 dimensions for hnsw index — use binary quantization or reduce dimensions.
Binary quantization with re-ranking#
binary_quantize() keeps one bit per dimension — 1 if the element is positive. Hamming distance between those bit strings roughly tracks angular distance, so a bit index can shortlist candidates cheaply; the full vectors, still in the table, then put them in the right order:
demo=# CREATE INDEX ON items USING hnsw ((binary_quantize(embedding)::bit(64)) bit_hamming_ops);
CREATE INDEX
demo=# EXPLAIN (COSTS OFF)
demo-# SELECT id FROM (
demo-# SELECT id, embedding FROM items
demo-# ORDER BY binary_quantize(embedding)::bit(64) <~> binary_quantize((SELECT embedding FROM items WHERE id = 1))
demo-# LIMIT 40
demo-# ) candidates
demo-# ORDER BY embedding <=> (SELECT embedding FROM items WHERE id = 1)
demo-# LIMIT 10;
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------
Limit
InitPlan 1
-> Index Scan using items_pkey on items items_1
Index Cond: (id = 1)
-> Sort
Sort Key: ((candidates.embedding <=> (InitPlan 1).col1))
-> Subquery Scan on candidates
-> Limit
InitPlan 2
-> Index Scan using items_pkey on items items_2
Index Cond: (id = 1)
-> Index Scan using items_binary_quantize_idx on items
Order By: ((binary_quantize(embedding))::bit(64) <~> binary_quantize((InitPlan 2).col1))
(13 rows)Binary quantization: recall vs re-rank candidates
Fetch N candidates by Hamming distance from the bit index, re-order them by exact cosine distance, keep 10.
- 1,536 dims
- 3,072 dims
| Dims | Candidates | Recall@10 | p50 |
|---|---|---|---|
| 1536 | 20 | 0.854 | 1.8 ms |
| 1536 | 40 | 0.918 | 2.3 ms |
| 1536 | 80 | 0.966 | 3.5 ms |
| 1536 | 160 | 0.985 | 5.7 ms |
| 1536 | 320 | 0.993 | 10 ms |
| 1536 | 640 | 0.997 | 20 ms |
| 3072 | 40 | 0.930 | 2.9 ms |
| 3072 | 80 | 0.970 | 4.9 ms |
| 3072 | 160 | 0.986 | 8.0 ms |
| 3072 | 320 | 0.993 | 16 ms |
On its own the bit index scored 0.699 at 1,536 dims — useful only as a filter. Re-ranking 160 candidates gave 0.985 in 5.7 ms; 640 candidates gave 0.997 in 20 ms. At 3,072 dims the extra bits helped only slightly — 0.930 vs 0.918 with 40 candidates, and about the same (0.986) at 160.
- Index size: 48 MB for 100K × 1,536 — 16× smaller than the
vectorindex. Build: 39 s. - The catch: re-ranking reads full vectors from the table, so the table (or at least the hot part) should still be cached.
- Set
hnsw.ef_search≥ the candidate count or the inner query returns fewer candidates than its LIMIT.
Shortening embeddings#
OpenAI's v3 models are trained so that the first k dimensions form a usable embedding on their own (request them with the dimensions parameter, or truncate and re-normalise). How much does that lose? We truncated the 3,072-dim vectors, re-normalised them, ran exact search, and compared the top-10 with exact search on the full vectors:
Shortening text-embedding-3-large: top-10 overlap with full 3,072 dims
Exact search on truncated, re-normalised vectors vs exact search on the full vectors. 99,000 rows, 1,000 queries.
| Dims | Top-10 overlap | vector bytes | halfvec bytes |
|---|---|---|---|
| 256 | 58.4% | 1,032 | 520 |
| 512 | 73.2% | 2,056 | 1,032 |
| 768 | 80.0% | 3,080 | 1,544 |
| 1024 | 84.1% | 4,104 | 2,056 |
| 1536 | 89.0% | 6,152 | 3,080 |
| 2048 | 91.9% | 8,200 | 4,104 |
| 3072 | 100.0% | 12,296 | 6,152 |
| Dimensions | Top-10 overlap with 3,072 | vector bytes | Indexable as |
|---|---|---|---|
| 256 | 58.4% | 1,032 | vector |
| 512 | 73.2% | 2,056 | vector |
| 768 | 80.0% | 3,080 | vector |
| 1,024 | 84.1% | 4,104 | vector |
| 1,536 | 89.0% | 6,152 | vector |
| 2,048 | 91.9% | 8,200 | halfvec |
| 3,072 | 100.0% | 12,296 | halfvec |
This measures agreement with the full model, not retrieval quality on a labelled task — but it tells you how much ranking changes. We also checked that truncating the 3,072-dim output to 1,536 and re-normalising matches what the API returns when you request 1,536 dimensions: across 2,000 articles the cosine similarity between the two averaged 0.99999 (minimum 0.997) — effectively the same vectors.
You can also keep full vectors and index only a prefix with subvector(), re-ranking with the full vector (pattern).
Which to use#
| Situation | Do this |
|---|---|
| ≤ 2,000 dims, index fits in RAM | vector index — or halfvec to halve memory at negligible cost |
| 2,001–4,000 dims (e.g. 3,072) | halfvec expression index; or request 1,536 dims from the model |
| Index doesn't fit in RAM | halfvec first; then binary quantization with re-ranking |
| > 4,000 dims | Binary quantization with re-ranking, or reduce dimensions |
Size your options with the storage calculator, or let the index planner choose.
Frequently asked questions
How do I index text-embedding-3-large (3,072 dimensions) in pgvector?
Index a half-precision cast: CREATE INDEX ON items USING hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops); and query with ORDER BY embedding::halfvec(3072) <=> $1::halfvec(3072). On 99,000 DBpedia articles that index reached recall@10 of 0.937 at ef_search 40 and 0.979 at 100.
Does halfvec reduce accuracy?
Barely. Half precision keeps about three significant digits per element. On our 100K × 1,536 benchmark, a halfvec HNSW index scored 0.949 recall@10 at ef_search 40 versus 0.947 for full precision, while the index shrank from 781 MB to 391 MB.
Is binary quantization worth it in pgvector?
Only with re-ranking. Searching the 1-bit index alone gave recall@10 of 0.699 on our 1,536-dim data. Fetching 160 candidates by Hamming distance and re-ranking them by exact cosine distance raised it to 0.985, with an index 16× smaller than full precision.