DEV Community

jidonglab
jidonglab

Posted on

Binary Quantized Embeddings: 32x Smaller, If You Center First

A 100M-chunk index at 1024 dimensions in float32 is 410 GB of raw vectors. Nobody wants to pay for that much RAM, so the index goes to disk, latency goes to 40ms per hop, and someone starts a doc titled "sharding strategy." Binary quantized embeddings turn those same 410 GB into 12.8 GB by keeping one bit per dimension — and if you do it right, top-10 results barely move.

The catch is that "do it right" is doing a lot of work in that sentence. Most teams that try binary quantization and get bad recall made one of two mistakes: they binarized around zero instead of around the corpus mean, or they skipped the float rescoring pass. Both are cheap to fix once you understand why one bit per dimension works at all.

TL;DR

  • Binary quantized embeddings store sign(x_i - c_i) per dimension: 1 bit instead of 32, a hard 32x memory cut, and distance becomes XOR + popcount instead of a float dot product.
  • It works because Hamming distance over sign bits is an unbiased estimator of the angle between vectors (random-hyperplane LSH): θ̂ = π · d_H / D.
  • Estimator noise scales as 1/√D. At 1024 dims the angular standard error is about 2.6°; at 256 dims it is about 5.3°. Below ~768 dims, binary alone is too noisy to rank with.
  • Always subtract the corpus mean before taking signs. Embeddings are anisotropic — dimensions whose sign is constant across the corpus contribute zero bits of information.
  • Always rescore. Retrieve k × 4..8 candidates by Hamming, then reorder them with the full-precision vectors. Rescoring costs microseconds and recovers nearly all the lost precision.

Why do binary quantized embeddings work at all?

Because the sign bit of each dimension is a random hyperplane test, and a bag of hyperplane tests measures angle.

Charikar's sim-hash result: for a random hyperplane with normal vector r, the probability that two vectors u and v land on the same side is 1 - θ/π, where θ is the angle between them. Take D independent hyperplanes and count disagreements, and you get a binomial estimate of θ:

d_H(u, v) ~ Binomial(D, θ/π)
θ̂ = π · d_H / D
Enter fullscreen mode Exit fullscreen mode

Binary quantization uses the coordinate axes as the hyperplanes instead of random ones. That's not free — it only behaves like the random case if the dimensions carry roughly comparable, roughly independent information. Modern embedding models trained with contrastive objectives are close enough to that for the estimator to hold in practice, and if yours isn't, there's a fix later in this post.

Since all cosine-similarity retrieval is really angle ranking, an unbiased angle estimator is all you need to build a first-pass candidate list. The float vectors then settle the final order.

How many dimensions do you need for binary quantization?

At least 768, and 1024+ is where it gets comfortable. The math is a binomial standard error, so you can compute the answer instead of guessing.

For two vectors 60° apart (p = θ/π = 1/3), the standard error of the angle estimate is π · √(p(1-p)/D):

D Angular std. error
256 ~5.3°
384 ~4.3°
768 ~3.1°
1024 ~2.6°
1536 ~2.2°

Now compare against the corpus. In a well-populated RAG index, the angular gap between the rank-1 neighbor and the rank-50 neighbor for a typical query is often just a few degrees. A ±2.6° estimator smears that ranking but keeps the true top-10 somewhere in the top-100. A ±5.3° estimator does not — the true best chunk lands at rank 400 and your oversampling window never sees it.

There's a second, sharper problem at low D: ties. Hamming distance over D bits takes exactly D+1 integer values. At D = 256 and 100M documents, the top of the ranking is thousands of documents deep in a single distance bucket, and the order within a bucket is whatever your argsort felt like. At D = 1024 the buckets are still coarse, but the oversampling pass plus float rescoring resolves them.

This is also why you should not stack Matryoshka truncation and binary quantization without measuring. Truncating a 1536-dim vector to 512 and then binarizing compounds two lossy steps in the dimension that matters. Pick one budget cut, not two.

Why does centering matter more than anything else?

Because sign(x_i) places the hyperplane at the origin, and your embeddings are not centered at the origin.

Contrastively trained embedding models produce a strongly anisotropic cloud: there is a large common mean vector shared by every document, and the semantic signal lives in the deviations from it. If dimension 412 is positive for 97% of your corpus, that bit is nearly constant — it costs storage and contributes almost nothing to the Hamming distance. Lose a few dozen dimensions that way and you've quietly dropped your effective D from 1024 to 700, straight into the noisy regime above.

The fix is one line: estimate the mean over a corpus sample and subtract it before taking signs. Per-dimension median works marginally better on skewed models, and is the more principled choice — it forces each bit to a 50/50 split by construction, maximizing per-bit entropy.

Use the same center for queries. The whole estimator assumes both sides were cut by the same hyperplanes.

If your model is genuinely anisotropic in a way centering doesn't fix — a handful of dimensions with enormous variance carrying most of the signal — apply a fixed random rotation (or a learned one, à la ITQ) to every vector before binarizing. A random orthogonal matrix preserves all angles exactly while spreading the variance across dimensions, which is precisely the condition the coordinate-hyperplane approximation needs. It costs one matmul at index time and one per query.

What does this look like in code?

Here's the whole thing in NumPy. packbits gives you the 32x, bitwise_count gives you the speed.

import numpy as np

def fit_center(embs: np.ndarray, sample: int = 200_000) -> np.ndarray:
    """Per-dimension median over a corpus sample. Store this — queries need it."""
    idx = np.random.default_rng(0).choice(
        len(embs), size=min(sample, len(embs)), replace=False
    )
    return np.median(embs[idx], axis=0)

def binarize(embs: np.ndarray, center: np.ndarray) -> np.ndarray:
    """(N, D) float32 -> (N, D/8) uint8. 1024 dims -> 128 bytes per doc."""
    return np.packbits(embs > center, axis=-1)

# numpy >= 2.0; otherwise use a 256-entry uint8 popcount LUT
def hamming_topk(qbin: np.ndarray, dbin: np.ndarray, k: int) -> np.ndarray:
    dist = np.bitwise_count(dbin ^ qbin).sum(axis=1)   # (N,) uint
    part = np.argpartition(dist, k)[:k]
    return part[np.argsort(dist[part], kind="stable")]

def search(q, dbin, dfloat, center, k=10, oversample=8):
    qbin = np.packbits(q > center)
    cand = hamming_topk(qbin, dbin, k * oversample)     # RAM, integer ops
    scores = dfloat[cand] @ q                           # disk/mmap, k*oversample reads
    return cand[np.argsort(-scores)[:k]]
Enter fullscreen mode Exit fullscreen mode

Two production details hidden in those seven lines:

dfloat should be an np.memmap (or an object-store range-read), not a resident array. The point of binary quantization is that the hot structure — the bits you scan or the HNSW graph you walk — fits in RAM, while full precision lives on SSD and is touched only for the k × oversample candidates. 80 random 4 KB reads is well under a millisecond on NVMe.

q stays float32. Never binarize the query if you're doing an exact scan — asymmetric scoring (float_query · sign(doc)) eliminates half the quantization error for free. You give up popcount speed, so use symmetric Hamming when you're scanning hundreds of millions of vectors, asymmetric when you're rescoring or working over a graph index.

How much oversampling do you need?

Start at 4x and measure; 8x is a safe default for 1024 dims. The cost curve is extremely favorable: doubling oversampling doubles only the rescoring reads, which are a rounding error against the candidate scan.

Measure it properly. Compute exact float32 top-10 for a few thousand held-out queries, then report recall@10 of the binary+rescore pipeline against that ground truth — not against human relevance labels, which conflate quantization loss with retrieval quality. You want to know exactly what quantization cost you, isolated. Published results for 1024-dim models with rescoring land in the mid-to-high 90s percent of float recall; your corpus will differ, which is why you measure rather than cite.

What does this look like in pgvector?

pgvector 0.7+ ships binary_quantize, a bit type, and Hamming/Jaccard index ops. The two-stage query is a CTE:

ALTER TABLE docs ADD COLUMN emb_bin bit(1024);

-- :center is your stored per-dimension median, as a vector literal
UPDATE docs SET emb_bin = binary_quantize(emb - :center);

CREATE INDEX ON docs USING hnsw (emb_bin bit_hamming_ops);

WITH cand AS (
  SELECT id, emb
  FROM docs
  ORDER BY emb_bin <~> binary_quantize(:q::vector - :center)
  LIMIT 80                       -- 8x oversample for a top-10 query
)
SELECT id FROM cand ORDER BY emb <=> :q::vector LIMIT 10;
Enter fullscreen mode Exit fullscreen mode

One thing that surprises people: once vectors are 128 bytes, the HNSW graph itself becomes the memory hog. At M = 16, layer 0 stores up to 32 neighbor IDs per node — 128 bytes at 4-byte IDs, the same size as the binary vector. Your 32x vector saving turns into roughly 16x total index saving. Budget for that before promising numbers to your infra team.

When does binary quantization actually fail?

Four failure modes, in rough order of how often I've seen them:

  1. Uncentered binarization. Recall drops 10–20 points and everyone blames the technique. Check the per-dimension bit balance: if any dimension is more than ~70/30, your center is wrong or stale.
  2. Center drift. You fit the median on the launch corpus, then ingest a new document type with a different distribution. Old and new documents are now cut by hyperplanes that are well-placed for one and badly placed for the other, and cross-comparisons degrade. Refit on a rolling sample; refitting requires a full re-binarize, which is a cheap batch job (it's one comparison per dimension) but not a free one.
  3. No rescoring. recall@100 looks fine, so someone ships without the float pass. Then top-1 precision is bad, because ranking within a Hamming tie bucket is arbitrary. Binary is a filter, not a ranker.
  4. Short documents / tiny corpora. Under a few hundred thousand vectors, none of this matters — float32 fits in RAM and you're adding a moving part for nothing.

Is int8 a better default than binary?

For most teams, yes — start there. Per-dimension scalar quantization to int8 gives 4x compression (410 GB → 102 GB), typically retains recall within a point or two of float32 without any rescoring pass, and has no anisotropy failure mode because per-dimension min/max calibration handles offset automatically.

Binary earns its complexity at a specific scale: when 4x isn't enough to fit the hot set in RAM, and when your embedding model is 1024 dims or wider. That's the crossover. Below it, int8 is the boring correct answer. Above it, binary plus int8 rescoring (skip float32 entirely — rescore against the int8 copy) gets you a RAM-resident scan and a disk-resident reorder on hardware you can actually rent.

The short answer

Binary quantized embeddings replace each float32 dimension with a single sign bit, cutting vector memory exactly 32x and turning similarity into XOR + popcount. The technique is sound because Hamming distance over sign bits is an unbiased estimator of the angle between vectors, with error shrinking as 1/√D — which is why it holds up at 1024 dimensions and falls apart below ~512. Two things make the difference between "near-identical recall" and "why is our RAG broken": subtract the corpus mean or median before taking signs, so every bit carries real information instead of encoding the model's anisotropy, and always rescore the top k × 4..8 Hamming candidates against full-precision vectors, because binary is a candidate filter and never a final ranker. Do both, measure recall@10 against exact float32 results on held-out queries, and you can keep a 100M-vector index resident in single-digit gigabytes.

Top comments (0)