← Back to all TILs

Translate To Canonical Tags With Small Model

llm Written September 27, 2026

In Don't Classify, Hallucinate, Doug Turnbull discusses a technique for hallucinating classifications for a product and then using MiniLM to translate those classifications to actual product classifications in your system / vocabulary.

The second step in the process he described seemed most interesting to me, so I wanted to experiment with it.

First, here is my classification system -- that is, these are all the categories of TIL posts that I write.

# from my TIL repo
❯ git ls-files '*/*.md' | cut -d/ -f1 | sort -u | paste -sd\| - | sed 's/.*/"&"/'
"ack|ansible|astro|aws|bash|brew|chrome|claude-code|clojure|css|cursor|deno|devops|docker|drizzle|elixir|gatsby|git|github|github-actions|go|groq|heroku|html|http|inngest|internet|java|javascript|jj|jq|kitty|linux|llm|mac|math|mise|mongodb|mysql|neovim|netlify|next-auth|nextjs|phoenix|planetscale|pnpm|postgres|prisma|python|rails|react|react_native|react-testing-library|reason|remix|rspec|ruby|sed|shell|sqlite|streaming|tailwind|taskfile|tmux|typescript|unix|vercel|vim|vscode|webpack|workflow|xstate|yaml|zed|zod|zsh"

I'll grab that output and use it in the following set of Python REPL steps. Here I need to import sentence_transformers which is "applicable for a wide range of tasks, such as semantic textual similarity, semantic search, clustering, classification, paraphrase mining, and more."

# I initiate this REPL with:
# ❯ uv run --with sentence_transformers python
>>> from sentence_transformers import SentenceTransformer
>>> model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
>>> raw_tags = "ack|ansible|astro|aws|bash|brew|chrome|claude-code|clojure|css|cursor|deno|devops|docker|drizzle|elixir|gatsby|git|github|github-actions|go|groq|heroku|html|http|inngest|internet|java|javascript|jj|jq|kitty|linux|llm|mac|math|mise|mongodb|mysql|neovim|netlify|next-auth|nextjs|phoenix|planetscale|pnpm|postgres|prisma|python|rails|react|react_native|react-testing-library|reason|remix|rspec|ruby|sed|shell|sqlite|streaming|tailwind|taskfile|tmux|typescript|unix|vercel|vim|vscode|webpack|workflow|xstate|yaml|zed|zod|zsh"
>>> tags = raw_tags.split("|")
>>> tag_vecs = model.encode(tags, normalize_embeddings=True)
>>> def resolve(fake_tags, threshold=0.5):
...     fake_vecs = model.encode(fake_tags, normalize_embeddings=True)
...     scores = fake_vecs @ tag_vecs.T
...     best, best_score = scores.argmax(1), scores.max(1)
...     return {tags[i] for i, s in zip(best, best_score) if s >= threshold
...

With that all set up, I take a set of tags for a post like this one that you're reading and see if I can get them to resolve to one or more tags directly from the canonical tag vocabulary.

>>> resolve(["Python 3.13", "uv", "ML / LLM / Classification"])
{'python', 'llm'}

A set of tags with varying structure and specificity can be translated into the relevant canonical tags that I started with.

I can also experiment a little with the threshold. Where is the line for "Version Control" being associated to "git"?

>>> resolve(["Version Control"])
set()
>>> resolve(["Version Control"], 0.2)
{'git'}
>>> resolve(["Version Control"], 0.3)
{'git'}
>>> resolve(["Version Control"], 0.4)
{'git'}
>>> resolve(["Version Control"], 0.5)
set()
>>> resolve(["Version Control"], 0.45)
set()
>>> resolve(["Version Control"], 0.42)
{'git'}

A quick note on what is happening in the resolve function above. We have the normalized embedding vectors for the fake tags and the set of canonical tags. We then compute the dot product of the fake tags embeddings with the transposed canonical tags. This gives us the cosine similarly between all of them which is an indication of how similar each fake tag vector is to each canonical tag vector. The ones above the 0.5 threshold make the cut and that set is returned. We may match one tag, a couple tags, or even no tags.

Other posts like this