壁紙アプリに投稿された画像を30カテゴリへ振り分けるバッチを回していたとき、responseSchema の enum に無い文字列が返ってくること自体は覚悟していました。想定していなかったのは、その外れ値をリトライで直そうとすると、二度目もほとんど同じ言葉が返ってくることです。「風景」を期待した画像に "scenery" が返り、もう一度投げても "scenery"。モデルは壊れているのではなく、その画像に対しては本気でその語がいちばん近いと考えていました。
つまりこれはランダムな揺らぎではなく、語彙のズレでした。ズレをリトライで殴っても、往復のコストと待ち時間が増えるだけで、返ってくる答えは変わりません。個人開発でバッチを夜間に流している身としては、この一往復が積み重なると翌朝の完了時刻にそのまま効いてきます。ここでは、外れ方をまず測り、リトライの前に「正規化する層」を挟んだときに何がどれだけ変わったのかを、実測値とともに残しておきます。
enum が無視される仕組みそのものと最初の回避策は、以前 responseSchema の enum 指定なのに違う文字列が返る現象の記事 にまとめました。本稿はその続きにあたり、「バリデーションして1回リトライする」という素直な対処が、規模が出たときになぜ割に合わなくなるのかを掘り下げます。
リトライを疑う前に、外れ方の分布を見た
対処を決める前に、まず一度だけ全件を素通しして、enum を外したラベルがどう外れているのかを数えました。判断材料が「たまに変な値が返る」という体感のままだと、正しい打ち手が選べないからです。
対象は壁紙8,142枚、responseSchema に30カテゴリの enum を指定した1パスの結果です。enum に一致しなかったのは312件(全体の約3.8%)。その312件を手作業で見ていくと、外れ方はきれいに3つの山に分かれました。
| 外れ方 | 件数 | 例(返ってきた値 → 本来のラベル) |
|---|---|---|
| 別名・表記ゆれ(機械的に解決可能) | 231件 | scenery → 風景、night view → 夜景、花 → 植物 |
| 意味は近いが別名表に無い | 49件 | 「渓谷の写真」→ 自然、「街の明かり」→ 夜景 |
| 本当に曖昧・複数該当 | 32件 | 「自然・動物」複合、抽象度が高く判断保留すべきもの |
231件、つまり約74%は、モデルが返した語と手元の正解ラベルが「同じものを別の言葉で言っている」だけでした。ここに気づいた瞬間、リトライという発想が的外れだと分かりました。同じ画像を投げ直しても、モデルの語彙観は変わらないので、二度目も "scenery" が返ってきます。直すべきは受け取り側であって、モデルへの問い合わせ回数ではありませんでした。
もう一つ大事だったのは、いちばん右下の32件です。これは正規化してはいけないグループでした。機械的に最も近いラベルへ寄せてしまうと、本来「その他」や人手確認に回すべき画像を、それらしいカテゴリに押し込んでしまいます。分類の信頼を落とすのは、実はこの押し込みのほうです。これは本番運用でいちばん静かに効く落とし穴で、精度の数字には表れません。
近ミスを機械的に解く二段構え
方針はシンプルです。out-of-set のラベルが来たら、まず決定的な別名表で引き、引けなければ埋め込みの最近傍で寄せ、それでも自信が持てなければ正規化せずに保留へ回す。モデルへの再問い合わせは、この保留の中でも「もう一度だけ視点を変えて訊く価値がある」ものに限定します。
別名表は、実際に観測した外れ値からしか作りません。想像で網羅しようとすると保守できない辞書になるので、逸脱ログに出てきた語だけを、確認しながら少しずつ canonical へ結びつけていきます。
import unicodedata
from google import genai
from google.genai import types
client = genai.Client(api_key="YOUR_API_KEY")
# 手元の正解語彙(30カテゴリのうち一部を抜粋)
CANONICAL = ["風景", "夜景", "植物", "動物", "自然", "抽象", "宇宙", "海", "その他"]
# 実際に逸脱ログで観測した語だけを結びつける。想像で足さない
ALIASES = {
"scenery": "風景", "landscape": "風景", "けしき": "風景",
"night view": "夜景", "夜の街": "夜景",
"花": "植物", "flower": "植物", "plant": "植物",
"animal": "動物", "nature": "自然",
}
def _norm(s: str) -> str:
# 全角半角・大文字小文字・前後空白の揺れを吸収してから引く
return unicodedata.normalize("NFKC", s).strip().lower()
_ALIAS_INDEX = {_norm(k): v for k, v in ALIASES.items()}第一段の別名表で引けたものは、そのまま canonical として受け入れます。ここで解決できるのが先ほどの231件の大半です。
第二段は、別名表に無い語(49件のグループ)を gemini-embedding-2 で最近傍のカテゴリへ寄せます。ポイントは、いちばん近いカテゴリとの距離だけで決めず、一位と二位の差(マージン)を見ることです。マージンが小さいときは「どちらとも言えない」ので、無理に寄せずに保留へ落とします。
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)
# コサイン類似度で比べるため単位ベクトルへ
return vecs / np.linalg.norm(vecs, axis=1, keepdims=True)
# canonical 側のベクトルは起動時に一度だけ作って使い回す
_CANON_VECS = _embed(CANONICAL)
def nearest_canonical(label: str, margin: float = 0.06):
v = _embed([label])[0]
sims = _CANON_VECS @ v # 各カテゴリとの類似度
order = np.argsort(sims)[::-1]
top, second = order[0], order[1]
if sims[top] - sims[second] < margin:
return None, float(sims[top]) # 一位と二位が僅差 → 寄せない
return CANONICAL[top], float(sims[top])この二段を、一つの受け入れゲートにまとめます。戻り値には「どう解決したか」の経路を必ず添えて、後でログとして残せるようにしておきます。経路が見えないと、別名表を育てるべきか、マージンを調整すべきかの判断ができません。
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}"
# ここまで来たものは機械的に確定しない。保留に回す
return None, "unresolved"resolve_label が None を返した画像だけが、人手確認か、視点を変えた再問い合わせの候補になります。ここで初めて、限定的にモデルへ投げ直す価値が出てきます。全件を無条件にリトライするのとは、往復回数がまるで違います。