Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
The last chapter taught an access-policy assistant to answer with the same chat template at train and serve time. Now consider a failure before generation: the assistant receives the right question but retrieves the wrong policy clause. "My service-account key is 18 days old. Can I keep using it?" should land near "Keys older than 14 days require a rotation ticket," not near an auto-merge rule.
Each clause is stored as a long embedding. An engineer proposes shortening every vector to save memory. What evidence would make that safe? A cleaner-looking plot isn't enough; the rotation clause still has to survive the top- retrieval that supplies the answer.
Keep two jobs separate:
- Can we store and search shorter vectors while still returning the same useful policy chunks?
- Can we draw a two-dimensional map that helps an engineer inspect confusing clusters?
Both jobs use dimensionality reduction, but they don't share a success criterion. A production vector is approved by retrieval quality and cost. A plot is approved by what it lets you investigate, with important conclusions checked back in the original embedding space.
The article keeps one policy index in view while changing one representation at a time. First establish cost and original neighbors. Then project, shorten, or quantize and ask which neighbors survived.


Start with the budget, not an algorithm
Before choosing a reducer, put the storage pressure on paper. An embedding is an array of numbers, and float32 storage costs four bytes per dimension. Storing ten million 1,536-dimensional policy or document vectors therefore costs:
That's 61.44 GB using decimal gigabytes, before the index graph, metadata, replicas, or backups. Cutting the vector to 256 dimensions makes the raw array six times smaller. The arithmetic proves a cost change, not a safe search change.
Run the arithmetic first. It turns "embeddings feel large" into a concrete engineering constraint.
1def decimal_gb(byte_count: int) -> float:
2 return byte_count / 1_000_000_000
3
4documents = 10_000_000
5bytes_per_float32 = 4
6full_dim = 1_536
7candidate_dim = 256
8
9full_bytes = documents * full_dim * bytes_per_float32
10candidate_bytes = documents * candidate_dim * bytes_per_float32
11
12print(f"Full float32 array: {decimal_gb(full_bytes):.2f} GB")
13print(f"256-d float32 array: {decimal_gb(candidate_bytes):.2f} GB")
14print(f"Raw-vector reduction: {full_bytes / candidate_bytes:.1f}x")1Full float32 array: 61.44 GB
2256-d float32 array: 10.24 GB
3Raw-vector reduction: 6.0xThe first applied habit is simple: separate a cost metric from a quality metric.
| Question | Useful metric |
|---|---|
| Will vectors fit in memory? | Bytes per vector, total RAM, index overhead |
| Will useful evidence still be retrieved? | Recall@K, nDCG@K, answer success on grounded evaluations |
| Will an engineer understand failures? | A labeled plot plus checks in the original space |
Why can't a six-times smaller vector index be approved from storage savings alone?
Answer
Storage reports the benefit, not the semantic loss. A shorter index can omit the policy chunk needed to answer a request correctly. Approval requires retrieval or downstream answer measurements on representative queries.
Build a retrieval baseline
Storage has a number now, but quality still needs a reference. Before compressing anything, define what "the same useful neighbors" means. The small fixture below represents six policy clauses. Its coordinates are hand-written so you can see the experiment without needing an API key: the first two coordinates concern key rotation, the next two concern merge gates, and the last two concern access escalations.
Cosine similarity compares directions rather than raw lengths. We L2-normalize each vector, score one 18-day-key query, and save the top results as the baseline that reduction must try to preserve.
Before running the cell, predict its first two clauses. The query points toward the first two coordinates, so the two rotation clauses should lead; the later merge and access clauses are controls for a false neighbor.
1import math
2
3documents = [
4 "Keys older than 14 days require a rotation ticket before continued use.",
5 "Rotate a stale service-account key and open a ticket before reuse.",
6 "Auto-merge only when tests, security scan, and reviewer approval all pass.",
7 "A failed security scan blocks merge even if tests are green.",
8 "Access escalations need an owner and an expiry; never grant standing admin.",
9 "Standing production admin is forbidden; use a time-bounded escalation.",
10]
11
12vectors = [
13 [1.00, 0.92, 0.04, 0.00, 0.02, 0.00],
14 [0.92, 0.80, 0.04, 0.01, 0.02, 0.00],
15 [0.02, 0.00, 1.00, 0.88, 0.04, 0.00],
16 [0.02, 0.02, 0.91, 0.98, 0.00, 0.00],
17 [0.00, 0.00, 0.04, 0.02, 1.00, 0.89],
18 [0.02, 0.00, 0.00, 0.02, 0.91, 1.00],
19]
20query = [0.98, 1.00, 0.03, 0.00, 0.01, 0.00]
21
22def dot(left: list[float], right: list[float]) -> float:
23 return sum(a * b for a, b in zip(left, right))
24
25def normalize(vector: list[float]) -> list[float]:
26 scale = math.sqrt(dot(vector, vector))
27 return [value / scale for value in vector]
28
29query_hat = normalize(query)
30scores = [dot(normalize(vector), query_hat) for vector in vectors]
31ranking = sorted(range(len(scores)), key=lambda index: -scores[index])
32
33for rank, index in enumerate(ranking[:3], start=1):
34 print(f"{rank}. {documents[index]} score={scores[index]:.3f}")11. Keys older than 14 days require a rotation ticket before continued use. score=0.999
22. Rotate a stale service-account key and open a ticket before reuse. score=0.997
33. A failed security scan blocks merge even if tests are green. score=0.036In a real evaluation set, each query has judged relevant documents. For a query with two relevant rotation clauses, recall@2 = 1.0 means both appear in the top two returned results. That's the comparison target for every compressed representation here.
PCA: learn a shorter linear coordinate system
The first post-hoc candidate changes coordinates without retraining the encoder. Principal component analysis (PCA) fits a linear transformation to a corpus: center the embedding matrix, find directions with the most variation, and keep the first k directions. If is the centered document matrix and holds the chosen directions, the reduced vectors are:
The rows are still documents, now with fewer columns. In implementations, singular value decomposition (SVD) is a practical way to find principal directions without explicitly constructing a covariance matrix.[1] The labs use power iteration so you can run them with the standard library; the geometry is the same idea.

Follow the right-hand path in the figure before reading the code: one fitted mean and basis must transform both sides of the search. Fitting PCA separately on document vectors and live queries creates different axes, so their dot products no longer compare like with like. Persist the transform fitted on representative documents and reuse it for indexed documents and future queries.
The next cell adds small variations around the three policy themes, fits a two-dimensional PCA model, and retrieves with a query transformed by that same model. Predict the final label first: the query still describes rotation, even though its coordinates will change.
1import math
2import random
3
4centers = [
5 [1.0, 0.9, 0.0, 0.0, 0.0, 0.0], # rotation
6 [0.0, 0.0, 1.0, 0.9, 0.0, 0.0], # merge
7 [0.0, 0.0, 0.0, 0.0, 1.0, 0.9], # access
8]
9labels = ["rotation"] * 20 + ["merge"] * 20 + ["access"] * 20
10rng = random.Random(4)
11documents = [
12 [center[j] + rng.gauss(0, 0.05) for j in range(6)]
13 for center in centers
14 for _ in range(20)
15]
16query = [[0.96, 0.91, 0.02, 0.01, 0.00, 0.00]]
17
18def dot(left: list[float], right: list[float]) -> float:
19 return sum(a * b for a, b in zip(left, right))
20
21def normalize(vector: list[float]) -> list[float]:
22 scale = math.sqrt(dot(vector, vector))
23 return [value / scale for value in vector]
24
25def mean_col(rows: list[list[float]]) -> list[float]:
26 n_rows, n_cols = len(rows), len(rows[0])
27 return [sum(row[j] for row in rows) / n_rows for j in range(n_cols)]
28
29def sub(left: list[float], right: list[float]) -> list[float]:
30 return [a - b for a, b in zip(left, right)]
31
32def fit_pca(rows: list[list[float]], k: int, seed: int = 0) -> tuple[list[float], list[list[float]]]:
33 mu = mean_col(rows)
34 centered = [sub(row, mu) for row in rows]
35 n_cols = len(rows[0])
36 local = random.Random(seed)
37 components: list[list[float]] = []
38 for _ in range(k):
39 vector = normalize([local.gauss(0, 1) for _ in range(n_cols)])
40 for _ in range(80):
41 projected = [dot(row, vector) for row in centered]
42 vector = [
43 sum(centered[i][j] * projected[i] for i in range(len(centered)))
44 for j in range(n_cols)
45 ]
46 for previous in components:
47 coeff = dot(vector, previous)
48 vector = [value - coeff * axis for value, axis in zip(vector, previous)]
49 vector = normalize(vector)
50 components.append(vector)
51 return mu, components
52
53def transform(rows: list[list[float]], mu: list[float], components: list[list[float]]) -> list[list[float]]:
54 return [[dot(sub(row, mu), axis) for axis in components] for row in rows]
55
56def explained_ratio(rows: list[list[float]], mu: list[float], components: list[list[float]]) -> float:
57 centered = [sub(row, mu) for row in rows]
58 total = sum(dot(row, row) for row in centered)
59 kept = 0.0
60 for axis in components:
61 scores = [dot(row, axis) for row in centered]
62 kept += sum(score * score for score in scores)
63 return kept / total
64
65mu, components = fit_pca(documents, k=2)
66reduced_documents = [normalize(row) for row in transform(documents, mu, components)]
67reduced_query = normalize(transform(query, mu, components)[0])
68best_index = max(range(len(reduced_documents)), key=lambda i: dot(reduced_documents[i], reduced_query))
69
70print(f"Shape: ({len(documents)}, {len(documents[0])}) -> ({len(reduced_documents)}, {len(reduced_documents[0])})")
71print(f"Explained variance kept: {explained_ratio(documents, mu, components):.3f}")
72print(f"Closest policy theme: {labels[best_index]}")1Shape: (60, 6) -> (60, 2)
2Explained variance kept: 0.992
3Closest policy theme: rotationPCA optimizes variance, not relevance. A direction that rarely changes in the corpus can still distinguish "auto-merge allowed" from "security scan failed." Explained variance helps diagnose what the projection retained; retrieval recall decides whether the index is safe to ship.
Sweep dimensions against neighbor recall
One successful query doesn't establish a dimension. On a small data set, you can provide the relevance judgments yourself. This lab creates twenty policy intents in a noisy 24-dimensional space, gives each query three acceptable chunks, and tries PCA dimensions 2, 4, 8, and 12. Predict the tradeoff: the first cuts should save more coordinates, but may lose neighbors.
1import math
2import random
3
4rng = random.Random(21)
5dimensions = 24
6intent_count = 20
7acceptable_chunks = 3
8
9def dot(left: list[float], right: list[float]) -> float:
10 return sum(a * b for a, b in zip(left, right))
11
12def normalize(vector: list[float]) -> list[float]:
13 scale = math.sqrt(dot(vector, vector))
14 return [value / scale for value in vector]
15
16def mean_col(rows: list[list[float]]) -> list[float]:
17 n_rows, n_cols = len(rows), len(rows[0])
18 return [sum(row[j] for row in rows) / n_rows for j in range(n_cols)]
19
20def sub(left: list[float], right: list[float]) -> list[float]:
21 return [a - b for a, b in zip(left, right)]
22
23def fit_pca(rows: list[list[float]], k: int, seed: int = 0) -> tuple[list[float], list[list[float]]]:
24 mu = mean_col(rows)
25 centered = [sub(row, mu) for row in rows]
26 n_cols = len(rows[0])
27 local = random.Random(seed)
28 components: list[list[float]] = []
29 for _ in range(k):
30 vector = normalize([local.gauss(0, 1) for _ in range(n_cols)])
31 for _ in range(60):
32 projected = [dot(row, vector) for row in centered]
33 vector = [
34 sum(centered[i][j] * projected[i] for i in range(len(centered)))
35 for j in range(n_cols)
36 ]
37 for previous in components:
38 coeff = dot(vector, previous)
39 vector = [value - coeff * axis for value, axis in zip(vector, previous)]
40 vector = normalize(vector)
41 components.append(vector)
42 return mu, components
43
44def transform(rows: list[list[float]], mu: list[float], components: list[list[float]]) -> list[list[float]]:
45 return [[dot(sub(row, mu), axis) for axis in components] for row in rows]
46
47def explained_ratio(rows: list[list[float]], mu: list[float], components: list[list[float]]) -> float:
48 centered = [sub(row, mu) for row in rows]
49 total = sum(dot(row, row) for row in centered)
50 kept = 0.0
51 for axis in components:
52 scores = [dot(row, axis) for row in centered]
53 kept += sum(score * score for score in scores)
54 return kept / total
55
56def top_k(query_rows: list[list[float]], doc_rows: list[list[float]], k: int) -> list[list[int]]:
57 doc_hat = [normalize(row) for row in doc_rows]
58 ranking: list[list[int]] = []
59 for query in query_rows:
60 query_hat = normalize(query)
61 scores = [dot(doc, query_hat) for doc in doc_hat]
62 order = sorted(range(len(scores)), key=lambda index: -scores[index])
63 ranking.append(order[:k])
64 return ranking
65
66intent_vectors = [[rng.gauss(0, 1) for _ in range(dimensions)] for _ in range(intent_count)]
67center = mean_col(intent_vectors)
68intent_vectors = [normalize(sub(vector, center)) for vector in intent_vectors]
69
70documents: list[list[float]] = []
71queries: list[list[float]] = []
72relevant: list[set[int]] = []
73for intent in intent_vectors:
74 first_chunk = len(documents)
75 for _ in range(acceptable_chunks):
76 documents.append([intent[j] + rng.gauss(0, 0.11) for j in range(dimensions)])
77 queries.append([intent[j] + rng.gauss(0, 0.03) for j in range(dimensions)])
78 relevant.append(set(range(first_chunk, first_chunk + acceptable_chunks)))
79
80for target_dim in [2, 4, 8, 12]:
81 mu, components = fit_pca(documents, k=target_dim)
82 short_docs = transform(documents, mu, components)
83 short_queries = transform(queries, mu, components)
84 returned = top_k(short_queries, short_docs, k=3)
85 recall = sum(
86 len(expected & set(found)) / acceptable_chunks
87 for expected, found in zip(relevant, returned)
88 ) / len(relevant)
89 print(
90 f"{target_dim:>2} dims: recall@3={recall:.3f}, "
91 f"variance={explained_ratio(documents, mu, components):.3f}"
92 )12 dims: recall@3=0.383, variance=0.294
2 4 dims: recall@3=0.867, variance=0.495
3 8 dims: recall@3=1.000, variance=0.727
412 dims: recall@3=1.000, variance=0.871These numbers belong to this generated fixture, not to all embedding models. The two curves answer different questions: variance describes the projection, while relevant-chunk recall describes the product. Keep the experiment pattern, then replace its documents, queries, and judgments with data from your own retrieval workload.
A PCA model keeps 95 percent of explained variance but loses a relevant rotation-ticket chunk from top-5 retrieval. Should it ship?
Answer
No. PCA preserved high-variance directions, but the product needs correct retrieval. Reduce less aggressively, try another method, or revise the index only after the retrieval metric meets the required threshold.
A two-dimensional map is an investigation tool
The baseline and PCA experiment protect serving quality with judged neighbors. Debugging needs a different view. A two-dimensional projection can help an engineer ask:
- Are 18-day-key queries scattered into the merge-gate cluster?
- Are clauses from a new policy version isolated from older versions?
- Which points should an engineer inspect in the original data?
It doesn't become a search index just because the plot looks separated. Compressing a high-dimensional semantic vector to two coordinates forces distortion, so use the map to choose cases for inspection and return to the original vectors for a decision.
Compare a linear map and a prettier picture
PCA already gave us a deterministic linear view. T-distributed stochastic neighbor embedding (t-SNE) and Uniform Manifold Approximation and Projection (UMAP) focus more strongly on neighborhood relationships, using nonlinear objectives. The visual difference matters because a layout can make clusters appear convincingly separated even when a production retrieval test would disagree.

The next lab uses the same six policy vectors as the baseline. Before running it, predict what a pretty island map could hide: a merge clause might look close to a rotation clause even when their original vectors are far apart. The cell reports each clause's original 1-nearest neighbor, then compares PCA with a cleaned-up island picture that an engineer might be tempted to trust.
1import math
2
3names = ["rot-a", "rot-b", "merge-a", "merge-b", "access-a", "access-b"]
4vectors = [
5 [1.00, 0.92, 0.04, 0.00, 0.02, 0.00],
6 [0.92, 0.80, 0.04, 0.01, 0.02, 0.00],
7 [0.02, 0.00, 1.00, 0.88, 0.04, 0.00],
8 [0.02, 0.02, 0.91, 0.98, 0.00, 0.00],
9 [0.00, 0.00, 0.04, 0.02, 1.00, 0.89],
10 [0.02, 0.00, 0.00, 0.02, 0.91, 1.00],
11]
12pretty_map = [
13 [0.00, 0.00],
14 [0.20, 0.10],
15 [0.30, 0.05], # merge-a pulled into the rotation island
16 [2.00, 2.00],
17 [0.00, 2.00],
18 [0.20, 2.10],
19]
20
21def dot(left: list[float], right: list[float]) -> float:
22 return sum(a * b for a, b in zip(left, right))
23
24def mean_col(rows: list[list[float]]) -> list[float]:
25 n_rows, n_cols = len(rows), len(rows[0])
26 return [sum(row[j] for row in rows) / n_rows for j in range(n_cols)]
27
28def sub(left: list[float], right: list[float]) -> list[float]:
29 return [a - b for a, b in zip(left, right)]
30
31def normalize(vector: list[float]) -> list[float]:
32 scale = math.sqrt(dot(vector, vector))
33 return [value / scale for value in vector]
34
35def fit_pca(rows: list[list[float]], k: int) -> tuple[list[float], list[list[float]]]:
36 mu = mean_col(rows)
37 centered = [sub(row, mu) for row in rows]
38 n_cols = len(rows[0])
39 components: list[list[float]] = []
40 seed = [1.0] * n_cols
41 for _ in range(k):
42 vector = normalize(seed)
43 for _ in range(80):
44 projected = [dot(row, vector) for row in centered]
45 vector = [
46 sum(centered[i][j] * projected[i] for i in range(len(centered)))
47 for j in range(n_cols)
48 ]
49 for previous in components:
50 coeff = dot(vector, previous)
51 vector = [value - coeff * axis for value, axis in zip(vector, previous)]
52 vector = normalize(vector)
53 components.append(vector)
54 seed = [1.0 if j == len(components) else 0.25 for j in range(n_cols)]
55 return mu, components
56
57def transform(rows: list[list[float]], mu: list[float], components: list[list[float]]) -> list[list[float]]:
58 return [[dot(sub(row, mu), axis) for axis in components] for row in rows]
59
60def nearest_names(rows: list[list[float]]) -> list[str]:
61 neighbors: list[str] = []
62 for i, left in enumerate(rows):
63 best_j, best_d = -1, float("inf")
64 for j, right in enumerate(rows):
65 if i == j:
66 continue
67 dist = sum((a - b) ** 2 for a, b in zip(left, right))
68 if dist < best_d:
69 best_j, best_d = j, dist
70 neighbors.append(names[best_j])
71 return neighbors
72
73def overlap(left: list[str], right: list[str]) -> float:
74 return sum(a == b for a, b in zip(left, right)) / len(left)
75
76original = nearest_names(vectors)
77mu, components = fit_pca(vectors, k=2)
78pca_map = transform(vectors, mu, components)
79pca_neighbors = nearest_names(pca_map)
80pretty_neighbors = nearest_names(pretty_map)
81
82print("Original 1-NN:", list(zip(names, original)))
83print(f"PCA 1-NN overlap: {overlap(original, pca_neighbors):.2f}")
84print(f"Pretty-island 1-NN overlap: {overlap(original, pretty_neighbors):.2f}")
85print(f"Pretty-island neighbor of merge-a: {pretty_neighbors[2]}")1Original 1-NN: [('rot-a', 'rot-b'), ('rot-b', 'rot-a'), ('merge-a', 'merge-b'), ('merge-b', 'merge-a'), ('access-a', 'access-b'), ('access-b', 'access-a')]
2PCA 1-NN overlap: 1.00
3Pretty-island 1-NN overlap: 0.50
4Pretty-island neighbor of merge-a: rot-bPCA kept the original pairs on this tiny set because the three themes already sit on high-variance axes. The prettier island map still "looks clustered," but it moved merge-a next to a rotation clause. Local-overlap checks whether a map is useful for investigating nearby cases; it doesn't become a retrieval release gate. That gate remains based on queries and relevant documents.
t-SNE: excellent local pictures, unsafe search coordinates
Start with one question: which points should stay neighbors after the map gets smaller? t-SNE converts high-dimensional neighbor relationships into probabilities , creates corresponding low-dimensional probabilities using a heavy-tailed Student-t distribution with one degree of freedom, and minimizes:
The heavy tail lets moderately distant points spread in the map instead of crowding into the center. This is the central construction in van der Maaten and Hinton's t-SNE paper.[2]

Because the map is built around neighborhoods, perplexity controls the rough scale considered by t-SNE. Trying several values is useful for investigation. Picking whichever rendering tells the cleanest story isn't a reliable analysis.
UMAP: optimize a weighted neighbor graph
UMAP starts with the same neighbor question but makes the intermediate object explicit: a weighted nearest-neighbor graph. It represents that graph as a fuzzy simplicial set, then optimizes a lower-dimensional graph with a cross-entropy objective.[3] Parameters such as n_neighbors and min_dist change the view.
One layout can expose small pockets of policy confusion while another reveals broader groupings. Read both as views of a chosen graph, not as a new semantic coordinate system.

Common UMAP implementations can place later samples into a fitted map with an approximate transform. That's convenient for a diagnostic dashboard, but it isn't the same as PCA's fixed linear transform. If incoming traffic changes, re-check the map before using it to explain failures.
| Goal | Appropriate first measurement | Suitable methods to test |
|---|---|---|
| Inspect clusters or outliers | Neighbor overlap plus human review | PCA, t-SNE, UMAP |
| Shorten vectors for retrieval | Recall@K or nDCG@K plus RAM and latency | Native shortening, PCA, random projection |
| Reduce memory further | Recall after compressed search and reranking | Scalar, PQ, binary quantization |
Random projection: a no-fit serving baseline
Suppose policy documents change often and fitting a corpus-dependent transform is inconvenient. PCA learns from the document corpus; a random projection samples a fixed matrix once and applies it to every document and query:
The Johnson-Lindenstrauss lemma establishes that a finite set of points can be projected into a lower-dimensional space while approximately preserving pairwise distances, given enough target dimensions.[4] That bound is a worst-case guarantee, not a claim that a specific 256-dimensional policy index will meet your recall requirement. Save the matrix, then let judged retrieval decide.

The experiment compares original cosine neighbors with a Gaussian random projection. A fixed seed makes the transform reproducible. The Gaussian entries are scaled by , matching the usual construction. Predict the result before running it: fewer bytes can come with a noticeable neighbor loss on this intentionally small target dimension.
1import math
2import random
3
4rng = random.Random(33)
5documents = [[rng.gauss(0, 1) for _ in range(48)] for _ in range(180)]
6queries = [
7 [documents[i][j] + rng.gauss(0, 0.03) for j in range(48)]
8 for i in range(12)
9]
10
11def dot(left: list[float], right: list[float]) -> float:
12 return sum(a * b for a, b in zip(left, right))
13
14def normalize(vector: list[float]) -> list[float]:
15 scale = math.sqrt(dot(vector, vector))
16 return [value / scale for value in vector]
17
18def neighbors(query_rows: list[list[float]], doc_rows: list[list[float]], k: int = 5) -> list[list[int]]:
19 doc_hat = [normalize(row) for row in doc_rows]
20 ranking: list[list[int]] = []
21 for query in query_rows:
22 query_hat = normalize(query)
23 scores = [dot(doc, query_hat) for doc in doc_hat]
24 order = sorted(range(len(scores)), key=lambda index: -scores[index])
25 ranking.append(order[:k])
26 return ranking
27
28def apply_R(rows: list[list[float]], matrix: list[list[float]]) -> list[list[float]]:
29 n_out = len(matrix[0])
30 return [
31 [sum(row[i] * matrix[i][j] for i in range(len(row))) for j in range(n_out)]
32 for row in rows
33 ]
34
35gold = neighbors(queries, documents)
36n_out = 16
37proj_rng = random.Random(7)
38scale = 1.0 / math.sqrt(n_out)
39projection = [[proj_rng.gauss(0, scale) for _ in range(n_out)] for _ in range(48)]
40short_docs = apply_R(documents, projection)
41short_queries = apply_R(queries, projection)
42predicted = neighbors(short_queries, short_docs)
43recall = sum(
44 len(set(left) & set(right)) / len(left)
45 for left, right in zip(gold, predicted)
46) / len(gold)
47
48print(f"Bytes/vector: {48 * 4} -> {n_out * 4}")
49print(f"Neighbor recall@5: {recall:.3f}")1Bytes/vector: 192 -> 64
2Neighbor recall@5: 0.317Random projection is worth benchmarking when fitting a data-dependent reducer is inconvenient or expensive. It isn't automatically better or worse than PCA. Let the same labeled retrieval set judge both.
Compression after reduction: quantization
Projection changes how many coordinates a vector has. Quantization changes how precisely those coordinates are stored. You can use the two levers independently or together.
For example, a 256-dimensional float32 vector occupies 256 × 4 = 1,024 bytes. Storing one signed byte per dimension uses 256 bytes. Storing one bit per dimension uses 32 bytes. The smaller representations buy memory by accepting more approximation.
Scalar and binary codes
Scalar quantization maps each value onto an integer grid. Binary quantization records only a sign or threshold result. A compressed index can search cheaply, then rescore a larger candidate set with a more accurate representation. Qdrant's quantization guide documents this as rescore plus oversampling: if oversampling is 2.4 and the limit is 100, the compressed index preselects 240 candidates, then the original vectors rescore that shortlist down to 100.[5] The same guide currently treats 4-bit TurboQuant (a random rotation followed by a learned quantizer) as a strong default at 8× compression, and it recommends enabling rescoring for binary codes.
Before trusting compressed candidates, separate the mechanical win from the quality question. The next lab quantizes four normalized vectors to int8 and converts their signs to bits. The bit count is exact; the quality decision still needs retrieval measurements.
1vectors = [
2 [0.90, 0.80, -0.10, -0.20, 0.04, 0.02, -0.05, 0.08],
3 [0.85, 0.74, -0.04, -0.16, 0.01, 0.04, -0.02, 0.05],
4 [-0.02, 0.04, 0.92, 0.81, -0.06, 0.01, 0.02, -0.04],
5 [-0.05, 0.03, 0.88, 0.79, -0.03, 0.00, 0.06, -0.02],
6]
7
8peak = max(abs(value) for row in vectors for value in row)
9scale = 127 / peak
10int8_vectors = [
11 [max(-128, min(127, int(round(value * scale)))) for value in row]
12 for row in vectors
13]
14binary_vectors = [[value > 0 for value in row] for row in vectors]
15query_bits = binary_vectors[0]
16hamming = [
17 sum(left != right for left, right in zip(row, query_bits))
18 for row in binary_vectors
19]
20
21print(f"float32 bytes/vector: {len(vectors[0]) * 4}")
22print(f"int8 bytes/vector: {len(int8_vectors[0])}")
23print(f"binary bits/vector: {len(binary_vectors[0])}")
24print(f"Binary Hamming distances from row 0: {hamming}")1float32 bytes/vector: 32
2int8 bytes/vector: 8
3binary bits/vector: 8
4Binary Hamming distances from row 0: [0, 0, 6, 7]Binary codes are tiny, but "same sign" is a much weaker statement than "same semantic evidence." For a policy assistant, retrieve more candidates with a compressed representation and rerank before final evidence selection if benchmarks justify that design. Measure the oversample factor against final recall and end-to-end latency; too small and true neighbors drop before rerank, too large and you pay second-stage cost without quality gain.
Product quantization: code sub-vectors
Binary codes have shown the precision tradeoff. Product Quantization (PQ) changes the storage unit again: it splits a vector into sub-vectors, learns a codebook for each subspace, and stores only the selected centroid IDs.[6] In the common case of 256 possible centroids, each ID fits in one byte. A 128-dimensional float32 vector split into eight subspaces can therefore be represented by eight code bytes, plus shared codebooks.

The original PQ paper describes asymmetric distance computation (ADC): keep a query unquantized, precompute its distances to codebook centroids, and score each stored code through table lookups.[6] The miniature implementation uses only two subspaces and four centroids so that every shape remains visible. Predict what shrinks: the stored code gets shorter, while the shared codebooks stay available for scoring.
1import random
2
3rng = random.Random(12)
4documents = (
5 [[rng.gauss(1, 0.08), rng.gauss(1, 0.08), rng.gauss(0, 0.08), rng.gauss(0, 0.08)] for _ in range(20)]
6 + [[rng.gauss(0, 0.08), rng.gauss(0, 0.08), rng.gauss(1, 0.08), rng.gauss(1, 0.08)] for _ in range(20)]
7)
8query = [0.94, 1.02, 0.01, -0.02]
9
10def mean_col(rows: list[list[float]]) -> list[float]:
11 n_rows, n_cols = len(rows), len(rows[0])
12 return [sum(row[j] for row in rows) / n_rows for j in range(n_cols)]
13
14def kmeans(rows: list[list[float]], k: int, seed: int) -> tuple[list[list[float]], list[int]]:
15 local = random.Random(seed)
16 centroids = [rows[local.randrange(len(rows))][:] ]
17 while len(centroids) < k:
18 weights = []
19 for row in rows:
20 nearest = min(
21 sum((row[j] - center[j]) ** 2 for j in range(len(row)))
22 for center in centroids
23 )
24 weights.append(nearest)
25 pick = local.random() * (sum(weights) or 1.0)
26 acc = 0.0
27 chosen = rows[-1]
28 for row, weight in zip(rows, weights):
29 acc += weight
30 if acc >= pick:
31 chosen = row
32 break
33 centroids.append(chosen[:])
34 labels = [0] * len(rows)
35 for _ in range(15):
36 for i, row in enumerate(rows):
37 labels[i] = min(
38 range(k),
39 key=lambda c: sum((row[j] - centroids[c][j]) ** 2 for j in range(len(row))),
40 )
41 for cluster in range(k):
42 members = [rows[i] for i, label in enumerate(labels) if label == cluster]
43 if members:
44 centroids[cluster] = mean_col(members)
45 return centroids, labels
46
47subspace_width = 2
48codebooks: list[list[list[float]]] = []
49codes: list[list[int]] = []
50for start in range(0, len(documents[0]), subspace_width):
51 block = [row[start:start + subspace_width] for row in documents]
52 centroids, labels = kmeans(block, k=4, seed=start)
53 codebooks.append(centroids)
54 codes.append(labels)
55
56adc_scores = [0.0] * len(documents)
57for subspace, start in enumerate(range(0, len(documents[0]), subspace_width)):
58 query_block = query[start:start + subspace_width]
59 table = [
60 sum((center[j] - query_block[j]) ** 2 for j in range(subspace_width))
61 for center in codebooks[subspace]
62 ]
63 for i, code in enumerate(codes[subspace]):
64 adc_scores[i] += table[code]
65
66best = min(range(len(adc_scores)), key=lambda i: adc_scores[i])
67print(f"Document shape: ({len(documents)}, {len(documents[0])})")
68print(f"Stored code shape: ({len(documents)}, {len(codes)})")
69print(f"Raw bytes/vector: {len(documents[0]) * 8} (float64 in this lab)")
70print(f"Code bytes/vector with <=256 centroids: {len(codes)}")
71print(f"Nearest PQ-coded document group: {'rotation' if best < 20 else 'merge'}")1Document shape: (40, 4)
2Stored code shape: (40, 2)
3Raw bytes/vector: 32 (float64 in this lab)
4Code bytes/vector with <=256 centroids: 2
5Nearest PQ-coded document group: rotationPQ isn't an interchangeable synonym for PCA. PCA shortens a coordinate system; PQ encodes sub-vectors with learned IDs. An index may use a shortened embedding and then PQ-code it when memory is tight.
Why can a system use both PCA or native shortening and product quantization?
Answer
They reduce different costs. PCA or native shortening reduces the number of coordinates to compare. PQ stores those coordinates as compact centroid IDs, reducing memory further and enabling lookup-based approximate scoring.
Native shortening: train prefixes to be useful
PCA and random projection operate after an encoder produces its vector. Native shortening moves the decision into training: Matryoshka Representation Learning (MRL) optimizes the model so prefixes of its representation remain useful at several selected lengths.[7]
For an embedding and prefix lengths in a set , a simplified view of the original nested objective is a weighted sum of task losses, each computed from a prefix through its own linear head :
Unlike PCA, serving a shorter MRL-trained representation doesn't require a separately fitted rotation. It still requires matching document and query lengths and testing retrieval quality. The original paper also notes that intermediate lengths between the trained cuts often interpolate reasonably, but that still isn't a substitute for a product recall gate.

The lab makes the promise concrete with deliberately ordered vectors. It isn't pretending to train an embedding model: the first coordinates carry policy-theme information, while later coordinates add detail and noise. Truncating a standard, unordered representation wouldn't have this promise. Predict which prefix should recover all three themes before running it.
1import math
2import random
3
4rng = random.Random(15)
5centers = [
6 [1.0, 0.9, 0.3, 0.2, 0.0, 0.0, 0.0, 0.0],
7 [0.0, 0.0, 1.0, 0.9, 0.3, 0.2, 0.0, 0.0],
8 [0.0, 0.0, 0.0, 0.0, 1.0, 0.9, 0.3, 0.2],
9]
10documents = [
11 [center[j] + rng.gauss(0, 0.04) for j in range(8)]
12 for center in centers
13 for _ in range(18)
14]
15queries = [
16 [center[j] + rng.gauss(0, 0.02) for j in range(8)]
17 for center in centers
18 for _ in range(3)
19]
20
21def dot(left: list[float], right: list[float]) -> float:
22 return sum(a * b for a, b in zip(left, right))
23
24def normalize(vector: list[float]) -> list[float]:
25 scale = math.sqrt(dot(vector, vector))
26 return [value / scale for value in vector]
27
28def top3(rows: list[list[float]], corpus: list[list[float]]) -> list[list[int]]:
29 corpus_hat = [normalize(row) for row in corpus]
30 ranking: list[list[int]] = []
31 for query in rows:
32 query_hat = normalize(query)
33 scores = [dot(doc, query_hat) for doc in corpus_hat]
34 order = sorted(range(len(scores)), key=lambda index: -scores[index])
35 ranking.append(order[:3])
36 return ranking
37
38gold = top3(queries, documents)
39for prefix in [2, 4, 8]:
40 shortened = top3(
41 [query[:prefix] for query in queries],
42 [doc[:prefix] for doc in documents],
43 )
44 recall = sum(
45 len(set(expected) & set(found)) / 3
46 for expected, found in zip(gold, shortened)
47 ) / len(gold)
48 print(f"Prefix {prefix}: recall@3={recall:.3f}")1Prefix 2: recall@3=0.185
2Prefix 4: recall@3=0.407
3Prefix 8: recall@3=1.000One hosted API example is OpenAI's text-embedding-3 family. Its documentation lists default lengths of 1,536 for text-embedding-3-small and 3,072 for text-embedding-3-large, and documents a dimensions request parameter for shorter outputs.[8] On MTEB, that page reports that a 256-dimensional text-embedding-3-large vector still outperforms an unshortened 1,536-dimensional text-embedding-ada-002 vector. That's a public benchmark comparison, not a promise about your rotation-ticket queries.
A request shape looks like this:
1{
2 "model": "text-embedding-3-large",
3 "input": "My service-account key is 18 days old. Can I keep using it?",
4 "dimensions": 256
5}⚠️ Common mistake: If you slice a returned vector yourself, L2-normalize the prefix again. OpenAI's
dimensionsparameter handles that on their side; a manual cut doesn't.
The provider feature makes generation of shorter vectors convenient. It doesn't decide whether 256 dimensions meet your 18-day-key retrieval requirement.
Choose a deployment experiment
The methods change different parts of the representation. Before choosing one, name the knob you need to turn: coordinates after encoding, coordinates learned into the encoder, or bits used to store each coordinate. The map methods stay on the investigation side.
These methods fall into three serving families and one diagnostic family:
| Tool family | What changes | Best first question |
|---|---|---|
| PCA or random projection | Number of served coordinates after encoding | How much recall survives at a lower dimension? |
| MRL-style native shortening | Number of coordinates emitted from a trained model | Does a supported prefix beat post-hoc alternatives at the same cost? |
| Scalar, binary, or product quantization | Precision or code used to store/search vectors | Can compressed candidate search plus reranking fit the RAM budget? |
| t-SNE or UMAP | Coordinates used for inspection maps | What failures should an engineer examine next? |
The table becomes useful when it turns into a decision someone else can audit. This lab accepts only configurations that meet both a recall floor and a raw-vector memory budget, then chooses the smallest accepted representation. Predict the winner from the two thresholds before reading the output.
1measurements = [
2 {"method": "float32-full", "bytes_per_vector": 6144, "recall_at_10": 1.000},
3 {"method": "native-256", "bytes_per_vector": 1024, "recall_at_10": 0.984},
4 {"method": "pca-256", "bytes_per_vector": 1024, "recall_at_10": 0.971},
5 {"method": "pq-16-code", "bytes_per_vector": 16, "recall_at_10": 0.939},
6]
7
8required_recall = 0.975
9maximum_bytes = 1200
10accepted = [
11 row for row in measurements
12 if row["recall_at_10"] >= required_recall
13 and row["bytes_per_vector"] <= maximum_bytes
14]
15choice = min(accepted, key=lambda row: row["bytes_per_vector"])
16
17for row in measurements:
18 status = "pass" if row in accepted else "reject"
19 print(f"{row['method']:<14} recall={row['recall_at_10']:.3f} "
20 f"bytes={row['bytes_per_vector']:<4} {status}")
21print(f"Selected representation: {choice['method']}")1float32-full recall=1.000 bytes=6144 reject
2native-256 recall=0.984 bytes=1024 pass
3pca-256 recall=0.971 bytes=1024 reject
4pq-16-code recall=0.939 bytes=16 reject
5Selected representation: native-256The values above are example measurements for the gate, not benchmark results for a real model. A serious report would include:
- A frozen set of realistic policy queries and judged clauses.
- Recall or nDCG at the candidate count sent to generation or reranking.
- Index RAM, build time, and query-latency percentiles for each candidate.
- A failure list: which queries lost relevant evidence, and what risk that creates.
- Re-evaluation when embedding models, policy documents, or traffic distributions change.
An engineer proposes using a visually clean UMAP plot as a 2D production index. What experiment should you request instead?
Answer
Keep UMAP for inspecting data. Request a serving benchmark that compares model-supported shortening, PCA, or random projection at plausible dimensions, followed by quantization if memory still binds. Approve only with retrieval-quality and resource measurements.
Carry the decision rule forward
You can now calculate the raw cost of a vector representation, establish cosine-retrieval neighbors before changing it, fit PCA once and reuse its transform, and benchmark random projection as a no-fit alternative. For inspection, t-SNE and UMAP can surface cases without becoming search coordinates. For storage, fewer dimensions, lower precision, PQ codes, and model-supported prefixes such as MRL-style shortening are different levers, so each one goes through the same retrieval gate.
No method wins by name alone. Compression is an experiment with a quality constraint: storage savings are immediate, while semantic safety has to be measured.