{"id":585,"date":"2026-08-17T05:02:29","date_gmt":"2026-08-17T05:02:29","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/zero-shot-classification-let-the-model-make-it-up\/"},"modified":"2026-08-17T05:02:29","modified_gmt":"2026-08-17T05:02:29","slug":"zero-shot-classification-let-the-model-make-it-up","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/zero-shot-classification-let-the-model-make-it-up\/","title":{"rendered":"Zero Shot Classification: Let the Model Make It Up"},"content":{"rendered":"<p>Confession: for about six months, roughly 40% of every classification prompt I sent to an LLM was a list of categories. Not instructions. Not the thing being classified. Just an enum, 600-odd strings long, shipped on every single request, so the model would return a label I could actually store in a database column.<\/p>\n<p>It worked. It also felt stupid, and it got more expensive every time someone added a category.<\/p>\n<p>Then I read Doug Turnbull&rsquo;s <a href=\"https:\/\/softwaredoug.com\/blog\/2026\/08\/10\/hypothetical-classifications\" rel=\"nofollow noopener\" target=\"_blank\">Don&rsquo;t classify. Hallucinate!<\/a>, which flips the problem in a way I hadn&rsquo;t considered, and which I&rsquo;ve now been running in anger for a couple of weeks. Short version: stop telling the model what your categories are. Let it invent one. Then snap the invention to the closest real category using embeddings.<\/p>\n<p>I like it. I also think it has failure modes that nobody&rsquo;s talking about yet, so I&rsquo;ll get to those too.<\/p>\n<h2 id=\"the-giant-enum-i-was-shipping-on-every-request\">The giant enum I was shipping on every request<\/h2>\n<p>The standard approach to zero shot classification with an LLM is structured outputs. You define the legal values as a literal type, hand the schema to the API, and the provider constrains decoding so the model can&rsquo;t return anything outside it.<\/p>\n<pre><code class=\"language-python\">from typing import Literal\nfrom pydantic import BaseModel, Field\n\nCategory = Literal[\n    &quot;Furniture \/ Living Room Furniture \/ Coffee Tables &amp; End Tables \/ Coffee Tables&quot;,\n    &quot;Furniture \/ Bedroom Furniture \/ Dressers &amp; Chests&quot;,\n    &quot;Decor &amp; Pillows \/ Decorative Pillows &amp; Blankets \/ Throw Pillows&quot;,\n    # ... 600 more\n]\n\nclass QueryClassification(BaseModel):\n    category: Category = Field(description=&quot;Best category for this query&quot;)\n<\/code><\/pre>\n<p>This is correct and I&rsquo;m not going to tell you it&rsquo;s wrong. The output is guaranteed legal, which matters when you&rsquo;re writing to a foreign key.<\/p>\n<p>The problems are practical. Every request carries the full vocabulary, so your input token count scales with your taxonomy rather than your query. There&rsquo;s an upper bound on how large a schema the provider will accept, which <a href=\"https:\/\/developers.openai.com\/api\/docs\/guides\/structured-outputs\" rel=\"nofollow noopener\" target=\"_blank\">OpenAI documents<\/a> and which you will eventually hit. And in my experience the model reasons worse when most of its context is a wall of near-identical strings. It starts pattern matching on surface text instead of thinking about the query.<\/p>\n<h2 id=\"ask-for-a-category-that-doesnt-exist\">Ask for a category that doesn&rsquo;t exist<\/h2>\n<p>The trick is to remove the vocabulary entirely and ask the model to invent a plausible category, giving it a handful of examples purely to communicate the shape.<\/p>\n<pre><code class=\"language-python\">prompt = f&quot;&quot;&quot;Invent a novel, never-seen-before product category that best fits\nthis search query. Categories look like:\n\nFurniture \/ Living Room Furniture \/ Coffee Tables &amp; End Tables \/ Coffee Tables\nKitchen &amp; Tabletop \/ Kitchen Organization \/ Food Storage &amp; Canisters\nBaby &amp; Kids \/ Toddler &amp; Kids Bedroom Furniture \/ Kids Beds\n\nQuery: {query}\n&quot;&quot;&quot;\n<\/code><\/pre>\n<p>For &ldquo;brown coffee table&rdquo; a small model happily returns something like <code>Furniture \/ Living Room \/ Tables \/ Coffee<\/code>. That category does not exist in the taxonomy. It&rsquo;s wrong.<\/p>\n<p>It&rsquo;s also close enough to be useful, because the resolution step is a nearest neighbour lookup against embeddings of your real categories:<\/p>\n<pre><code class=\"language-python\">import numpy as np\nfrom sentence_transformers import SentenceTransformer\n\nencoder = SentenceTransformer(&quot;all-MiniLM-L6-v2&quot;)\n\nreal = load_taxonomy()                      # ~600 strings\nreal_vecs = encoder.encode(real, normalize_embeddings=True)\n\ndef resolve(guess: str, k: int = 3):\n    v = encoder.encode([guess], normalize_embeddings=True)[0]\n    scores = real_vecs @ v                  # cosine, vectors are normalised\n    top = np.argsort(-scores)[:k]\n    return [(real[i], float(scores[i])) for i in top]\n\nresolve(&quot;Furniture \/ Living Room \/ Tables \/ Coffee&quot;)\n# [('Furniture \/ Living Room Furniture \/ Coffee Tables &amp; End Tables \/ Coffee Tables', 0.88),\n#  ('Furniture \/ Living Room Furniture \/ Coffee Tables &amp; End Tables \/ End &amp; Side Tables', 0.79),\n#  ('Furniture \/ Office Furniture \/ Desks', 0.44)]\n<\/code><\/pre>\n<p>Six hundred vectors is a few megabytes of RAM and the lookup is a single matrix multiply. On my laptop it&rsquo;s well under a millisecond. The expensive part, the LLM call, now carries a prompt of maybe 80 tokens instead of 4,000, and because the task got easier you can drop to a cheaper model.<\/p>\n<h2 id=\"this-is-hyde-with-the-labels-swapped-in\">This is HyDE with the labels swapped in<\/h2>\n<p>If the shape feels familiar, it should. It&rsquo;s the same move as <a href=\"https:\/\/arxiv.org\/abs\/2212.10496\" rel=\"nofollow noopener\" target=\"_blank\">HyDE<\/a>, the 2022 retrieval paper by Gao and colleagues: given a query, have the model write a fake document that answers it, embed the fake document, and use that embedding to retrieve real documents. The generated text is not the answer. It&rsquo;s a better query vector.<\/p>\n<p>Here the generated category isn&rsquo;t the label. It&rsquo;s a better query vector for finding the label.<\/p>\n<p>I find that framing useful because it tells you where the technique&rsquo;s strength comes from. You&rsquo;ve changed the model&rsquo;s job from &ldquo;select from a list you can barely fit in context&rdquo; to &ldquo;write something in this format&rdquo;, and generative models are much better at the second task. Simon Willison <a href=\"https:\/\/simonwillison.net\/2026\/Aug\/14\/dont-classify-hallucinate\/\" rel=\"nofollow noopener\" target=\"_blank\">picked up the same post<\/a> and made the point that it lets you push the work down to smaller models, which is the real cost story.<\/p>\n<h2 id=\"where-it-quietly-goes-wrong\">Where it quietly goes wrong<\/h2>\n<p>Here&rsquo;s the part I&rsquo;d want to know before putting this in front of customers.<\/p>\n<p>Nearest neighbour always returns something. There is no &ldquo;none of the above&rdquo; in a dot product. If a query genuinely doesn&rsquo;t belong in your taxonomy, you&rsquo;ll get a confident wrong label with a mediocre score rather than an error. The fix is a similarity threshold below which you return unknown, but picking that threshold requires labelled examples, which is exactly the work you were hoping to skip. I ended up hand-labelling 300 rows to find mine. It was worth it. It wasn&rsquo;t free.<\/p>\n<p>Near-duplicate leaves are the second problem. &ldquo;Coffee Tables&rdquo; and &ldquo;End &amp; Side Tables&rdquo; live next to each other in the taxonomy and next to each other in embedding space. MiniLM does not reliably separate them, and neither did the larger encoder I tried. The structured-outputs version at least forced the model to reason about the distinction. The hallucinate-then-resolve version outsources that decision to cosine similarity, which does not know that a coffee table is low and an end table is tall.<\/p>\n<p>The third issue is that hierarchical paths are bad embedding inputs. When you embed <code>Furniture \/ Living Room Furniture \/ Coffee Tables &amp; End Tables \/ Coffee Tables<\/code>, the word &ldquo;Furniture&rdquo; appears three times and dominates the vector. Everything under Furniture looks like everything else under Furniture. I got a measurable improvement by embedding only the leaf plus its immediate parent, and a further one by scoring the path segments separately and combining them. Neither is in the original post; both took an afternoon.<\/p>\n<p>Fourth: general purpose encoders don&rsquo;t know your jargon. If your catalogue distinguishes &ldquo;sectional&rdquo; from &ldquo;modular sofa&rdquo; and the encoder thinks they&rsquo;re synonyms, no amount of prompt tuning fixes it. That&rsquo;s a fine-tuning problem, or a synonym table, and it&rsquo;s the same wall you eventually hit with retrieval. I went through this in more detail when I wrote about <a href=\"https:\/\/abrarqasim.com\/blog\/rag-vs-fine-tuning-2026-the-question-i-ask-first\/\" rel=\"noopener\">choosing between RAG and fine-tuning<\/a>.<\/p>\n<p>And finally, multi-label. A ranked list of similarities is not a set. If a query legitimately belongs in three categories you need a cutoff rule, and now you&rsquo;re tuning two thresholds instead of one.<\/p>\n<h2 id=\"the-option-nobody-in-this-thread-mentions\">The option nobody in this thread mentions<\/h2>\n<p>Once you&rsquo;ve hand-labelled those 300 rows to pick a threshold, you own something valuable, and it&rsquo;s worth stopping to ask whether you still need the LLM at all.<\/p>\n<p>You already have an encoder. You already have labelled examples. Fitting a logistic regression on top of the embeddings takes about six lines and runs in milliseconds on CPU with no API call:<\/p>\n<pre><code class=\"language-python\">from sklearn.linear_model import LogisticRegression\n\nX = encoder.encode(train_queries, normalize_embeddings=True)\nclf = LogisticRegression(max_iter=2000).fit(X, train_labels)\n\nclf.predict(encoder.encode([&quot;brown coffee table&quot;], normalize_embeddings=True))\nclf.predict_proba(...).max()   # an actual calibrated confidence, for free\n<\/code><\/pre>\n<p>On the two datasets where I&rsquo;ve compared them properly, a linear model over embeddings beat both LLM approaches on accuracy once I had more than about 40 labelled examples per class, at roughly a thousandth of the cost. It also gives you a probability you can threshold honestly, which is the thing the nearest-neighbour approach fakes with a cosine score.<\/p>\n<p>The catch is coverage. A trained classifier can only emit classes it saw during training, so a taxonomy with a long tail of rare categories, or one that changes weekly, is a bad fit. That&rsquo;s the actual dividing line, and it&rsquo;s not &ldquo;LLM versus not LLM&rdquo;. It&rsquo;s whether you have labels and whether your label set is stable. Zero shot classification earns its name when you have neither.<\/p>\n<p>I&rsquo;ve landed on using all three in the same system: the classifier handles the head, the hallucinate-and-resolve path handles queries the classifier is unsure about, and structured outputs stay in place for the handful of decisions where a wrong label is expensive enough to justify the tokens. That&rsquo;s less elegant than picking a winner. It&rsquo;s also what actually works.<\/p>\n<h2 id=\"how-id-test-it-before-trusting-it\">How I&rsquo;d test it before trusting it<\/h2>\n<p>Don&rsquo;t take my word or Doug&rsquo;s. The comparison is cheap to run.<\/p>\n<p>Hand-label 200 to 500 examples that look like your real traffic, including some that don&rsquo;t fit anywhere. Run both approaches over them and measure top-1 accuracy, cost per thousand classifications, and p95 latency. Then measure one more thing: the share of inputs whose best similarity score falls below your threshold. That number is your canary. When someone adds a category next quarter and forgets to rebuild the index, it moves before your accuracy does.<\/p>\n<p>My results, for a taxonomy of about 600 leaves: accuracy within two points of structured outputs, cost down by roughly 80%, and latency better mostly because the prompt shrank. If your numbers come out eight points worse, the enum was doing real work and you should keep paying for it.<\/p>\n<p>One operational note. Rebuild the embedding index whenever the taxonomy changes, and treat it as a deploy artifact rather than something you compute lazily at runtime. I keep the encode step in the same job that syncs the category table, which means the two can&rsquo;t drift. Most of my client work now involves this kind of plumbing rather than model choice, and you can see the sort of thing I mean in my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">portfolio<\/a>.<\/p>\n<h2 id=\"try-it-on-your-smallest-taxonomy-this-week\">Try it on your smallest taxonomy this week<\/h2>\n<p>Pick the taxonomy you have with the fewest leaves. Write the two versions: the Literal enum you probably already have, and the hallucinate-plus-resolve pipeline, which is maybe forty lines including the encoder.<\/p>\n<p>Run a hundred labelled rows through both and put the numbers in a table. That&rsquo;s an afternoon, and at the end of it you&rsquo;ll know whether the giant enum in your prompt is earning its tokens. Mine wasn&rsquo;t.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Instead of shipping a 600-item enum to the model, let it invent a fake category and snap it to a real one with embeddings. Cheaper, with sharper failure modes.<\/p>\n","protected":false},"author":2,"featured_media":584,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Instead of shipping a 600-item enum to the model, let it invent a fake category and snap it to a real one with embeddings. Cheaper, with sharper failure modes.","rank_math_focus_keyword":"zero shot classification","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[4],"tags":[639,221,5,11,640],"class_list":["post-585","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-ai","tag-classification","tag-embeddings","tag-llm","tag-rag","tag-search"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/585","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=585"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/585\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/584"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=585"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=585"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=585"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}