rumblr Work in progressWIP

● The AI Primer · Lesson 29 · Embeddings, the centerpiece

Compression

smaller vectors, same neighbours

You'll be able to explain Storage math, Matryoshka truncation, int8 and binary quantization

Members · open during launch 22 min7 figures and diagrams
Guide is what to use and when. How it works builds it from scratch. Math & code adds the formulas and the Python.

The lesson in one minute

What you'll be able to explain

  1. Raw storage is n × d × bytes per number: 10M × 1,536 float32 ≈ 61 GB, before index overhead.
  2. Matryoshka models put the important information in the leading numbers, so you can truncate and re-normalize.
  3. int8 stores 256 levels per number (4× smaller, little loss); binary keeps signs only (32× smaller, big loss alone).
  4. Shortlist with the crude form, re-score with full vectors: most of the quality at a fraction of the memory.
  5. 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

384 768 1024 1536 3072 dimensions per vector 1 0 0 1 0 1 1 0 2 GB for 10 million vectors (log scale) Raw vector storage float32 int8 binary

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

Reading it: each group of bars is one common embedding size, from 384 to 3,072 dimensions. Within a group, the three bars are float32, int8 and binary storage for ten million vectors, on a log scale (each gridline is 10×). A 3,072-dimension float32 index needs over 120 GB of memory; the same vectors as bits fit in under 4 GB.

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

Reading it: one text, one encoder, one vector, scored several times. Each prefix of the vector is judged by the usual contrastive loss, and the model is trained on the sum. That's the whole trick: nothing about the model changes, only how its output is graded.

Figure 2 · Drawn from the lesson's code

0 50 100 150 200 250 dimension number 1 0 − 5 1 0 − 4 1 0 − 3 1 0 − 2 1 0 − 1 variance along that dimension (log scale) Where the information lives importance order (principal directions) random order

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

Reading it: the horizontal axis is the dimension number and the vertical axis is how much the collection varies along it (its variance: the average squared distance from the mean, on a log scale). In importance order, the first few dimensions carry most of the variation and it falls steadily after that, so cutting from the end loses little. In a random order every dimension carries a similar share, so cutting any of them costs the same.

Figure 3 · Drawn from the lesson's code

4 8 16 32 64 128 256 dimensions kept (of 256) 0.0 0.2 0.4 0.6 0.8 1.0 recall@10 Truncation only works when importance comes first importance order (Matryoshka-like) random order 32 dims → shortlist 100 → re-score

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

Reading it: the horizontal axis is how many leading dimensions we keep (of 256); the vertical axis is recall@10, the share of each query's true top-10 neighbours we still find. In importance order, 32 dimensions (one eighth) still find about 93% of the neighbours; in random order they find about 44%. The star is the two-stage design: search with the first 32 numbers to shortlist 100 candidates, then re-rank those 100 with the full vectors. It finds all of them.

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

−0.075 −0.050 −0.025 0.000 0.025 0.050 0.075 0.100 value One vector, first 40 numbers original float32 int8, decoded 0 5 10 15 20 25 30 35 40 dimension −0.0005 0.0000 0.0005 rounding error

The decoded int8 steps track the first 40 numbers of the vector almost exactly; the rounding error never exceeds half of one step

Reading it: the line shows the first 40 numbers of one real vector from this lesson's corpus; the steps show the same numbers after rounding to 256 levels and decoding. The two are almost indistinguishable: the rounding error (the bottom panel) never exceeds half a mark. That's why int8 search here still finds about 98% of the true neighbours.

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

Reading it: the cheap representation is used where the work is big (every document), and the expensive one where the work is small (100 candidates). The bits live in fast memory; the full vectors can live somewhere slower because only a hundred are read per query.

Figure 6 · Drawn from the lesson's code

float32 (exact) int8 binary binary → re-score 100 32 dims → re-score 100 0.0 0.2 0.4 0.6 0.8 1.0 recall@10 Crude first pass, exact second pass 1.00 0.98 0.54 0.97 1.00

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

Reading it: each bar is one way of storing the documents, measured by recall@10 against exact float32 search. int8 alone keeps about 98%. Binary alone keeps only about half: signs lose a lot. But binary as a shortlist, re-scored with full vectors, climbs back to about 97%, and 32-dimension Matryoshka shortlists to 100%. Crude-then-exact is the pattern to remember.

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

Kusupati et al., Matryoshka Representation Learning (2022)

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.