In vector databases and search engines like Turbopuffer, K-Means clustering is the mathematical engine used to partition high-dimensional vector spaces into localized spatial clusters (often referred to as an Inverted File Index or IVF). By grouping similar vectors together, the database can restrict its search query to only the most relevant clusters, avoiding an expensive exhaustive search over the entire database.
This document walks through the mathematical formulation of K-Means clustering, performs a step-by-step hand-calculation using a simple 2D dataset, and provides clean code implementations in Python and TypeScript.
1. Mathematical Formula
The objective of K-Means clustering is to partition N observations (vectors) x1,x2,…,xN∈RD into K disjoint clusters S={S1,S2,…,SK} so as to minimize the Within-Cluster Sum of Squares (WCSS), also known as Inertia:
J=j=1∑Kxi∈Sj∑∥xi−cj∥2
where cj is the mean (centroid) of the points in cluster Sj:
cj=∣Sj∣1xi∈Sj∑xi
The algorithm achieves this by alternating between two main steps until the centroids converge (stop moving).
Step 1: Assignment Step (Voronoi Partitioning)
Each data point xi is assigned to the cluster with the nearest centroid. This step geometrically partitions the space into Voronoi cells:
Sj(t)={xi:xi−cj(t)2≤xi−cl(t)2∀l,1≤l≤K}
Step 2: Update Step (Centroid Re-calculation)
The coordinates of each centroid are updated to be the arithmetic mean of all data points assigned to that cluster in the assignment step:
cj(t+1)=Sj(t)1xi∈Sj(t)∑xi
2. Walkthrough: 2D Numerical Example
Let's trace K-Means clustering manually using a simple two-dimensional dataset (D=2) and K=2 clusters.
The Dataset
We have N=5 points on a 2D coordinate grid:
A=(2,1)
B=(6,5)
C=(6,1)
D=(6,3)
E=(1,1)
We wish to group these points into K=2 clusters.
Iteration 1
Step 1.1: Initialization
We select two initial starting points to act as our centroids. Notice that both start on the right side of the grid:
Centroid 1 (c1):(6,0)
Centroid 2 (c2):(5,2)
Step 1.2: Distance Calculation & Assignment
We compute the squared Euclidean distance from each point to both centroids:
d2(p,c)=(xp−xc)2+(yp−yc)2
Point A(2,1):
To c1(6,0): (2−6)2+(1−0)2=16+1=17
To c2(5,2): (2−5)2+(1−2)2=9+1=10←Closest
Assignment: Cluster 2 (S2)
Point B(6,5):
To c1(6,0): (6−6)2+(5−0)2=0+25=25
To c2(5,2): (6−5)2+(5−2)2=1+9=10←Closest
Assignment: Cluster 2 (S2)
Point C(6,1):
To c1(6,0): (6−6)2+(1−0)2=0+1=1←Closest
To c2(5,2): (6−5)2+(1−2)2=1+1=2
Assignment: Cluster 1 (S1)
Point D(6,3):
To c1(6,0): (6−6)2+(3−0)2=0+9=9
To c2(5,2): (6−5)2+(3−2)2=1+1=2←Closest
Assignment: Cluster 2 (S2)
Point E(1,1):
To c1(6,0): (1−6)2+(1−0)2=25+1=26
To c2(5,2): (1−5)2+(1−2)2=16+1=17←Closest
Assignment: Cluster 2 (S2)
Our partitioned clusters at t=1 are:
S1(1)={C},S2(1)={A,B,D,E}
Step 1.3: Centroid Update
We recalculate the centroids as the mean of their assigned points:
New Centroid c1(2) (only point C):
c1(2)=(6,1)
New Centroid c2(2) (mean of A,B,D,E):
c2(2)=(42+6+6+1,41+5+3+1)=(3.75,2.5)
Iteration 2
We repeat the assignment step using our updated centroids:
c1=(6,1)
c2=(3.75,2.5)
Step 2.1: Distance Calculation & Re-assignment
Point A(2,1):
To c1(6,1): (2−6)2+(1−1)2=16+0=16
To c2(3.75,2.5): (2−3.75)2+(1−2.5)2=3.06+2.25=5.31←Closest
Assignment: Cluster 2 (S2)
Point B(6,5):
To c1(6,1): (6−6)2+(5−1)2=0+16=16
To c2(3.75,2.5): (6−3.75)2+(5−2.5)2=5.06+6.25=11.31←Closest
Assignment: Cluster 2 (S2)
Point C(6,1):
To c1(6,1): (6−6)2+(1−1)2=0←Closest
To c2(3.75,2.5): (6−3.75)2+(1−2.5)2=5.06+2.25=7.31
Assignment: Cluster 1 (S1)
Point D(6,3):
To c1(6,1): (6−6)2+(3−1)2=0+4=4←Closest
To c2(3.75,2.5): (6−3.75)2+(3−2.5)2=5.06+0.25=5.31
Assignment: Cluster 1 (S1) — Dswitched clusters!
Point E(1,1):
To c1(6,1): (1−6)2+(1−1)2=25+0=25
To c2(3.75,2.5): (1−3.75)2+(1−2.5)2=7.56+2.25=9.81←Closest
Assignment: Cluster 2 (S2)
Our partitioned clusters at t=2 are:
S1(2)={C,D},S2(2)={A,B,E}
Step 2.2: Centroid Update
New Centroid c1(3) (mean of C,D):
c1(3)=(26+6,21+3)=(6,2)
New Centroid c2(3) (mean of A,B,E):
c2(3)=(32+6+1,31+5+1)=(3,2.33)
Iteration 3
c1=(6,2)
c2=(3,2.33)
Step 3.1: Distance Calculation & Re-assignment
Point A(2,1):
To c1(6,2): (2−6)2+(1−2)2=16+1=17
To c2(3,2.33): (2−3)2+(1−2.33)2=1+1.78=2.78←Closest
Assignment: Cluster 2 (S2)
Point B(6,5):
To c1(6,2): (6−6)2+(5−2)2=0+9=9←Closest
To c2(3,2.33): (6−3)2+(5−2.33)2=9+7.11=16.11
Assignment: Cluster 1 (S1) — Bswitched clusters!
Point C(6,1):
To c1(6,2): (6−6)2+(1−2)2=0+1=1←Closest
To c2(3,2.33): (6−3)2+(1−2.33)2=9+1.78=10.78
Assignment: Cluster 1 (S1)
Point D(6,3):
To c1(6,2): (6−6)2+(3−2)2=0+1=1←Closest
To c2(3,2.33): (6−3)2+(3−2.33)2=9+0.44=9.44
Assignment: Cluster 1 (S1)
Point E(1,1):
To c1(6,2): (1−6)2+(1−2)2=25+1=26
To c2(3,2.33): (1−3)2+(1−2.33)2=4+1.78=5.78←Closest
Assignment: Cluster 2 (S2)
Our partitioned clusters at t=3 are:
S1(3)={B,C,D},S2(3)={A,E}
Step 3.2: Centroid Update
New Centroid c1(4) (mean of B,C,D):
c1(4)=(36+6+6,35+1+3)=(6,3)
New Centroid c2(4) (mean of A,E):
c2(4)=(22+1,21+1)=(1.5,1)
Iteration 4
c1=(6,3)
c2=(1.5,1)
Step 4.1: Distance Calculation & Re-assignment
Point A(2,1):
To c1(6,3): (2−6)2+(1−3)2=16+4=20
To c2(1.5,1): (2−1.5)2+(1−1)2=0.25+0=0.25←Closest
Assignment: Cluster 2 (S2)
Point B(6,5):
To c1(6,3): (6−6)2+(5−3)2=0+4=4←Closest
To c2(1.5,1): (6−1.5)2+(5−1)2=20.25+16=36.25
Assignment: Cluster 1 (S1)
Point C(6,1):
To c1(6,3): (6−6)2+(1−3)2=0+4=4←Closest
To c2(1.5,1): (6−1.5)2+(1−1)2=20.25+0=20.25
Assignment: Cluster 1 (S1)
Point D(6,3):
To c1(6,3): (6−6)2+(3−3)2=0←Closest
To c2(1.5,1): (6−1.5)2+(3−1)2=20.25+4=24.25
Assignment: Cluster 1 (S1)
Point E(1,1):
To c1(6,3): (1−6)2+(1−3)2=25+4=29
To c2(1.5,1): (1−1.5)2+(1−1)2=0.25+0=0.25←Closest
Assignment: Cluster 2 (S2)
Our partitioned clusters at t=4 are:
S1(4)={B,C,D},S2(4)={A,E}
Since the point assignments have not changed from the previous iteration, recalculating the centroids will result in the exact same coordinates:
c1(5)=(6,3)
c2(5)=(1.5,1)
The algorithm has converged after 4 iterations! The final clusters are the right-hand column {B,C,D} and the bottom-left pair {A,E} — even though both centroids started on the right side of the grid.
3. Implementations
Here are clean, reference implementations of the K-Means algorithm for 2D vectors.
Python (NumPy)
python
import numpy as npdef kmeans_2d(points, k, max_iters=100, tol=1e-4): # Convert input to numpy array X = np.array(points, dtype=np.float64) # 1. Initialize centroids randomly from the dataset points rng = np.random.default_rng() initial_idx = rng.choice(X.shape[0], size=k, replace=False) centroids = X[initial_idx] for iteration in range(max_iters): # 2. Assignment Step: compute pairwise Euclidean distances # X[:, np.newaxis, :] is shape (N, 1, 2) # centroids[np.newaxis, :, :] is shape (1, K, 2) distances = np.linalg.norm(X[:, np.newaxis, :] - centroids[np.newaxis, :, :], axis=2) labels = np.argmin(distances, axis=1) # 3. Update Step: compute new centroids new_centroids = np.array([ X[labels == j].mean(axis=0) if np.any(labels == j) else centroids[j] for j in range(k) ]) # Check for convergence (if centroids move less than tolerance) if np.all(np.linalg.norm(new_centroids - centroids, axis=1) < tol): print(f"Converged at iteration {iteration}") break centroids = new_centroids return centroids, labels# Test the exampledata_points = [[2, 1], [6, 5], [6, 1], [6, 3], [1, 1]]centroids, labels = kmeans_2d(data_points, k=2)print("Centroids:\n", centroids)print("Labels:\n", labels)
TypeScript / JavaScript
typescript
interface Point2D { x: number; y: number;}function calculateDistanceSquared(p1: Point2D, p2: Point2D): number { return Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2);}function kmeans2D( points: Point2D[], k: number, maxIters = 100,): { centroids: Point2D[]; labels: number[] } { // 1. Initialize centroids: take first K unique points (for simple deterministic seed) const centroids: Point2D[] = points.slice(0, k).map((p) => ({ ...p })); const labels: number[] = new Array(points.length).fill(-1); for (let iter = 0; iter < maxIters; iter++) { let changed = false; // 2. Assignment Step for (let i = 0; i < points.length; i++) { const p = points[i]; let minDistance = Infinity; let closestCentroidIndex = -1; for (let j = 0; j < k; j++) { const distSq = calculateDistanceSquared(p, centroids[j]); if (distSq < minDistance) { minDistance = distSq; closestCentroidIndex = j; } } if (labels[i] !== closestCentroidIndex) { labels[i] = closestCentroidIndex; changed = true; } } // If no point changed its cluster assignment, we have converged if (!changed) { console.log(`Converged at iteration ${iter}`); break; } // 3. Update Step const sumsX = new Array(k).fill(0); const sumsY = new Array(k).fill(0); const counts = new Array(k).fill(0); for (let i = 0; i < points.length; i++) { const label = labels[i]; sumsX[label] += points[i].x; sumsY[label] += points[i].y; counts[label]++; } for (let j = 0; j < k; j++) { if (counts[j] > 0) { centroids[j] = { x: sumsX[j] / counts[j], y: sumsY[j] / counts[j], }; } } } return { centroids, labels };}// Test the exampleconst dataPoints: Point2D[] = [ { x: 2, y: 1 }, { x: 6, y: 5 }, { x: 6, y: 1 }, { x: 6, y: 3 }, { x: 1, y: 1 },];const result = kmeans2D(dataPoints, 2);console.log("Centroids:", result.centroids);console.log("Labels:", result.labels);