The lesson in one minute
What you'll be able to explain
- Raw storage is n × d × bytes per number: 10M × 1,536 float32 ≈ 61 GB, before index overhead.
- Matryoshka models put the important information in the leading numbers, so you can truncate and re-normalize.
- int8 stores 256 levels per number (4× smaller, little loss); binary keeps signs only (32× smaller, big loss alone).
- Shortlist with the crude form, re-score with full vectors: most of the quality at a fraction of the memory.
- Always measure the cost as recall@k against exact search.
Level 1
The practitioner's guide
In one sentence
Embedding compression stores each vector in fewer numbers, or rougher ones, so that an index of millions fits in memory and searches faster, while still returning the same neighbours as the full vectors would.
When you need it
The moment a vector index stops fitting on the machine you meant to run it on, or its bill stops fitting the budget. Do the sum before you pick either: vectors × dimensions × 4 bytes. Ten million 1,536-dimension vectors are 61 GB as 32-bit floats, before the index adds its own links (about 1.3 GB for an HNSW graph at 16 links per node); at 3,072 dimensions it is 123 GB. Fast indexes keep every vector in RAM, so that number is the server. The tell is a search service whose memory line is the vectors themselves, or an embedding upgrade to a longer model that doubled the hosting cost. You don't need any of this while the sum is small (a hundred thousand 1,536-dimension vectors are 0.6 GB of floats), and you never need it at the price of neighbours you can't measure: the cost of every option here is recall@k against exact search on your own queries.
Your options
From the least saving to the most, each measured in this lesson on 5,000 documents of 256 dimensions:
| Option | What it does | What it guarantees | What it costs | Where it lives |
|---|---|---|---|---|
| Full float32 | Stores every number as is | Exact: recall 1.00 by definition | 4 bytes per dimension; 61 GB for 10M × 1,536 | The default everywhere |
| Half precision | Stores each number in 2 bytes instead of 4 | Halves storage and keeps every dimension | 16-bit rounding, small but still to be measured | The column type (pgvector's halfvec) |
| Fewer dimensions (Matryoshka) | Keeps the first m numbers of a vector trained so the important ones come first, and re-normalizes | Any size you choose, with graceful loss: 32 of 256 dimensions keep 93% of the top-10 neighbours here; 98% of benchmark performance at 8% of the size in Hugging Face's tests | A model trained for it; a random model loses more than half its neighbours at the same cut | The embedding call (a dimensions parameter) or your write path |
| int8 scalar quantization | Rounds each number to one of 256 levels between the dimension's minimum and maximum | 4× smaller with little loss: recall 0.98 here, about 99% retained in Hugging Face's benchmarks | A calibration pass to find each dimension's range, and clipping for values outside it | The database's quantization setting |
| Binary with re-scoring | Keeps one bit per number (its sign), searches by counting differing bits, then re-ranks a shortlist with the full vectors | 32× smaller in the fast path: bits alone keep 0.54 of the neighbours here, re-scoring the top 100 brings back 0.97 | The full vectors kept somewhere slower for the re-score, and a second stage per query | Index settings with rescoring or oversampling on |
| Product quantization | Splits each vector into chunks and replaces every chunk with the id of its nearest learned centroid | Up to 64× (Qdrant's figure) when memory is everything | A training step for the codebooks and the largest quality loss; measure before trusting it | Faiss, Qdrant |
| Two stages combined | Shortlists with a short or binary form, re-ranks with full vectors | Most of the quality at a fraction of the memory: 32 dimensions to shortlist 100, full vectors to re-rank, recall 1.00 here | Full vectors on disk, two lookups per query | Your search code, or an index with rescoring built in |
How to choose
Start from the sum, then from what your model supports.
- It fits with headroom: change nothing. Every option below costs neighbours or complexity.
- Your model exposes a dimensions parameter (it was trained Matryoshka style): cut dimensions first. It is the cheapest knob and it shrinks compute per comparison as well as memory.
- Memory tight by a factor of a few: int8. It is the safe default; 4× for a loss you will struggle to see.
- Memory tight by an order of magnitude, or a corpus in the hundreds of millions: binary for the scan, floats on disk for the re-score, with the shortlist size tuned until recall@10 on your queries is back where you need it.
- Whatever you pick, measure recall@k against exact float search on a sample of your own queries before and after, and keep the number with the index configuration.
What it costs
Memory follows the bytes: for 10 million 1,536-dimension vectors, 61 GB as float32, 15 GB as int8, 1.9 GB as bits (this lesson's sum). Hugging Face's benchmark prices it at 250 million 1,024-dimension vectors on a cloud instance: about $3,623 a month as float32, $905 as int8, $113 as binary. Speed follows memory: binary search runs up to 45× faster than float in that benchmark (a mean of 25×), int8 up to 4×. Quality is the cost you pay in neighbours: here int8 loses 2% of the top-10, binary alone loses 46% and gets 43 points back from re-scoring, and Matryoshka at one eighth of the dimensions loses 7%. Effort is a calibration pass for int8, a training step for product quantization, and a second query stage for any two-stage design. Nothing here changes the model or its vectors' meaning: compression is applied on the write path and can be undone by re-indexing.
What breaks
- Truncating a model not trained for it. Cut a plain model's vector to 32 of 256 dimensions and recall@10 drops to 0.44; the same cut on importance-ordered vectors keeps 0.93. Check the model card, or order the dimensions yourself as Level 2 does.
- Forgetting to re-normalize. A truncated vector is shorter than 1; the provider's parameter does this for you, a manual slice does not, and OpenAI's guide says so in as many words.
- Binary as the final answer. Signs alone keep half the neighbours. Bits are a shortlist, never the ranking.
- A calibration range that drifted. int8's levels span the minimum and maximum seen at calibration; documents added later that fall outside are clipped. Recalibrate when the corpus changes character.
- Bits on bunched vectors. Vectors that crowd into a narrow cone
(
primer.ml.embeddings.similarity) share most of their signs, so their bits carry little; Qdrant recommends binary for centred, high-dimensional distributions. Mean-centre first, or pick int8. - Measuring on someone else's queries. A benchmark's recall is not yours. Sample your own queries and compare against exact search.
- Counting only the vectors. The graph's links, the full vectors kept for re-scoring and the working memory of a build all add to the bill.
In the wild
OpenAI's text-embedding-3 models take a dimensions parameter that shortens their 1,536 or 3,072 numbers; Cohere's embed-v4.0 offers 256 to 1,536 dimensions and returns float, int8, uint8, binary or ubinary embeddings from one call; Nomic's nomic-embed-text-v1.5 is an open Matryoshka model, and sentence-transformers trains one with MatryoshkaLoss wrapped around any base loss. Qdrant ships scalar, binary and product quantization with rescoring and oversampling; pgvector has a halfvec column, a bit column and a binary_quantize function for its HNSW indexes; Faiss offers scalar quantizers, product quantization (PQ, OPQ) and RaBitQ at about d/8 + 8 bytes per vector. The idea comes from Kusupati et al. (2022), Matryoshka Representation Learning, which reported up to 14× smaller embeddings at the same ImageNet accuracy and up to 14× faster retrieval; the numbers above come from Hugging Face's embedding quantization and Matryoshka posts and from this lesson's own experiment.
Go deeper
Level 2 does the byte arithmetic, builds Matryoshka ordering from a principal-direction rotation and measures recall as dimensions fall away, rounds a real vector to 256 levels and shows the error never exceeds half a step, packs signs into bytes and counts differing bits with one XOR, and runs the two-stage search that gets the neighbours back. If you only needed to choose, you are done.
Level 2
How it works, from scratch
An embedding describes a text with a long list of numbers, like describing a person with hundreds of adjectives. More adjectives let you tell very similar people apart, but every adjective takes shelf space, and a search system has to keep millions of these descriptions in fast memory.
This lesson is about making the descriptions smaller without mixing people up. There are three tricks, each with an everyday twin:
- Keep fewer numbers (Matryoshka truncation): a well-written news story puts the headline first and the details later, so you can cut from the bottom and still know what happened.
- Store each number more roughly (scalar quantization): round every price to the nearest dollar. Totals barely change, and the list gets much shorter to write down.
- Keep only a yes/no per number (binary quantization): instead of "4.7 out of 10", just record "above average: yes". Crude, but amazingly good for a first sort.
And one trick that rescues the crude ones: shortlist, then check. Skim a pile of CVs fast to pick 100, then read those 100 carefully.
Chapter 1
A tiny worked example: counting bytes
A dimension is one number in the vector. A 32-bit float (the usual format) takes 4 bytes. So one 1,536-dimension vector takes 1,536 × 4 = 6,144 bytes, and ten million of them take:
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | Example value |
|---|---|---|
| n | number of vectors | 10,000,000 |
| d | dimensions per vector | 1,536 |
| b | bits per number | 32 (float32), 8 (int8), 1 (binary) |
| b / 8 | bytes per number (8 bits in a byte) | 4, 1, or 1/8 |
In words: storage is the number of vectors, times the numbers in each, times the bytes per number.
On the example: 10,000,000 × 1,536 × 32/8 = 61,440,000,000 bytes ≈ 61 GB. As int8 it's 15.4 GB; as bits, 1.9 GB.
In Python:
n, d = 10_000_000, 1536
# float32, int8, binary
for b in (32, 8, 1):
# bytes = n × d × b/8
size = n * d * b // 8
print(b, size, round(size / 1e9, 1), "GB") # → 32 61440000000 61.4 GB 8 15360000000 15.4 GB 1 1920000000 1.9 GB
# HNSW: 2·M ids of 4 bytes each, M = 16, in GB
n * 2 * 16 * 4 / 1e9 # → 1.28
The index adds its own overhead. An HNSW graph (primer.ml.embeddings.ann)
stores about 2·M neighbour ids per vector at 4 bytes each: with M = 16 that's
10M × 32 × 4 = 1.28 GB more.
Figure 1 · Drawn from the lesson's code
Ten million 3,072-dimension vectors take 123 GB as float32, 31 GB as int8 and under 4 GB as binary; smaller dimensions shrink each bar in proportion
In code: storage_bytes computes n × d × b/8 exactly, and
hnsw_link_bytes adds the 2·M neighbour ids an HNSW graph keeps per vector.
Why it matters fast vector indexes like HNSW want every vector in RAM, so storage is the server bill. Being able to do this sum in your head tells you in seconds whether a design fits on one machine.
Chapter 2
Matryoshka: important numbers first, then cut
Everyday picture Russian nesting dolls: a small doll inside a bigger one inside a bigger one, each complete on its own. A Matryoshka embedding is trained so its first 64 numbers are a decent embedding, its first 256 a better one, and the full vector the best.
Tiny example a vector (0.9, 0.4, 0.1, 0.05) where the numbers shrink in importance. Keep the first two, (0.9, 0.4), and rescale it to length 1 (divide by √(0.81 + 0.16) = 0.985): (0.914, 0.406). The dropped numbers were small, so the direction barely moves: its cosine with the full (normalized) vector is 0.994.
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | Shape |
|---|---|---|
| v | the full embedding | d numbers |
| m | how many leading numbers we keep | 1 to d |
| v₁ … vₘ | the first m numbers | m numbers |
| ‖·‖ | length (norm) | one number |
| v₍:ₘ₎ | the truncated, re-normalized vector | m numbers, length 1 |
In words: keep the first m numbers and rescale them to length 1.
On the example: m = 2: (0.9, 0.4) / 0.985 = (0.914, 0.406).
In Python:
import math
v = [0.9, 0.4, 0.1, 0.05]
m = 2
# (v_1, ..., v_m)
prefix = v[:m]
# ‖(v_1, ..., v_m)‖
length = math.sqrt(sum(v_i ** 2 for v_i in prefix))
round(length, 3) # → 0.985
# rescale to length 1
v_m = [v_i / length for v_i in prefix]
[round(v_i, 3) for v_i in v_m] # → [0.914, 0.406]
full = math.sqrt(sum(v_i ** 2 for v_i in v))
# cosine with the full, normalized vector
round(sum(a * b / full for a, b in zip(v_m, v)), 3) # → 0.994
This only works if the important numbers really come first. A real
Matryoshka model is trained that way: the same contrastive loss
(primer.ml.embeddings.contrastive) is applied to several prefixes at once
(the first 64, 128, 256, … numbers), so each prefix must work on its own.
Here we imitate it by rotating vectors onto their principal directions
(the directions along which the collection varies most, found with the SVD;
see primer.notation), which puts the most informative number first.
Figure 4 · Diagram
flowchart LR T[Text] --> E[Encoder] --> V["full vector (d numbers)"] V --> P64["first 64"] --> L64[loss] V --> P256["first 256"] --> L256[loss] V --> PD["all d"] --> LD[loss] L64 & L256 & LD --> S["sum: every prefix<br/>must work on its own"]
Figure 2 · Drawn from the lesson's code
In importance order variance drops steeply, the first 16 of 256 dimensions holding 96% of it; in random order it stays in a narrow band with no standouts
Figure 3 · Drawn from the lesson's code
Keeping 32 of 256 dimensions finds 93% of true top-10 neighbours in importance order but 44% in random order; re-ranking a 100 shortlist finds all
In code: matryoshka_order rotates vectors onto their principal
directions, most informative first, and random_order is the control.
search_truncated searches with the first m numbers only;
search_truncated_then_rescore shortlists that way, then re-ranks the
shortlist with the full vectors.
Chapter 3
Measuring what compression costs: recall@k
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | Example |
|---|---|---|
| k | how many results we look at | 10 in this lesson; 3 in the example |
| foundₖ | the k results the compressed search returned | (3, 4, 1) |
| trueₖ | the k results an exact, full-precision search returns | (1, 2, 3) |
| ∩ | "items in both" | {1, 3} |
| |·| | count the items | 2 |
In words: the share of the true top-k that the compressed search also found.
On an example: true (1, 2, 3), found (3, 4, 1): two of the three appear, so recall@3 = 2/3 ≈ 0.67.
In Python:
true_k = {1, 2, 3}
found_k = {3, 4, 1}
k = 3
# ∩: the items in both
found_k & true_k # → {1, 3}
# |found ∩ true| / k
round(len(found_k & true_k) / k, 2) # → 0.67
In code: recall_at_k averages this share over every query. top_k
runs the exact full-precision search that supplies trueₖ, and make_corpus
builds the documents, queries and true neighbours every experiment here
uses.
Chapter 4
Scalar quantization: 256 levels per number
Everyday picture rounding prices to the nearest dollar. Here, every number is rounded to one of 256 marks on a ruler that runs from the smallest to the largest value seen in that dimension. 256 marks fit in one byte (int8), a quarter of a float's 4 bytes.
Tiny example a dimension whose values run from lo = −1 to hi = 1. The 256 marks are 2/255 = 0.00784 apart. The value 0 sits at (0 − (−1)) / 2 × 255 = 127.5 marks, rounds to mark 128, and decodes back to −1 + 128/255 × 2 = 0.00392. It's off by 0.00392, half a mark, the worst case.
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | Range |
|---|---|---|
| x | one number in a vector | between lo and hi (clipped if outside) |
| lo, hi | smallest and largest value of this dimension across the documents | calibrated once |
| (x − lo)/(hi − lo) | where x sits between lo and hi, as a fraction | 0 to 1 |
| × 255 | stretch to the 256 marks 0 … 255 | 0 to 255 |
| round | nearest whole number | |
| code | the stored byte | 0 to 255 |
| x̂ (x-hat) | the value decoded back | within half a mark of x |
In words: find where x sits between the dimension's minimum and maximum, turn that into one of 256 whole-number marks, and store the mark; to decode, walk back from the mark to the value.
On the example: x = 0, lo = −1, hi = 1: code = round(0.5 × 255) = round(127.5) = 128; x̂ = −1 + (128/255)·2 = 0.00392.
In Python:
x, lo, hi = 0, -1, 1
# round(127.5): ties go to the even mark
code = round((x - lo) / (hi - lo) * 255)
code # → 128
# decode: walk back from the mark
x_hat = lo + code / 255 * (hi - lo)
round(x_hat, 5) # → 0.00392
Figure 5 · Drawn from the lesson's code
The decoded int8 steps track the first 40 numbers of the vector almost exactly; the rounding error never exceeds half of one step
In code: scalar_quantize_int8 turns each number into its code,
dequantize_int8 walks back to x̂, and search_int8 calibrates lo and hi
on the documents and searches the decoded vectors.
Chapter 5
Binary quantization: one bit per number
Everyday picture a yes/no questionnaire. For each number, record only "positive: yes or no". Two texts are compared by counting how many answers differ, the Hamming distance.
Tiny example the eight values (0.3, −0.2, 0.0, 5, −1, 2, −3, 0.1) become the bits 1 0 0 1 0 1 0 1, packed into one byte: 0b10010101 = 149. Compare with 0b00010100: they differ in the first and last positions, so the Hamming distance is 2.
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | Range |
|---|---|---|
| xᵢ | the i-th number of the vector | any real number |
| [ condition ] | 1 if the condition is true, 0 if not | 0 or 1 |
| bitᵢ | the stored bit for position i | 0 or 1 |
| a, b | two bit codes being compared | d bits each |
| aᵢ ≠ bᵢ | the two codes disagree at position i | |
| Σ | add up over all d positions | |
| hamming(a, b) | number of positions that disagree | 0 to d |
In words: keep one bit per number that says whether it was positive, and measure distance as the number of positions where two codes disagree.
On the example: 149 = 10010101 vs 20 = 00010100: positions 1 and 8 differ, so the distance is 2. Computers do this with one XOR (mark the differing bits) and one popcount (count them), which is why binary search is extremely fast.
In Python:
x = [0.3, -0.2, 0.0, 5, -1, 2, -3, 0.1]
# bit_i = [x_i > 0]
a = [int(x_i > 0) for x_i in x]
a # → [1, 0, 0, 1, 0, 1, 0, 1]
# packed into one byte
int("".join(map(str, a)), 2) # → 149
# 0b00010100 = 20
b = [0, 0, 0, 1, 0, 1, 0, 0]
# hamming(a, b) = Σ [a_i ≠ b_i]
sum(a_i != b_i for a_i, b_i in zip(a, b)) # → 2
# the computer's way: XOR, then count the 1s
bin(149 ^ 20).count("1") # → 2
Figure 7 · Diagram
flowchart LR
Q[Query] --> B["Stage 1: bits + Hamming<br/>scan all 5,000 docs<br/>(32× smaller, very fast)"]
B --> S[Shortlist of 100]
S --> F["Stage 2: full float vectors<br/>exact dot product on 100 only"]
F --> T[Top 10]
STORE[("bits for every doc (RAM)<br/>floats for every doc (disk or RAM)")] --> B
STORE --> F
Figure 6 · Drawn from the lesson's code
Recall@10: int8 keeps 0.98 and binary alone only 0.54, but binary re-scored over 100 candidates recovers 0.97 and a 32-dimension shortlist reaches 1.00
In code: binary_quantize keeps each number's sign and packs 8 bits per
byte, and hamming_distances counts differing bits with XOR and a popcount
table. search_binary ranks by bits alone; search_binary_then_rescore
re-ranks the bit-based shortlist with the full float vectors.
Why it matters these knobs move real money. Many vector databases ship int8 and binary quantization with re-scoring built in, and embedding providers increasingly ship Matryoshka-trained models so you can pick your dimension. The cost is always measured the same way: recall@k on your own queries.
Test yourself
4 questions
Answer each one out loud or on paper before you open it. If you can explain it, you know it.
Question 1Q: How much memory do ten million 1,536-dimension float32 vectors need?Think it through, then reveal
10,000,000 × 1,536 × 4 bytes = 61.4 GB of raw vectors, plus index overhead (e.g. ~1.3 GB of HNSW links at M = 16).
Question 2Q: What makes Matryoshka embeddings truncatable, and how do you use that?Think it through, then reveal
They're trained with the loss applied to several prefixes at once, so the first m numbers form a good embedding by themselves. Search with short vectors for speed and memory, then re-score the top candidates with the full vectors.
Question 3Q: Scalar vs. binary quantization: what do you give up?Think it through, then reveal
int8 rounds each number to 256 levels, 4× smaller with a small recall loss. Binary keeps only signs, 32× smaller, but recall drops a lot on its own; it works as a first-stage shortlist followed by full-precision re-scoring.
Question 4Q: Why not just use as many dimensions as possible?Think it through, then reveal
Gains flatten out while storage, memory and search time grow linearly. A smaller model trained for your domain often beats a bigger generic one, so benchmark on your own queries.
Primary sources
The papers behind this lesson
Trained embeddings whose every prefix is a usable embedding, by summing the loss over nested prefix lengths.
Read the annotated companion →The paper ↗Researcher's shelf
Further reading
- Hugging Face, Embedding Quantization (binary and int8 with re-scoring): https://huggingface.co/blog/embedding-quantization
- Hugging Face, Introduction to Matryoshka Embedding Models: https://huggingface.co/blog/matryoshka
- Faiss wiki, Guidelines to choose an index: https://github.com/facebookresearch/faiss/wiki/Guidelines-to-choose-an-index
About this lesson. This is the illustrated edition of a lesson from the open-source AI Primer. Its text, figures and numbers are generated from the Primer's source at commit 048aeaa, so the two always agree: the explanation, the code that builds it and the tests that prove it.