Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
A developer-support team is preparing semantic search over billing, login, and deploy-policy messages. The embedding model has produced vectors, but nobody has reviewed their neighborhoods. When someone searches for “I was charged twice,” will the nearest message be another billing case or an unrelated deploy note?
Without reviewed query-document pairs, we can't yet score retrieval relevance. We can start by inspecting the vectors: k-means groups nearby points, while principal component analysis (PCA) gives them fewer coordinates for inspection or compression. Neither method reads the messages. This chapter builds their core update rules from scratch, connects them to coarse vector search indexing, and tests whether changing units, similarity metrics, or dimensions breaks a useful neighbor relationship.

Start with six messages, not six labels
We'll use six invented two-number embeddings, chosen to make the arithmetic readable. Real embeddings often contain hundreds or thousands of coordinates; here we can see every coordinate and check every distance. The examples use NumPy and assume you're comfortable with arrays and coordinate-wise averages.
Before looking for groups, identify the pairs you'd expect from reading the messages. We can then check whether distance agrees with that judgment.
| Message ID | Message text | ||
|---|---|---|---|
billing_A | "I was charged twice." | 1.0 | 1.0 |
billing_B | "Please undo this duplicate payment." | 1.2 | 1.8 |
login_A | "My reset link never arrives." | 4.0 | 4.2 |
login_B | "Two-factor code keeps failing." | 5.0 | 3.8 |
deploy_A | "Which runbook handles rollback?" | 8.0 | 1.0 |
deploy_B | "The deploy gate is blocking again." | 9.0 | 1.8 |
Names in the first column serve our audit rather than feeding into the algorithm. In practice, the clustering logic receives only six coordinate pairs without ever seeing billing, login, or deploy.
Looking at the billing pair, the points differ by 0.2 horizontally and 0.8 vertically. Straight-line distance between them is . For any two points and , that Euclidean distance is:
Evaluating this formula measures separation in the representation we currently have. Run it before clustering. A sensible local neighborhood gives the later groups something concrete to explain.
Our code computes all 36 pairwise distances. Broadcasting creates a (6, 6, 2) array of coordinate differences; axis=2 reduces each coordinate pair to one distance. Setting the diagonal to infinity prevents each message from selecting itself. This all-pairs approach is for a small audit, not a large search index.
1import numpy as np
2
3names = np.array(["billing_A", "billing_B", "login_A", "login_B", "deploy_A", "deploy_B"])
4vectors = np.array([
5 [1.0, 1.0],
6 [1.2, 1.8],
7 [4.0, 4.2],
8 [5.0, 3.8],
9 [8.0, 1.0],
10 [9.0, 1.8],
11])
12
13distances = np.linalg.norm(vectors[:, None, :] - vectors[None, :, :], axis=2)
14np.fill_diagonal(distances, np.inf)
15
16for row, name in enumerate(names):
17 neighbor = distances[row].argmin()
18 print(f"{name:10} -> {names[neighbor]:10} distance={distances[row, neighbor]:.2f}")1billing_A -> billing_B distance=0.82
2billing_B -> billing_A distance=0.82
3login_A -> login_B distance=1.08
4login_B -> login_A distance=1.08
5deploy_A -> deploy_B distance=1.28
6deploy_B -> deploy_A distance=1.28Each message's closest neighbor belongs to the same readable theme. That's useful evidence about this representation, not a promise about future messages. Keep that distinction in view as we move from pairs to groups.
Partition unlabeled vectors with Lloyd's algorithm
Search doesn't require clusters. Here, clustering helps us select groups of messages to review together. On these six unlabeled vectors, k-means receives the coordinates and a requested number of groups, . A centroid is a cluster's center, calculated by averaging its points.
The algorithm minimizes the within-cluster sum of squares (WCSS), commonly referred to as inertia:[1][2]
Here, is message vector , is the set of points assigned to cluster , and is the centroid of cluster . Lloyd's algorithm optimizes this objective through coordinate descent, alternating between two steps until convergence:[3]
- Assignment step: Hold the centroids fixed. Assign each vector to its nearest centroid:
This minimizes with respect to the assignments while keeping centroids stationary.
- Update step: Hold the assignments fixed. Recompute each centroid to minimize :
Taking the arithmetic mean isn't an arbitrary choice. The derivative shows that the sample mean is the unique point that minimizes the sum of squared Euclidean distances to all members of the set.
Because both steps strictly decrease or preserve , the sequence of objective values is monotonically non-increasing. Since there are only finitely many ways to partition points into clusters, Lloyd's algorithm is guaranteed to terminate. It settles on a local minimum, not necessarily the global optimum.
Start with and pick one seed from each visible area:
- from
billing_A - from
login_A - from
deploy_A
Before any update, the initial inertia evaluated at these seeds is:
After assignment, each pair stays with its local seed. Averaging the coordinates in each pair produces:
| Candidate group | Assigned points | Updated centroid |
|---|---|---|
| left group | billing_A, billing_B | |
| upper group | login_A, login_B | |
| right group | deploy_A, deploy_B |
Evaluating the objective around these updated means cuts inertia in half:
1import numpy as np
2
3vectors = np.array([
4 [1.0, 1.0], [1.2, 1.8],
5 [4.0, 4.2], [5.0, 3.8],
6 [8.0, 1.0], [9.0, 1.8],
7])
8centroids = vectors[[0, 2, 4]].copy()
9
10squared_distance = ((vectors[:, None, :] - centroids[None, :, :]) ** 2).sum(axis=2)
11cluster = squared_distance.argmin(axis=1)
12updated = np.vstack([vectors[cluster == index].mean(axis=0) for index in range(3)])
13inertia = ((vectors - updated[cluster]) ** 2).sum()
14
15print("assignment:", cluster.tolist())
16print("updated centroids:", np.round(updated, 2).tolist())
17print(f"inertia after update: {inertia:.2f}")1assignment: [0, 0, 1, 1, 2, 2]
2updated centroids: [[1.1, 1.4], [4.5, 4.0], [8.5, 1.4]]
3inertia after update: 1.74Why can't you write cluster 0 = billing into a long-lived dashboard?
Answer
Cluster integers have no stable semantic meaning. Another initialization or retraining run can number the same groups differently. Persist a run ID, inspect current members, and attach a reviewed human label separately.

Build the k-means loop and prevent initialization traps
Production libraries provide careful seeding and convergence controls. Our scratch implementation accepts a matrix of vectors and initial centers. Iteration stops when cluster assignments repeat, which guarantees that the calculated means remain unchanged too. Halting on stable assignments avoids relying on arbitrary coordinate tolerances that might stop prematurely.
1import numpy as np
2
3names = np.array(["billing_A", "billing_B", "login_A", "login_B", "deploy_A", "deploy_B"])
4vectors = np.array([
5 [1.0, 1.0], [1.2, 1.8],
6 [4.0, 4.2], [5.0, 3.8],
7 [8.0, 1.0], [9.0, 1.8],
8])
9
10def kmeans(x: np.ndarray, initial: np.ndarray, max_steps: int = 20):
11 centers = initial.astype(float).copy()
12 previous_labels = None
13 for step in range(1, max_steps + 1):
14 squared = ((x[:, None, :] - centers[None, :, :]) ** 2).sum(axis=2)
15 labels = squared.argmin(axis=1)
16 next_centers = []
17 for index in range(len(centers)):
18 members = x[labels == index]
19 if members.shape[0] == 0:
20 raise ValueError(f"cluster {index} is empty")
21 next_centers.append(members.mean(axis=0))
22 next_centers = np.vstack(next_centers)
23 if np.array_equal(labels, previous_labels):
24 inertia = ((x - next_centers[labels]) ** 2).sum()
25 return labels, next_centers, inertia, step
26 centers = next_centers
27 previous_labels = labels.copy()
28 raise RuntimeError("k-means did not converge")
29
30labels, centers, inertia, steps = kmeans(vectors, vectors[[0, 2, 4]])
31for index in range(3):
32 members = names[labels == index].tolist()
33 print(f"cluster {index}: {members} center={centers[index].round(2).tolist()}")
34print(f"converged in {steps} steps with inertia={inertia:.2f}")1cluster 0: ['billing_A', 'billing_B'] center=[1.1, 1.4]
2cluster 1: ['login_A', 'login_B'] center=[4.5, 4.0]
3cluster 2: ['deploy_A', 'deploy_B'] center=[8.5, 1.4]
4converged in 2 steps with inertia=1.74The initialization trap: why k-means++ matters
Uniform random initialization frequently picks multiple seeds that sit within the same dense cluster. When that happens, two seeds split a single coherent topic while another distant region is left without any seed at all.
Arthur and Vassilvitskii addressed this vulnerability with k-means++ initialization.[3] Rather than sampling centroids uniformly, k-means++ spreads initial seeds across the feature space:
- Select the first centroid uniformly at random from the dataset .
- For each point , compute , the shortest Euclidean distance from to any already chosen centroid:
- Sample the next centroid from with probability proportional to the squared distance:
- Repeat steps 2 and 3 until all centroids have been selected.
Points close to existing centroids have small , making duplicate seeding in the same neighborhood improbable. Points far away have large , drawing the next centroid toward unexplored clusters. This simple stochastic heuristic guarantees an expected bound of times the optimal WCSS.
We can see this probability distribution in action on our developer messages. Suppose billing_A was chosen as the first seed:
1import numpy as np
2
3vectors = np.array([
4 [1.0, 1.0], [1.2, 1.8],
5 [4.0, 4.2], [5.0, 3.8],
6 [8.0, 1.0], [9.0, 1.8],
7])
8names = ["billing_A", "billing_B", "login_A", "login_B", "deploy_A", "deploy_B"]
9
10# First seed picked at index 0 (billing_A)
11seed_0 = vectors[0]
12d2 = ((vectors - seed_0) ** 2).sum(axis=1)
13prob = d2 / d2.sum()
14
15for name, dist2, p in zip(names, d2, prob):
16 print(f"{name:10} D(x)^2={dist2:5.2f} P(selection)={p:5.3f}")1billing_A D(x)^2= 0.00 P(selection)=0.000
2billing_B D(x)^2= 0.68 P(selection)=0.004
3login_A D(x)^2=19.24 P(selection)=0.122
4login_B D(x)^2=23.84 P(selection)=0.151
5deploy_A D(x)^2=49.00 P(selection)=0.311
6deploy_B D(x)^2=64.64 P(selection)=0.411The neighboring billing message has less than a 0.5% chance of being selected as the next seed. The distant deploy messages receive over 72% of the cumulative selection probability.
The loop also exposes a failure that a library call can hide. If a center gets no assigned vectors, mean is undefined. The guard raises instead of writing nan into the next iterate:
1import numpy as np
2
3vectors = np.array([
4 [1.0, 1.0], [1.2, 1.8],
5 [4.0, 4.2], [5.0, 3.8],
6 [8.0, 1.0], [9.0, 1.8],
7])
8centers = np.array([[1.0, 1.0], [1.2, 1.8], [100.0, 100.0]])
9squared = ((vectors[:, None, :] - centers[None, :, :]) ** 2).sum(axis=2)
10labels = squared.argmin(axis=1)
11for index in range(3):
12 print(f"cluster {index}: {(labels == index).sum()} points")1cluster 0: 1 points
2cluster 1: 5 points
3cluster 2: 0 pointsDon't average an empty slice. A production implementation needs an explicit repair policy, such as reseeding the empty cluster at the point farthest from any current center.
One embedding dimension ranges from 0 to 1, while another ranges from 0 to 10,000. What happens if Euclidean k-means uses them without scaling?
Answer
The large-range dimension can dominate distance and manufacture clusters around its units. Standardize or otherwise justify feature scales before interpreting the assignments.
Evaluate cluster geometry with elbow curves and silhouette scores
The toy map made feel natural because it contained three distinct pairs. A production corpus won't hand you its correct cluster count. Treat as an architectural decision to evaluate, not a fixed constant.
A cluster audit combines two quantitative diagnostic tools with human validation:
- The elbow method: Plot inertia as a function of . Since adding more centroids grants extra degrees of freedom, the best achievable inertia can't increase as grows, reaching zero when . Look for the inflection point (the "elbow") where the rate of improvement drops:
- Silhouette analysis: For each vector , compute its intra-cluster cohesion (the average distance to all other points in its assigned cluster):
Next, find its nearest neighbor cluster and compute separation (the smallest mean distance to any other cluster):
The silhouette score for point is:[4]
Scores near indicate that a point is well inside its cluster and far from neighbors. Scores near mean the point lies on a cluster boundary. Negative scores mean the point is closer to a neighboring cluster on average than to its assigned center.
Watch how adding a fourth cluster continues lowering inertia while degrading silhouette:
1from itertools import combinations
2
3import numpy as np
4
5vectors = np.array([
6 [1.0, 1.0], [1.2, 1.8],
7 [4.0, 4.2], [5.0, 3.8],
8 [8.0, 1.0], [9.0, 1.8],
9])
10
11def kmeans(x: np.ndarray, initial: np.ndarray, max_steps: int = 20):
12 centers = initial.astype(float).copy()
13 previous_labels = None
14 for _ in range(max_steps):
15 squared = ((x[:, None, :] - centers[None, :, :]) ** 2).sum(axis=2)
16 labels = squared.argmin(axis=1)
17 next_centers = []
18 for index in range(len(centers)):
19 members = x[labels == index]
20 if members.shape[0] == 0:
21 return None
22 next_centers.append(members.mean(axis=0))
23 next_centers = np.vstack(next_centers)
24 if np.array_equal(labels, previous_labels):
25 inertia = ((x - next_centers[labels]) ** 2).sum()
26 return labels, inertia
27 centers = next_centers
28 previous_labels = labels.copy()
29 return None
30
31def mean_silhouette(x: np.ndarray, labels: np.ndarray) -> float:
32 if not 2 <= len(np.unique(labels)) <= len(x) - 1:
33 raise ValueError("silhouette needs 2 through n-1 nonempty clusters")
34 scores = []
35 for i, point in enumerate(x):
36 same = np.where(labels == labels[i])[0]
37 others = same[same != i]
38 if others.size == 0:
39 scores.append(0.0)
40 continue
41 cohesion = np.linalg.norm(x[others] - point, axis=1).mean()
42 separation = min(
43 np.linalg.norm(x[labels == cluster] - point, axis=1).mean()
44 for cluster in np.unique(labels) if cluster != labels[i]
45 )
46 scale = max(cohesion, separation)
47 scores.append((separation - cohesion) / scale if scale > 0 else 0.0)
48 return float(np.mean(scores))
49
50for k in (2, 3, 4):
51 best = None
52 for idx in combinations(range(len(vectors)), k):
53 result = kmeans(vectors, vectors[list(idx)])
54 if result is None:
55 continue
56 labels, inertia = result
57 if best is None or inertia < best[0]:
58 best = (inertia, labels)
59 if best is None:
60 raise RuntimeError(f"no converged nonempty partition for k={k}")
61 inertia, labels = best
62 print(f"k={k}: inertia={inertia:.2f}, silhouette={mean_silhouette(vectors, labels):.2f}")1k=2: inertia=20.06, silhouette=0.56
2k=3: inertia=1.74, silhouette=0.76
3k=4: inertia=0.92, silhouette=0.51Moving from to creates a steep drop in inertia (from 20.06 down to 1.74) while driving silhouette up to 0.76. Moving to reduces inertia only marginally (from 1.74 down to 0.92) while splitting a natural pair, which pulls silhouette down to 0.51.
Equal scores can still tell different stories
An objective can be stable while the semantic interpretation remains ambiguous. Place four messages at the vertices of a square. Two orthogonal two-cluster partitions yield the exact same inertia: split left from right, or split bottom from top.
1import numpy as np
2
3square = np.array([[0.0, 0.0], [0.0, 2.0], [2.0, 0.0], [2.0, 2.0]])
4seeds = {
5 "left/right": np.array([[0.0, 1.0], [2.0, 1.0]]),
6 "bottom/top": np.array([[1.0, 0.0], [1.0, 2.0]]),
7}
8
9for name, seed in seeds.items():
10 squared = ((square[:, None, :] - seed[None, :, :]) ** 2).sum(axis=2)
11 labels = squared.argmin(axis=1)
12 centers = np.vstack([square[labels == i].mean(axis=0) for i in range(2)])
13 inertia = ((square - centers[labels]) ** 2).sum()
14 groups = [np.where(labels == i)[0].tolist() for i in range(2)]
15 print(f"{name:10} groups={groups} inertia={inertia:.1f}")1left/right groups=[[0, 1], [2, 3]] inertia=4.0
2bottom/top groups=[[0, 2], [1, 3]] inertia=4.0Both groupings optimize the objective equally well. If horizontal separation represented user tier and vertical separation represented issue severity, geometry alone wouldn't tell you which grouping your support team needs. Equal objective values don't make the product stories interchangeable.
Compress representations with principal component analysis
K-means assigns vectors to discrete categories. PCA addresses a complementary question: can we describe the cloud with fewer continuous coordinates while preserving as much variance as possible?
PCA has two equivalent mathematical foundations:[2]
- Maximum variance formulation: Find an orthonormal unit vector () such that projecting the centered data onto maximizes the empirical variance of the projections:
where is the sample covariance matrix.
- Minimum reconstruction error formulation: Find an orthonormal basis that minimizes the sum of squared reconstruction errors when projecting points back to the original space:
These two views are identical because of the Pythagorean theorem for orthogonal projections. For any point and unit direction :
Summing over all points in the centered dataset yields:
Because total variance is fixed for any given dataset, maximizing projected variance automatically minimizes reconstruction error.
Covariance eigendecomposition versus singular value decomposition
First center the matrix by subtracting the column-wise mean vector :
From , two computational routes lead to the same principal components:[5]
- Route A: Eigendecomposition of the covariance matrix. Form , then solve the eigenvalue equation:
The eigenvectors form the principal directions, and the eigenvalues represent the variance captured along each axis.
- Route B: Singular Value Decomposition (SVD) directly on . Factor the centered data matrix into:
Plugging this factorization into the covariance definition reveals the exact equivalence:
Because , the right singular vectors are precisely the eigenvectors of , and the singular values relate directly to the covariance eigenvalues:
Production libraries (such as scikit-learn and PyTorch) compute PCA via SVD rather than explicit covariance eigendecomposition for two numerical reasons:
- Condition number squaring: Forming squares the condition number of the data matrix: . If a feature has small singular values, squaring them can push values below machine precision, causing catastrophic numerical cancellation.
- Memory and computation at scale: If you have 100 documents represented by 1536-dimensional embeddings (), forming requires allocating and operating on a covariance matrix. Truncated SVD computes the top principal components in without ever materializing the covariance matrix.
1import numpy as np
2
3vectors = np.array([
4 [1.0, 1.0], [1.2, 1.8],
5 [4.0, 4.2], [5.0, 3.8],
6 [8.0, 1.0], [9.0, 1.8],
7])
8n = len(vectors)
9centered = vectors - vectors.mean(axis=0)
10
11# Route A: SVD on centered data
12_, s, vt = np.linalg.svd(centered, full_matrices=False)
13eigenvalues_svd = (s ** 2) / (n - 1)
14
15# Route B: Eigendecomposition of sample covariance matrix
16cov = (centered.T @ centered) / (n - 1)
17eigenvalues_cov = np.sort(np.linalg.eigvalsh(cov))[::-1]
18
19print("SVD eigenvalues: ", np.round(eigenvalues_svd, 4).tolist())
20print("Covariance eigenvalues:", np.round(eigenvalues_cov, 4).tolist())
21print("Eigenvalues match: ", np.allclose(eigenvalues_svd, eigenvalues_cov))1SVD eigenvalues: [11.1825, 1.9442]
2Covariance eigenvalues: [11.1825, 1.9442]
3Eigenvalues match: TrueThe explained variance ratio measures the proportion of total linear spread retained by each component:
For our six developer messages, PC1 runs almost horizontally, accounting for 85.2% of the total geometric variance:
1import numpy as np
2
3names = np.array(["billing_A", "billing_B", "login_A", "login_B", "deploy_A", "deploy_B"])
4vectors = np.array([
5 [1.0, 1.0], [1.2, 1.8],
6 [4.0, 4.2], [5.0, 3.8],
7 [8.0, 1.0], [9.0, 1.8],
8])
9
10centered = vectors - vectors.mean(axis=0)
11_, singular_values, vt = np.linalg.svd(centered, full_matrices=False)
12pc1 = vt[0]
13if pc1[0] < 0:
14 pc1 = -pc1
15projection = centered @ pc1
16ratio = singular_values**2 / (singular_values**2).sum()
17
18print("mean:", np.round(vectors.mean(axis=0), 2).tolist())
19print("pc1:", np.round(pc1, 3).tolist())
20print("explained variance:", np.round(ratio, 3).tolist())
21for name, value in zip(names, projection):
22 print(f"{name:10} pc1={value:5.2f}")1mean: [4.7, 2.27]
2pc1: [1.0, -0.016]
3explained variance: [0.852, 0.148]
4billing_A pc1=-3.68
5billing_B pc1=-3.49
6login_A pc1=-0.73
7login_B pc1= 0.27
8deploy_A pc1= 3.32
9deploy_B pc1= 4.31PCA produces a single coordinate, pc1, as a linear combination of original dimensions. Sign orientation is arbitrary: flipping the sign of an eigenvector reverses coordinates without affecting pairwise distances or reconstruction error.

⚠️ Common mistake: Cluster a 2D PCA plot and assume you've recovered the neighborhoods in the full embedding. PCA before clustering can help remove noise or reduce computation, but it changes the space being grouped. Compare it with clustering the original vectors and evaluate the result against the task. A clean plot alone doesn't justify the transform.
Measure what compression discards
Connecting explained variance to reconstruction error demonstrates the trade-off. Project the data onto one component, reconstruct it in the original coordinate system, and measure the squared residual:
1import numpy as np
2
3vectors = np.array([
4 [1.0, 1.0], [1.2, 1.8],
5 [4.0, 4.2], [5.0, 3.8],
6 [8.0, 1.0], [9.0, 1.8],
7])
8mean = vectors.mean(axis=0)
9centered = vectors - mean
10_, singular_values, vt = np.linalg.svd(centered, full_matrices=False)
11
12for components in (1, 2):
13 basis = vt[:components]
14 reconstructed = (centered @ basis.T) @ basis + mean
15 error = ((vectors - reconstructed) ** 2).sum()
16 kept = (singular_values[:components] ** 2).sum() / (singular_values ** 2).sum()
17 print(f"components={components}: variance_kept={kept:.3f}, squared_error={error:.2f}")1components=1: variance_kept=0.852, squared_error=9.72
2components=2: variance_kept=1.000, squared_error=0.00The boundary is concrete: explained variance measures geometry, not semantic importance. A subtle feature separating critical edge cases might carry low total variance, yet determine whether a query succeeds or fails.
Two principal components explain 95% of total variance. Does that prove they preserve retrieval quality?
Answer
No. PCA preserves high-variance directions, not task relevance. A low-variance direction may still carry the signal needed for a query, so measure downstream neighbors or retrieval metrics after projection.
Connect clustering to vector search: inverted file indexing
Clustering isn't just an exploratory diagnostic. In production vector databases (such as Faiss, Milvus, Qdrant, and pgvector), k-means forms the foundation of the Inverted File Index (IVF) for fast approximate nearest neighbor (ANN) search.[6][7]
When serving millions of -dimensional embeddings, exhaustive brute-force search (a flat index) compares the query against every stored vector, requiring operations per query. Under strict latency budgets (often under 10 milliseconds), brute-force scanning fails at scale.
IVF turns this linear search into a sublinear candidate lookup using k-means:
- Training phase: Run k-means over the document embeddings to learn coarse centroids (often or ), creating a Voronoi partition of the vector space.
- Index building: Assign each document vector to its closest centroid. Instead of storing a flat array, the index maintains an inverted posting list for each centroid containing its assigned document IDs.
- Query phase:
- Compute distances from the incoming query vector to the coarse centroids.
- Select the closest centroids (typically , such as ).
- Scan only the document vectors in the inverted posting lists belonging to those cells.
This prunes of the search space. If and , the engine skips 99.6% of the database during retrieval.

We can simulate an IVF coarse index over our developer messages:
1import numpy as np
2
3vectors = np.array([
4 [1.0, 1.0], [1.2, 1.8], # billing
5 [4.0, 4.2], [5.0, 3.8], # login
6 [8.0, 1.0], [9.0, 1.8], # deploy
7])
8names = ["billing_A", "billing_B", "login_A", "login_B", "deploy_A", "deploy_B"]
9
10# Centroids learned during index training (K=3)
11centroids = np.array([
12 [1.1, 1.4], # C0: billing
13 [4.5, 4.0], # C1: login
14 [8.5, 1.4], # C2: deploy
15])
16
17# Build inverted lists
18assignments = ((vectors[:, None, :] - centroids[None, :, :]) ** 2).sum(axis=2).argmin(axis=1)
19inverted_lists = {cluster_id: [] for cluster_id in range(3)}
20for doc_idx, cluster_id in enumerate(assignments):
21 inverted_lists[cluster_id].append(doc_idx)
22
23# Incoming user query: "Cannot authenticate with my 2FA token"
24query = np.array([4.2, 4.0])
25
26# Step 1: Coarse search over 3 centroids
27centroid_distances = np.linalg.norm(centroids - query, axis=1)
28nprobe = 1
29probed_clusters = centroid_distances.argsort()[:nprobe]
30
31# Step 2: Fine search only within probed inverted lists
32candidate_indices = [idx for c in probed_clusters for idx in inverted_lists[c]]
33candidate_distances = np.linalg.norm(vectors[candidate_indices] - query, axis=1)
34best_match = candidate_indices[candidate_distances.argmin()]
35
36print("probed clusters:", probed_clusters.tolist())
37print("scanned docs: ", [names[i] for i in candidate_indices])
38print("nearest match: ", names[best_match], f"(distance={candidate_distances.min():.2f})")
39print(f"pruning ratio: {len(names) - len(candidate_indices)} of {len(names)} vectors skipped")1probed clusters: [1]
2scanned docs: ['login_A', 'login_B']
3nearest match: login_A (distance=0.28)
4pruning ratio: 4 of 6 vectors skippedThe parameter governs the trade-off between search latency and recall:
- Low (): Maximal query throughput. If a document lies close to a Voronoi boundary, the query might land in the adjacent cell, causing a boundary recall miss.
- High (): High recall approaching exact brute-force search, but at the cost of evaluating more candidates.
Failure test 1: let units manufacture the clusters
An embedding often sits beside tabular metadata such as message length, incident count, or timestamps. Mixing unlike measurements into one matrix causes k-means or PCA to track the largest raw numerical units rather than semantic meaning.
Here an invented topic coordinate separates billing and deploy, while message length varies across both. In raw units, a short billing note is closer to a short deploy note than to a long billing note. We'll run the same two-center loop twice, always seeding from bill_100 and dep_110. Only feature scaling changes.
1import numpy as np
2
3# Column 0 carries topic; column 1 is message length in characters.
4names = ["bill_100", "bill_500", "bill_900", "dep_110", "dep_510", "dep_910"]
5features = np.array([
6 [-3.2, 100.0], [-3.0, 500.0], [-2.8, 900.0],
7 [ 2.8, 110.0], [ 3.0, 510.0], [ 3.2, 910.0],
8])
9
10def cluster(x: np.ndarray) -> list[int]:
11 centers = x[[0, 3]].copy() # Same source rows in both runs.
12 previous = None
13 for _ in range(20):
14 squared = ((x[:, None, :] - centers[None, :, :]) ** 2).sum(axis=2)
15 labels = squared.argmin(axis=1)
16 if np.array_equal(labels, previous):
17 return labels.tolist()
18 groups = [x[labels == i] for i in range(2)]
19 if any(len(group) == 0 for group in groups):
20 raise ValueError("empty cluster")
21 centers = np.vstack([group.mean(axis=0) for group in groups])
22 previous = labels.copy()
23 raise RuntimeError("k-means did not converge")
24
25print("raw dist bill_100 to dep_110:", round(float(np.linalg.norm(features[0] - features[3])), 1))
26print("raw dist bill_100 to bill_900:", round(float(np.linalg.norm(features[0] - features[2])), 1))
27
28raw_labels = cluster(features)
29print("raw groups, seeds bill_100 and dep_110:")
30for index in range(2):
31 members = [names[i] for i, label in enumerate(raw_labels) if label == index]
32 print(f" cluster {index}: {members}")
33
34scaled = (features - features.mean(axis=0)) / features.std(axis=0)
35scaled_labels = cluster(scaled)
36print("scaled groups, same seed rows:")
37for index in range(2):
38 members = [names[i] for i, label in enumerate(scaled_labels) if label == index]
39 print(f" cluster {index}: {members}")1raw dist bill_100 to dep_110: 11.7
2raw dist bill_100 to bill_900: 800.0
3raw groups, seeds bill_100 and dep_110:
4 cluster 0: ['bill_100', 'dep_110']
5 cluster 1: ['bill_500', 'bill_900', 'dep_510', 'dep_910']
6scaled groups, same seed rows:
7 cluster 0: ['bill_100', 'bill_500', 'bill_900']
8 cluster 1: ['dep_110', 'dep_510', 'dep_910']The raw 800-character gap dwarfs the topic difference of 6: the two short messages group together, regardless of topic. With zero mean and unit variance, the loop recovers the intended topic split.
Don't standardize every embedding blindly. If a model's vectors come with a specified normalization contract, follow it. When fitting a scaler or PCA, learn its parameters on the development corpus and reuse that transform for held-out queries. As with supervised validation, fitting scalers on the evaluation set causes data leakage.
Failure test 2: let the similarity rule change neighbors
Text retrieval often compares vector directions with cosine similarity, while ordinary k-means minimizes Euclidean distance. Dot product rewards magnitude as well as angular alignment.
OpenAI's embedding models return vectors normalized to length 1.[8] Under that contract, cosine similarity equals the dot product, and squared Euclidean distance is an inverted linear transform of cosine similarity:
Because the relation is strictly monotonic, cosine ranking and Euclidean nearest-neighbor ranking produce identical orderings for unit vectors. Mix in unnormalized metadata or switch to an unnormalized model, and that shortcut fails.
1import numpy as np
2
3query = np.array([1.0, 0.0])
4names = ["duplicate_charge_policy", "very_long_deploy_page"]
5documents = np.array([
6 [1.0, 0.1],
7 [8.0, 4.0],
8])
9
10dot = documents @ query
11cosine = dot / (np.linalg.norm(documents, axis=1) * np.linalg.norm(query))
12
13print("dot-product winner:", names[int(dot.argmax())])
14print("cosine winner: ", names[int(cosine.argmax())])
15print("cosine scores: ", np.round(cosine, 3).tolist())1dot-product winner: very_long_deploy_page
2cosine winner: duplicate_charge_policy
3cosine scores: [0.995, 0.894]The equivalence applies to nearest neighbors, not standard k-means centroids. Standard k-means computes the coordinate mean of assigned points. The mean of several unit vectors is generally not unit length, causing centroids to drift off the hypersphere:
1import numpy as np
2
3a = np.array([1.0, 0.0])
4b = np.array([0.0, 1.0])
5mean = (a + b) / 2
6print("mean:", np.round(mean, 3).tolist())
7print(f"mean L2 norm: {np.linalg.norm(mean):.3f}")1mean: [0.5, 0.5]
2mean L2 norm: 0.707If you cluster the same vectors you'll cosine-search later, align the algorithm with the serving contract:[9]
- Run spherical k-means: Assign points by cosine similarity (maximum dot product on unit vectors), then project centroids back to the unit sphere () after each update step.
- Or treat running Euclidean k-means on normalized vectors and normalizing centroids only at inference time as an approximation. Validate held-out recall against a spherical baseline before deploying it.
K-means also favors roughly spherical clusters of comparable size. Elongated manifolds or nested topics score poorly under inertia even when they represent meaningful semantic groupings.
Failure test 3: centering changes cosine geometry
Cosine similarity measures the angle between two vectors from the origin:
PCA introduces an origin trap. It centers the dataset by subtracting the column-wise mean from each vector, shifting the origin to the center of the data cloud.[5] If we then perform cosine retrieval on the centered vectors, we're measuring angles from an entirely different point.
Shifting the origin alters angles between vectors, so original cosine distances and neighbor rankings aren't preserved. Two vectors pointing in nearly identical directions from the original origin can point in opposite directions after centering.
Fit the mean on corpus documents, then apply it to the query:
1import numpy as np
2
3query = np.array([1.0, 2.0])
4doc1 = np.array([1.1, 2.1]) # Close to query direction
5doc2 = np.array([2.0, 1.0]) # Further away in direction
6
7def cos_sim(u, v):
8 return float(np.dot(u, v) / (np.linalg.norm(u) * np.linalg.norm(v)))
9
10orig_cos1 = cos_sim(query, doc1)
11orig_cos2 = cos_sim(query, doc2)
12
13documents = np.array([doc1, doc2])
14mean = documents.mean(axis=0)
15q_centered = query - mean
16d1_centered = doc1 - mean
17d2_centered = doc2 - mean
18
19centered_cos1 = cos_sim(q_centered, d1_centered)
20centered_cos2 = cos_sim(q_centered, d2_centered)
21
22print(f"Original cos(query, doc1): {orig_cos1:.4f}")
23print(f"Original cos(query, doc2): {orig_cos2:.4f}")
24print(f"Centered cos(query, doc1): {centered_cos1:.4f}")
25print(f"Centered cos(query, doc2): {centered_cos2:.4f}")
26
27assert orig_cos1 > orig_cos2
28assert centered_cos1 > 0
29assert centered_cos2 < 01Original cos(query, doc1): 0.9998
2Original cos(query, doc2): 0.8000
3Centered cos(query, doc1): 0.9802
4Centered cos(query, doc2): -0.9802After centering, doc2 sits more than 90 degrees from the query. The transformation altered the geometric question the angle answers. If you reduce dimensions for cosine retrieval, treat the compressed space as a new representation and validate retrieval metrics directly.
Failure test 4: PCA can hide the dimension your query needs
A retrieval system doesn't need a globally optimal low-rank approximation. It needs the correct document for a specific query. Suppose horizontal variation across billing and deploy policies dominates corpus variance, while the distinction between password-reset and general billing pages lies in a quieter vertical direction.
Project documents to one principal component, then execute nearest-document lookup for a login question:
1import numpy as np
2
3names = np.array(["billing_policy", "billing_help", "login_2fa", "deploy_policy", "runbook_note"])
4documents = np.array([
5 [-20.0, 0.0],
6 [ 0.0, 0.0],
7 [ 0.0, 3.0],
8 [20.0, 0.0],
9 [ 0.0, -3.0],
10])
11query = np.array([0.0, 3.2])
12
13full_distance = np.linalg.norm(documents - query, axis=1)
14full_winner = names[full_distance.argmin()]
15
16mean = documents.mean(axis=0)
17centered = documents - mean
18singular_values, vt = np.linalg.svd(centered, full_matrices=False)[1:]
19pc1 = vt[:1]
20compressed_documents = centered @ pc1.T
21compressed_query = (query - mean) @ pc1.T
22compressed_distance = np.linalg.norm(compressed_documents - compressed_query, axis=1)
23compressed_ties = names[np.isclose(compressed_distance, compressed_distance.min())].tolist()
24kept = float((singular_values ** 2)[0] / (singular_values ** 2).sum())
25
26print(f"PC1 variance kept: {kept:.3f}")
27print("full-space nearest: ", full_winner)
28print("one-PC nearest ties: ", compressed_ties)1PC1 variance kept: 0.978
2full-space nearest: login_2fa
3one-PC nearest ties: ['billing_help', 'login_2fa', 'runbook_note']After projection, billing_help, login_2fa, and runbook_note collapse to the exact same one-dimensional coordinate. PCA eliminated the quieter vertical dimension that separated the correct login document from tied alternatives. An argmin call now returns whichever candidate appears first in storage.
Reconstruction error and query relevance answer different questions. Measure retrieval metrics (like Recall@K and NDCG) on labeled query sets before deploying compressed embeddings.
Turn every plot into a representation audit
An unsupervised plot earns its keep when it generates testable hypotheses. Before claiming that an embedding space is organized, compile an audit record:
| Audit question | Evidence to collect |
|---|---|
| Do local neighbors discuss the same issue? | Read nearest-message pairs sampled across the corpus. |
| Do proposed clusters have coherent content? | Review messages nearest each centroid and near boundaries. |
| Is a large axis merely formatting or length? | Compare PCA coordinates against metadata such as length and source. |
| Does an embedding metric match later search? | Run cosine or dot-product checks according to model contract. |
| Can compression hurt a rare but important query? | Evaluate retrieval before and after PCA on held-out query-document pairs. |
Use reviewed examples as an audit set. If you repeatedly tune your pipeline after inspecting them, treat them as development data, not an untouched evaluation benchmark.
1import numpy as np
2
3names = np.array(["billing_A", "billing_B", "login_A", "login_B", "deploy_A", "deploy_B"])
4reviewed_topic = np.array(["billing", "billing", "login", "login", "deploy", "deploy"])
5vectors = np.array([
6 [1.0, 1.0], [1.2, 1.8],
7 [4.0, 4.2], [5.0, 3.8],
8 [8.0, 1.0], [9.0, 1.8],
9])
10
11squared = ((vectors[:, None, :] - vectors[[0, 2, 4]][None, :, :]) ** 2).sum(axis=2)
12labels = squared.argmin(axis=1)
13centers = np.vstack([vectors[labels == index].mean(axis=0) for index in range(3)])
14labels = ((vectors[:, None, :] - centers[None, :, :]) ** 2).sum(axis=2).argmin(axis=1)
15
16for cluster in range(3):
17 topics, counts = np.unique(reviewed_topic[labels == cluster], return_counts=True)
18 dominant = topics[counts.argmax()]
19 purity = counts.max() / counts.sum()
20 members = names[labels == cluster].tolist()
21 print(f"cluster {cluster}: proposed={dominant:8} purity={purity:.2f} members={members}")1cluster 0: proposed=billing purity=1.00 members=['billing_A', 'billing_B']
2cluster 1: proposed=login purity=1.00 members=['login_A', 'login_B']
3cluster 2: proposed=deploy purity=1.00 members=['deploy_A', 'deploy_B']On this teaching set, purity is 1.0 because the coordinates were designed to be clean. A production audit must evaluate boundary edge cases, multilingual queries, and ambiguous intents.
Practice: try to break the representation
Use the clean six-message run as a baseline, then perturb one decision at a time:
- Empty center repair. Modify the initial seeds in
kmeans-from-scratch.pyso one cluster empties. Implement a reseed policy that restarts the orphaned centroid at the point having the highest current squared distance from any center. - K-means++ restart test. Implement the full k-means++ selection loop. Compare the variance of final inertia across 50 random restarts between uniform random seeding and k-means++ seeding.
- Competing units. Add a metadata column (like character count) with values in . Compare cluster assignments before and after z-score standardization.
- IVF recall boundary. In
ivf-coarse-quantization-search.py, move the query point to (equidistant between billing and login). Measure whether misses the true nearest neighbor and check if recovers it. - Lost query signal. Retain both principal components in
compression-can-change-retrieval.py. Confirm that the login query recoverslogin_2fa, and explain why high explained variance didn't prevent the rank-1 tie.
What to look for
- An empty
mean(axis=0)produces an invalid center. Detectmembers.shape[0] == 0and trigger an explicit reseed rather than propagatingNaN. - K-means++ yields consistently lower inertia and faster convergence across restarts by avoiding bunched seeds.
- When unscaled metadata dominates distance, clustering reflects units rather than semantics. Verify feature normalization before interpreting clusters.
- On Voronoi boundaries, setting causes recall drops. Production vector indexes calibrate against target Recall@K.
- PCA preserves global variance, not task labels. Evaluating downstream retrieval metrics on held-out pairs is mandatory before deploying compressed representations.