Survey verbatims, support tickets, and review corpora almost never need an LLM call per row first — they need embeddings, density clustering, and then an LLM to label what the math found.
Tier: Core · Workflow area: Embeddings & Unsupervised Text Analysis
This playbook is the embed → reduce → cluster → LLM-label pipeline, plus semantic near-duplicate detection, that turns 50k free-text rows into a defensible topic taxonomy in an afternoon.
sentence-transformers, umap-learn, hdbscan, faiss-cpu (all free, laptop-runnable; GPU optional).Embed once, persist forever. Use a strong open model and normalize:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-small-en-v1.5") # or bge-m3 for multilingual
emb = model.encode(texts, normalize_embeddings=True, batch_size=256, show_progress_bar=True)
np.save("emb.npy", emb) # embeddings are an asset; never recompute casually
Semantic dedup with FAISS. Cosine similarity over normalized vectors; pairs above threshold are duplicate candidates:
import faiss
index = faiss.IndexFlatIP(emb.shape[1]); index.add(emb)
D, I = index.search(emb, k=5) # top-5 neighbors per row
pairs = [(i, j, d) for i, row in enumerate(I) for j, d in zip(row, D[i]) if j > i and d > 0.92]
Calibrate the threshold by hand-reviewing 30 pairs at 0.88 / 0.92 / 0.96 — the right value is corpus-specific. Collapse duplicates via union-find, keeping the longest exemplar.
Reduce, then cluster. HDBSCAN on raw 384–1024-dim vectors is slow and noisy; reduce with UMAP first:
import umap, hdbscan
red = umap.UMAP(n_neighbors=15, n_components=5, metric="cosine", random_state=42).fit_transform(emb)
labels = hdbscan.HDBSCAN(min_cluster_size=max(20, len(texts)//200),
min_samples=5).fit_predict(red)
Sweep min_cluster_size over 3–4 values; prefer the granularity where clusters survive a change of random seed. Expect 10–30% noise (-1) — that is HDBSCAN being honest, not failing.
LLM-label each cluster from exemplars. For each cluster, take the 10 members closest to the centroid + 5 random members, and request a structured label:
class ClusterLabel(BaseModel):
name: str = Field(description="3-6 word topic name")
description: str
is_actionable: bool
suggested_merge_with: str | None
One call per cluster (50–300 calls total). Feed suggested_merge_with hints into a manual merge pass — LLMs are good at spotting that "billing error" and "wrong invoice amount" are one topic.
Quantify and report. Topic share, topic-over-time trend if you have dates, dedup rate, and a 2-D UMAP scatter colored by cluster for the appendix. Report noise share explicitly rather than force-assigning it.
Run steps 3–4 twice with different seeds; topics that appear both times are real, ones that don't are artifacts. This 20-minute stability check is the difference between analysis and apophenia.
bge-small/base-en-v1.5 (local, free) is the default; hosted embedding APIs (Voyage, OpenAI text-embedding-3-*, Gemini) trade cost for a few points of quality — check MTEB and your wallet (pb-23 covers rigorous selection).claude-opus-4-8) for labeling reads exemplars carefully; volume is low so tier cost barely matters here.min_cluster_size set too high — investigate before reporting.