Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
An operator asks, How do I rotate an API key? The embedding model should put that query near approved api-key-rotation-v3 and away from a quota page. The system still has to put the guide first, fit millions of vectors in memory, and never turn private-incident-note-44 into answer evidence.
That gives us one evidence boundary to protect. The private note can mention API keys and still be unauthorized. A raw dot-product search can reward its larger norm even when its direction is worse.
A separate capacity shortcut can drop the approved guide from a compressed shortlist. Neither mistake is obvious if you inspect only the final answer, so we'll keep both failures visible as we move through the index.
Sentence embeddings showed how contrastive training shapes this space. Here the encoder stays fixed. We'll keep both chunks in view while we choose a scoring contract, a width, and a representation that preserve the right evidence.
Follow one question: can api-key-rotation-v3 survive every cheaper shortcut? We'll compute three notions of closeness, test normalization and shorter prefixes, size float and compressed vectors with scalar, product, and binary quantization. Then we'll add approximate search and a final approval gate.
Three ways to measure closeness
Start with one small question: what should "close" mean for these two chunks? An embedding is a point in high-dimensional space, represented by a list of numbers from the encoder. Three common rulers answer different versions of that question.
| Ruler | Question | Range | Ranking changes if you scale a vector? |
|---|---|---|---|
| Cosine similarity | Do they point the same way? | to | No |
| Dot product | Same direction, and how long are they? | to | Yes |
| Euclidean (L2) distance | What's the straight-line gap? | to | Yes |
The table becomes easier to use once you make the contract explicit. If every stored vector and query is normalized to length 1, all three rulers rank candidates in the same order. You can then choose the scoring kernel your index runs quickly, but you must apply the same normalization at ingest and query time.
A zero vector is the exception you have to reject explicitly: cosine divides by both vector norms, so a zero-norm query has no defined direction. Replacing that division with an arbitrary zero score can quietly reorder an entire shortlist. Record the encoder failure and refuse retrieval instead of pretending the query matched nothing.
A tiny 2D example makes the split visible. Two paraphrases of the rotation guide map to:
- Passage A,
api-key-rotation-v3:[3, 4] - Passage B, a second rotation phrasing:
[4, 3]
Both point northeast, so predict that every ruler will call them close. Work the arithmetic by hand before touching code.
Keep this equal-length pair in mind. Later fixtures will make the less obvious case visible: same direction, different length.
Cosine similarity
For A and B, the shared direction should produce a high score even though the score must ignore their lengths. Cosine keeps only that direction:
Start with the numerator. Multiply matching dimensions and add:
The length of a vector is the square root of the sum of squared components:
Their lengths are both 5, so now divide by :
1 means the same direction, 0 means orthogonal, and -1 means opposite. That's a geometry result, not a relevance guarantee. Whether the direction tracks useful matches still depends on the encoder and retrieval evaluation.
Dot product
Now remove the length correction. Dot product stops at the sum we already computed:
These passages happen to have the same length, so dot and cosine agree. If A gets longer without changing direction, dot product rises while the angle stays put. That's useful when training made magnitude meaningful. It's a bug when your contract was angular.
Euclidean distance
The third ruler asks for the straight-line gap. Subtract, square, sum, and take the square root:
A small L2 distance also says these paraphrases are close. For this pair, all three rulers agree. Add candidates with different norms and that agreement can disappear, which is why the serving contract matters more than the label on an index.
You've worked the numbers. Predict the next output: after A is scaled by 10, should cosine change, should dot product change, or should both stay fixed? This snippet checks the hand math and makes that prediction concrete:
1import math
2
3passage_a = [3.0, 4.0]
4passage_b = [4.0, 3.0]
5
6def dot(left: list[float], right: list[float]) -> float:
7 return sum(x * y for x, y in zip(left, right, strict=True))
8
9def l2_norm(vector: list[float]) -> float:
10 return math.sqrt(sum(value * value for value in vector))
11
12score = dot(passage_a, passage_b)
13cosine = score / (l2_norm(passage_a) * l2_norm(passage_b))
14euclidean = l2_norm([x - y for x, y in zip(passage_a, passage_b, strict=True)])
15print(f"dot={score:.0f}, cosine={cosine:.2f}, euclidean={euclidean:.2f}")
16
17longer_a = [10.0 * value for value in passage_a]
18same_direction_cosine = dot(longer_a, passage_b) / (
19 l2_norm(longer_a) * l2_norm(passage_b)
20)
21print(f"10x vector: cosine={same_direction_cosine:.2f}, dot={dot(longer_a, passage_b):.0f}")1dot=24, cosine=0.96, euclidean=1.41
210x vector: cosine=0.96, dot=240
If vector A is [3, 4] and vector C is [30, 40], what happens to cosine similarity and dot product when you compare both vectors to [4, 3]?
Answer
Cosine similarity stays the same because A and C point in the same direction. Dot product grows by 10x because C is 10x longer. Cosine ignores magnitude. Dot product keeps it.
The experiment gives you a serving decision. Use cosine when the model card, training setup, or labeled queries say length shouldn't change rank. Use raw dot product when magnitude is part of the learned score, as in maximum inner product search (MIPS).
If every vector is already unit length, the rankings match and inner product avoids norm divisions while scoring candidates.
When the three rulers agree
The first fixture used equal-length vectors. Real encoders don't promise that unless their documentation or your pipeline says so. Treat normalization as an explicit contract shared by model output, index construction, and query serving. Don't infer it from a model name.
With that contract in place, the three rulers collapse to one ranking. When both vectors have length 1, the cosine denominator is , so cosine equals raw dot product. Squared Euclidean distance becomes a strictly decreasing function of that same inner product:
Higher inner product, higher cosine, and smaller squared L2 distance now induce the same ranking. Normalize stored rows once and each incoming query once. Candidate scoring can use a matrix-multiply kernel without repeated norm divisions.
Here's the failure in the opening story. The next fixture gives private-incident-note-44 a huge raw norm, so it wins unnormalized dot product even though its direction is less aligned with the query. Before running it, predict which label each score will choose. Then compare raw dot, normalized dot, and cosine.
1import math
2
3labels = ["api-key-rotation-v3", "private-incident-note-44", "service-token-lifecycle-v2"]
4documents = [
5 [0.95, 0.05], # Direction closely matches the rotation query.
6 [8.00, 6.00], # Large norm lets raw dot product dominate.
7 [0.20, 0.90],
8]
9query = [1.0, 0.0]
10
11def dot(left: list[float], right: list[float]) -> float:
12 return sum(x * y for x, y in zip(left, right, strict=True))
13
14def l2_normalize(vector: list[float]) -> list[float]:
15 norm = math.sqrt(sum(value * value for value in vector))
16 return [value / norm for value in vector]
17
18def l2_norm(vector: list[float]) -> float:
19 return math.sqrt(sum(value * value for value in vector))
20
21raw_dot = [dot(row, query) for row in documents]
22unit_documents = [l2_normalize(row) for row in documents]
23unit_query = l2_normalize(query)
24unit_dot = [dot(row, unit_query) for row in unit_documents]
25raw_cosine = [
26 dot(row, query) / (l2_norm(row) * l2_norm(query))
27 for row in documents
28]
29
30print(f"raw dot winner: {labels[raw_dot.index(max(raw_dot))]}")
31print(f"normalized winner: {labels[unit_dot.index(max(unit_dot))]}")
32print(
33 "cosine equals unit dot: "
34 f"{all(abs(left - right) < 1e-6 for left, right in zip(raw_cosine, unit_dot, strict=True))}"
35)1raw dot winner: private-incident-note-44
2normalized winner: api-key-rotation-v3
3cosine equals unit dot: TrueA large-norm vector isn't automatically junk. The narrower conclusion is about serving: if the contract is cosine-like ranking, skipping normalization changes what the index optimizes.
L2 distance isn't automatically wrong in high dimension either. On unit vectors, squared L2 produces the same ranking through the identity above. Without normalization, L2 also includes norm differences. Keep it when the model, index, and evaluation were built around that geometry.
The original product-quantization formulation approximates this squared L2 distance between a full-precision query and compressed database vectors.[1]
We've fixed what "close" means. The next pressure is capacity: how many coordinates can you afford to keep before the scoring contract becomes too expensive?
Choosing how many dimensions you need
We've chosen a scoring contract. Now ask what that contract costs. Width controls how much of the trained representation you store, along with memory traffic and scoring work.
A dimension count has no universal quality meaning: a 512-dimensional model trained for your API-doc queries can beat a different 1,536-dimensional model. Compare widths only after holding model family, scoring convention, corpus, and evaluation set fixed.
| Choice under evaluation | What you can conclude | What you still must measure |
|---|---|---|
| Full trained width | Baseline payload and retrieval score for this model | Latency, RAM, and evidence recall |
| Model-supported shorter prefix | Lower raw payload for the same family | Recall loss and failure slices |
| Different smaller model | Possibly cheaper candidate system | Everything again; training changed too |
The table separates two decisions that are easy to conflate. Dimension count changes capacity. Normalization changes score meaning. A supported shorter vector still needs the serving normalization contract documented by its model or applied by your pipeline.
For one representation family, extra dimensions buy capacity but charge the same predictable systems costs:
- Storage. Each float32 is 4 bytes. 1M vectors at 1536d is about 5.7 GiB of raw payload.
- Exact scoring work. A dot product or L2 comparison does linear work in the number of dimensions, written , per candidate.
- Memory bandwidth. Loading vectors from memory can bottleneck approximate nearest neighbor (ANN) search, which finds close vectors without scanning every row.
- End-to-end latency. Halving dimensions doesn't promise halved latency. Graph traversal, filters, networking, and reranking still take time, so measure before you pay or save the memory bill.
Matryoshka prefixes still need a recall test
Suppose memory pressure makes you want 256 dimensions instead of 1,536. Should the first 256 coordinates be useful by accident, or should the model have been trained to put signal there?
Matryoshka Representation Learning (MRL) answers the second question: training minimizes a weighted sum of losses at selected truncation widths , so those prefixes are meant to work on their own.[2]
That's different from post-hoc PCA, which projects already-trained vectors into a smaller linear subspace, usually by preserving variance on a fixed dataset. PCA can still help frozen embeddings. MRL fits a serving path with several dimension budgets when the model was trained for truncation.
Hosted APIs can expose a supported shortening control without you slicing tensors by hand. OpenAI's embeddings guide documents a dimensions parameter for text-embedding-3 models, with defaults of 1,536 for text-embedding-3-small and 3,072 for text-embedding-3-large.[3]
The same guide reports that a text-embedding-3-large vector shortened to 256 dimensions outperforms unshortened text-embedding-ada-002 at 1,536 dimensions on MTEB. That's a provider benchmark, not a promise that a 256-d prefix will retrieve api-key-rotation-v3 for your queries.
The provider's shortening control and a local slice aren't interchangeable. If you cut a prefix from an already generated vector, normalize that sliced prefix before indexing, then apply the same operation to queries. OpenAI's embeddings guide shows this L2 renormalization after a manual cut.[3]
This local check enforces the contract without an API call:
1import math
2
3full_embedding = [0.21, -0.05, 0.44, 0.31, 0.18, -0.27, 0.12, 0.09]
4
5def l2_normalize(vector: list[float]) -> list[float]:
6 norm = math.sqrt(sum(value * value for value in vector))
7 if norm == 0:
8 return vector
9 return [value / norm for value in vector]
10
11short_embedding = l2_normalize(full_embedding[:4])
12norm = math.sqrt(sum(value * value for value in short_embedding))
13print(f"dims={len(short_embedding)}, norm={norm:.3f}")1dims=4, norm=1.000Normalization restores a unit-vector scoring contract. It can't restore coordinates you threw away. In the next fixture, the last two dimensions carry the distinction between the two passages. Cut to width 2 and the private note ties the guide, so argmax picks the unauthorized row. Predict that result before reading the output.
![Full 4-d fixture: rotation query [1,0,0,1] matches api-key-rotation-v3. After keeping only d0 and d1, that query matches private-incident-note-44 because both rows collapse to [1,0].](/cdn/content-image/fundamentals/embeddings-cosine-dot-product-quantization/illustrations/_generated/matryoshka_dimensions_dark.png?v=5cb63db52559)
1import math
2
3labels = ["private-incident-note-44", "api-key-rotation-v3", "service-token-lifecycle-v2"]
4documents = [
5 [1.0, 0.0, 1.0, 0.0],
6 [1.0, 0.0, 0.0, 1.0],
7 [0.0, 1.0, 0.0, 1.0],
8]
9queries = [
10 [1.0, 0.0, 0.0, 1.0],
11 [0.0, 1.0, 0.0, 1.0],
12]
13expected = ["api-key-rotation-v3", "service-token-lifecycle-v2"]
14
15def l2_normalize(vector: list[float]) -> list[float]:
16 norm = math.sqrt(sum(value * value for value in vector))
17 return [value / norm for value in vector]
18
19def dot(left: list[float], right: list[float]) -> float:
20 return sum(x * y for x, y in zip(left, right, strict=True))
21
22def top1_at_width(width: int) -> list[str]:
23 docs = [l2_normalize(row[:width]) for row in documents]
24 asks = [l2_normalize(row[:width]) for row in queries]
25 winners: list[str] = []
26 for ask in asks:
27 scores = [dot(ask, row) for row in docs]
28 winners.append(labels[scores.index(max(scores))])
29 return winners
30
31full = top1_at_width(4)
32short = top1_at_width(2)
33full_recall = sum(got == want for got, want in zip(full, expected, strict=True)) / len(expected)
34short_recall = sum(got == want for got, want in zip(short, expected, strict=True)) / len(expected)
35print(f"full top-1 recall={full_recall:.1%}, results={full}")
36print(f"short top-1 recall={short_recall:.1%}, results={short}")1full top-1 recall=100.0%, results=['api-key-rotation-v3', 'service-token-lifecycle-v2']
2short top-1 recall=50.0%, results=['private-incident-note-44', 'service-token-lifecycle-v2']This isn't a benchmark for a real MRL model. It's the shape of a release test: evaluate each supported width on approved-query fixtures, including near-duplicate unauthorized passages. A prefix that saves bytes but loses the guide fails the contract.
You shorten a unit-normalized 3072-dimensional embedding by taking only the first 256 values. Should you index that prefix immediately?
Answer
No. Slicing usually changes the vector's L2 norm, so normalize the 256-dimensional prefix before indexing. Otherwise cosine, dot product, and Euclidean conventions no longer match the index contract you think you are using.
The memory problem: a sizing example
The prefix test exposed a quality budget. Now put a number on the memory budget. Use the default text-embedding-3-small width of 1,536 dimensions and ask what one million rows cost before graph links, metadata, or replicas.
Scenario: 1 million embeddings, 1,536 dimensions, stored as 32-bit floats (FP32). Predict the raw payload first. Then we'll replace four-byte floats with one-byte values and finally with PQ IDs.
Step 1: the FP32 baseline
Each dimension is a float32, which takes 4 bytes. Multiply rows, dimensions, and bytes per value:
That's only the raw numbers. Real indexes add graph links, metadata, and replicas, but the vector payload alone is already over 5.7 GiB. If that's too large for hot memory, precision is the next lever.
Step 2: 8-bit scalar quantization
Map each float32 to a single byte (INT8). The row count and width stay fixed, so storage drops by about 4x:
Step 3: product quantization
Split each 1,536-dimensional vector into 96 subvectors and store one 8-bit centroid ID per subvector. The compressed row is 96 bytes:
That's a 64x cut from the original FP32 payload. The trade is now explicit: you're comparing approximate codes instead of exact floats, so pair PQ with a higher-precision reranking stage.

The byte math is easy to turn into a sizing helper. Predict the units before running it: decimal gigabytes divide by , while operating systems usually report binary gibibytes divided by :
1def payload_bytes(num_vectors: int, bytes_per_vector: int) -> int:
2 return num_vectors * bytes_per_vector
3
4raw_bytes = payload_bytes(1_000_000, 1_536 * 4)
5int8_bytes = payload_bytes(1_000_000, 1_536)
6pq_bytes = payload_bytes(1_000_000, 96)
7
8print(f"FP32={raw_bytes / 1e9:.2f} GB ({raw_bytes / 1024**3:.2f} GiB)")
9print(f"INT8={int8_bytes / 1e9:.2f} GB ({int8_bytes / 1024**3:.2f} GiB)")
10print(f"PQ={pq_bytes / 1024**2:.1f} MiB")1FP32=6.14 GB (5.72 GiB)
2INT8=1.54 GB (1.43 GiB)
3PQ=91.6 MiBRe-embedding cost scales with total input tokens, not document count alone. Compute it as total_tokens / 1_000_000 * embedding_price_per_1M_tokens, using the current price for the model you selected.
Keep model version, dimensions, normalization, and quantization settings alongside every index. Otherwise a migration can mix vectors that no longer share a scoring contract.
Shrinking vectors without losing the hit
The sizing math says where the pressure comes from. An exact scan touches up to values. An ANN index reduces how many rows you visit, while quantization reduces bytes per row. Those are separate shortcuts, and either can lose the approved guide, so each needs its own evaluation gate.

Read the flow as a release decision, not a compression recipe. If the FP32 baseline fits RAM and latency, keep it. If it doesn't, benchmark one shortcut at a time, reject any version that drops required evidence from the shortlist, and keep authorization on the precise serving path.
Scalar quantization (SQ)
Start with the least structural change. Scalar quantization replaces each float32 (4 bytes) with a lower-precision number, typically mapping each dimension independently. The simplest signed int8 version is symmetric around zero:
Here is the scale. Each dimension goes from 4 bytes to 1 byte, so raw storage drops by about 4x while you keep an approximate path back to float space. Some libraries use this symmetric signed form; others use affine quantization with a zero-point, often over uint8. The code below makes the scale and reconstruction error inspectable.
This function implements the symmetric version. It takes float vectors and returns integer codes plus the per-dimension scales needed to reconstruct later:
1vectors = [
2 [0.20, -1.00, 2.50],
3 [0.24, -0.80, 2.10],
4 [0.50, -1.40, 2.30],
5]
6
7def symmetric_quantize_int8(
8 rows: list[list[float]],
9) -> tuple[list[list[int]], list[float]]:
10 qmax = 127
11 width = len(rows[0])
12 max_abs = [max(abs(row[dim]) for row in rows) for dim in range(width)]
13 scale = [max(bound, 1e-8) / qmax for bound in max_abs]
14 codes = [
15 [
16 max(-qmax, min(qmax, round(row[dim] / scale[dim])))
17 for dim in range(width)
18 ]
19 for row in rows
20 ]
21 return codes, scale
22
23codes, scale = symmetric_quantize_int8(vectors)
24reconstructed = [
25 [scale[dim] * code for dim, code in enumerate(row)]
26 for row in codes
27]
28max_error = max(
29 abs(original - approx)
30 for original_row, approx_row in zip(vectors, reconstructed, strict=True)
31 for original, approx in zip(original_row, approx_row, strict=True)
32)
33print(f"codes[0]={codes[0]}, max_error={max_error:.4f}")1codes[0]=[51, -91, 127], max_error=0.0063INT8 cuts the raw payload from four bytes per dimension to one. It doesn't promise any fixed Recall@K. Model distribution, calibration sample, scoring rule, shortlist depth, and reranking all change the observed loss.
Treat INT8 as the first candidate to benchmark, not an automatic release.
⚠️ Common mistake: Deriving the scale from the absolute min and max of a calibration sample without checking outliers. If one dimension spikes far outside the usual range, the scale stretches and ordinary values collapse into fewer integer buckets. Percentile clipping can preserve ordinary resolution while saturating outliers, but the percentile is another measured choice, not a universal constant.
The next example isolates that failure with ordinary values plus one spike. Predict which calibration range gives ordinary values more resolution, then compare a scale set by the spike with a clipped scale set from the ordinary range:
1ordinary = [index / 50 - 1.0 for index in range(101)]
2with_outlier = [*ordinary, 25.0]
3
4def quantize_reconstruct(values: list[float], bound: float) -> list[float]:
5 scale = bound / 127
6 return [
7 max(-127, min(127, round(value / scale))) * scale
8 for value in values
9 ]
10
11def mean_abs_error(left: list[float], right: list[float]) -> float:
12 return sum(abs(x - y) for x, y in zip(left, right, strict=True)) / len(left)
13
14bound_from_max = max(abs(value) for value in with_outlier)
15max_scaled = quantize_reconstruct(ordinary, bound_from_max)
16clipped_scaled = quantize_reconstruct(ordinary, 1.0)
17print(f"outlier-bound ordinary MAE={mean_abs_error(ordinary, max_scaled):.4f}")
18print(f"clipped-bound ordinary MAE={mean_abs_error(ordinary, clipped_scaled):.4f}")1outlier-bound ordinary MAE=0.0483
2clipped-bound ordinary MAE=0.0019Lower reconstruction error on ordinary values is encouraging, but retrieval is the target. A release check must measure whether relevant approved passages stay in the shortlist after calibration and compression.
Product quantization (PQ)
SQ keeps one approximate number for every coordinate. If that still leaves too many bytes, PQ changes the unit of storage: instead of storing coordinates, store the IDs of nearby codewords. Jégou, Douze, and Schmid introduced this pattern by splitting each vector into subvectors and quantizing each independently with a learned codebook.[1]
See the storage change in a small example with on an 8-dimensional vector:
Each 2-d chunk maps to the nearest centroid ID in that subspace's codebook. Stored as 8-bit IDs, the row becomes four bytes. At query time you don't reconstruct the floats. You score those IDs against the full-precision query with lookup tables.
With 256 centroids per subspace (8 bits), compressing 768-d float32 vectors (3,072 bytes) using 96 subspaces produces a 96-byte code, a 32x payload cut. Shared codebooks and index metadata add separate overhead.
Asymmetric distance computation (ADC)
The asymmetry is deliberate: keep the query in full precision, and compare each query subvector with every centroid in its matching codebook.[1] That builds a small lookup table per subspace:
The database row stores only centroid IDs . Scoring one candidate becomes table lookups and additions, so PQ can search compressed vectors without fully decompressing them. Production libraries such as FAISS implement this ADC path for PQ codes.[4]
The distance equation above minimizes summed subspace distances, so it matches an L2 serving contract. Many embedding indexes serve maximum inner product search (MIPS) or unit-vector cosine, where higher wins. For those contracts, use a sum of subspace inner products instead:
Build a lookup table of for each subspace and centroid . For each stored code, sum the selected entries and rank high-to-low. When every vector is unit-normalized, IP ADC approximates cosine ranking under compression.
Don't train or evaluate L2 PQ tables against an unnormalized IP index and expect the same order: minimizing differs from maximizing unless norms are controlled.
OPQ rotates the space before PQ so subspaces carry more independent signal.[5] Residual / IVFADC pipelines quantize a residual after a coarse quantizer.[1] Either can raise recall. Neither changes the rule: match the score you serve.
The next snippet makes ADC concrete with two-centroid codebooks. The full-precision query creates four lookup tables; each stored ID selects one entry per table. Predict which code should win before adding those four distances.
![Asymmetric distance computation for an 8-d query split into four pairs. The rotation code [1,1,1,1] sums to 0.08. The lifecycle code [0,0,0,0] sums to 56.57, so ADC ranks rotation nearer without reconstructing floats.](/cdn/content-image/fundamentals/embeddings-cosine-dot-product-quantization/illustrations/_generated/product_quantization_flow_dark.png?v=fe69f2289a4a)
1codebooks = [
2 [[0.0, 0.0], [1.2, 3.4]],
3 [[0.0, 0.0], [0.8, 2.1]],
4 [[0.0, 0.0], [5.3, 1.7]],
5 [[0.0, 0.0], [0.4, 3.2]],
6]
7query = [1.1, 3.3, 0.7, 2.0, 5.2, 1.8, 0.3, 3.1]
8stored_codes = {
9 "api-key-rotation-v3": [1, 1, 1, 1],
10 "service-token-lifecycle-v2": [0, 0, 0, 0],
11}
12
13query_chunks = [query[index : index + 2] for index in range(0, 8, 2)]
14lookup = []
15for chunk, codebook in zip(query_chunks, codebooks, strict=True):
16 distances = [
17 sum((chunk[dim] - centroid[dim]) ** 2 for dim in range(2))
18 for centroid in codebook
19 ]
20 lookup.append(distances)
21
22scores = {
23 name: sum(lookup[subspace][code] for subspace, code in enumerate(code))
24 for name, code in stored_codes.items()
25}
26winner = min(scores, key=scores.get)
27print({name: round(score, 2) for name, score in scores.items()})
28print(f"ADC nearest code: {winner}")1{'api-key-rotation-v3': 0.08, 'service-token-lifecycle-v2': 56.57}
2ADC nearest code: api-key-rotation-v3More compression can lower recall. The loss depends on subspace count, centroid count, query distribution, and reranking budget. Benchmark PQ against the real query distribution instead of trusting one headline number.
Binary quantization (BQ)
If one byte per coordinate still misses the budget, binary quantization keeps only one bit per dimension. This version thresholds at zero: positive numbers become 1, and zero or negative numbers become 0. Magnitude disappears. That makes BQ attractive for a very cheap shortlist, not a final ranking guarantee.
Pack the sign bits into an integer. Comparing two packed rows then becomes XOR plus popcount, which gives Hamming distance:
1rotation_a = [0.3, -0.1, 0.8, -0.4]
2rotation_b = [0.5, -0.2, 0.7, 0.1]
3
4def pack_sign_bits(vector: list[float]) -> int:
5 packed = 0
6 for index, value in enumerate(vector):
7 if value > 0:
8 packed |= 1 << index
9 return packed
10
11def hamming_distance(packed_a: int, packed_b: int) -> int:
12 return (packed_a ^ packed_b).bit_count()
13
14packed_a = pack_sign_bits(rotation_a)
15packed_b = pack_sign_bits(rotation_b)
16print(f"packed_a={packed_a}, packed_b={packed_b}, hamming={hamming_distance(packed_a, packed_b)}")1packed_a=5, packed_b=13, hamming=1Predict the count before reading the output: the two vectors differ only at the last sign. [0.3, -0.1, 0.8, -0.4] becomes bits [1, 0, 1, 0]. Little-endian packing puts dimension 0 in bit 0, so the integer is 0b0101 = 5. The second vector becomes [1, 0, 1, 1] = 13. XOR leaves one differing bit, so Hamming distance is 1.
A Hamming distance of 1 out of 4 bits means the vectors are close in this binary approximation. Sign bits discard magnitude, so this zero-threshold version is most informative when coordinates are centered around zero. Other distributions need different calibration or encoding, and the first stage still needs a recall test.
| Dimension | Practical effect |
|---|---|
| Compression | 32x raw payload reduction when moving from float32 to one bit per dimension. |
| Speed | Packed bits use XOR plus popcount, so both memory traffic and arithmetic drop. Implementations can add architecture-specific SIMD for packed-byte comparisons. |
| Quality | Recall can drop sharply, so BQ usually fits best as a first-stage filter. |
Shortlist cheaply, then rescore
The three representations now have distinct jobs. Stage 1 scans a large candidate set with a cheap code. Stage 2 gives a shortlist a precise comparison. You wouldn't inspect 10,000 API-doc chunks in full precision on every query, and you wouldn't cite the top Hamming match as evidence.
One cost-sensitive pattern uses three stages: binary Hamming search for the cheap shortlist, an INT8 dot-product rescore of that shortlist, and an optional cross-encoder reranker on the final handful when answer quality justifies the extra latency.[6]
Each stage sees fewer candidates and more precision than the one before it.
Compression doesn't move the evidence boundary. Hamming can tie a private note with the rotation guide. Unit cosine can separate them, but the approval filter still has to drop the note.

1import math
2
3labels = ["private-incident-note-44", "api-key-rotation-v3", "service-token-lifecycle-v2"]
4approved = [False, True, True]
5documents = [
6 [0.05, -2.00, 0.05, -2.00], # Same signs, wrong private evidence.
7 [0.78, -0.38, 0.58, -0.22], # Approved rotation guide, close in full precision.
8 [0.10, 0.60, -0.40, -0.20],
9]
10query = [0.80, -0.40, 0.60, -0.20]
11expected = "api-key-rotation-v3"
12
13def sign_bits(vector: list[float]) -> list[bool]:
14 return [value > 0 for value in vector]
15
16def l2_normalize(vector: list[float]) -> list[float]:
17 norm = math.sqrt(sum(value * value for value in vector))
18 return [value / norm for value in vector]
19
20def dot(left: list[float], right: list[float]) -> float:
21 return sum(x * y for x, y in zip(left, right, strict=True))
22
23query_signs = sign_bits(query)
24hamming = [
25 sum(doc_bit != query_bit for doc_bit, query_bit in zip(sign_bits(row), query_signs, strict=True))
26 for row in documents
27]
28shortlist = sorted(range(len(hamming)), key=lambda index: (hamming[index], index))[:2]
29allowed_shortlist = [index for index in shortlist if approved[index]]
30unit_query = l2_normalize(query)
31precise_scores = [dot(l2_normalize(documents[index]), unit_query) for index in allowed_shortlist]
32served = labels[allowed_shortlist[precise_scores.index(max(precise_scores))]]
33
34recall_at_2 = expected in [labels[index] for index in shortlist]
35print(f"binary shortlist={[labels[index] for index in shortlist]}, recall@2={recall_at_2}")
36print(f"served evidence={served}, authorized={approved[labels.index(served)]}")
37assert recall_at_2 and served == expected1binary shortlist=['private-incident-note-44', 'api-key-rotation-v3'], recall@2=True
2served evidence=api-key-rotation-v3, authorized=TrueOn real data, run that same gate over many labeled queries and failure slices. A compressed first pass earns its place only if shortlist recall stays high enough for precise, authorized evidence selection to succeed.
Why is binary quantization usually paired with reranking instead of used as the final answer?
Answer
Binary quantization keeps only one sign bit per dimension, so Hamming distance is fast but coarse. It's useful for finding a shortlist cheaply. The final ranking should use a higher-precision representation, such as INT8 or FP32 dot product, and often a cross-encoder reranker when result quality matters.
Indexing the compressed codes
Compression answers "how many bytes per row?" ANN answers "how many rows do we visit?" Keep those levers separate. Exact composition is library-specific: some implementations combine HNSW with scalar or product quantization, while others keep full-precision vectors beside compressed codes for rescoring.
Many vector databases offer HNSW (Hierarchical Navigable Small World) graphs as an ANN structure.[7] HNSW builds a multi-layered proximity graph: upper layers contain progressively fewer sampled nodes, while the base layer contains the collection.
Search starts high, follows promising links, and descends toward a candidate neighborhood at the base.
If a compressed index still misses the guide, you need to know which knob can change without rebuilding. HNSW implementations commonly expose three controls; exact names and allowed values depend on the library:
| Control | Changed during | Usually buys | Usually costs |
|---|---|---|---|
| M (graph connectivity) | Index build | More routes to true neighbors | More graph memory and build work |
| ef_construction | Index build | A better-connected graph | Slower index construction |
| ef_search | Query serving | More candidate exploration | Higher query latency |
Start with query-time exploration and measure recall and latency together. If the graph can't reach required recall within the latency budget, rebuild with adjusted construction settings. A copied parameter triple says nothing about a new corpus.
Public benchmarks such as MTEB evaluate embedding models across several task families.[8] They help you assemble candidates. They can't tell you which index retrieves api-key-rotation-v3 under your storage, authorization, and latency limits.
| Decision | Establish a baseline | Accept a change only when |
|---|---|---|
| Model family | Full-precision Recall@K on approved evidence queries | Target slices and latency remain acceptable |
| Supported prefix width | Full trained width with identical scoring | Measured recall loss fits your release threshold |
| SQ, PQ, or BQ | Uncompressed shortlist and final ranking | Compressed shortlist preserves required evidence recall |
| ANN settings | Exact-search results for a labeled sample | Recall and latency both fit the serving budget |
Keep a versioned experiment row for model, width, normalization, quantizer, index settings, shortlist size, and reranker. That row lets you explain a compressed-index rollout when a rotation-guide answer later changes.
Bytes, RAM, and the shortlist bill
The one-million-row example hides scale. As the corpus grows from millions to billions of chunks, low-latency ANN search keeps more index data hot in memory, and raw float32 payload can dominate the bill. The ratios stay simple, so use the table to choose where to measure next.
The byte math is deterministic even when cloud pricing isn't. This table uses the same 1,536-d width as the sizing example, rounded in binary units, before HNSW links, metadata, replicas, or region-specific RAM pricing:
| Scale | Raw payload (1536d, FP32) | With INT8 SQ | With PQ (96-byte code) |
|---|---|---|---|
| 1M vectors | 5.72 GiB | 1.43 GiB | 92 MiB |
| 10M vectors | 57.2 GiB | 14.3 GiB | 916 MiB |
| 100M vectors | 572 GiB | 143 GiB | 8.94 GiB |
| 1B vectors | 5.6 TiB | 1.4 TiB | 89 GiB |
Practice: choose a retrieval stack
You have 50 million approved API-doc embeddings at 768 dimensions. private-incident-note-44 must never be served as answer evidence. P99 latency budget is 60 ms, and raw FP32 vectors would crowd out the rest of the service's RAM. Choose a path that saves memory without weakening the evidence boundary.
- Would you keep raw FP32 payload in memory for first-stage search?
- If your ingest path already L2-normalizes vectors, would you score with cosine or dot product online?
- Would you start with INT8 SQ, PQ, or BQ for the first pass?
- Where would you spend full-precision compute?
- What test prevents a cheaper index from silently changing evidence quality?
Use this as a design review. Give each answer a reason tied to the scoring contract, memory budget, or evidence gate.
Solution sketch:
- Don't start with raw FP32 if memory is already tight. Benchmark INT8 first, then PQ if you still need a bigger cut.
- Use dot product online if vectors are already unit-normalized. It preserves cosine ranking without redundant norm work.
- Start by benchmarking INT8 SQ because it changes payload less aggressively than PQ or BQ. Move further only when the measured RAM and latency gain justifies shortlist loss.
- Spend full-precision compute on a shortlist, not on the whole corpus. That's where rescoring and reranking pay off.
- Build a labeled release set containing approved rotation and token-lifecycle docs plus tempting unauthorized near-matches. Compare Recall@K with the full-precision baseline, and reject any serving result sourced from unapproved text.
Common pitfalls
When a release goes wrong, the symptom usually points to one boundary. Use it to choose the next measurement instead of changing every knob at once.
- Recall falls after INT8 rollout. Cause: scale got stretched by outliers, or offline and online normalization disagree. Fix: clip the calibration range robustly, recheck the normalization contract, and benchmark recall on real queries.
- ANN results look fine offline but wrong in production. Cause: you tuned on MTEB or synthetic samples instead of real ticket language and chunking. Fix: build an eval set from your own queries and review misses manually.
- Search is still too slow after quantization. Cause: you compressed payload but still rescore too many candidates or set
ef_searchtoo high. Fix: tune shortlist size, HNSW parameters, and rerank depth together. - Migration silently degrades quality. Cause: old and new vectors use different model versions, dimensions, or quantization settings. Fix: version the whole embedding contract and build the new index side by side before traffic cutover.