LLMによるカタログ翻訳パイプラインの初版は通常動作しますが、まだ誤りがあります。誰も触っていないフィールドを再翻訳し、モデルが電圧を書き換えたり、プレースホルダーを削除したり、SKUをより良く見えるように変えたりした説明を平然と公開します。

私は国際向けに製品カタログを翻訳・適応させるLLMパイプラインを構築してきました。翻訳品質自体が悩みの種になったことはありません。デモスクリプトを本番のカタログに適用できるようにする仕組みは、以下の2つです。出力に影響し得るすべての入力を含むキャッシュキーと、モデルの応答を信頼せず安全性を証明するまで使用しないバリデータです。

作業単位は製品ではなくフィールド

製品全体を1つの塊として翻訳すると、どの属性を編集しても全体が無効化され、モデルの出力のどの部分が変わったのか判別できません。さらに悪いことに、「タイトルはレビュー済み、説明は未レビュー」と明言できなくなります。

そこでパイプラインは (product_id, field, target_lang) の3つ組で動作します。各要素は他の処理に先立ち正規化されます。スクレイパー由来の不安定な空白やエンコーディング差異が、実際には変更されていないテキストに対するキャッシュミスを引き起こすためです。

import hashlib
import re
import unicodedata

WHITESPACE = re.compile(r'\s+')

def normalize(text: str) -> str:
    text = unicodedata.normalize('NFC', text)
    text = text.replace('\u00a0', ' ')
    return WHITESPACE.sub(' ', text).strip()

Enter fullscreen mode Exit fullscreen mode

この正規化は見た目の問題ではありません。カタログソースはノーブレークスペースや末尾タブ、クロールごとに変わる混在Unicode形式を出力します。正規化がなければ、意味的には同一でもバイトが異なるテキストの再翻訳に多額のコストが浪費されます。

キャッシュキーは出力に影響し得るすべてを含まなければならない

最もよく見るミスは、ソーステキストだけでキャッシュキーを構成することです。その後システムプロンプトが編集されたり、モデルバージョンが上がったりすると、2世代の出力が混在し、判別できなくなります。

文字列生成に関与するすべてをキーに含めます。

PROMPT_VERSION = 'catalog-v4'

def cache_key(
    source: str,
    field: str,
    target_lang: str,
    model: str,
    glossary_version: str,
) -> str:
    payload = '\u241f'.join([
        normalize(source),
        field,
        target_lang,
        model,
        PROMPT_VERSION,
        glossary_version,
    ])
    return hashlib.sha256(payload.encode('utf-8')).hexdigest()

Enter fullscreen mode Exit fullscreen mode

区切り文字は見た目以上に重要です。単なるカンマやスペースで結合すると、異なるタプルが同一キーへ衝突します。入力に含まれ得ない文字を使用してください。

キーは翻訳とともに保存し、モデルとプロンプトバージョンも別列で保持します。プロンプトを更新した際、「どの行が古くなったか、更新コストはいくらか」という運用上唯一重要な質問を、推測ではなくクエリで答えられます。プロンプト変更は言語別・カテゴリ別のスライスで実行可能なマイグレーションになり、カタログ全体の再翻訳を避けられます。

ガードレール:ソースから不変条件を抽出し、出力で検証する

翻訳モデルはテキスト生成器であり、保証付きの変換器ではありません。時に小数点区切りをローカライズしたり、指示されていない単位を変換したり、テンプレートプレースホルダーを「文が読みやすくなるから」と削除したりします。これらは例外を投げません。カタログに残り、後で誤った仕様に関するサポートチケットとして表面化します。

解決策はより良いプロンプトではなく、ソースから不変条件を抽出し、それが維持されたかを検証するバリデータです。

PLACEHOLDER = re.compile(r'\{[a-z_]+\}')
HTML_TAG = re.compile(r'</?[a-z][a-z0-9]*[^>]*>', re.IGNORECASE)
NUMBER = re.compile(r'\d+(?:[.,]\d+)?')


class InvariantError(Exception):
    pass


def numbers(text: str) -> list[str]:
    return [n.replace(',', '.') for n in NUMBER.findall(text)]


def check_invariants(source: str, translated: str) -> None:
    src_ph = sorted(PLACEHOLDER.findall(source))
    out_ph = sorted(PLACEHOLDER.findall(translated))
    if src_ph != out_ph:
        raise InvariantError(f'placeholders changed: {src_ph} -> {out_ph}')

    src_tags = sorted(t.lower() for t in HTML_TAG.findall(source))
    out_tags = sorted(t.lower() for t in HTML_TAG.findall(translated))
    if src_tags != out_tags:
        raise InvariantError('markup changed')

    src_nums = sorted(numbers(source))
    out_nums = sorted(numbers(translated))
    if src_nums != out_nums:
        raise InvariantError(f'numbers changed: {src_nums} -> {out_nums}')

Enter fullscreen mode Exit fullscreen mode

シーケンスではなくソート済みマルチセットを比較するのは意図的です。語順は言語間で正当に変わりますが、数値は変わりません。比較前の小数点正規化は、カンマを使うロケールへの翻訳時にモデルが 1.51,5 と整形するためです。これはレンダリング判断であり値の変更ではないため、フォーマッタに任せるべきで、モデルに任せるべきではありません。

このバリデータは意図的に安価で機械的です。翻訳の良し悪しを判断しません。モデルが商業的・法的意味を持つ何かを静かに変更しなかったことを証明するだけです。

失敗ポリシーが本当の設計判断

検証に失敗した場合、3つの選択肢があり、どれを選ぶかが意見を必要とする部分です。

def translate_field(client, source, field, lang, model, glossary_version):
    key = cache_key(source, field, lang, model, glossary_version)
    cached = store.get(key)
    if cached is not None:
        return cached

    violation = None
    for _ in range(2):
        out = client.translate(
            source=source,
            field=field,
            target_lang=lang,
            model=model,
            correction=violation,
        )
        try:
            check_invariants(source, out)
        except InvariantError as exc:
            violation = str(exc)
            continue
        store.put(key, out, status='ok')
        return out

    store.put(key, source, status='needs_review', reason=violation)
    return source

Enter fullscreen mode Exit fullscreen mode

1回のリトライを行い、リトライには具体的な違反内容をモデルへ返します。「もう一度試せ」という汎用指示ではありません。破られた制約を明示することが、2回目の試行を1回目と実質的に異なるものにします。単なるリトライは同じ失敗を再サンプリングするだけです。

その後は停止します。ソース文字列へフォールバックし、行にフラグを立てます。未翻訳の英語テキストをローカライズ済みカタログへ公開するのは、目に見える正直な劣化です。仕様が変わった翻訳を公開するのは目に見えない劣化であり、カタログデータの目に見えない失敗は、支払いアトリビューションの目に見えない失敗と全く同じように振る舞います。人間が文句を言うまで誰も気づかず、その時点でいつから誤っていたか、何行影響を受けているか判別できません。

needs_review キューは、ここでの成果物であり、あったら嬉しいものではありません。パイプラインの実際のエラー率を観測できる唯一の場所です。

やり直すとしたら

用語集(ブランド名、翻訳してはならない製品用語、単位表記)は初日からバージョン管理されたデータテーブルとして保持し、プロンプトへ貼り付けたテキストとしては扱いません。用語集がプロンプト内にあると、用語集の編集がキャッシュ全体を暗黙に無効化し、実際に変更された用語だけで更新をスライスできなくなります。

また、自由形式のプレースホルダーを翻訳可能テキストへ入れないようにします。Regexで {price} にマッチさせる方法は、ネストした波括弧やプレースホルダーの隣に通貨記号があるテンプレートを書かれるまでしか機能しません。翻訳前に文字列を構造化セグメントへ分割し、翻訳後に再構成すれば、バリデータの偽陰性というクラスの問題を丸ごと排除できますが、レンダラはより複雑になります。

より広い視点:データパイプライン内のLLMは、以前は決定的だった場所に非決定的なコンポーネントを置くことです。上記は、冪等性キーや境界での不変条件チェック、明示的な失敗状態など、信頼できない依存関係の周りに構築する内容です。モデルは新しいものです。それを取り巻くエンジニアリングは新しいものではありません。