GEMINI LABJP
FLASH35 — Gemini 3.5 Flash is now GA and powers gemini-flash-latest, making everyday generation faster and more affordableAGENTS — Managed Agents launch in public preview in the Gemini API, running in secure, isolated Google-hosted Linux sandboxesMEDIA — Nano Banana 2 Lite and Gemini Omni Flash bring faster image and high-quality video generation across AI Studio and the APITTS — Streaming speech generation is now supported for gemini-3.1-flash-tts-preview via streamGenerateContentTRANSLATE — A new audio model detects 70+ languages for live speech-to-speech translation while preserving natural intonationSPENDCAP — Project-level spend caps for billing have been added in Google AI Studio to keep costs under controlFLASH35 — Gemini 3.5 Flash is now GA and powers gemini-flash-latest, making everyday generation faster and more affordableAGENTS — Managed Agents launch in public preview in the Gemini API, running in secure, isolated Google-hosted Linux sandboxesMEDIA — Nano Banana 2 Lite and Gemini Omni Flash bring faster image and high-quality video generation across AI Studio and the APITTS — Streaming speech generation is now supported for gemini-3.1-flash-tts-preview via streamGenerateContentTRANSLATE — A new audio model detects 70+ languages for live speech-to-speech translation while preserving natural intonationSPENDCAP — Project-level spend caps for billing have been added in Google AI Studio to keep costs under control
Articles/Advanced
Advanced/2026-07-15Advanced

A near-miss label won't fix itself on retry — a normalization layer for closed-vocabulary classification

When responseSchema enum returns an out-of-set label, retrying tends to return the same near-miss. From a wallpaper app's 30-category batch, here is the distribution of how labels miss, plus a normalization layer built on an alias table and gemini-embedding-2 nearest-neighbor, with measured results.

Gemini API184Structured Output9Image Classification3gemini-embedding-26Indie Development11Cost Optimization13

Premium Article

I was running a batch that sorts wallpaper submissions into 30 categories, and I fully expected responseSchema to occasionally return a string outside its enum. What I did not expect was that retrying those outliers would mostly return the same word a second time. An image I wanted labeled "landscape" came back as "scenery", and throwing it again returned "scenery". The model was not broken. For that image, it genuinely believed that word was the closest fit.

So this was a vocabulary mismatch, not a random glitch. Hitting a mismatch with retries only adds round trips and wait time while the answer stays the same. As an indie developer who runs these batches overnight, every extra round trip shows up directly in the next morning's completion time. What follows is a field note: measure how the labels miss first, then see what actually changes when you slip a normalization layer in ahead of any retry.

I covered why the enum gets ignored, and the first-line workaround, in an earlier piece on why responseSchema returns strings outside the enum. This is the sequel: it digs into why the straightforward "validate and retry once" stops paying off once the volume grows.

Before suspecting retries, I looked at how labels miss

Before deciding on a fix, I ran the whole set through once and simply counted how the out-of-enum labels were missing. Acting on a vague sense that "it sometimes returns odd values" makes it impossible to pick the right move.

The set was 8,142 wallpapers, classified in a single pass with a 30-category enum in responseSchema. 312 results (about 3.8%) failed to match the enum. Going through those 312 by hand, the misses split cleanly into three piles.

How it missedCountExample (returned value to intended label)
Alias / spelling variation (mechanically resolvable)231scenery to landscape, night view to nightscape, flower to plant
Semantically close but not in the alias table49"a photo of a canyon" to nature, "city lights" to nightscape
Genuinely ambiguous / multiple matches32compound "nature and animal", high-abstraction cases to hold

231, roughly 74%, were cases where the model's word and my ground-truth label were simply saying the same thing in different words. The moment that landed, retrying revealed itself as the wrong idea. Resubmit the same image and the model's sense of vocabulary is unchanged, so "scenery" comes back again. What needed fixing was the receiving side, not the number of calls to the model.

The other thing that mattered was the 32 in the bottom row. That group must not be normalized. Snapping each one to its nearest label pushes images that belong in "other," or in a human review queue, into a plausible-looking category. Ironically, that push is what erodes trust in the classifier more than an empty label ever does.

Resolving near-misses mechanically, in two stages

The approach is simple. When an out-of-set label arrives, look it up in a deterministic alias table first; if that misses, snap it to the nearest category by embedding; and if you still lack confidence, leave it un-normalized and route it to hold. A re-query to the model is reserved only for the held items that are worth asking again from a different angle.

The alias table is built only from outliers I have actually observed. Trying to cover every case from imagination produces a dictionary nobody can maintain, so I grow it slowly, wiring words that show up in the deviation log to a canonical label after confirming them.

import unicodedata
from google import genai
from google.genai import types
 
client = genai.Client(api_key="YOUR_API_KEY")
 
# the ground-truth vocabulary (an excerpt of the 30 categories)
CANONICAL = ["landscape", "nightscape", "plant", "animal", "nature", "abstract", "space", "sea", "other"]
 
# wire only words actually seen in the deviation log; never add from imagination
ALIASES = {
    "scenery": "landscape", "けしき": "landscape", "風景": "landscape",
    "night view": "nightscape", "夜景": "nightscape",
    "flower": "plant", "花": "plant", "plants": "plant",
    "wildlife": "animal", "nature scene": "nature",
}
 
def _norm(s: str) -> str:
    # absorb width, case, and surrounding-space variation before lookup
    return unicodedata.normalize("NFKC", s).strip().lower()
 
_ALIAS_INDEX = {_norm(k): v for k, v in ALIASES.items()}

Anything the first-stage alias table resolves is accepted straight as canonical. This handles the bulk of those 231 cases.

The second stage snaps words not in the alias table (the group of 49) to their nearest category with gemini-embedding-2. The key is not to decide on the distance to the top category alone, but to look at the gap (margin) between first and second place. When the margin is small, the case is genuinely a toss-up, so I drop it to hold rather than force a fit.

import numpy as np
 
def _embed(texts: list[str]) -> np.ndarray:
    resp = client.models.embed_content(
        model="gemini-embedding-2",
        contents=texts,
        config=types.EmbedContentConfig(task_type="SEMANTIC_SIMILARITY"),
    )
    vecs = np.array([e.values for e in resp.embeddings], dtype=np.float32)
    # normalize to unit vectors so we can compare with cosine similarity
    return vecs / np.linalg.norm(vecs, axis=1, keepdims=True)
 
# build the canonical vectors once at startup and reuse them
_CANON_VECS = _embed(CANONICAL)
 
def nearest_canonical(label: str, margin: float = 0.06):
    v = _embed([label])[0]
    sims = _CANON_VECS @ v            # similarity to each category
    order = np.argsort(sims)[::-1]
    top, second = order[0], order[1]
    if sims[top] - sims[second] < margin:
        return None, float(sims[top])  # first and second too close, do not snap
    return CANONICAL[top], float(sims[top])

I fold both stages into a single acceptance gate. The return value always carries the route by which it was resolved, so it can be logged later. Without a visible route, you cannot tell whether to grow the alias table or tune the margin.

def resolve_label(raw: str):
    if raw in CANONICAL:
        return raw, "in_set"
 
    hit = _ALIAS_INDEX.get(_norm(raw))
    if hit:
        return hit, "alias"
 
    canon, sim = nearest_canonical(raw)
    if canon is not None and sim >= 0.82:
        return canon, f"embedding:{sim:.3f}"
 
    # anything reaching here is not settled mechanically; send it to hold
    return None, "unresolved"

Only the images for which resolve_label returns None become candidates for human review or a re-query from a different angle. That is the first point where a limited resubmission to the model earns its keep. It is a completely different round-trip count from retrying everything unconditionally.

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
Across an 8,142-image batch, 312 labels fell outside the enum, and about 74% of them were near-misses resolvable mechanically by alias
A two-stage acceptance layer that normalizes out-of-set labels with an alias table and gemini-embedding-2 nearest-neighbor instead of retrying
A design that cut model re-queries from 312 to 82, and a clear boundary for the ambiguous cases you should deliberately leave un-normalized
Secure payment via Stripe · Cancel anytime

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

Related Articles

Advanced2026-07-10
The Day We Went From 30 Categories to 34 — Reclassifying 1,180 Assets Instead of 8,142
Adding categories to a taxonomy does not require reclassifying everything. Here is how embeddings and confidence margins narrowed a backfill from 8,142 assets to 1,180, with the numbers.
Advanced2026-07-10
Images Made With a Retiring Model Can Never Be Made Again — Tracking Regenerability in a Ledger
When Gemini's image generation models shut down on August 17, the assets you made with them can no longer be reproduced the same way. Here is the ledger design and code I use to decide, before the deadline, which assets are regenerable and which must be frozen.
Advanced2026-07-01
Getting Artifacts Out of a Managed Agents Sandbox Safely — Scoped Credentials and Egress Design
Gemini API Managed Agents run in a Google-hosted isolated sandbox. Here is the short-lived, least-privilege credential and egress-boundary design I use to return generated artifacts to my own repository safely.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →