rumblr Work in progressWIP

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

Clustering and everyday uses of embeddings

grouping, mapping, routing, caching

This lesson covers k-means, density clustering, dedup, routing, semantic caching

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

At a glance

Key takeaways

  1. k-means alternates "assign to nearest centre" and "move centre to the mean"; you choose k, using the silhouette or the elbow.
  2. DBSCAN/HDBSCAN find crowds by density, choose the number of clusters themselves, and mark loners as noise; better for messy text.
  3. PCA gives an honest but lossy 2-D shadow; UMAP/t-SNE keep neighbours but distort distances and sizes.
  4. Near-duplicates, routing, anomaly detection and semantic caching are all "embed, compare, threshold".
  5. Semantic caches need a strict threshold, a context key and an expiry.

Level 2

How it works, from scratch

Level 2 builds each of those methods from nothing, starting with a sack of mail.

The everyday picture. Tip a sack of unlabeled mail onto a table and sort it into piles by what each letter is about. Nobody gave you the pile names; you notice that some letters are about passwords and others about holidays, and similar letters end up together. That's clustering: finding groups in data without being told what the groups are.

Embeddings make it possible for text. Every ticket, email or document gets coordinates on a map of meaning (primer.ml.embeddings.similarity), so "similar" becomes "close", and sorting mail becomes finding crowds of nearby points. The same closeness also powers four everyday jobs: spotting near-duplicates, routing requests to the right team, flagging anomalies, and caching answers to questions already asked.

This lesson uses 30 short IT, HR and finance support tickets (five kinds, six of each) and two off-topic ones, embedded with the repo's toy embedder (primer.common.embedder).

Figure 1 · Drawn from the lesson's code

login vpn expense leave printer off-topic login vpn expense leave printer off-topic Cosine similarity between every pair of tickets −0.2 0.0 0.2 0.4 0.6 0.8 1.0 cosine similarity

Tickets of the same kind form five bright squares along the diagonal, and the two off-topic tickets are dark almost everywhere

Reading it: rows and columns are the 32 tickets in the same order, grouped by kind, and brighter cells mean a higher cosine similarity. The five bright squares along the diagonal are the five kinds: tickets of one kind resemble each other and not the rest. The last two rows and columns (the off-topic tickets) are dark almost everywhere. Clustering is the job of finding those squares without knowing the order.

In code: ticket_embeddings returns the tickets' unit vectors and texts, and ticket_kinds their true kinds, which the clustering never sees.

Chapter 1

k-means: k meeting points

Everyday picture a town wants k post boxes placed so that everyone's walk to their nearest box is as short as possible. Start with the boxes anywhere. Everyone walks to their nearest box; then each box moves to the middle of the people who chose it. Repeat until nobody switches box.

Tiny worked example four points, (0, 0), (0, 1), (10, 0), (10, 1), and k = 2. However the boxes start, the two left points pick one box and the two right points the other. Each box moves to the middle of its pair: (0, 0.5) and (10, 0.5). Every point is now 0.5 away from its box, so the total squared walk is 4 × 0.5² = 1.0, and nothing changes on the next round.

Figure 2 · Diagram

Reading it: two steps alternate, assign and move, until the centres stop moving. Each step can only shrink the total squared distance (assigning to the nearest centre can't make anyone's walk longer; moving a centre to the mean of its points is the spot that minimizes their squared walks), so the loop always ends. The start matters: k-means++ picks each new starting centre far from the ones already chosen, which avoids two centres fighting over one crowd.
Level 3: the formula and its symbols

Symbols

Symbol Meaning here Shape / range
J the total squared distance, called inertia; k-means makes it small ≥ 0
N number of points 4 in the example; 30 tickets
i which point 1 to N
xᵢ the i-th point (an embedding) d numbers
c(i) the cluster point i is assigned to 1 to k
μ_c (mu) the centroid of cluster c: the mean of its points d numbers
‖·‖² squared Euclidean distance ≥ 0
Σ add up over all points

In words: add up, over every point, the squared distance from the point to the centre of its cluster.

On the example: four points each 0.5 from their centre: J = 4 × 0.25 = 1.0.

In Python:

x = [(0, 0), (0, 1), (10, 0), (10, 1)]
# the two centroids
mu = [(0, 0.5), (10, 0.5)]
# c(i): the cluster each point joined
c = [0, 0, 1, 1]
# ‖x_i - μ_c(i)‖²
J = sum((x_i[0] - mu[c_i][0]) ** 2 + (x_i[1] - mu[c_i][1]) ** 2
        # Σ over every point
        for x_i, c_i in zip(x, c))
J  # → 1.0

Figure 3 · Drawn from the lesson's code

−2 −1 0 1 2 3 4 5 x −1 0 1 2 3 4 5 y start (k-means++) inertia 335.6 −2 −1 0 1 2 3 4 5 x after round 1 inertia 127.8 −2 −1 0 1 2 3 4 5 x converged (round 3) inertia 60.2

k-means centres drift into the middle of their crowds round by round while the total squared distance only goes down

Reading it: three snapshots of k-means on 2-D points. Colours are the current assignments and black crosses are the centres. On the left, the k-means++ starting centres; in the middle, after the first assign-and-move round; on the right, the final state. The centres drift into the middle of their crowds and the total squared distance printed above each panel only goes down.

In code: kmeans alternates assign and move from k-means++ starts and returns the labels, centres and inertia J of the best of several restarts; kmeans_history records one run round by round, which is what this figure draws.

Why it matters k-means is fast and simple, and it's inside things you use: IVF vector indexes cluster the corpus with it (primer.ml.embeddings.ann), and topic discovery over tickets or documents often starts with it. Its weaknesses are that you must choose k and that it assumes round, similar-sized clusters.

Chapter 2

Choosing k: the elbow and the silhouette

Everyday picture at a party, you're in the right group if the people in your group are much closer to you than the people in the next group over.

Tiny worked example the four points again, split into the two pairs. For (0, 0): its partner is a = 1 away; the other pair is 10 and √101 = 10.05 away, on average b = 10.025. Its score is (b − a) / b = 9.025 / 10.025 = 0.900, and by symmetry every point scores the same, so the silhouette is 0.900.

Level 3: the formula and its symbols

Symbols

Symbol Meaning here Range
s(i) silhouette score of point i −1 to 1
a(i) average distance from i to the other points in its own cluster ≥ 0
b(i) average distance from i to the points of the nearest other cluster ≥ 0
max(a, b) the larger of the two, to scale the score into −1 … 1

In words: how much farther the nearest other group is than your own, as a share of the larger distance. The silhouette of a clustering is the average over all points: near 1 is crisp, near 0 is overlapping, negative means points sit in the wrong cluster.

On the example: (10.025 − 1) / 10.025 = 0.900.

In Python:

import math
# a(i): distance to its partner
a = math.dist((0, 0), (0, 1))
# b(i): the other pair, averaged
b = (math.dist((0, 0), (10, 0)) + math.dist((0, 0), (10, 1))) / 2
a, round(b, 3)  # → (1.0, 10.025)
# s(i)
round((b - a) / max(a, b), 3)  # → 0.9

Figure 4 · Drawn from the lesson's code

2 3 4 5 6 7 8 k 12 14 16 18 20 22 24 inertia (total squared distance) Elbow: inertia always falls 2 3 4 5 6 7 8 k 0.08 0.10 0.12 0.14 0.16 0.18 mean silhouette Silhouette peaks at the true k = 5

Inertia falls at every k, so it cannot pick k, while the silhouette peaks clearly at the true k of 5

Reading it: both panels sweep k from 2 to 8 on the 30 tickets. On the left, inertia always falls as k grows (more boxes, shorter walks), so the lowest value is useless; you look for the elbow where it stops falling steeply. On the right, the silhouette has a clear peak at k = 5, the true number of ticket kinds. When the elbow is vague, the silhouette usually isn't.

In code: silhouette averages s(i) over every point, and best_k_by_silhouette runs kmeans for each candidate k and keeps the one with the highest silhouette.

Chapter 3

Density clustering: DBSCAN and HDBSCAN

Everyday picture a festival seen from a drone. A crowd is wherever people stand shoulder to shoulder; a loner by the fence belongs to no crowd. You don't decide in advance how many crowds there are.

Tiny worked example points on a line at 0, 0.1, 0.2, 5.0, 5.1, 5.2 and 20, with reach ε = 0.15 and "a crowd needs at least 2". 0, 0.1 and 0.2 chain together (each within 0.15 of the next); so do 5.0, 5.1 and 5.2. 20 has nobody within 0.15. Result: two clusters and one noise point, labels 0 0 0 1 1 1 −1.

Level 3: the formula and its symbols

Symbols

Symbol Meaning here
x, y points
dist distance: Euclidean, or 1 − cosine for embeddings
ε (epsilon) the reach: how close counts as "shoulder to shoulder"
N_ε(x) the ε-neighbourhood: every point within reach of x (x included)
{… : …} "the set of … such that …"
|·| how many points are in the set
minPts how many points within reach make x a core point

In words: a point's neighbourhood is everything within reach; a point with enough neighbours is a core point; clusters grow outward from core points, and anything no core point can reach is noise.

On the example: N₀.₁₅(0.1) = {0, 0.1, 0.2}, 3 ≥ 2, so 0.1 is core; N₀.₁₅(20) = {20}, 1 < 2, so 20 is noise.

In Python:

points = [0, 0.1, 0.2, 5.0, 5.1, 5.2, 20]
eps, minPts = 0.15, 2
# N_ε(x): every point within reach of x
def N(x):
    return [y for y in points if abs(x - y) <= eps]
# a core point
N(0.1), len(N(0.1)) >= minPts  # → ([0, 0.1, 0.2], True)
# nobody within reach: noise
N(20), len(N(20)) >= minPts  # → ([20], False)

Figure 5 · Diagram

Reading it: DBSCAN walks the points. A point with too few neighbours is left as noise (it can still be claimed later as the edge of someone else's crowd). A core point starts a cluster that floods outward through other core points, the self-loop on the "Add" box, until the crowd's edge is reached.

In code: dbscan finds the core points, floods each cluster outward through them, and labels everything unreached −1 (noise).

Figure 6 · Drawn from the lesson's code

−0.8 −0.6 −0.4 −0.2 0.0 0.2 0.4 principal component 1 −0.6 −0.4 −0.2 0.0 0.2 0.4 0.6 principal component 2 DBSCAN (cosine, ε = 0.6, minPts = 3) the coffee machine is … can I bring my dog to … noise cluster 0 cluster 1 cluster 2 cluster 3

DBSCAN at reach 0.6 marks exactly the two off-topic tickets as noise but merges the VPN and printer tickets into one cluster

Reading it: the tickets drawn on the 2-D map from the next section, coloured by the clusters DBSCAN found (cosine distance, so ε = 0.6 means "cosine at least 0.4"; minPts = 3). Grey crosses are noise: exactly the two off-topic tickets, found without anyone telling DBSCAN how many clusters exist. Notice that VPN and printer tickets share a cluster: a chain of tickets that are each close to the next ("can't connect", "not working", "offline") bridges the two kinds.

Figure 7 · Drawn from the lesson's code

0.45 0.50 0.55 0.60 0.65 0.70 0.75 0.80 0.85 reach ε (cosine distance) 0 5 10 15 20 count DBSCAN: too small strands tickets, too large chains kinds clusters found noise points truly off-topic tickets

No reach gets DBSCAN right: small reaches strand real tickets as noise, and by the time noise is only the off-topic pair the kinds have begun chaining together

Reading it: the horizontal axis is the reach ε. The blue line counts clusters and the orange line counts noise points; the dashed line marks the two truly off-topic tickets. At the smallest reach (0.45) most tickets have too few neighbours: DBSCAN finds only three clusters and calls 22 of the 32 tickets noise. Between 0.5 and 0.55 it finds all five kinds, but still strands genuine tickets as noise (15, then 9). By 0.6 noise is down to just the off-topic pair, but kinds have already started chaining together (A is near B, B is near C, so A and C end up in one cluster): VPN and printer share a cluster, so only four are left. Keep widening and the chains keep growing, to three clusters, then two, until by 0.825 one cluster holds every kind and has swallowed an off-topic ticket as well, leaving a single noise point. No single ε gets everything right, and on real data you rarely know where the sweet spot is.

That's the problem HDBSCAN solves. It effectively runs DBSCAN at every reach at once, builds a tree of how crowds merge as the reach grows, and keeps the crowds that persist over the widest range of reaches. It handles clusters of different densities, needs no ε, and is the usual choice for exploring messy real text such as support tickets.

Chapter 4

Seeing the map: PCA (and why UMAP and t-SNE mislead on distance)

Everyday picture a shadow on a wall. A 3-D object casts a 2-D shadow; turn the object and the shadow changes. PCA (principal component analysis) turns the object so its shadow is as spread out as possible, keeping as much of the original variation as a flat picture can.

Tiny worked example three points on a straight line, (1, 1), (2, 2), (3, 3). All their variation runs along the diagonal, so the first direction explains 100% of it and the second 0%. Along that direction the points sit √2 = 1.414 apart, exactly as in the original.

Level 3: the formula and its symbols

Symbols

Symbol Meaning here Range
j which direction (principal component) 1 to d
σⱼ (sigma) the j-th singular value of the centred data, from the SVD (see primer.notation) ≥ 0, largest first
σⱼ² proportional to the variance (average squared spread) along direction j ≥ 0
Σₖ σₖ² the total spread over all directions k
explainedⱼ the share of all the spread that direction j captures 0 to 1

In words: the share of all the spread that direction j captures.

On the example: centred, the points are (−1, −1), (0, 0), (1, 1). Their singular values are 2 along the diagonal and 0 across it, so the shares are 2² / (2² + 0²) = 1 and 0 / 4 = 0.

In Python:

import math
centred = [(-1, -1), (0, 0), (1, 1)]
# along the diagonal
u = [(1 / math.sqrt(2), 1 / math.sqrt(2)),
     # across it (the SVD finds these; here we know them)
     (1 / math.sqrt(2), -1 / math.sqrt(2))]
# spread along u_j
sigma = [math.sqrt(sum((x * u_j[0] + y * u_j[1]) ** 2 for x, y in centred))
         for u_j in u]
[round(sigma_j, 3) for sigma_j in sigma]  # → [2.0, 0.0]
[round(sigma_j ** 2 / sum(sigma_k ** 2 for sigma_k in sigma), 3) for sigma_j in sigma]  # → [1.0, 0.0]

Figure 8 · Drawn from the lesson's code

−0.8 −0.6 −0.4 −0.2 0.0 0.2 0.4 principal component 1 −0.6 −0.4 −0.2 0.0 0.2 0.4 0.6 principal component 2 Tickets on a 2-D map (keeps 26% of the variation) login vpn expense leave printer off-topic k-means centres

Squashed to 2-D, each kind of ticket forms its own patch with its k-means centre inside it

Reading it: the 128-dimensional ticket embeddings squashed to 2-D with PCA, coloured by their true kind; black crosses are the k-means centres, projected the same way, and the grey ✕ markers are the off-topic tickets. Kinds form separate patches and the centres sit inside them. The title says what share of the variation the two axes keep: the rest is invisible here, so tickets that look close on this map can be far apart in the real space.

In code: pca centres the data, projects it onto the top n directions from the SVD, and returns each direction's explained share.

UMAP and t-SNE draw prettier maps by a different rule: keep each point's neighbours next to it, and let distances elsewhere stretch. Like a subway map, they're great for "what's near what" and misleading for "how far" or "how big": gaps between clusters and cluster sizes in those plots don't reflect the real space. Use them for intuition, never for measurement.

Chapter 5

Everyday uses

Figure 9 · Diagram

Reading it: one embedding feeds four independent checks, each a comparison with things already known. All four have the same shape: a distance and a threshold. What differs is what's being compared against (stored documents, route examples, cluster centres, cached questions) and what happens on a hit.

Near-duplicates. Photocopies with a sticky note on them: nearly identical vectors. "password reset link expired" and "The password reset link has expired!" score cosine 1.0 here; flag pairs above a threshold calibrated on labeled pairs (primer.ml.embeddings.similarity).

In code: near_duplicates embeds a list of texts and returns every pair whose cosine reaches the threshold.

Routing. A receptionist listening to a request and pointing to the right desk. Each route (a team, a tool, an agent) is represented by the centroid of a few example requests; a new request goes to the closest centroid, or to a fallback when nothing is close:

Level 3: the formula and its symbols

Symbols

Symbol Meaning here Range
q the incoming request's embedding unit vector
r a route: a team, tool or agent it_helpdesk, finance, hr
μᵣ (mu) route r's centroid: the mean of its example requests, rescaled to length 1 unit vector
cos(q, μᵣ) cosine similarity of the request with that route −1 to 1
arg maxᵣ the route that gives the largest value
θ (theta) the confidence threshold 0.3 here

In words: send the request to the route it's most similar to, unless even the best match is weak, in which case hand it off.

On the example: "my vpn tunnel drops when I work remote" scores highest against the IT helpdesk; "what is the capital of france" is near 0 against every route, below θ = 0.3, so it goes to the fallback.

With the numbers: a toy version with three routes in four dimensions: μ_it = (1, 0, 0, 0), μ_finance = (0, 1, 0, 0), μ_hr = (0, 0, 1, 0). The request q = (0.8, 0.6, 0, 0) has cosines 0.8, 0.6 and 0.0 with them; the best, 0.8, clears θ = 0.3, so it goes to it_helpdesk. The request q = (0.1, 0.2, 0, 1) points mostly where no route lies: its cosines are about 0.1, 0.2 and 0.0, all below θ, so it goes to the fallback.

In Python:

import math
def cos(a, b):
    dot = sum(a_k * b_k for a_k, b_k in zip(a, b))
    return dot / (math.sqrt(sum(a_k ** 2 for a_k in a)) * math.sqrt(sum(b_k ** 2 for b_k in b)))
mu = {"it_helpdesk": (1, 0, 0, 0), "finance": (0, 1, 0, 0), "hr": (0, 0, 1, 0)}
theta = 0.3
def route(q):
    scores = {r: cos(q, mu_r) for r, mu_r in mu.items()}
    # arg max_r cos(q, μ_r)
    best = max(scores, key=scores.get)
    return best if scores[best] >= theta else "fallback"
[round(cos((0.8, 0.6, 0, 0), mu_r), 2) for mu_r in mu.values()], route((0.8, 0.6, 0, 0))  # → ([0.8, 0.6, 0.0], 'it_helpdesk')
[round(cos((0.1, 0.2, 0, 1), mu_r), 2) for mu_r in mu.values()], route((0.1, 0.2, 0, 1))  # → ([0.1, 0.2, 0.0], 'fallback')

Figure 10 · Drawn from the lesson's code

my vpn tunnel drops when I w… who reimburses my hotel rece… what is the capital of france −0.1 0.0 0.1 0.2 0.3 0.4 0.5 0.6 cosine with route centroid Route to the closest centroid, or fall back threshold θ = 0.3 it_helpdesk finance hr

The VPN complaint clears the threshold only for IT, the receipt question only for finance, and the off-topic question for no route, so it goes to a human

Reading it: each group of bars is one incoming request; each bar is its cosine with one route's centroid; the dashed line is the threshold θ. The VPN complaint clears the line only for IT, the receipt question only for finance, and the off-topic question clears nothing, so it goes to a human. The fallback is the important part: a router without one sends every unanswerable question somewhere.

In code: Router holds one unit-length centroid per route; Router.scores gives a request's cosine with each, and Router.route applies θ and the fallback. support_router builds the lesson's three routes.

Anomaly detection. A stranger at a party is far from every group. Score each item by its distance to the nearest cluster centre; the largest scores are the unusual items. Here the coffee machine and the dog come out on top.

In code: anomaly_scores returns each point's distance to its nearest cluster centre.

Semantic caching. An FAQ desk that remembers answers. If a new question means the same as one already answered, return the stored answer and skip the model call, saving cost and latency. Three guards keep it safe:

  • A strict, calibrated threshold. "How do I reset my VPN?" is only about 0.46 similar to "How do I reset my password?" here: related, but a different question.
  • A context key. "What is my PTO balance?" means something different for Alice and Bob. Answers are only reused within the same user, tenant and permission scope.
  • Expiry. A time-to-live, so answers age out when the facts change.

Figure 11 · Diagram

Reading it: the filter comes before the similarity search, not after: an answer from another user's context is never even a candidate, so no threshold setting can leak it. Only then does similarity decide.

In code: SemanticCache.put stores a question, its answer and its context; SemanticCache.lookup filters by context and expiry, then finds the most similar entry, and SemanticCache.get returns its answer only above the threshold.

Test yourself

5 questions

Answer each one out loud or on paper before you open it. If you can explain it, you know it.

Question 1Q: How does k-means work, and what are its limitations?Think it through, then reveal

Alternate assigning each point to its nearest centre and moving each centre to the mean of its points until nothing changes. You must choose k, results depend on the start (k-means++ helps), and it assumes round, similar-sized clusters.

Question 2Q: When would you choose HDBSCAN over k-means for text embeddings?Think it through, then reveal

When you don't know how many groups exist, clusters have different shapes and densities, and some items belong nowhere (support tickets, logs). It finds the number of clusters itself and labels outliers as noise.

Question 3Q: Why can't you trust distances in a UMAP or t-SNE plot?Think it through, then reveal

They preserve local neighbourhoods and deliberately distort everything else, so gaps between clusters and cluster sizes don't reflect the real space.

Question 4Q: How would you route requests to the right agent or tool with embeddings?Think it through, then reveal

Represent each route by the centroid of example requests, send each new request to the most similar centroid, and fall back to a human or general assistant when the best similarity is below a calibrated threshold.

Question 5Q: What can go wrong with a semantic cache?Think it through, then reveal

A loose threshold returns the answer to a different question; missing context keys leak one user's or tenant's answer to another; missing expiry serves stale facts. Filter by context first, then match strictly, and expire entries.

Primary sources

The papers behind this lesson

Arthur and Vassilvitskii, k-means++: The Advantages of Careful Seeding (2007)

Showed that spreading out the starting centres makes k-means provably close to optimal and much faster to converge.

The paper ↗
Ester, Kriegel, Sander and Xu, A Density-Based Algorithm for Discovering Clusters (DBSCAN, 1996)

Defined clusters as dense regions reachable through core points, with everything else as noise.

The paper ↗
Campello, Moulavi and Sander, Density-Based Clustering Based on Hierarchical Density Estimates (HDBSCAN, 2013)

Removed DBSCAN's single reach parameter by building a hierarchy over all reaches and keeping the most persistent clusters.

The paper ↗
McInnes, Healy and Melville, UMAP (2018)

A fast neighbour-preserving projection now standard for visualizing embeddings.

The paper ↗

Researcher's shelf

Further reading

  • scikit-learn user guide, clustering: https://scikit-learn.org/stable/modules/clustering.html
  • hdbscan documentation, How HDBSCAN Works: https://hdbscan.readthedocs.io/en/latest/how_hdbscan_works.html
  • UMAP documentation: https://umap-learn.readthedocs.io/
  • Wattenberg, Viégas and Johnson, How to Use t-SNE Effectively (Distill): https://distill.pub/2016/misread-tsne/

About this lesson. This is the illustrated edition of a lesson from the open-source AI Primer. Its text, figures and numbers are generated from the Primer's source at commit c8d5c21, so the two always agree: the explanation, the code that builds it and the tests that prove it.