001Notes

ClusterLens Algorithms: Clustering Image Embeddings From First Principles

On this page110

A beginner-first, experimental tutorial for PCA, KMeans, MiniBatchKMeans, FAISS, HDBSCAN, similarity graphs, outliers, reranking, and cluster quality.

Article details
Status
Building Publicly
Subcategory
ClusterLens
Last reviewed
2 Sept 2026
Prerequisites
None; the article starts with points, distance, and grouping
110 sections

ClusterLens begins with a sentence that sounds almost too simple:

Group similar images.

But the word similar hides several decisions.

Should a red car be grouped with a blue car because both are cars? Should it be grouped with a red apple because both are red? Should two beach photographs be grouped together even if one is a close-up and the other is a wide landscape?

The clustering algorithm cannot answer those questions by itself. It receives numbers produced by an embedding model, and it organizes those numbers according to a mathematical rule.

This tutorial starts before PCA, KMeans, or HDBSCAN. We will first establish what the algorithm receives, what a cluster label means, and why several different answers can all be internally consistent. Then we will perform the algorithms by hand, animate their state changes, run them on actual image embeddings, and inspect where they fail.

Every artifact on this page is labelled as one of:

  • Conceptual diagram: an illustration built to expose one idea.
  • Controlled dry run: exact, inspectable inputs with reproducible arithmetic.
  • Actual run: code executed using ClusterLens services, cached production models, and a recorded dataset.

The actual-run artifact is loading, generated loading with seed loading.


1. What is clustering trying to do?

Imagine six objects on a table:

red car       blue car
red apple     green apple
beach         ocean

There are several reasonable ways to organize them.

  • By subject, the two cars belong together, the two apples belong together, and the two water scenes belong together.
  • By colour, the red car moves closer to the red apple.
  • By composition, centred objects might form one group while wide horizon scenes form another.

Try all three definitions below.

Subject grouping puts both cars together, both apples together, and both water scenes together.

The important observation is not that one grouping is correct. It is that the grouping changes when the representation of similarity changes.

Clustering compared with familiar tasks

TaskWhat information is supplied?Question
ClassificationExamples with known names“Which known class is this new object?”
SearchA query plus an existing collection“Which existing objects are nearest to this query?”
Duplicate detectionA strict identity or perceptual rule“Is this effectively the same file or image?”
ClusteringA collection and a relationship rule“How can the whole collection be divided or connected?”

Clustering usually returns an integer for every row:

image 0 → cluster 2
image 1 → cluster 2
image 2 → cluster 0
image 3 → noise (-1)

The integers are identifiers, not meanings. Cluster 2 is not inherently more important than cluster 0. If an implementation runs again, the same group might receive a different integer while preserving exactly the same membership.

What should I understand now? Clustering does not discover the one true organization of a folder. It produces an organization that follows a representation, a distance rule, an algorithm, and a set of parameters.
Common mistake: treating a cluster ID as a semantic label. The algorithm returns membership. A later explanation layer may infer that a group looks like “cars,” but that name is not contained in the integer `2`.

2. From an image to a point

A clustering algorithm does not receive a JPEG, a bicycle, or the idea of a beach. It receives numbers.

The earlier semantic-search tutorial builds this transformation in detail. The compact version is:

image file
   ↓ decode and preprocess
pixel tensor
   ↓ learned image encoder
embedding vector
   ↓ interpret the vector as coordinates
point in an embedding space

Suppose a tiny teaching encoder emits only two coordinates:

car-01.jpg     → [0.92, 0.18]
car-02.jpg     → [0.88, 0.23]
beach-01.jpg   → [0.12, 0.94]
invoice-01.jpg → [-0.71, 0.08]

Each ordered pair can be drawn as a point on paper. Nearby points have similar coordinate patterns. Real ClusterLens embeddings have hundreds of coordinates, so we cannot draw the full space directly, but the same geometric operations still apply.

Why not name every coordinate?

In a hand-designed vector, coordinate one might mean “redness” and coordinate two might mean “edge density.” Learned embeddings rarely offer that clean interpretation. Training shapes many coordinates together so that useful relationships emerge from the whole direction.

This is similar to a city map. A location is meaningful because every place uses the same latitude/longitude coordinate system. A latitude from one map and an arbitrary first coordinate from another model cannot be compared just because both are numbers.

Image embedding contract

xi=fθ(imagei)RD

fθ is the learned encoder, i selects one image, and D is the embedding dimension.

Stack the points into a matrix

For (N) images with (D)-dimensional embeddings, stack one vector per row:

The matrix contract

X=[x1; x2; …; xN]RN × D

Row i must continue to refer to the same image path throughout preparation, clustering, post-processing, and rendering.

If there are 20,000 images and each vector has 512 float32 coordinates:

Raw embedding memory

20,000×512×4 bytes=40,960,000 bytes ≈ 39.1 MiB

This counts only the dense matrix. Paths, thumbnails, model memory, indexes, labels, and UI objects require additional memory.

The row-order bug that mathematics will not catch

The backend returns labels by row number. If paths are reordered later while the label array stays fixed, the arithmetic can be perfect and the product can still show the wrong images.

0car-a.jpg[.92, .18]C0
1car-b.jpg[.88, .23]C0
2beach.jpg[.12, .94]C1
3ocean.jpg[.08, .97]C1

Correct: paths, embedding rows, and labels share one frozen index.

A safe sequence is:

scan paths
→ freeze their order
→ build/load embeddings in that order
→ cluster rows
→ map labels back through the frozen order
→ apply display filters

3. Distance, direction, and normalization

Before dividing points into groups, we need a rule for comparing them.

Length and direction are different properties

Think of an arrow on a compass. [1, 0] and [10, 0] point east, but the second arrow is ten times longer. Cosine similarity asks how closely the directions agree. A raw dot product is affected by direction and length.

Change both properties below. The blue reference vector always points right.

referenceadjustable
cosine0.906
raw dot product0.906

Changing only length leaves cosine unchanged but changes the raw dot product. Normalization removes that magnitude variable.

Calculate vector length

L2 length

∥x∥2=√(x12 + x22 + … + xD2)

Squaring prevents positive and negative coordinates from cancelling. The square root returns to the coordinate scale.

For [3, 4]:

Complete normalization dry run

∥[3,4]∥=√(32+42)=5[3,4]5=[0.6,0.8]

The direction is preserved, while the new length is exactly one.

Cosine similarity

Cosine similarity

cos(x,y)=x · y∥x∥ ∥y∥

For already-normalized vectors, both lengths equal one, so cosine becomes the dot product.

There is a second useful equivalence. For unit vectors:

Unit-sphere equivalence

∥x−y∥2=2−2(x·y)

Maximizing dot product and minimizing Euclidean distance produce the same pair ordering when both inputs are unit length.

A high cosine is not “90% the same”

Cosine similarity is a geometric measurement, not a probability and not a human judgement.

If two normalized embeddings have cosine 0.90, we know that their angle is small relative to other directions in that model’s space. We do not know that:

  • the images are 90% visually identical;
  • there is a 90% chance they have the same class;
  • a person will consider them interchangeable;
  • 0.90 means the same thing for CLIP, SigLIP, and DINO;
  • 0.90 is a good acceptance threshold for every collection.

The encoder decides which image differences affect direction. Cosine only measures the directions it receives.

This distinction is like measuring two houses with a road map. The map can say that their road directions are similar while omitting building colour, number of rooms, and who lives there. The angle calculation is correct; the map does not contain every fact the user had in mind.

Surprise 1: two opposites can be equally similar to one query

Let the blue query vector point right. Candidate A sits above it and candidate B sits below it by the same angle.

Because cosine depends on the angle to the query:

Symmetric candidates

cos(q,A)=cos(q,B)=cos(θ)butcos(A,B)=cos(2θ)

Equal similarity to the same query does not imply that the two results are similar to each other.

Move the candidates or play the animation:

query q A B
cos(q, A)0.500
cos(q, B)0.500
cos(A, B)−0.500

At 60°, A and B both score 0.500 against the query, yet they score −0.500 against each other. A broad query can retrieve two very different branches.

A familiar semantic analogy is the query “pet.” A cat and a dog can both be good matches for “pet” without being the same animal. For an image query, a photograph containing both “road” and “vehicle” can similarly retrieve one result dominated by road layout and another dominated by vehicle identity.

This matters for clustering. If A and B both have a strong edge to a central bridge image q, a graph can connect all three even when A and B feel unrelated to each other.

Surprise 2: cosine ignores magnitude completely

Consider:

x = [1, 0]
y = [100, 0]

They point in exactly the same direction, so cosine is 1. Their Euclidean distance is 99.

For normalized image embeddings this is intentional: the system discards magnitude and preserves direction. But it demonstrates the general rule that cosine similarity can call two vectors maximally similar while another distance measure calls them far apart.

Surprise 3: a model can preserve the “wrong” shared feature

Suppose a person wants object identity:

query:      small bird against a pale sky
candidate:  airplane against a pale sky

A model may encode shared silhouette, sky background, scale, or composition strongly enough that the two directions become close. Cosine then reports that closeness accurately. The surprising part came earlier: the representation emphasized features that did not match the person’s current intention.

The following are actual nearest-neighbour mistakes from the fixed CIFAR-10 run. They are not hand-picked vector arithmetic. Change the model and case, then compare the image labels with the measured cosine.

Query Actual CIFAR-10 query used in a cosine false-friend example
human label: airplane
cosine0.889
Nearest result Actual nearest result with a different CIFAR-10 label
human label: frog
RankHuman labelCosine

CLIP's closest gallery vector has a different CIFAR-10 class. Inspect the images for shared colour, texture, silhouette, background, or framing; the evidence records the score, but it cannot prove which visual cue caused it.

The explanation deliberately says “inspect for” rather than declaring a cause. An embedding has hundreds of interacting coordinates. Without a controlled intervention or interpretability experiment, a plausible visual story remains a hypothesis.

Common kinds of cosine false friends

What appears similar to the modelWhy it may feel wrong to a personExample
background or scenethe user cared about the foreground objectbird in sky ↔ airplane in sky
colour palettethe user cared about categoryred car ↔ red toy or red sign
outline or posethe user cared about identitystanding dog ↔ standing deer
text layoutthe user cared about document contentinvoice ↔ unrelated form
broad conceptthe user expected a duplicatetwo entirely different beach photographs
generic compositionthe image sits near many ordinary imagescentred object on plain background

None of these means cosine is broken. They mean similarity needs a noun:

similar subject?
similar colour?
similar composition?
same photograph?
same event?
same person?

Cosine supplies one number only after the embedding model has blended many of those possible meanings into a direction.

How should I read a cosine score? Treat it as a model-relative ranking signal: “under this encoder and preprocessing, these directions are closer than many alternatives.” Validate thresholds on the intended collection, inspect false positives, and never translate the decimal directly into a probability of human agreement.

This does not make ordinary scikit-learn KMeans identical to spherical KMeans. ClusterLens normalizes input rows, but scikit-learn updates centroids using ordinary arithmetic means and does not project each centroid back to the unit sphere during every Lloyd iteration. “Cosine KMeans” in the UI is therefore best read as KMeans in a cosine-prepared input space.


4. The actual ClusterLens pipeline

The complete production path is not “images → algorithm → folders.”

paths
→ decoded/model-specific pixels
→ normalized embeddings
→ frozen N × D matrix
→ optional semantic PCA
→ L2-normalized prepared matrix
→ backend labels
→ outlier policy
→ tiny-cluster policy
→ centroid reranking
→ explanations and review UI

The production UI currently exposes three logical backends:

  • cosine-kmeans
  • hdbscan
  • graph

FAISS KMeans remains a hidden legacy/experimental backend until its packaging and runtime surface is validated. MiniBatchKMeans is not a separate visible backend; the logical KMeans path chooses it automatically for large or max-speed runs.

What “semantic” and “cosine” modes mean here

Within the current clustering service:

ModePreparation
semanticnormalize input → fit conditional PCA → project → normalize again
cosinenormalize full vector without PCA

Those names do not select an entirely different semantic or visual model. Model selection happens earlier. Here the modes compare a PCA-projected space with the full normalized space.

The service records this explicitly:

semantic projection | input 768 -> PCA 50 | normalized

cosine full vector | input 768 -> full 768 | normalized

5. PCA: choosing a useful shadow

PCA stands for Principal Component Analysis. It is easier to understand as a projection problem.

Hold a long object in front of a wall. One shadow might preserve its length. A different shadow might collapse most of that length into a short blob. Both shadows are two-dimensional, but they preserve different information.

PCA searches for projection directions that capture the most variance in the observed dataset.

An everyday example: height and arm span repeat information

Imagine recording two measurements for many adults:

person       height       arm span
A            160 cm       162 cm
B            170 cm       171 cm
C            180 cm       179 cm

The columns are not identical, but they mostly rise together. Storing both is useful; treating them as two completely independent facts overstates how much new information the second measurement adds.

If we draw height on one axis and arm span on another, the points form a diagonal cloud. One direction along that diagonal explains most of the differences between people. The perpendicular direction mostly describes the smaller “arm span relative to height” variation.

PCA rotates the coordinate system toward those directions and lets us retain the first few rotated coordinates.

original coordinates:  height, arm span

PCA coordinates:       overall body-size direction,
                       arm-span-versus-height direction

PCA does not understand bodies. It discovers that the observed numbers vary together.

Step 1: centre the data

If the points are x₁ … xₙ, calculate their mean:

Dataset mean

μ=1NΣi=1…N xi

The mean gives the centre of the observed cloud, coordinate by coordinate.

Then subtract it:

Centred point

i=xi−μ

PCA studies variation around the collection’s centre rather than variation around the coordinate origin.

A complete two-dimensional PCA calculation

Use three deliberately simple points:

A = (2, 1)
B = (4, 3)
C = (6, 5)

First calculate the coordinate-wise mean:

1 · Mean

μ=(2,1)+(4,3)+(6,5)3=(4,3)

The centre is calculated separately for each original coordinate.

Subtract that mean:

A − μ = (−2, −2)
B − μ = ( 0,  0)
C − μ = ( 2,  2)

The sample covariance matrix records how coordinates vary alone and together:

2 · Covariance

Σ=TN−1=[[4,4],[4,4]]

Both diagonal entries are 4, so both coordinates vary. The off-diagonal value is also 4, so they move together perfectly in this toy dataset.

The first eigenvector points along the diagonal:

3 · Principal directions

v1=(1,1)√2with λ1=8;v2=(−1,1)√2with λ2=0

The eigenvalue is the variance captured along that direction. Here every point lies exactly on the first direction, so the perpendicular direction captures zero variance.

Project each centred point onto v₁:

4 · One-dimensional result

z=[−2√2, 0, 2√2]

We replaced two coordinates with one without reconstruction loss for these three points. Real embeddings do not normally collapse this perfectly.

The sign of an eigenvector is arbitrary. Another correct implementation may return −v₁ and therefore reverse all projected signs. Distances and cluster membership remain unchanged.

Step 2: project onto a direction

For unit direction v, one projected coordinate is:

One-dimensional projection

zi=i·v

The dot product asks how far the centred point travels along direction v.

Rotate the purple projection axis. Orange segments show what the one-dimensional shadow loses.

projected varianceloading
reconstruction errorloading

The first principal component chooses the direction with maximum projected variance. In this teaching cloud, that direction also produces the shortest discarded orange segments.

From one direction to several components

After choosing the first direction, PCA chooses a perpendicular direction that captures as much remaining variance as possible, then repeats.

512 original coordinates
→ choose component 1
→ choose perpendicular component 2
→ …
→ retain first K components
→ K projected coordinates

The retained-variance ratio is:

Explained variance

retained ratio=variance in retained componentsvariance in all components

This measures variance, not semantic truth. A low-variance direction can still contain a distinction the user cares about.

PCA is fitted on the current collection. Its learned mean and component directions therefore belong to the transformation contract. A query or later image must use the same fitted basis if it is to enter the same projected coordinate system.

The variance trap: PCA can discard the feature you care about

PCA has no labels. It keeps directions with large variation, even when a quieter direction contains the distinction a person wants.

The controlled dataset below has twelve points. Both colours span the same wide horizontal range, but blue points sit slightly above red points. Horizontal position contributes about 97% of the variance; vertical colour separation contributes only about 3%.

Switch which one-dimensional axis survives:

original 2D points retained coordinate: horizontal PC1
variance retained97.0%
cross-colour overlaps6 of 6
colour separationlost

PC1 is the correct PCA choice because it preserves the widest numeric variation. It is the wrong one-dimensional choice if the task is to separate blue from red.

This is why explained variance must be paired with task evidence. In an image embedding, lighting, camera style, background, or composition may vary more than the rare semantic distinction the workflow cares about.

When ClusterLens applies PCA

The semantic mode requests 50 components by default, but the actual count is bounded by:

Safe component count

Kactual=min(Krequested, D, N−1)

ClusterLens also skips semantic PCA for fewer than four samples and whenever reduction would not reduce the input dimension.

After projection, ClusterLens normalizes again because PCA changes vector lengths.

Actual dimension experiment

Choose a model and dimension. These are actual post-hoc PCA runs on the fixed 500-image dataset.

projected width64D
variance retained
top-10 neighbours retained
KMeans ARI
silhouette
bytes/vector

The dimensions are ordered 256, 128, 64, 50, 32, and 16. A smaller vector can improve this particular clustering while still discarding exact neighbours. That is why “variance retained” and “task result retained” are separate measurements.

The run found no monotonic rule. On this dataset, CLIP’s strongest measured PCA KMeans ARI occurred at 128 dimensions, SigLIP’s at 32, and DINO’s at 50. Those are results for this dataset and pipeline—not universal model settings.

PCA variants and nearby tools are not interchangeable

MethodMain ideaUseful whenImportant warning
ordinary PCAfit linear orthogonal directions on the full matrixa moderate matrix fits in memorycurrent ClusterLens semantic path
randomized PCA solverapproximate leading directions efficientlydimensions or sample counts are largestill linear PCA; approximation settings matter
IncrementalPCAupdate the basis from batchesthe full matrix cannot fit comfortablybatch ordering and batch size can affect approximation
Kernel PCAperform PCA after a nonlinear kernel mappingcurved structure matters on smaller datasetsstorage and computation scale poorly
whiteningdivide retained coordinates by their standard deviationsa downstream method needs equal component varianceamplifies low-variance/noisy directions
t-SNE or UMAPbuild a low-dimensional visualizationinspecting a 2D or 3D mapvisual spacing is not a drop-in production metric space

ClusterLens uses PCA as a clustering preparation step, not only to draw a pretty two-dimensional chart. A visualization method may deliberately distort global distances to make local structure visible, which changes the clustering contract.

Common mistake: choosing a PCA dimension solely because it retains a visually satisfying percentage such as 95%. The product cares about useful neighbourhoods and cluster behavior, not variance in isolation.

6. KMeans from the first assignment

Imagine opening k tables in a room.

  1. Every guest walks to the nearest table.
  2. Each table moves to the average position of its guests.
  3. Guests reconsider which table is nearest.
  4. Repeat until the tables stop moving.

The tables are centroids. The procedure is Lloyd’s algorithm.

The objective

KMeans inertia

J=Σi=1…N∥xi−μcᵢ2

xi is one point, cᵢ is its assigned cluster, and μcᵢ is that cluster’s centroid. KMeans tries to reduce this total squared error.

Perform the iterations

The lab below uses six exact 2D points and two intentionally awkward initial centroids. Use Next to inspect every assignment table.

iteration0
inertia before update
pointdistance C1distance C2owner

Assign every point to its nearest centroid, then replace each centroid with the arithmetic mean of its assigned points.

Why the update is the mean

For one cluster (C):

Centroid update

μC=1|C|Σx∈C x

The arithmetic mean minimizes the sum of squared Euclidean distances to the members of that cluster.

If A=(1.0,1.0), B=(1.2,0.8), and C=(0.8,1.3) belong together:

Centroid dry run

μ=(1.0,1.0)+(1.2,0.8)+(0.8,1.3)3=(1.0,1.033)

Calculate each coordinate independently: `(1+1.2+.8)/3 = 1`; `(1+.8+1.3)/3 ≈ 1.033`.

What KMeans assumes

KMeans works best when groups are reasonably compact around means. It struggles with:

  • curved moon-shaped groups;
  • long chains;
  • strongly unequal sizes;
  • strongly unequal densities;
  • extreme points that pull a mean;
  • data where every point should not be assigned.

The algorithm is not malfunctioning in those cases. Its objective describes a different shape from the one the reader may expect.

KMeans draws nearest-centroid territories

After centroids stop moving, every point belongs to the centroid with the smallest squared distance. In two dimensions, each centroid owns a region of the plane called a Voronoi cell. The boundary between two centroids contains points equally far from both:

Two-centroid boundary

∥x−μ12=∥x−μ22

Expanding both sides produces a straight boundary. With many centroids, KMeans divides space into straight-edged convex territories.

That shape constraint explains several failures better than memorizing a list. A crescent cannot be represented by one convex nearest-centroid territory without also claiming some empty or foreign space inside the curve.

Switch among four exact teaching datasets. Point fill is the intended group; the outer ring is the final KMeans assignment. A point whose fill and ring disagree is counted as a mismatch.

teaching mismatches0
final inertia
iterations
cluster sizes

Compact, similarly sized groups match the geometry that two centroids can summarize well.

“Teaching mismatch” is available because this synthetic dataset has an intended colour. A real unlabelled folder has no automatic answer key.

Why a single outlier can move a centroid

The mean has no built-in resistance to extreme values. Start with one-dimensional members:

[0, 1, 2]

mean = 1

Add one distant point:

Outlier pull

mean(0,1,2,100)=1034=25.75

The centroid moved from 1 to 25.75 even though three of four observations remain between 0 and 2.

With a fixed k, an extreme point can capture a centroid for itself. Two ordinary groups may then be forced to share the remaining centroid.

MethodRepresentativeAssignmentWhat changes
ordinary KMeansarithmetic meannearest Euclidean centroidthe baseline used for smaller ClusterLens runs
cosine-prepared KMeansarithmetic mean after unit-normalizing inputsnearest Euclidean centroid in prepared spacecurrent ClusterLens cosine-kmeans meaning
spherical KMeanscentroid projected back to unit lengthmaximum cosinedirection is enforced during every update
MiniBatchKMeansrunning approximate meannearest current centroidcheaper updates from small batches
K-medoidsan actual member pointnearest medoidmore resistant to outliers, usually more expensive
Gaussian mixture modelmean plus covariancesoft probability of component membershipcan model ellipses and uncertainty
balanced/constrained KMeansmean with size rulesnearest feasible centroidenforces workflow limits but changes the optimization problem

Calling every row “KMeans” would hide meaningful differences. The representative, distance, initialization, batch strategy, and constraints are all part of the contract.


7. Initialization and local answers

Lloyd’s algorithm only moves downhill from its initial centroids. Different starts can reach different local minima.

This is like placing two meeting points before knowing where a city’s residents live. If both begin in the same neighbourhood, one may spend several iterations escaping while the other population is represented poorly.

KMeans++ intuition

KMeans++ chooses the first centroid, then gives points farther from the chosen set a greater chance of becoming the next centroid.

KMeans++ sampling pressure

P(choose x)D(x)2

D(x) is the distance from point x to its closest already-selected centroid. Far regions receive more probability, not a guarantee.

ClusterLens uses a fixed random seed for reproducibility and multiple initializations on the ordinary KMeans path. A fixed seed answers “can I reproduce this run?” It does not answer “would all reasonable seeds find the same membership?”

The evidence runner therefore also uses deliberately fragile random initialization with one start across ten seeds and measures pairwise Adjusted Rand Index between the resulting partitions.

Move through those actual runs:

selected seed0
ARI
purity
silhouette
smallest / largest
mean pairwise run ARI

These runs intentionally use random initialization and only one start, making seed sensitivity visible. Production KMeans uses multiple starts to reduce this risk.

For CLIP, the ten fragile starts ranged from ARI 0.523 to 0.727; one seed created a cluster containing only one image. The mean pairwise ARI between partitions was 0.616. “Converged” was true for every run, while “same answer” was false.

What should I understand now? Convergence means the current update no longer changes the solution. It does not prove that this is the globally best KMeans solution.

8. Choosing k

KMeans requires the number of clusters in advance.

Think of organizing 500 photographs into boxes:

  • k=2 gives two large, probably mixed boxes;
  • k=500 gives one box per photograph and no useful compression;
  • a useful value lies between and depends partly on the review task.

Three different questions

  1. Inertia: how tightly does KMeans fit the points?
  2. Silhouette: are points closer to their own group than to a competitor?
  3. Workflow: are the resulting groups manageable for a person to review?

Inertia always falls or stays equal as k grows because extra centroids add flexibility. Selecting the smallest inertia therefore selects the largest allowed k.

Try the actual sweep:

k10
ARI vs 10 CIFAR labels
silhouette
purity
smallest group
largest group

The slider positions represent k = 2, 4, 6, 8, 10, 12, 16, 20, and 32. CIFAR-10 happens to have ten labels, so ARI can tell us how closely a run recovers that external partition. A normal photo folder does not provide this answer key.

Useful product heuristics include:

Review-workload heuristic

knumber of imagestarget images per group

For 10,000 images and about 100 desired thumbnails per review group, start near `k=100`, then inspect quality and group sizes.

This is a product decision, not a theorem about the natural number of concepts in the folder.


9. MiniBatchKMeans: learn from spoonfuls

Full KMeans examines every point during each assignment/update cycle. MiniBatchKMeans updates centroids using small samples.

The familiar analogy is tasting a spoonful of soup. A spoonful is cheaper than inspecting the entire pot, but an unrepresentative spoonful can mislead the adjustment.

For a single incoming point, the update has the form:

Incremental centroid update

μnew=μold+η(x−μold)

η controls how much this observation can move the running centroid. Implementations derive it from accumulated counts rather than keeping one arbitrary constant.

ClusterLens does not expose MiniBatchKMeans as a separate production checkbox. The logical cosine-kmeans backend selects it when:

  • the matrix has at least 2,000 rows; or
  • max-speed mode is active and the matrix has at least 512 rows.

Actual runtime comparison

This controlled benchmark uses normalized 64-dimensional mixtures with ten known centres. Timing is evidence about this machine, not every machine.

2,000
scikit-learn KMeans
MiniBatchKMeans
FAISS KMeans

The bars are relative within the selected vector count. Library startup, thread pools, convergence rules, and initialization all affect short timings, so quality and repeated runs belong beside speed.

Approximate speed is useful for previews, but the UI should not imply that a faster approximate run is more authoritative.


10. FAISS from the first distance calculation

FAISS is a software library for similarity search and clustering of dense vectors. Its name originally came from Facebook AI Similarity Search.

That description contains three separate ideas:

TermBeginner meaning
dense vectora fixed-width row of numbers, such as a 512-number image embedding
similarity searchfind the stored rows nearest to a query row
clusteringrepeatedly use nearness to organize many rows into groups

FAISS is not a new definition of similarity. It is an implementation toolbox. KMeans remains the recipe; FAISS supplies optimized machinery for expensive parts of that recipe.

What FAISS is not

Three common shortcuts are wrong:

FAISS = vector database             ✗
FAISS = approximate search          ✗
FAISS = GPU-only                    ✗

FAISS indexes live inside an application unless that application builds the surrounding database behavior. FAISS supports exact and approximate indexes, and it has CPU as well as GPU implementations.

The distinction is easier to see as a stack:

product behavior
  file paths, deletion, permissions, metadata, persistence

FAISS library
  vector indexes, distance kernels, clustering, quantization

mathematical operation
  L2 distance, inner product, nearest neighbour, KMeans objective

Changing the middle layer can make an operation faster without changing the question at the bottom. Changing the metric or index family can change the question or make its answer approximate.

Start with one exact FAISS index

An index is an object that owns or refers to searchable vectors and knows how to answer a nearest-neighbour query.

The simplest L2 index is conceptually:

index = faiss.IndexFlatL2(dimension)
index.add(database_vectors)
distances, indices = index.search(query_vectors, k)

IndexFlatL2 performs exhaustive search. “Flat” means it compares the query against every stored vector. There is no approximation and no training phase.

Suppose the query and four stored two-dimensional vectors are:

query q        = [ 0.95, 0.05]

row 0 Ferrari = [ 1.00, 0.00]
row 1 Porsche = [ 0.80, 0.20]
row 2 beach   = [ 0.00, 1.00]
row 3 invoice = [-0.80, 0.10]

Squared L2 distance is:

Exact flat distance

d²(q,x)=Σj=1…D(qj−xj

FAISS reports squared L2 values for this index. Taking a square root would preserve the ranking, so the cheaper squared value is enough for nearest-neighbour selection.

Step through the complete scan. The orange line is the candidate currently being measured; the green line is the best result seen so far.

row IDmeaningsquared L2state
comparisons completed0 / 4
best row ID so far
best squared distance

No database row has been measured yet. With k = 1, the index will return one distance and one row ID for this query.

The exact calculations are:

Ferrari: (0.95−1.00)² + (0.05−0.00)² = 0.005
Porsche: (0.95−0.80)² + (0.05−0.20)² = 0.045
beach:   (0.95−0.00)² + (0.05−1.00)² = 1.805
invoice: (0.95+0.80)² + (0.05−0.10)² = 3.065

For one query and k=1, the result is conceptually:

distances = [[0.005]]
indices   = [[0]]

The index returns row ID 0, not Ferrari.jpg. The application must preserve the mapping from vector row 0 back to its file metadata. This is the same row contract introduced at the beginning of the chapter.

Batch shapes: why the result is a matrix

FAISS accepts several queries together. If there are Q queries and we request the nearest K neighbours for each, both outputs have shape:

Search output contract

shape(distances)=shape(indices)=Q × K

Every output row belongs to the query at the same row position. Every column advances from nearest to farther returned neighbours.

Example:

3 query vectors × top 2 neighbours

indices =
[[ 0,  1],      ← query 0
 [17,  4],      ← query 1
 [ 8, 11]]      ← query 2

Batching is not only a convenient API shape. Native code can process matrix blocks, reuse caches, use vector instructions, and keep worker threads busy more effectively than repeated one-query calls.

Exact and approximate indexes belong to the same library

FAISS structureSearch meaningTraining?Main tradeoff
IndexFlatL2exact minimum squared L2noscans every stored vector
IndexFlatIPexact maximum inner productnocosine requires unit-normalized vectors first
HNSW indexgraph-guided approximate neighboursno dataset trainingextra graph memory for faster navigation
IndexIVFFlatsearch selected coarse buckets, then exact vectors inside themyesnprobe trades recall for work
IndexIVFPQselected buckets plus compressed vector codesyesmuch lower memory with quantization error
faiss.Kmeanstrain centroids using repeated assignment and mean updatesyesproduces a partition, not a general-purpose database

So “we use FAISS” is incomplete. A useful statement names the index, metric, training state, search parameters, and whether the result is exact.

Recall Lloyd’s KMeans loop:

assign every point to its nearest centroid

replace each centroid with its assigned mean

repeat

The assignment step is itself nearest-neighbour search:

database inside the temporary index = K centroids
queries sent to that index           = N image vectors
requested neighbours                 = 1
returned row ID                       = cluster label

This inversion is worth pausing over. During ClusterLens’s FAISS KMeans assignment, the index contains the centroids, not all image embeddings. Every image embedding is a query asking, “which one centroid is nearest?”

Use a four-point dry run:

points:       A=(1,1)  B=(1,2)  C=(8,8)  D=(9,8)
centroids:   μ₀=(1,1)            μ₁=(9,8)

nearest centroid search returns:
A → 0    B → 0    C → 1    D → 1

mean update:
μ₀ ← mean(A,B) = (1,1.5)
μ₁ ← mean(C,D) = (8.5,8)

The search(..., 1) result is the label array [0,0,1,1]. Another iteration uses the updated means.

FAISS’s own implementation notes identify assignment as the dominant KMeans cost. For N vectors, K centroids, D dimensions, and T iterations, a useful work estimate is:

KMeans assignment work

coordinate workN × K × D × T

This is an intuition for scaling, not a stopwatch prediction. Vectorized kernels, cache behavior, convergence, threading, and hardware determine elapsed time.

Change the workload below, then play one FAISS-style iteration. The animation shows the execution phases; the counters show why dimension and cluster count matter even when the number of images stays fixed.

10,000 10 384
1dense float32 rowsN × D input
2centroid indexK × D candidates
3batched distancesN × K comparisons
4top-1 IDsN assignments
5mean updatenew centroids
vector-centroid comparisons100,000
coordinate terms / iteration38.4 M
coordinate terms / 20 iterations768.0 M
input matrix float3215.4 MB

FAISS can tile this work into cache- and SIMD-friendly blocks. It changes how efficiently the comparisons run; it does not make N × K relationships disappear.

Where CPU, SIMD, threads, and GPU enter

A Python loop that computes one distance at a time pays interpreter overhead and rarely uses the processor well. FAISS moves the heavy loops into native C++ and uses optimized kernels. Depending on the build and workload, execution can benefit from vector instructions, matrix multiplication routines, multiple CPU threads, or supported GPU indexes.

The GPU does not erase transfer cost:

CPU-resident vectors
      ↓ host-to-device copy
GPU distance / selection work
      ↓ result copy
CPU-resident IDs and distances

If vectors already live on the same GPU as the index, those host/device copies can be avoided. If the dataset is tiny, setup and transfer can cost more than the computation saved. FAISS’s GPU guidance explicitly warns that a small dataset may show no noticeable acceleration.

CPU path4 units
GPU path7 units

Conceptual units only: for a tiny CPU-resident job, launch and transfer overhead can make the GPU path slower even when its distance kernel is faster.

These bars teach causal components; they are not measurements from this machine. The actual ClusterLens artifact used faiss-cpu 1.13.2 on a CPU-only run. No GPU result is implied by the benchmark below.

What ClusterLens actually calls

The experimental clustering helper is short:

d = embeddings.shape[1]
kmeans = faiss.Kmeans(d, num_clusters, niter=20, verbose=False)
kmeans.train(embeddings)
_, assignments = kmeans.index.search(embeddings, 1)
labels = assignments.reshape(-1)

Line by line:

  1. d fixes the width of every vector accepted by this run.
  2. Kmeans(...) configures num_clusters centroids and at most 20 iterations.
  3. train(...) alternates assignment and centroid updates.
  4. kmeans.index exposes the trained centroid index.
  5. search(embeddings, 1) asks for one nearest centroid per image.
  6. the N × 1 ID matrix is flattened into N integer labels.

The production helper does not pass an explicit FAISS seed or restart count; those use the installed library’s defaults. The controlled evidence runner does pass seed 42. This difference belongs in any reproducibility claim.

The input has already passed ClusterLens’s preparation contract, so it is a dense float32 matrix. In cosine mode those rows are unit-normalized. FAISS KMeans then uses its configured KMeans assignment behavior; “the inputs were normalized” and “the algorithm is spherical KMeans” are not interchangeable claims.

Actual runtime: read the surprising result carefully

The runtime lab immediately above this section contains three single recorded runs on normalized 64-dimensional synthetic blobs. Its FAISS times were:

500 rows      0.2032 s
2,000 rows    0.2401 s
10,000 rows   0.0042 s

That is not a believable monotonic scaling curve. The 10,000-row run being far faster than the smaller runs tells us that startup, thread-pool warm-up, different convergence work, or timing noise dominates at least part of this small experiment.

This is a valuable failed benchmark, because it teaches what a benchmark must add:

  • warm-up runs that are not measured;
  • many measured repetitions;
  • median and tail latency rather than one duration;
  • fixed thread counts and power state;
  • matching initialization, iterations, and convergence conditions;
  • quality checks beside speed;
  • separate index construction, training, and query timing.

The existing artifact proves that each implementation ran on this machine. It does not prove a universal FAISS speedup.

Same objective family, different actual partition

Select a model to compare the measured scikit-learn and FAISS KMeans runs on the same 500 image embeddings:

scikit-learn KMeans

ARI
purity
size range
time

FAISS KMeans

ARI
purity
size range
time
FAISS minus sklearn ARI
FAISS / sklearn elapsed time

The same input, k, and broad objective family do not force identical memberships. Initialization and implementation defaults are part of the experiment.

ARI in this lab compares each result with the ten known CIFAR labels. It does not directly compare the two partitions with one another. Cluster numbers are arbitrary, so comparing raw label 3 from one implementation with raw label 3 from another would also be invalid.

Fallbacks must not borrow the requested backend’s name

FAISS remains hidden from the production clustering controls until packaging and runtime validation are complete. A legacy or experimental request can still name it.

Toggle the dependency state to see the result contract:

requested backendfaiss
actual backendfaiss
implementationfaiss-kmeans
fallback reasonnone

The requested and actual backend agree because the optional dependency is available.

When the import is missing, ClusterLens records:

requested_backend = faiss
backend            = cosine-kmeans
implementation     = sklearn-kmeans
fallback_reason    = FAISS is unavailable; used normalized scikit-learn KMeans instead.

Previously, the fallback could run scikit-learn while still reporting faiss. That made performance and quality evidence impossible to trust. A fallback can be correct product behavior; it cannot silently inherit the identity of code that never ran.

What should I understand now? FAISS does not decide what an image means. The embedding and metric create the geometry. The chosen FAISS index decides how that geometry is searched, and `faiss.Kmeans` uses nearest-centroid search as the expensive assignment step of a familiar clustering objective.

From a FAISS index to a vector database

Suppose ClusterLens grows from a single-user desktop application into a service used by many people. The vector calculation does not suddenly change:

The mathematical core remains

results=top K eligible records ranked by distance(query, vector)

The database adds a reliable definition of “eligible records,” durable identity, updates, filtering, and operational behavior around that ranking.

What changes is everything surrounding the calculation.

The service may need to answer:

  • Which user owns this vector?
  • Does the record still exist after a restart?
  • Can a query restrict results to folder = cars and year >= 2024?
  • What happens while another process updates or deletes the record?
  • How are writes recovered after a crash?
  • How do multiple machines share the collection?
  • Who is authorized to read this tenant’s vectors?
  • How are keyword and vector scores combined?

FAISS supplies useful primitives for several of these workflows. It can store IDs in suitable index wrappers, serialize indexes, perform some forms of ID selection, and power indexes inside larger systems. It does not by itself become the entire networked, transactional, metadata-aware product.

Think of the difference this way:

FAISS
    a high-performance search engine on a workbench

vector database
    the engine plus shelves, durable labels, receiving desk,
    inventory rules, filters, concurrent access, and operations

One record, six vocabularies

All of the systems below can be understood through one logical record:

ID        = photo-184
vector    = [0.14, -0.07, 0.81, ...]
metadata  = {owner: "alice", folder: "cars", year: 2025}

The words change:

SystemContainerOne stored itemNon-vector data
FAISSindexvector at an ID or row positionusually managed by application code
PostgreSQL + pgvectortableSQL rowordinary typed columns and JSON
QdrantcollectionpointJSON payload
Milvuscollectionentityscalar fields
Weaviatecollectionobjecttyped properties
Pineconeindex and namespacerecordflat metadata fields

Those names are not the important difference. Deployment, filtering strategy, index choices, consistency, scaling, and integration with the rest of the application are.

Switch systems below. The five layers show who is responsible for each part of the query path.

accessin-process C++ / Python API
record and metadataapplication-owned mapping
filter pathapplication logic or supported ID selection
vector pathFlat, HNSW, IVF, PQ, and more
durability and scaleapplication owns files, synchronization, and deployment
deployment shapeembedded library
best starting use caselocal vector computation
main cost acceptedyou build database behavior

FAISS gives an application direct control over vector algorithms with little surrounding machinery. That is excellent for local tools, experiments, offline jobs, and custom infrastructure.

The “best starting use case” is a teaching shortcut, not a universal product ranking. Real selection requires the workload’s dimensions, vector count, filter selectivity, write rate, latency target, consistency needs, operating skills, and cost model.

Why metadata filtering is not a decorative feature

Suppose a user asks for three images similar to a sports-car query, but only inside Alice’s cars folder.

The global similarity order is:

Global rankRecordCosineEligible?
1another user’s Ferrari0.99no
2Alice’s Porsche0.95yes
3another user’s race car0.94no
4Alice’s Mustang0.91yes
5Alice’s red coupe0.89yes
6Alice’s beach photo0.80no: wrong folder

If the application retrieves global top three and filters afterward, only one result survives. It cannot recover ranks four and five because it never asked for them.

Compare three strategies:

records examined in this dry run3
eligible results returned1 / 3
true eligible top 3 recovered33%
wasted examined candidates2

Post-filtering a fixed global top 3 leaves only Alice’s Porsche. The missing slots are not automatically refilled.

This is a conceptual comparison. Real databases combine scalar indexes, graph traversal, partitions, bitsets, iterative scans, or internal query planning in different ways. “Supports filters” does not reveal whether a strict filter keeps latency and recall healthy. That must be tested with realistic filter selectivity.

PostgreSQL with pgvector: add vectors to familiar SQL rows

pgvector is an open-source PostgreSQL extension rather than a separate database server. It adds vector data types, distance operators, and vector index access methods to PostgreSQL.

A row can keep business data and its embedding together:

CREATE TABLE photos (
    id        bigint PRIMARY KEY,
    owner     text,
    folder    text,
    path      text,
    embedding vector(512)
);

SELECT id, path
FROM photos
WHERE owner = 'alice' AND folder = 'cars'
ORDER BY embedding <=> :query_vector
LIMIT 3;

The <=> operator represents cosine distance. Without an approximate vector index, PostgreSQL can calculate exact distances over eligible rows. With pgvector, the table can also use HNSW or IVFFlat indexes for approximate nearest-neighbour search.

Why choose it:

  • the application already depends on PostgreSQL;
  • vector search must join ordinary relational data;
  • SQL transactions, constraints, backups, and access patterns are valuable;
  • operational simplicity matters more than introducing a dedicated service.

How it differs from FAISS:

FAISS:     application calls an in-process vector index
pgvector:  application sends SQL to PostgreSQL;
           PostgreSQL plans filters, table access, and vector ordering

The tradeoff is that filtered approximate search interacts with PostgreSQL’s query planner and index behavior. pgvector documents that approximate index scans may apply filters after scanning candidates; iterative scans, partial indexes, ordinary column indexes, or partitioning can be needed to recover enough results.

Qdrant is a vector database service organized around collections of points. A point has an integer or UUID identifier, one or more vectors, and optional JSON payload.

collection: photos

point:
  id       = photo-184
  vector   = [0.14, -0.07, ...]
  payload  = {owner: alice, folder: cars, year: 2025}

Clients communicate through HTTP, gRPC, or language SDKs. Qdrant stores points in segments; distributed collections can be split into shards.

Its usual dense-vector path uses HNSW. Payload indexes accelerate fields used for filtering, and Qdrant’s filterable HNSW adds graph connections intended to keep traversal useful when some nodes fail the payload condition.

Why choose it:

  • retrieval frequently combines semantic similarity with structured filters;
  • JSON payload fits the application’s record shape;
  • a dedicated self-hosted or managed vector service is acceptable;
  • dense, sparse, named, or multiple vectors per point are useful.

How it differs from FAISS: Qdrant owns network APIs, point persistence, payload indexes, segments, background optimization, sharding, and distributed service behavior. FAISS exposes lower-level algorithms that an application can compose without adopting Qdrant’s record or server model.

Milvus: a distributed vector-search data plane

Milvus stores entities in collections. Entities can contain vector fields, a primary key, and scalar fields used in expressions.

At a beginner level, imagine a large warehouse divided into segments:

incoming entities

growing segments
      ↓ sealed and indexed
object storage keeps durable segment/index files

query nodes load relevant sealed segments and search them

For a filtered search, Milvus can turn the scalar expression into a bitset of eligible entities inside a segment, then restrict vector search using that bitset. Its distributed architecture separates responsibilities so query and storage resources can scale independently.

Why choose it:

  • the collection is large enough to justify distributed vector infrastructure;
  • independent scaling and object-storage-backed architecture are valuable;
  • multiple vector and scalar index choices must be available;
  • a team is prepared for a larger operational system or a managed Milvus service.

How it differs from FAISS: FAISS is one library in one application process unless you build more around it. Milvus is the surrounding distributed system and can use vector-search libraries and algorithms—including FAISS-related building blocks—inside its execution layer.

Weaviate: objects plus vector and inverted indexes

Weaviate stores JSON-like objects in typed collections. An object has properties and normally one or more vectors.

Within a shard, it combines different structures:

object store      → retrieve the stored object
inverted indexes  → property filters and BM25 keyword search
vector index      → semantic nearest neighbours

Its vector index can be HNSW, flat, dynamic, or another supported option. The dynamic choice begins flat and can switch to HNSW after a configured object threshold. Hybrid search runs vector and BM25 retrieval and fuses their scores.

Why choose it:

  • the domain naturally looks like objects with searchable properties;
  • keyword, semantic, hybrid, and filtered search belong in one service;
  • integrated vectorization modules are useful, while supplying your own vectors remains possible;
  • per-collection or multi-tenant vector indexes fit the application.

How it differs from FAISS: Weaviate couples the vector index with object storage and inverted indexes, exposes database APIs, and manages CRUD at the object level. FAISS leaves object storage, keyword indexes, schema, and service behavior to the embedding application.

Pinecone: managed records, namespaces, and search APIs

Pinecone is a managed vector database service. A common vector record contains a string ID, dense or sparse vector values, and flat metadata. Records live in an index and are partitioned into namespaces.

index:      photo-search
namespace: alice

record:
  id        = photo-184
  values    = [0.14, -0.07, ...]
  metadata  = {folder: cars, year: 2025}

Namespaces can isolate tenants or logical datasets, while metadata filters restrict search inside the selected namespace. Pinecone can accept application-generated vectors, and supported integrated-embedding indexes can convert source text using an associated hosted model.

Why choose it:

  • the team wants a hosted service rather than operating vector servers;
  • namespace-based tenant isolation fits the data model;
  • managed ingestion, scaling, and availability are worth the external-service dependency;
  • vectors and simple filter metadata are the primary access pattern.

How it differs from FAISS: Pinecone exposes a remote managed service and hides most low-level infrastructure. FAISS exposes algorithms and index objects directly. Pinecone’s documented query model is eventually consistent, so a recent upsert or delete can take a short time to become visible; an in-process FAISS mutation has a different freshness contract but leaves crash safety and coordination to the application.

Same query, different ownership boundaries

QuestionFAISSpgvectorQdrantMilvusWeaviatePinecone
Who owns the service?your applicationPostgreSQL operatorself-host or vendor cloudself-host or managed providerself-host or vendor cloudvendor-managed
Primary data modelvector indexrelational rowspoints + payloadentities + scalar fieldsobjects + propertiesrecords + flat metadata
Familiar strengthalgorithm controlSQL, joins, transactionsfilter-aware vector retrievaldistributed vector scalehybrid object searchlow-operations managed retrieval
Typical vector choicesbroad FAISS index familyexact, HNSW, IVFFlatHNSW plus quantization/optionsseveral configurable ANN familiesHNSW, flat, dynamic, HFreshservice-managed search configuration
Metadata responsibilitymostly applicationSQL engine and indexespayload and payload indexesscalar fields and indexesproperties and inverted indexesrecord metadata filters
Important costbuild the surrounding systemshare resources with OLTP/query planneroperate another service if self-hostedgreater distributed-system complexityschema/index resource choicesnetwork, service, and vendor dependency

This table describes ownership boundaries, not a performance leaderboard. Any row can win or lose on a particular dataset.

A complete choice dry run

Suppose the product is a single-user, offline desktop gallery with 30,000 image embeddings and no concurrent writers.

local SQLite metadata
+ NumPy or FAISS vector index
+ explicit cache invalidation
= a reasonable small system

Adding a distributed vector database would introduce a server, deployment, versioning, networking, and backup surface without automatically making the embeddings better.

Now change the product:

500 business customers
millions of document chunks
tenant isolation
continuous writes
metadata filters
hybrid keyword + semantic retrieval
availability requirements

The operational layer is no longer incidental. A vector database or a mature database extension can remove large amounts of custom infrastructure.

A practical starting guide is:

SituationFirst system worth evaluatingWhy
local experiment, offline batch, custom algorithmFAISSdirect control and minimal surrounding service
vectors belong beside relational business rowspgvectorone SQL and transactional model
dedicated retrieval with rich JSON filteringQdrantpoints, payload indexes, and filter-aware HNSW
large distributed vector data planeMilvussegmented, disaggregated architecture
object search mixing BM25 and vectorsWeaviateobject, inverted, and vector indexes together
fully managed vector service is the priorityPineconevendor operates the retrieval infrastructure

Do not choose from this table alone. Build an exact baseline, replay actual filters and updates, measure recall and latency, test deletion and freshness, estimate memory and operating cost, and rehearse recovery.

What a vector database still does not decide

It does not decide:

  • which embedding model represents the objects;
  • whether a 512-dimensional vector is better than a 768-dimensional one;
  • whether cosine, dot product, or L2 matches the product meaning;
  • how source objects are split into chunks;
  • whether a retrieved result is useful to a person;
  • whether an approximate index has acceptable recall.

Some products can run an embedding model for you. That combines two stages in one API; it does not erase the representation decision. The model name, version, preprocessing, dimension, and update policy are still part of the data contract.

The boundary to remember FAISS is a vector-algorithm library. A vector database is a data system that surrounds vector search with records, metadata, filtering, persistence, updates, APIs, and operational guarantees. The database can make retrieval manageable at product scale, but it cannot rescue a representation that encodes the wrong notion of similarity.

11. Density: how crowded is this point’s neighbourhood?

KMeans asks which centroid owns a point. Density methods ask whether the point lives in a crowded, persistent region.

Imagine two locations:

  • a person in a busy railway station has many people within a few metres;
  • a person alone in a field must travel much farther to reach the same number of neighbours.

The second point has a larger core distance.

For a teaching convention where min_samples=3 means the third other neighbour:

distances from A: 0.2, 0.4, 0.7, 3.8, 5.1

core₃(A) = 0.7

Larger min_samples asks for evidence from a wider neighbourhood and tends to make density membership more conservative.

Move min_samples through one exact neighbourhood. The orange radius must expand until it reaches the requested neighbour:

A
selected neighbour3rd
core distance0.7
neighbours inside radius3

With min_samples = 3 in this teaching convention, A needs radius 0.7 to reach its third other neighbour.

Libraries differ on whether the point itself counts toward min_samples. The production HDBSCAN implementation follows its library’s convention; this diagram explicitly counts other neighbours so the hand calculation stays visible.

Density needs both a radius and a count

“Crowded” is incomplete until we say within what distance. Ten people in a train carriage are crowded; ten people spread across a football field are not. Two knobs therefore appear repeatedly in density algorithms:

  • a neighbourhood size or radius says how far we are willing to look;
  • a neighbour count says how much nearby evidence is enough.

The same radius can describe two very different local situations. In the left region, points are packed tightly. In the right region, the same number of points are spread out. Move the radius and watch when each centre gathers the four other neighbours required by this dry run.

required other neighbours4
tight region inside radius
spread region inside radius
core-point result

The two circles have the same radius. Only the tightly packed centre currently has enough local support to be a core point.

This is why density is local rather than a global statement such as “this dataset contains many car photographs.” A car image can still be isolated in embedding space if its pose, crop, lighting, or learned features place it far from the other cars.

There is also no universal numeric radius. A radius of 0.2 is meaningful only with a particular representation, normalization rule, and distance metric. Changing the embedding model changes the geometry and therefore changes what “nearby” means.

Centroid versus density KMeans can assign a lonely point because some centroid is still the nearest. A density algorithm can say that the point has no sufficiently stable dense home and label it noise.

12. HDBSCAN from core distance to stable islands

HDBSCAN means Hierarchical Density-Based Spatial Clustering of Applications with Noise.

The long name describes the stages:

  • density-based: local crowding affects distance;
  • spatial clustering: nearby dense points form groups;
  • hierarchical: groups exist across many density levels;
  • noise: some points receive no selected group.

Intuition: islands as the water level changes

Imagine lowering water around a landscape. Mountain peaks appear first as small islands. As more land appears, islands grow and sometimes join. A useful cluster is like an island that persists across a substantial range of water levels rather than appearing for one fragile instant.

The analogy becomes more useful when animated. The left hill is dense and tall; the right hill is smaller; a few weak points form a bridge. Increase the density requirement to remove less persistent points.

visible density groups1
points treated as noise0
bridge statusmerges hills
interpretationone loose region

At the loosest level, bridge points connect both hills. A flat density cut would call this one region.

This is a conceptual hierarchy, not a claim that HDBSCAN deletes points with one literal slider. The actual algorithm builds a mutual-reachability tree, condenses it, and evaluates branch persistence across levels. The animation shows why persistence contains more information than choosing one global radius.

Stage 1: core distances

Calculate the distance needed for each point to reach its min_samples neighbourhood.

core(A) = 0.7
core(B) = 0.5
core(C) = 2.4

A and B live in denser regions than C.

Stage 2: mutual-reachability distance

Mutual-reachability distance

dmreach(A,B)=max(core(A), core(B), d(A,B))

A connection cannot look denser than either endpoint’s local neighbourhood. This enlarges edges involving sparse points.

If core(A)=0.7, core(B)=0.5, and d(A,B)=0.4:

Mutual-reachability dry run

dmreach(A,B)=max(0.7,0.5,0.4)=0.7

The raw pair is only 0.4 apart, but A needs a radius of 0.7 to establish its local density. The adjusted edge therefore costs 0.7.

Stage 3: a minimum spanning tree

Connect every point through the cheapest possible set of mutual-reachability edges without cycles. This minimum spanning tree contains the density hierarchy compactly.

As high-cost edges are removed:

one connected tree
→ two dense branches plus sparse points
→ smaller dense branches
→ individual points

Stage 4: condense and select stable branches

min_cluster_size removes branches that never become large enough. HDBSCAN measures how long candidate groups persist across density levels and selects a non-overlapping set, using ClusterLens’s eom or excess-of-mass selection.

One common stability expression integrates cluster size over inverse-distance density:

Persistence intuition

stability(C)=Σp∈Cleave,p−λbirth,C)

λ is inverse distance. A large cluster that survives across a long density interval accumulates more stability than a brief, fragile branch.

Put the four HDBSCAN stages together

The individual definitions can feel unrelated when first encountered. The following dry run holds one seven-point dataset fixed and changes only what is drawn. Step through it from ordinary distances to the selected branches.

candidate branch Acandidate branch Bsparse bridge
distance being viewedordinary + core radius
edges retainedall local candidates
bridge interpretationweak local support
current outputno clusters selected yet

First estimate how far every point must reach to establish a neighbourhood. The middle point needs a much larger core radius than points inside either compact group.

At stage 2, the bridge edges become expensive because mutual reachability uses the maximum of endpoint core distances and ordinary distance. At stage 3, the minimum spanning tree removes redundant cycles but preserves the cheapest way to connect everything. At stage 4, cutting expensive edges reveals the two branches that survive as groups while the weak bridge becomes noise.

The tree is not a decorative implementation detail. It is the compact object from which many possible density cuts can be read. HDBSCAN selects persistent branches from that family instead of asking us to guess one perfect global radius in advance.

Tune the real model output

clusters
noise
coverage
ARI
non-noise silhouette

All 25 measured settings. Darker green means higher ARI for this labelled experiment; every cell also shows its noise rate. Select a cell to load its exact result above.

rows: minimum cluster sizecolumns: minimum samples

This grid changes only the two displayed parameters. Notice that a high silhouette can coexist with low coverage: rejecting difficult points makes the retained subset easier to separate.

Parameter meanings

ParameterBeginner interpretationTypical consequence when increased
min_cluster_sizesmallest persistent group worth returningfewer tiny groups; more points may become noise
min_samplesevidence required for local densitymore conservative membership; often more noise
cluster_selection_epsilonpermit nearby branches to merge below a distancefewer splits in very close dense regions
allow_single_clusterallow one global grouppermits a one-group explanation
Method or styleWhat must be chosen?What it returnsMain tradeoff
DBSCANone neighbourhood radius eps plus min_samplesflat dense components and noiseone global radius struggles when densities differ
OPTICSneighbourhood and reachability settingsan ordering/reachability structureexposes several density scales but needs extraction/interpretation
HDBSCAN EOMminimum cluster size, density conservatismpersistent non-overlapping branches and noisefavours broader stable clusters
HDBSCAN leafthe same hierarchy with leaf selectionfiner terminal branchescan create more small, homogeneous groups
HDBSCAN with epsilon mergehierarchy plus merge distancenearby selected branches combineduseful when the hierarchy over-splits close regions

ClusterLens currently requests HDBSCAN’s EOM selection. Changing to leaf selection would not be a cosmetic setting: it changes which level of the hierarchy becomes the displayed grouping.

Three outcomes that can all be valid

Consider a folder containing 200 ordinary family photographs, 20 tightly repeated screenshots, and 15 unrelated one-off images:

  • KMeans with k=5 must place all 235 images somewhere.
  • DBSCAN at one strict radius may find the screenshot group and reject most photographs.
  • HDBSCAN may preserve several dense family-event branches, keep the screenshots, and label one-offs as noise.

Which is better depends on the task. “Make five review piles,” “find repeated-looking sets,” and “show only stable visual families” are different product questions.

HDBSCAN is not “better KMeans.” It answers a different question and may return a very different number of groups.

Common mistake: reading the noise fraction alone as a failure rate. Noise means “not assigned to a selected persistent density region.” Whether that is useful depends on whether the workflow prefers abstention or complete coverage.

13. Similarity graphs: groups made from relationships

Imagine a friendship network:

  • each person is a node;
  • a friendship is an edge;
  • everyone reachable through friendships belongs to one connected region.

A similarity graph replaces people with images and friendships with sufficiently strong vector relationships.

Construct the graph

ClusterLens currently:

  1. finds up to 20 cosine-nearest candidates per image;
  2. keeps candidate edges with similarity at least 0.86;
  3. makes accepted edges undirected;
  4. finds connected components;
  5. marks components smaller than two as noise.

The edge rule can be written as:

ClusterLens graph edge

(A,B)∈Eif[A→B or B→A in kNN]andcos(A,B)≥τ

Candidate selection limits work; threshold τ decides whether a candidate relationship is strong enough; symmetrization makes connectivity independent of row order.

The undirected step matters. k-nearest-neighbour candidate selection is directed: A can choose B even if B’s own top-k list does not include A. The previous traversal followed those directed lists in row order, so permuting identical input rows could change membership. The implementation now explicitly symmetrizes accepted edges, and the evidence run records permutation ARI 1.0.

The bridge problem

Connected components use transitive membership:

A is similar to B
B is similar to C
therefore A, B, and C are connected

A and C do not need to be directly similar. One ambiguous bridge can merge two otherwise distinct regions.

That rule surprises people because connected components do not calculate a single “whole-group similarity.” They repeatedly ask a yes/no question about edges. Consider five images arranged in a chain:

beach sunset — coast road — blue car — sports car — race track

The two ends may be poor semantic neighbours. Nevertheless, if every adjacent pair survives the threshold, there is a path from one end to the other and the entire chain is one connected component.

Move the threshold through the exact four edge scores below. The animation recomputes components after every edge decision.

accepted edges
connected components
A and E directly compared?no
A and E same component?

At this threshold, three edges survive. The failed C–D edge breaks the chain into two components even though both sides contain strong local relationships.

This behavior can be ideal for duplicate families: original → resized copy → cropped copy → screenshot can remain one lineage even when the first and last files differ substantially. It can be dangerous for broad semantic grouping, where one bridge image can join two concepts the user wanted separated.

components
noise
largest component
ARI
permutation ARI

Lower thresholds add edges and can create one giant component. Higher thresholds remove edges and can turn almost everything into noise. The useful region depends strongly on the embedding model.

At the production default, the actual run produced dramatically different graph behavior:

  • CLIP formed a component containing most of the collection.
  • DINO rejected almost the entire collection as isolated or undersized components.
  • SigLIP landed between those extremes.

Therefore a similarity threshold is not portable across embedding models just because all scores are called cosine similarity.

Threshold graph, union kNN, and mutual kNN

These constructions are related but not interchangeable:

ConstructionEdge requirementRisk
all-pairs thresholdany pair above τquadratic comparisons and giant components
union kNNeither endpoint selects the other, then passes τpreserves more links and more bridges
mutual kNNboth endpoints select each other, then pass τconservative; can fragment sparse regions

ClusterLens currently uses the union interpretation. The article names it because saying only “graph clustering” would hide the central design choice.


14. Outliers are a policy, not bad images

An unmatched sock is not a bad sock. It has no confident partner under the current sorting rule.

Backends expose outliers differently:

BackendNatural behavior
KMeansevery point is assigned
MiniBatchKMeansevery point is assigned
HDBSCANcan return noise label −1
graphisolated nodes and undersized components become −1

ClusterLens supports three policies:

  • keep: retain backend noise as an outlier lane;
  • assign: move backend noise to its nearest surviving centroid;
  • isolate: for backends without natural noise, isolate a low-cohesion tail; preserve natural backend noise otherwise.

Nearest-centroid assignment is:

Assign an outlier

c*=arg maxcx·μc

Because prepared points and product centroids are normalized, the dot product ranks centroid directions by cosine similarity.

Suppose an outlier has similarities:

car centroid      0.61
beach centroid    0.24
invoice centroid  0.18

The assign policy chooses the car cluster. This says only that car is the nearest surviving centroid; it does not say 0.61 was independently strong enough to be confident.

Switch policies on the exact same raw HDBSCAN result:

remaining outliers
coverage
ARI
raw-backend silhouette
displayed-cluster silhouette

The `keep` policy preserves the backend's uncertainty. The uncertain images remain visible in a separate review lane instead of being forced into the least-bad cluster.

The application now records raw-backend and final displayed quality separately. Previously one score described raw labels even after post-processing had changed what the user saw.

Nearest does not automatically mean near enough

Suppose the best and second-best centroid scores are 0.61 and 0.60. argmax still returns the first cluster, but its margin is only 0.01.

Assignment margin

margin(x)=best scoresecond-best score

A small margin means the winner barely beat an alternative. A product can require both an absolute score and a margin before hiding uncertainty.

A safer policy might require:

best score ≥ 0.70
and
best score − second-best score ≥ 0.08

The correct numbers must be calibrated on representative data. The important beginner lesson is structural: choosing the maximum and deciding that the maximum is trustworthy are two separate operations.


15. Tiny clusters: signal or clutter?

A table with two diners may be a legitimate small party. It may also be inefficient fragmentation if the goal is a few large review groups.

ClusterLens’s current default tiny-cluster minimum is 2. In practice, non-outlier singleton clusters are merged into the nearest surviving larger cluster.

The operation is:

singleton point
→ compare with normalized centroids of large clusters
→ choose greatest dot product
→ append point to that cluster
→ recompute centroid for later explanation/reranking

Consider a singleton S and two large-cluster centroids:

S · μ₀ = 0.72
S · μ₁ = 0.69

The current policy merges S into cluster 0. The margin is only 0.03, so the result should not be described as a confident semantic truth just because an argmax exists.

Other products might instead:

  • keep tiny groups for duplicate review;
  • move them to outliers;
  • retain the data but collapse them visually;
  • require a minimum merge similarity or margin.

The policy must match the workflow. A two-image group can be exactly what duplicate review is looking for.

Try four product policies on the same raw result. The small receipt pair is not changing; only the product’s treatment of it changes.

documents · 8
invoiceletterform
receipt pair · 2
receipt Areceipt B
outliers
odd crop
stored memberships changed?yes
visible review groups2
tiny members still recoverable?only with audit data
product interpretationreduce clutter

Merge treats the receipt pair as fragmentation and moves both members into the nearest larger document cluster. This changes membership and should be recorded.

collapse visually is importantly different from merge. It can hide a tiny group behind an expandable “small groups” control while preserving the raw labels. The screen looks simpler, but the mathematical result remains recoverable. Presentation policy is often a better first response than irreversible membership mutation.


16. Ranking members inside a cluster

A mathematical cluster is an unordered set. A gallery is not: the user sees the first thumbnails first.

If members remain in filesystem scan order, an unusual edge case may become the cover image and make a coherent group look confusing.

ClusterLens calculates a normalized cluster centroid and sorts members by dot product:

Representative-member score

score(x)=mean(C)∥mean(C)∥

Central members appear first. Peripheral members remain in the group but move later in the review order.

Worked ranking:

MemberSimilarity to centroidGallery position
beach-020.941
beach-010.892
coast-road0.723
blue-pool0.514

This does not improve the raw cluster labels. It improves the presentation of those labels, which strongly affects whether a person can understand and review them.

“Representative” can mean several different things

Centroid ranking answers “which member is closest to the average direction?” That is useful for a cover thumbnail, but it can show four nearly identical images and hide the group’s range. Compare three orderings of one fixed six-image beach cluster:

    first itembeach-02
    what the order optimizescentrality
    best usecover thumbnail
    main blind spothides cluster range

    Centroid-first ordering begins with the most average member. It quickly communicates the dominant pattern, but adjacent results may be visually redundant.

    A medoid is another useful term. It is the actual member whose average distance to all other members is smallest. A centroid may not correspond to any real image; a medoid always does. For normalized embeddings and compact groups, centroid-nearest and medoid choices are often similar, but they are not mathematically identical.

    For review tools, a strong layout often combines several intentions:

    1. show one centroid-near cover;
    2. show a few diverse representatives so the range is visible;
    3. occasionally sample random members to expose mistakes that centrality hides;
    4. let the user sort by filename or time when chronology matters more than geometry.

    17. How do we know whether a grouping is good?

    Running an algorithm always produces an output. That does not mean the output is useful.

    Suppose a teacher arranges students at lunch tables. A student has a good seat when:

    1. the other students at the same table are close friends; and
    2. the students at the next-best table are noticeably less familiar.

    Silhouette score measures the geometric version of that idea for one point.

    Calculate one silhouette score by hand

    Use four points:

    A = [0, 0]       C = [4, 0]
    B = [0, 2]       D = [4, 2]
    
    cluster blue = {A, B}
    cluster red  = {C, D}

    For point A, define:

    • a(A): the mean distance from A to the other points in A’s own cluster;
    • b(A): the smallest mean distance from A to any different cluster.

    There is only one other blue point, so:

    Step 1 · within-cluster distance

    a(A)=distance(A,B)=√((0−0)2+(0−2)2)=2

    Do not include A's zero distance to itself. That would make every point look artificially well packed.

    The red cluster is the only competing cluster:

    Step 2 · nearest competing cluster

    b(A)=distance(A,C)+distance(A,D)2=4+√2024.236

    With several competing clusters, calculate one mean per cluster and keep the smallest. That is the easiest rival group for A to join.

    Combine both quantities:

    Point silhouette

    s(i)=b(i)−a(i)max(a(i),b(i))

    The denominator scales the result into the interval from −1 to 1.

    For A:

    Complete substitution

    s(A)=4.236−24.2360.528

    A is closer on average to its own cluster than to the nearest rival, so its score is positive.

    Step through that same arithmetic visually:

    Choose point A. Its own cluster contains one other point; the other cluster contains two points.

    Interpret the range carefully:

    Score nearIntuitionWhat it may indicate
    +1much closer to its own groupa compact, separated assignment
    0near a borderoverlapping groups or a useful bridge
    −1closer to another groupa likely misassignment

    The dataset silhouette is the mean of the eligible point scores. ClusterLens excludes label −1 when reporting non-noise silhouette because “all outliers” are not one coherent cluster.

    Why silhouette cannot be the only judge

    Silhouette rewards geometric separation under its chosen distance. It does not know the user’s intended concepts.

    Imagine that all animal images form one compact island and all vehicles form another. A two-cluster answer may have excellent silhouette even when the desired review groups are cats, dogs, horses, cars, trucks, and airplanes. Conversely, a useful fine-grained answer may have lower silhouette because cats and dogs really do overlap in the embedding space.

    That is why the evidence run records several complementary measurements:

    MetricNeeds ground-truth names?What it asksImportant limitation
    Silhouettenoare points nearer their own cluster than another?prefers geometric separation, not human intent
    Cohesionnohow similar are members to their centroid?one tight giant cluster can hide missing distinctions
    Separationnohow far apart are cluster centroids?ignores the spread inside each cluster
    ARIyesdo pairs of points agree with known class pairs?the known classes may not match the product task
    NMIyeshow much class information and cluster information agree?can respond differently to cluster count
    Purityyeswhat fraction follows each cluster’s majority class?singleton clusters can achieve perfect purity
    Coveragenowhat fraction was not labelled noise?says nothing about correctness of covered points
    Stabilitynodoes the grouping survive seeds, order, or perturbations?a consistently wrong result can still be stable

    One dataset, four ways to make one metric look good

    Metrics become easier to understand when we deliberately construct answers that exploit them. The following experiment keeps eight labelled objects fixed:

    animals: cat, dog, horse, deer
    vehicles: car, truck, plane, ship

    The desired answer for this lesson is the two broad families. Switch outputs and observe which metric is flattered and what a person would actually receive.

    clusters shown2
    coverage100%
    purity100%
    pair agreement100%
    geometric silhouette0.54

    The desired output groups all four animals and all four vehicles. Coverage and purity are both complete, and same-versus-different pair decisions match the labels.

    These are controlled teaching numbers, not results from the CIFAR experiment. Their job is to expose incentives:

    • one giant cluster has perfect coverage but destroys distinctions;
    • singleton clusters have perfect purity because no cluster can contain a disagreement;
    • selective clustering can make purity and silhouette rise by refusing to decide difficult rows;
    • the desired output can have a lower silhouette than a selective output while being more useful for complete organization.

    No scalar should be displayed without the quantities needed to interpret its failure mode. At minimum, pair purity with cluster count and coverage; pair silhouette with the output contract and a gallery.

    ARI ignores cluster-number names

    This matters because cluster IDs are arbitrary. These two outputs describe identical membership:

    run 1: [0, 0, 1, 1]
    run 2: [7, 7, 3, 3]

    Adjusted Rand Index, or ARI, compares pairs rather than raw identifier numbers:

    • were A and B together in both answers?
    • were A and C separate in both answers?

    ARI is 1 for identical pair relationships, approximately 0 for chance-level agreement, and can be negative for worse-than-chance agreement.

    For four objects there are six unordered pairs. Suppose the truth and prediction are:

    truth       {cat, dog} {car, truck}
    prediction  {cat, dog, car} {truck}

    Inspect the pairs:

    PairTruth saysPrediction saysAgreement?
    cat–dogtogethertogetheryes
    cat–carseparatetogetherno
    cat–truckseparateseparateyes
    dog–carseparatetogetherno
    dog–truckseparateseparateyes
    car–trucktogetherseparateno

    The unadjusted Rand agreement is 3 / 6 = 0.5. ARI starts from this pairwise idea and corrects for agreement expected by chance given the cluster sizes. That correction is why ARI is more than “percentage of matching pairs,” but the pair ledger is the right beginner intuition.

    Renaming prediction groups from 0,1 to 91,−4 changes none of these pair relationships. ARI therefore stays the same. Sorting cluster IDs or comparing the raw label arrays directly would be a bug.

    Purity can be gamed

    If every image receives its own cluster, every cluster contains only one class and purity becomes 100%. That output is useless for organizing a collection.

    Always read purity with at least cluster count, cluster-size distribution, and coverage. Metrics are witnesses with limited viewpoints, not a jury that produces one indisputable verdict.


    18. Put the backends on the same actual embeddings

    We can now compare algorithms without pretending that their output contracts are identical.

    The actual-run dataset contains 500 fixed CIFAR-10 test images: 50 each from airplane, automobile, bird, cat, deer, dog, frog, horse, ship, and truck. The same image selection was embedded separately by cached CLIP, SigLIP, and DINO models on CPU. Each backend received one model’s prepared matrix.

    Choose a model and then a backend:

    implementation used
    clusters
    noise
    ARI
    purity
    silhouette
    clustering time

    These numbers compare recorded outcomes, not universal algorithm rankings. KMeans is told to make ten groups; HDBSCAN and graph discover a count from their density or connectivity settings.

    Read one result completely

    With CLIP and the recorded settings, KMeans produced:

    10 clusters
    0% noise
    ARI       0.715
    purity    85.8%
    silhouette 0.100

    Those statements answer different questions:

    • 10 clusters is partly a consequence of asking for k = 10.
    • 0% noise follows from KMeans assigning every point.
    • ARI 0.715 says the predicted same/different pair relations agreed fairly strongly with CIFAR-10 labels.
    • 85.8% purity says most members followed each cluster’s majority label.
    • 0.100 silhouette says the embedding clusters were not separated by enormous geometric gaps.

    There is no contradiction between good label agreement and a modest silhouette. Real image classes overlap, and the embedding model was not trained specifically to reproduce CIFAR-10’s ten names as ten spherical islands.

    Why HDBSCAN’s higher silhouette can coexist with lower ARI

    For the same CLIP vectors, the recorded HDBSCAN setting produced 3 clusters, left 35% as noise, and had silhouette 0.166 but ARI 0.101.

    It selected fewer, denser regions. Removing ambiguous border points can raise geometric separation while losing most of the ten-class organization. That may be desirable for “show me only very confident piles,” but not for “place every image into ten review categories.”

    Why graph purity can look absurdly good

    For DINO, the recorded graph setting reported 100% purity—but labelled 98.2% of images as noise. The few surviving tiny components were pure. Almost the whole dataset was left ungrouped.

    This is the clearest reason to put coverage beside purity.

    Coverage

    coverage=number of rows with label ≠ −1total number of rows

    A selective method may be precise about what it keeps. Coverage reveals how much it refused to decide.

    Inspect actual representatives, not only scalar scores

    The gallery below uses DINO to make the differences visible. Images are the members nearest each normalized cluster centroid. A red border marks a representative whose CIFAR-10 label differs from that cluster’s majority label.

    The gallery also exposes a subtle ranking issue. “Nearest to the cluster centroid” means “most central under this representation.” It does not mean “most beautiful,” “most informative,” or even “correctly labelled.” A centrally placed mistake can be the best explanation of what the cluster actually learned.

    This is not a speed tournament

    The displayed clustering times exclude model loading and embedding extraction. They measure only the recorded backend call on 500 prepared vectors. At this scale, process noise and implementation constants matter more than an asymptotic slogan.

    For realistic sizing, the separate synthetic runtime artifact uses fixed 64-dimensional blobs at 500, 2,000, and 10,000 rows. It is useful for observing growth on this machine, not for predicting another machine’s production latency.


    19. A complete 12-image run, from files to displayed groups

    Large benchmarks are useful, but they are difficult to inspect image by image. The controlled fixture therefore contains only twelve anonymous generated images:

    3 beaches
    3 bicycles
    3 cars
    3 fictional invoices

    No personal photographs, EXIF metadata, usernames, or private paths are part of the artifact.

    We will follow the CLIP KMeans run first.

    Before reading the ledger one stage at a time, play the whole transformation. The same twelve image identities remain present throughout; only the object we use to describe them changes.

    object at this stageordered file ledger
    shape or count12 paths
    contract that must surviverow i identifies one file
    failure exposed herepaths reordered independently

    Start with twelve paths in a frozen order. Every later vector row, backend label, membership change, and thumbnail must still refer to these same identities.

    The animation is not pretending that filenames literally morph into coloured cards inside the program. It is a tensor-and-metadata ledger: at every boundary it names the current data shape and the identity contract that lets us join the numeric result back to a real file.

    Stage 1: freeze the file ledger

    row  0 → beach-01.webp
    row  1 → beach-02.webp
    row  2 → beach-03.webp
    row  3 → bicycle-01.webp
    ...
    row 11 → invoice-03.webp

    This row order becomes the join key shared by paths, vectors, labels, and results.

    Stage 2: encode each image

    The CLIP image encoder produces one 512-coordinate vector per image:

    12 image paths
    → preprocessing batches
    → CLIP image encoder
    → matrix shape [12, 512]

    SigLIP produces [12, 768]; DINO produces [12, 384]. These widths are model contracts, not quality scores. A 768-dimensional vector is not automatically “more semantic” than a 384-dimensional vector.

    Stage 3: prepare the geometry

    The cosine path L2-normalizes every row. After this step:

    length(row 0) ≈ 1
    length(row 1) ≈ 1
    ...
    length(row 11) ≈ 1

    If semantic PCA requests 50 dimensions, the safe count is not 50 here:

    Small-dataset PCA bound

    Kactual=min(50,512,12−1)=11

    Twelve centred observations cannot identify more than eleven independent directions of variation.

    Stage 4: run KMeans with k = 4

    KMeans++ chooses initial representatives, then assignment and mean-update steps repeat until convergence. The numeric cluster IDs are arbitrary, but this run’s membership was:

    cluster 0 → bicycle-01, bicycle-02, bicycle-03
    cluster 1 → invoice-01, invoice-02, invoice-03
    cluster 2 → beach-01, beach-02, beach-03
    cluster 3 → car-01, car-02, car-03

    The four intended groups were recovered exactly:

    ARI        1.000
    NMI        1.000
    purity     100%
    coverage   100%
    silhouette 0.274

    Notice the silhouette. It is positive, but nowhere near 1. Exact agreement with these four human labels does not require enormous empty space between every category.

    Stage 5: compare different algorithm contracts

    On the same twelve CLIP embeddings:

    BackendGroups foundNoiseARIWhat happened?
    KMeans, k=4401.000recovered the four requested partitions
    HDBSCAN300.645combined bicycles and cars into one dense region
    Graph190.267kept the invoice component and rejected the other nine

    The graph result had 100% purity among clustered rows and only 25% coverage. The KMeans result is stronger for this known four-group task. The graph result is not mathematically broken; its threshold answered a stricter connectivity question.

    Now change only the representation:

    ModelKMeans ARIHDBSCAN ARIGraph coverage
    CLIP1.0000.64525.0%
    DINO1.0001.00016.7%
    SigLIP1.0000.27525.0%

    The algorithm names stayed the same. Their observed grouping changed because the local shapes and densities of the three embedding spaces differ.

    Stage 6: post-process without hiding the change

    If the graph result uses assign, the nine outliers are moved to their nearest surviving centroid. Because only the invoice component survived, every outlier would be forced toward the invoice cluster unless another guard intervened. That is a technically valid nearest-centroid operation and a terrible explanation of the raw graph result.

    For that reason the service exposes:

    requested backend
    actual implementation
    fallback reason
    raw outlier count
    raw-backend silhouette
    final displayed silhouette

    The article and the UI should not call post-processed output “the HDBSCAN result” or “the graph result” without saying what happened after the backend returned.

    What this tiny run proves—and what it does not

    It proves that:

    • real model preprocessing and inference execute offline;
    • shapes and path-row contracts agree;
    • each backend can be run through the production service;
    • metrics and membership ledgers can be reproduced;
    • post-processing is observable.

    It does not prove that k=4 is generally best, that CLIP always separates those subjects, or that the pipeline handles a million messy personal photographs. A controlled fixture is a microscope for mechanics, not a substitute for a representative dataset.


    20. Choosing an algorithm by the question you actually have

    The safest choice starts with the desired output contract, not the most sophisticated name.

    If the product needs…Start with…Because…Check carefully…
    every image in roughly k review pilesKMeansit gives a complete partition and has a clear baselinesensitivity to k, seeds, non-spherical groups
    faster repeated updates on very large matricesMiniBatchKMeansit updates from small random batchesquality variance, batch size, convergence
    the KMeans objective with optimized native routinesFAISS KMeansit can execute that objective efficientlypackaging, CPU/GPU parity, initialization defaults
    dense groups plus honest noiseHDBSCANit can discover cluster count and reject sparse pointscoverage and density parameters
    connected chains or near-duplicate familiessimilarity graphconnectivity can represent relationships a centroid missesthreshold bridges, giant components, isolated points

    A practical decision sequence is:

    Must every image receive a group?
    ├─ yes → begin with KMeans
    │        ├─ too slow at scale? → test MiniBatchKMeans or FAISS
    │        └─ unknown k? → sweep k and inspect stability + galleries
    └─ no  → should groups mean dense regions?
             ├─ yes → begin with HDBSCAN
             └─ no, groups mean connected similarity chains
                      → begin with a graph

    “Begin with” is deliberate. Choose a baseline, measure it, inspect its failures, and add complexity only when a failure mode justifies it.

    Answer the same decision as a small product interview. There is no universal winner; the recommendation changes when the required output changes.

    Must every item receive a group?
    Do you know a useful group count?
    What should hold a group together?
    Is repeated assignment the measured bottleneck?
    recommended first experiment KMeans

    You need complete coverage and can define or sweep k. A centroid baseline is the clearest first falsifiable experiment.

    parameter to sweep firstk and seed
    failure to inspectforced spherical partitions
    metric pairsilhouette + stability
    visual evidencecentres and boundary members

    The output says “first experiment,” not “permanent architecture.” If a KMeans baseline fails because crescent-shaped families are split, the observed failure justifies testing density or graph structure. If it succeeds, additional complexity needs a measurable reason.

    A minimum experimental protocol

    For a new collection:

    1. Freeze a versioned sample and, where possible, a small labelled evaluation slice.
    2. Cache embeddings together with model ID, preprocessing signature, dimension, file size, and modification time.
    3. Run an exact, simple baseline such as normalized KMeans.
    4. Sweep the parameter that changes the backend’s meaning: k, density size, or graph threshold.
    5. Record cluster count, noise, coverage, size distribution, silhouette, and stability.
    6. When labels exist, also record ARI, NMI, and purity.
    7. Render representative members and random members from every cluster.
    8. Inspect errors, bridges, tiny groups, and outliers—not only the prettiest clusters.
    9. Re-run with another seed and a shuffled row order.
    10. Store raw-backend and post-processed results separately.

    Four implementation bugs the experiments exposed

    The tutorial work also changed the code, because an experimental article should be able to falsify the system it describes.

    1. A directed-neighbour traversal was order-sensitive

    The graph path previously allowed traversal details to influence component formation. It now constructs an explicit undirected adjacency from qualifying k-nearest-neighbour edges, ignores self-edges, and computes deterministic connected components. The permutation test now reports ARI 1.000 between original and shuffled input order.

    2. A missing optional backend could be reported as if it had run

    Fallback is sometimes correct; silent identity theft is not. The result now distinguishes:

    requested_backend = hdbscan
    backend           = cosine-kmeans
    implementation    = sklearn-kmeans
    fallback_reason   = dependency unavailable

    3. Quality could describe labels the user no longer saw

    Outlier assignment and tiny-cluster merging can change memberships after the backend returns. The service now records raw_cluster_quality_score before policy and cluster_quality_score after policy.

    4. “Offline” model loading depended on import-time cached state

    Some Hugging Face modules cache offline mode when imported. Updating only an environment variable was insufficient after another model path had already imported those modules. The offline context now synchronizes and restores the library’s cached state as well, and a CLIP-then-DINO regression test confirms no network retry path is entered.

    These are not side notes. They demonstrate why tested explanations are more valuable than descriptions written from function names.

    Production blueprint

    The whole system can be summarized as a series of explicit contracts:

    IMAGE FILES
      │  path order + invalidation metadata
    
    EMBEDDING MODEL
      │  model ID + preprocessing + output dimension
    
    PREPARED MATRIX
      │  optional fitted PCA + normalization + row map
    
    RAW BACKEND
      │  requested backend + actual implementation + raw labels
    
    POST-PROCESSING
      │  outlier policy + tiny-group policy + membership changes
    
    PRESENTATION
      │  centroid ranking + explanations + review actions
    
    EVALUATION
         geometry + coverage + labels + stability + human inspection

    Every arrow has state that must be preserved or measured. If the model changes, the vectors are stale. If PCA is refitted, old projected points are incompatible. If rows move, labels attach to the wrong paths. If post-processing changes membership, raw quality no longer describes the gallery.

    Inspect those arrows as contracts. Selecting a layer shows what it consumes, what it must emit, and the most common silent failure at that boundary.

    consumesdirectory scope and supported file rules
    must emitstable ordered paths plus invalidation metadata
    silent failurerow identity changes later
    testshuffle and join-back regression

    A file scan is already part of the mathematical contract. If row identity is lost, perfectly calculated labels can be attached to the wrong photographs.


    21. The deeper lesson

    ClusterLens does not contain one “similarity algorithm.” It contains a stack of choices that progressively redefine what differences matter:

    embedding model  → which visual relationships become geometric
    normalization    → whether vector magnitude matters
    PCA              → which observed directions are retained
    backend          → partitions, density islands, or connected regions
    parameters       → desired count, density scale, or edge strictness
    outlier policy   → whether uncertainty remains visible
    tiny-group rule  → whether rare groups survive
    member ranking   → which examples explain the result first
    metrics          → which properties we reward

    Change one layer in the interactive stack to see which downstream artifacts become stale or acquire a new meaning.

    filessource identity
    embeddingsmodel-dependent coordinates
    geometrynormalization and PCA
    labelsraw backend output
    displaypolicy and ranking
    metricsevidence about that exact output

    Changing the embedding model invalidates every stored vector and every downstream geometric result. The source files remain valid, but PCA, labels, rankings, and recorded metrics must be recomputed.

    The central engineering question is therefore not:

    Which clustering algorithm is best?

    It is:

    Which definition of a useful group does this workflow need, what evidence would show that the definition is being met, and which failures must remain visible to the user?

    Once that question is explicit, KMeans, HDBSCAN, graphs, PCA, FAISS, outlier policies, and quality scores stop looking like a bag of impressive names. They become inspectable tools with different contracts.

    That is the durable learning from ClusterLens: a clustering system is not finished when it returns labels. It is finished when the representation, geometry, algorithm, policies, evidence, and user-facing explanation all describe the same result.

    Primary references

    1. scikit-learn clustering documentation
    2. scikit-learn PCA documentation
    3. Arthur and Vassilvitskii: k-means++
    4. McInnes, Healy, and Astels: hdbscan
    5. HDBSCAN: how the algorithm works
    6. FAISS source and research references
    7. FAISS getting started: exact flat indexes
    8. FAISS implementation notes: KMeans assignment
    9. FAISS CPU and GPU interoperability
    10. pgvector: vector search for PostgreSQL
    11. Qdrant architecture and data model
    12. Milvus architecture overview
    13. Weaviate data and indexing concepts
    14. Pinecone indexing and record model
    Diagram