rumblr Work in progressWIP

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

Compression

smaller vectors, same neighbours

This lesson covers Storage math, Matryoshka truncation, int8 and binary quantization

Members · open during launch 22 min7 figures and diagrams
How it works builds the idea from scratch. Math & code adds the formulas and the Python.

At a glance

Key takeaways

  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 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 · Chart

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 2 · 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 3 · Chart

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 4 · Chart

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 · Chart

−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 6 · 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 7 · Chart

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 c8d5c21, so the two always agree: the explanation, the code that builds it and the tests that prove it.