2014年から個人で iPhone / Android アプリを公開し続けてきて、累計ダウンロード数が 5,000 万を超える頃には、いくつかのアプリが 10 言語以上に対応するようになっていました。最初のうちは英語と日本語だけで運用していたのですが、海外比率が伸びるにつれて、ローカリゼーションの「気づかれない劣化」が静かに評価を下げていく問題に何度もぶつかりました。
たとえば、サブスク導線のスペイン語版で「Subscribe」を「suscribirse」と訳していたのですが、本来 UI 上で求めていたのは「会員登録(registrarse)」のニュアンスでした。日本語のソース文字列が "登録する" だったため、各言語の翻訳者によって解釈がぶれ、結果として一部の言語だけ離脱率が高い、という状態に半年以上気づけませんでした。気づいたきっかけは、レビュー欄に "I cannot find how to sign up" というコメントが何度か並んだことです。
人手レビューだけでこの種のドリフトを止めるのは現実的ではありません。アプリ本体のリリースサイクルは速く、文言は週単位で動きます。一方で機械翻訳のスコアリングは Bleu / chrF のような古典指標では「自然さ」や「コンテキスト整合性」を捉えきれません。そこで取り組んだのが、Gemini 2.5 Pro の構造化出力(responseSchema)を中核に据えたローカリゼーションの自動 QA パイプラインです。設計から実装まで、私自身がアプリ事業の運用で実際に使っている構成をベースに整理しました。個人開発でも月数ドルで回せる範囲に収める工夫まで踏み込みます。
翻訳ドリフトを 3 類型で見る
最初に、なぜ「単純に翻訳の良し悪し」ではなく、複数軸で評価する必要があるのかを共有させてください。実運用で出会う翻訳の問題は、おおむね以下の 3 種類に分けると扱いやすくなります。
用語ドリフト(Terminology Drift) : 同じ概念に対して、画面ごとに違う訳語が当たる現象です。Subscribe, Plan, Membership のような語が、画面によって 購読 / プラン / 会員 と揺れる、という形で表れます。レビュー時にひとつの画面だけ見ても気づきにくく、全体俯瞰してはじめて顕在化します。
語調ドリフト(Register Drift) : 同じ言語のなかで、敬語と非敬語、フォーマルとカジュアルが混ざる現象です。日本語アプリでは「設定を保存しました。」と「保存したよ!」が混在するなど。ブランドトーンに直結する重要な軸です。
コンテキスト誤訳(Context Mismatch) : ソース文字列だけ見ると正しい訳が、UI の文脈で誤解を生むケースです。ボタンラベル Run を「走る」と訳してしまう典型例です。これは翻訳者にスクリーンショットを渡せば防げますが、文字列が増えるとコストが合いません。
この 3 類型に対して、それぞれ別の戦略で対応する設計にしています。用語ドリフトは「用語集(Glossary)との突き合わせ」、語調ドリフトは「同じ言語内の他の文字列との一貫性スコア」、コンテキスト誤訳は「画面コンテキスト(screen_id / control_kind)を Gemini に渡した上での自然さスコア」という具合です。
アーキテクチャ全体像
以下の構成で運用しています。あえて簡素に保ち、CI からも手元からも同じスクリプトで動かせるようにしています。
[Localizable.strings / strings.xml]
│ (git diff)
▼
[diff_extractor.py] ── 変更されたキーだけを抽出
│
▼
[localization_qa.py]
├─ 用語集マッチ (Glossary, JSON)
├─ 一貫性スキャン (同一言語の既存翻訳と類似度)
└─ Gemini 2.5 Pro 評価 (responseSchema)
│
▼
[gate.py] 平均スコア < 3.5 → 失敗
│
▼
[PR コメント / Slack 通知]
ポイントは「変更があったキーだけ評価する」増分 QA にすることです。アプリの文字列は数千キーになることがありますが、1 リリースで変わるのはせいぜい数十キー程度です。フル評価を毎回走らせると、コストよりもむしろ「警告が多すぎて誰も見なくなる」ことが致命傷になります。
実装 1: 構造化出力で翻訳を評価する
評価の中核は Gemini 2.5 Pro の responseSchema を使った関数です。曖昧な自然言語の出力ではなく、JSON で「accuracy / fluency / consistency / context_fit」の 4 軸スコアを返してもらいます。
# localization_qa.py
import os
import google.generativeai as genai
genai.configure( api_key = os.environ[ "GEMINI_API_KEY" ])
EVAL_SCHEMA = {
"type" : "object" ,
"properties" : {
"accuracy" : { "type" : "integer" , "minimum" : 1 , "maximum" : 5 },
"fluency" : { "type" : "integer" , "minimum" : 1 , "maximum" : 5 },
"consistency" : { "type" : "integer" , "minimum" : 1 , "maximum" : 5 },
"context_fit" : { "type" : "integer" , "minimum" : 1 , "maximum" : 5 },
"issues" : { "type" : "array" , "items" : { "type" : "string" }},
"suggested_translation" : { "type" : "string" },
},
"required" : [ "accuracy" , "fluency" , "consistency" , "context_fit" , "issues" ],
}
EVAL_INSTRUCTIONS = """ \
あなたはアプリのローカリゼーション QA エンジニアです。
以下の翻訳を 4 軸で 1〜5 のスケールで評価してください。
- accuracy: ソース文字列の意味を保っているか
- fluency: 対象言語のネイティブから見て自然か
- consistency: 提示した過去の翻訳例と用語・語調が揃っているか
- context_fit: 提示した UI コンテキストに合致しているか
issues には具体的な問題点を箇条書きで挙げ、提案訳を suggested_translation に入れてください。
"""
def evaluate_translation (
source_text: str ,
translated_text: str ,
target_lang: str ,
glossary: dict[ str , str ],
prior_examples: list[ dict ],
ui_context: dict ,
) -> dict :
model = genai.GenerativeModel(
"gemini-2.5-pro" ,
generation_config = {
"response_mime_type" : "application/json" ,
"response_schema" : EVAL_SCHEMA ,
"temperature" : 0.1 ,
},
system_instruction = EVAL_INSTRUCTIONS ,
)
prompt = f """ \
[Target Language] { target_lang }
[UI Context] screen_id= { ui_context[ 'screen_id' ] } , control= { ui_context[ 'control_kind' ] }
[Glossary] { glossary }
[Prior Examples in this language]
{ prior_examples }
[Source (en)] { source_text }
[Translation ( { target_lang } )] { translated_text }
"""
response = model.generate_content(prompt)
return response.parsed if hasattr (response, "parsed" ) else \
__import__ ( "json" ).loads(response.text)
response_schema を渡しておくと、評価結果が必ずスキーマに沿った JSON で返ります。これにより、後段のゲート判定(平均スコア閾値や issues 件数判定)が単純な辞書アクセスで書けるようになります。実運用で温度を下げる(0.1)のは、同じ入力に対するスコアが日によって動くと、CI が不安定になり「誰も見なくなる」典型パターンに陥るからです。
公式ドキュメントには明記されていないのですが、response_schema を渡す場合は temperature を高めに振ると稀にスキーマ違反の出力が混ざります。0.0 〜 0.2 の範囲で運用すると安定しました。
実装 2: 差分検出と増分QA
増分 QA の部分はシンプルな Python で十分です。Localizable.strings を 1 行ずつパースし、git の前回コミットとの差分を取ります。
# diff_extractor.py
import re
import subprocess
from pathlib import Path
STRING_RE = re.compile( r ' ^ " ([ ^ " ] + ) " \s * = \s * " ((?:[ ^ " \\ ] | \\ .) * ) "; \s * $ ' )
def parse_strings (path: Path) -> dict[ str , str ]:
out = {}
for line in path.read_text( encoding = "utf-8" ).splitlines():
m = STRING_RE .match(line.strip())
if m:
out[m.group( 1 )] = m.group( 2 )
return out
def extract_changed_keys (repo: Path, file_rel: str , base_ref: str = "HEAD~1" ):
new = parse_strings(repo / file_rel)
try :
old_raw = subprocess.check_output(
[ "git" , "show" , f " { base_ref } : { file_rel } " ],
cwd = repo, text = True
)
old = {}
for line in old_raw.splitlines():
m = STRING_RE .match(line.strip())
if m:
old[m.group( 1 )] = m.group( 2 )
except subprocess.CalledProcessError:
old = {}
changed = {k: v for k, v in new.items() if old.get(k) != v}
return changed
このとき、base_ref を「前回 main にマージされた時点」にすると、フィーチャーブランチ内の往復編集をすべて拾ってしまい、評価コストが膨らみます。私は git merge-base HEAD origin/main の出力をベースに使うようにしました。これだけで評価対象が体感 3〜5 割減ります。
実装 3: 一貫性スキャンを軽量化する
同一言語内の一貫性チェックを毎回 Gemini に投げると、コストが線形に伸びます。軽量化のため、まず Embeddings で類似翻訳候補を 5 件絞り込んでから Gemini に渡しています。
# consistency.py
import google.generativeai as genai
import numpy as np
def embed (texts: list[ str ]) -> np.ndarray:
res = genai.embed_content(
model = "models/text-embedding-004" ,
content = texts,
task_type = "SEMANTIC_SIMILARITY" ,
)
return np.array(res[ "embedding" ])
def top_k_similar (query: str , corpus: list[tuple[ str , str ]], k: int = 5 ):
"""corpus: [(key, translation), ...] 同じ target_lang のもの"""
if not corpus:
return []
q = embed([query])[ 0 ]
matrix = embed([t for _, t in corpus])
sims = matrix @ q / (np.linalg.norm(matrix, axis = 1 ) * np.linalg.norm(q) + 1e-9 )
idx = np.argsort( - sims)[:k]
return [corpus[i] for i in idx]
text-embedding-004 は 1M トークンあたり $0.025 と圧倒的に安いので、ここで前処理してから Gemini 2.5 Pro に渡すと、全体コストが半分以下に抑えられます。私のアプリ群(17 言語 × 約 2,800 キー)で月 5,000 翻訳評価の規模だと、Embeddings 部分は月 $0.5 を切ります。
実装 4: CI 統合と PR コメント
GitHub Actions に組み込み、PR ベースで動かしています。失敗を exit 1 ではなく「コメントで通知 + ラベル付与」にしているのは、自動ゲートでブロックすると翻訳者の心理的負担が大きくなり、結果的に PR が放置されるためです。
# .github/workflows/localization-qa.yml
name : Localization QA
on :
pull_request :
paths :
- "Localizable/**/*.strings"
- "app/src/main/res/values-*/strings.xml"
jobs :
qa :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
with :
fetch-depth : 0
- uses : actions/setup-python@v5
with : { python-version : "3.12" }
- run : pip install google-generativeai numpy
- name : Run QA
env :
GEMINI_API_KEY : ${{ secrets.GEMINI_API_KEY }}
run : python scripts/localization_qa.py --base-ref ${{ github.event.pull_request.base.sha }}
- name : Comment on PR
if : always()
uses : actions/github-script@v7
with :
script : |
const fs = require("fs");
const body = fs.readFileSync("qa-report.md", "utf-8");
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body
});
レポートは Markdown で、key | lang | avg_score | issues の表形式にしています。avg_score < 3.5 のものだけを上位に並べ、それ以外は折りたたみにすると、PR レビュー時の認知負荷がぐっと下がります。
運用知見: スコア閾値の決め方
しきい値設定は、リリース直前に最も悩むポイントです。私の経験では以下のステップで決めると失敗しにくいです。
第一に、最初の 2 週間は「ゲートをかけずに観測のみ」に留めること。スコアの分布を見ないまま 3.5 を閾値にすると、特定の言語(ポーランド語やトルコ語など、Gemini の評価傾向が日本語と異なる言語)で誤検出が頻発します。
第二に、言語ごとに閾値を持つこと。私は CJK 系(日本語・中国語・韓国語)は 3.7、ラテン系(英語・フランス語・スペイン語)は 3.5、それ以外は 3.3 という保守的な設定にしています。Gemini は言語によって評価のスケール感覚が異なり、これを揃えるのは現実的でないと判断しました。
第三に、issues 配列の中身を週次でレビューして、誤検出が多い指摘パターンを system_instruction に書き戻すこと。3 ヶ月くらいでドメイン特化の評価器に育っていきます。
コスト試算: 月 5,000 翻訳を約 $4 で回す
具体的な金額感を共有させてください。あくまで 2026 年 5 月時点の Gemini 2.5 Pro の価格(input $1.25/1M tokens, output $10/1M tokens)での試算です。
1 翻訳の評価あたり、入力 ~600 トークン(system_instruction + glossary + 5 件の prior_examples + ソース・翻訳)、出力 ~150 トークン(JSON のスコアと issues)で見積もると、1 件あたり約 $0.00225 です。月 5,000 件で約 $11。
ここに Context Caching を組み合わせます。system_instruction と「言語ごとの用語集」「言語ごとの prior_examples 上位 100 件」は時間をまたいで使い回せます。これらをまとめて Cached Content にしておくと、キャッシュヒット時の入力コストが大幅に下がります。私の運用では、評価リクエストの 70〜80% がキャッシュヒットになり、全体で月 $4 前後に収まっています。
さらに、ローカリゼーション QA は本質的に「単純な翻訳ペアの一致度確認」がほとんどなので、initial pass を Gemini 2.5 Flash で走らせ、スコアが 3.0〜4.0 のグレーゾーンだけ Pro に再評価させる、という 2 段階ルーティングも有効です。これだとさらに半額程度になります。
よくある落とし穴と回避策
3 年ほどこの仕組みを運用してきて、いくつかの典型的な落とし穴に当たりました。
ひとつめは「ソース文字列の英語が曖昧なまま QA を走らせてしまう」ケースです。"Save" のような短い文字列は、日本語に訳す時点で「保存/保管/救う」のどれかが選べません。これは Gemini を責めても解決しないので、ソース側に必ず screen_id と control_kind を渡し、必要なら comment フィールド(Apple の .stringsdict でも対応)でコンテキストを補足することが先決です。
ふたつめは「Gemini の評価スコアを信じすぎる」ケースです。スコアが 4.5 でも、UI に流し込むと文字が枠に収まらない、という物理的な問題は別レイヤーで見る必要があります。文字数チェック(target_lang_len / source_lang_len の比率)は別ステップで必ず入れています。
みっつめは「全言語をひとつのジョブで評価する」設計です。最初これでやっていたのですが、Gemini API の rate limit に当たって全体が落ちる事故が何度かありました。matrix: で言語ごとにジョブを分割し、max-parallel: 4 程度に抑えると安定します。
次の一歩
もしいま、自分のアプリでローカリゼーションの「気づかれない劣化」に心当たりがあるなら、最初の一週間は「ゲートなしの観測モード」だけ動かしてみることをおすすめします。スコア分布をプロットして、自分のアプリ/言語構成に合った閾値の感覚を掴むのが、長く運用するための一番の近道でした。
私自身、まだこの仕組みを磨いている途中で、特に非ラテン系言語の評価精度には改善の余地を感じています。同じ課題に向き合っている方の参考になれば嬉しいです。お読みいただきありがとうございました。