浮世絵壁紙の配信前に、カタログを上から順に見返していた夜のことでした。同じ絵師の名前が、三つの表記で並んでおりました。「歌川広重」「安藤広重」「Utagawa Hiroshige」。どれも誤りではありません。ただ、私のスクリプトにとっては三人の別人でした。
版木から起こされた古い紙をスキャンして、折れや汚れを一枚ずつ手で直しています。そこは道具を替えていない領域です。一方で、直し終えた画像に添えるメタデータ——絵師名、画題、シリーズ名——は、人の手で入力している以上、必ず揺れます。
この揺れを Gemini に吸収させようとして、最初のうちは遠回りをしました。書き残しておきます。
「同じ人ですか」と聞いた日に起きたこと
最初に書いたのは、二つの名前をそのまま渡して同一人物かどうかを尋ねる関数でした。ほとんどの場合、返事は正しいのです。問題は、正しくない数十件のほうにありました。
初代の広重と、その門人が襲名した二代広重が、同じ人物として返ってきます。しかも返事は「はい、同一人物です」という断定の形をとりますので、一覧の上では正解と見分けがつきません。私はそれをそのまま採用して、配信済みの数点に誤った作者名を載せてしまいました。
判断は私が持ち、モデルには材料だけを出してもらいます。 この線引きにたどり着いたところまでは良かったのですが、判定をやめただけでは足りませんでした。呼び出しの件数そのものが多すぎたのです。同じ絵師の表記ゆれを、毎回モデルに考えさせていたわけです。
第一層: 正規化はコードの仕事です
揺れの大半は、意味の問題ではありません。全角と半角、旧字体と新字体、ヘボン式の長音、括弧書きの補足——機械的に潰せるものばかりです。ここに推論を持ち込む理由はありません。
# normalize.py — モデルを呼ぶ前に、決定論で潰せる揺れを潰す
import re
import unicodedata
# 旧字体・異体字 → 新字体(実際のカタログで出会ったものだけを足していきます)
KYUJI = str.maketrans({
"廣": "広", "國": "国", "齋": "斎", "澤": "沢",
"溪": "渓", "藝": "芸", "驛": "駅", "豐": "豊",
})
# 漢数字・大字の混在(「五拾三次」「五十三次」を同じ列に寄せます)
NUM_FIX = [("拾", "十"), ("卅", "三十")]
# 括弧の補足、肩書き、丸数字などの装飾
DECOR = re.compile(r"[((\[][^))\]]*[))\]]|画$|筆$|[①-⑳]")
def normalize(name: str) -> str:
s = unicodedata.normalize("NFKC", name) # 全角英数と半角カナを揃えます
s = s.translate(KYUJI)
for a, b in NUM_FIX:
s = s.replace(a, b)
s = DECOR.sub("", s)
s = re.sub(r"[\s・,.'’\-]", "", s) # 区切り記号は落とします
return s.lower()
def romaji_key(name: str) -> str:
"""ローマ字表記の長音ゆれ(Hiroshige / Hirōshige / Hiroshige)を1本に寄せます。"""
s = unicodedata.normalize("NFKD", name)
s = "".join(c for c in s if not unicodedata.combining(c)) # ō → o
s = re.sub(r"(ou|oo|uu)", lambda m: m.group(0)[0], s.lower())
return re.sub(r"[^a-z]", "", s)
if __name__ == "__main__":
for a, b in [("歌川廣重", "歌川広重"), ("Utagawa Hirōshige", "Utagawa Hiroshige")]:
key = normalize if re.search(r"[ぁ-んァ-ヶ一-龥]", a) else romaji_key
print(a, b, key(a) == key(b))
# 期待する出力:
# 歌川廣重 歌川広重 True
# Utagawa Hirōshige Utagawa Hiroshige True
私のカタログでは、この関数を通すだけで、揺れの大部分が同じキーに落ちました。残ったのは「歌川広重」と「安藤広重」のように、文字の上ではどうやっても結びつかない組です。モデルの出番はここから先だけで十分でした。
第二層: モデルには候補の列挙だけを頼みます
第一層を抜けてきた名前について、Gemini に頼むのは一つだけです。「この名前で活動した絵師の、別名・号・襲名前の名前を挙げてください」。同一かどうかは尋ねません。
# alias_lookup.py — 判定させず、候補と根拠だけを受け取ります
import json
import os
from google import genai
from google.genai import types
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
SCHEMA = {
"type": "object",
"properties": {
"canonical": {"type": "string"},
"aliases": {"type": "array", "items": {"type": "string"}},
"generation": {"type": "string", "enum": ["first", "later", "unknown"]},
"note": {"type": "string"},
},
"required": ["canonical", "aliases", "generation"],
}
SYSTEM = """あなたは浮世絵の作者名の索引を作る補助をします。
- 与えられた名前で活動した絵師の、別名・号・襲名前の名前だけを挙げてください。
- 二人以上の人物が同じ名跡を継いでいる場合、generation を later にし、note にその旨を書いてください。
- 確認できない場合、aliases を空配列にしてください。名前を創作してはいけません。
- 名前を翻訳・音訳しないでください。原綴りのまま返してください。"""
def lookup(name: str) -> dict:
res = client.models.generate_content(
model="gemini-3.8-flash",
contents=f"名前: {name}",
config=types.GenerateContentConfig(
system_instruction=SYSTEM,
response_mime_type="application/json",
response_schema=SCHEMA,
),
)
return json.loads(res.text)
if __name__ == "__main__":
out = lookup("安藤広重")
print(out["canonical"], out["generation"], out["aliases"][:3])
# 期待する出力の形:
# 歌川広重 unknown ['安藤重右衛門', '一遊斎', '一幽斎']
note は人が読むための欄で、突き合わせの判定には使いません。モデルに自己申告させた確からしさの数値も、私は受け取らないことにしております。数字が入ると、それを閾値にしたくなるからです。閾値にした瞬間、判定を戻したことになります。
temperature を下げても揺れが止まらない理由
出力を安定させたいとき、まず temperature を 0 に近づけたくなります。私もそうしました。効きませんでした。
Gemini の 3.x Flash 系では temperature・top_p・top_k が非推奨の扱いになっており、リクエストは受理されたうえで、値は無視されます。エラーも警告も返りません。設定した本人だけが「効いている」と思い込む形になります。
安定は、別の場所で作ります。
response_schema で形を固定します。配列の要素型まで書き、required を明示します。
enum で語彙を閉じます。generation を自由文にしていた頃は「二代目か」「おそらく初代」といった表現が混ざり、後段の分岐が壊れました。
- 「確認できない場合は空配列」を system_instruction に書きます。空を許さないと、モデルは形を埋めるために名前を作ります。
| やりたいこと | 効かない設定 | 実際に効く場所 |
| 語彙を揃える | temperature を下げる | enum で選択肢を閉じる |
| 形を固定する | プロンプトで「JSON で返して」と頼む | response_mime_type と response_schema |
| 創作を止める | 「正確に」と強く書く | 空配列を明示的に許可する |
世代違いを同一視しないための否定辞書
二層に分けても、最後の穴が残ります。aliases の配列に、同一人物ではない名前が紛れ込むことです。名跡を継いだ二代・三代は、文献の上では同じ名前で呼ばれますので、列挙の対象として自然に上がってきます。
そこで、正の別名辞書とは別に、決して同じ列に入れてはいけない組を持つようにしました。
# guard.py — モデルの出力を、こちらの禁止表で削ります
NEVER_SAME = {
frozenset({"歌川広重", "二代広重"}),
frozenset({"歌川広重", "三代広重"}),
frozenset({"歌川豊国", "三代豊国"}),
frozenset({"歌川国貞", "三代豊国"}), # 同一人物の説もあるため、自動採用はしません
}
GENERATION_HINT = ("二代", "三代", "四代", "門人", "襲名")
def filter_aliases(query: str, result: dict) -> dict:
kept, dropped = [], []
for a in result.get("aliases", []):
if frozenset({query, a}) in NEVER_SAME or any(h in a for h in GENERATION_HINT):
dropped.append(a)
else:
kept.append(a)
result["aliases"] = kept
result["needs_review"] = dropped # 捨てずに、人が見る列へ回します
return result
落とし穴として書いておきたいのは、dropped を捨ててしまう設計にすると、後から「なぜこの候補は落ちたのか」を追えなくなる点です。私は一度それで、同じ組を二週間おきに二回調べる羽目になりました。落とした理由は必ず残す、という一行の判断が、後の自分を助けます。
突き合わせの結果を三つの箱に分ける
ここまでの二層を通すと、カタログの各行は次のどれかに落ちます。運用としては、この分け方が要になります。
- 正規化で一致した行 — 自動で採用します。人は見ません。
- 別名辞書で一致した行 — 採用しますが、根拠の列(どの別名で結びついたか)を残します。
- どちらでも決まらなかった行 — 人が見ます。ここだけが手作業です。
| 箱 | 入力側の状態 | モデル呼び出し | その後 |
| 1 | 正規化キーが一致 | なし | そのまま配信メタデータへ |
| 2 | 別名として結びついた | あり(列挙のみ) | 根拠列つきで採用 |
| 3 | 世代の疑いあり・候補ゼロ | あり(列挙のみ) | 私が原典と照らします |
三つ目の箱が小さくなりすぎたときは、むしろ疑うようにしています。否定辞書が緩んでいるか、正規化が寄せすぎているかのどちらかで、見た目のきれいさと正しさが逆を向いている合図だからです。
千件規模で回すときの費用と時間
モデルを呼ぶのは、第一層で決まらなかった行だけです。私のカタログでは、それが全体の一部にとどまりました。仮に千件すべてを呼んだとしても、費用の桁は変わりません。
1件あたり入力 400 トークン・出力 200 トークンとして、千件で入力 40 万トークン、出力 20 万トークンになります。gemini-3.8-flash の導入価格は入力 $0.75 / 出力 $3.75 per MTok ですので、$0.30 と $0.75 で、合わせて 1 ドル強です。
一点だけ、期限に注意が必要です。この導入価格は 2026年12月31日までで、2027年1月1日から入力 $1.50 / 出力 $7.50 になります。年をまたぐ試算をするなら、二段で書いておくほうが安全です。同じ理由で、モデル ID は設定ファイルへ出しておくことを推奨します。プレビュー系のエンドポイントには廃止の日付が付きますので、コードに直書きすると、その日に止まります。モデルの世代選びについては同じ価格で使えるトークンが増えた 3.8 Flash の選び方で別途整理しています。
画像そのものの分類を Gemini に任せる話は壁紙アプリのカテゴリ自動分類を個人開発で回すに書きました。今回のメタデータ整備は、その前段にあたります。
明日からの一歩
もし同じような名寄せを抱えていらっしゃるなら、まず normalize() だけを書いて、手元のデータに通し、何件が同じキーに落ちるかを数えてみてください。モデルを呼ぶ設計を考えるのは、その数を見てからで遅くありません。私の場合、数えた時点で、当初考えていた呼び出しの大半が不要だと分かりました。
古い紙を一枚ずつ直す作業と、その紙に名前を添える作業は、別の種類の仕事です。前者は手を動かし続けるしかありませんが、後者は仕組みで減らせます。その境目を自分で引けたことが、この二層構成でいちばん良かった点だと感じています。
最後までお読みくださり、ありがとうございました。