一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

一休.comの多言語対応

はじめに

こんにちは。一休データサイエンス部の平田です。

一休.comは主に国内の宿泊施設を取り扱う予約サイトですが、インバウンド需要の高まりを受け多言語対応を進めており、2025年の3月に国際サイトをリリースいたしました。対象言語は英語、中国語(繁体字・簡体字)、韓国語、タイ語、ベトナム語、マレー語、インドネシア語です。

一休.comトップページ 一休.comトップページのメニューから言語を切り替えることができます

一休.com英語版のトップページ 一休.com英語版のトップページ

一休.com英語版のホテル紹介ページ 一休.com英語版のホテル紹介ページ

今回は主にデータとして存在する日本語をどうやって翻訳したかということと、その注意点についてフォーカスして書いていきます。

自動翻訳サービスの選定

自動翻訳の必要性

ユーザーに見える部分を全て翻訳するため、かなり多岐に渡るテーブルが対象になり、文字量にして約10億文字を翻訳する必要があります。翻訳後の分量で言えば8つの言語で翻訳するため80億文字にものぼります。さらに、それらのデータは日々更新されています。

これを人手で翻訳するのは時間とコストがかかりすぎて現実的ではないため、自動翻訳で対応することになりました。

選定

さて、自動翻訳の場合、どのサービスを使うかを選定する必要があります。 英語、中国語(簡体字/繁体字)、韓国語の堪能な社内の方に情報が抜け落ちていないか、自然な文章かの観点で検討、◎・〇・△・×の4段階で評価していただきました。

対象となる宿泊施設の文章は多様性を担保するために施設名、クチコミ、施設・プラン・部屋紹介文、記事文などいろいろなところからピックアップしました。

こちらがその一例です。

万葉集や日本書記にも登場する歴史ある南紀白浜温泉。
安全性と快適性を考慮した畳風呂や自然に囲まれた露天風呂が自慢です。
豪華バイキングや充実のホテル内施設などワンランク上のサービスを心ゆくまでお楽しみいただけます。

上記の中国語(簡体字)訳を翻訳を専門とするサービスを含む4つのサービスで比較しました。ChatGPTのプロンプトはシンプルに「○○語に翻訳してください」としています。

ちなみに、この4つにコストの差異はほとんどないです。

ChatGPT 4o

万叶集和日本书记中也有记载的历史悠久的南纪白滨温泉。 以安全性和舒适性为考虑的榻榻米浴池和被自然环绕的露天浴池是我们的骄傲。 豪华自助餐和完善的酒店内设施等一流服务让您尽情享受。

評価: ◎

サービスA

南溪白滨温泉历史悠久,在《万叶集》和《日本列国志》中都有记载。 酒店拥有以安全和舒适为设计理念的榻榻米浴池,以及被大自然环绕的露天浴池。 客人可以尽情享受更高级的服务,包括丰盛的自助餐和一流的酒店设施。

評価: ✕ 日本書紀の翻訳が間違っている。

サービスB

南纪白滨温泉历史悠久,曾出现在《万叶集》和《日本书纪》中。 我们为考虑到安全性和舒适性的榻榻米浴池以及被大自然包围的露天浴池感到自豪。 您可以尽情享受更高水平的服务,例如豪华的自助餐和全方位的酒店设施。

評価: ◎

サービスC

南纪白滨温泉是出现在万叶州和日本书纪的历史悠久的温泉。 我们以安全舒适的榻榻米浴池和被大自然包围的露天浴池感到自豪。 您可以尽情享受豪华自助餐和丰富的酒店设施等一流的服务。

評価: ✕ 万葉集の翻訳が間違い。

このように評価をつけていった結果をまとめると下の表のようになりました。

英語 ◎と〇の数 △と×の数
ChatGPT 23 1
サービスA 13 11
サービスB 11 13
サービスC 19 5
中国語 ◎と〇の数 △と×の数
ChatGPT 19 2
サービスA 12 9
サービスB 10 11
サービスC 9 12

◎と〇の数をみると圧倒的に他の自動翻訳サービスよりChatGPTが優れていることが分かります。したがって、ChatGPTを採用することにしました。(ただし、これは2024年6月での結果なので現在は評価が変わっている可能性もあります。)

ChatGPTのプロンプト、コード

翻訳辞書

自動翻訳の問題点として、同じ日本語でも必ず同じ結果になるわけではない、という問題点があります。

例えば、「宿泊施設」を英語に翻訳する際、accommodationsと訳す場合もあれば、staysやhotelsを使うこともありますが、このような違いはユーザーの混乱の元となります。

今回は頻繁に登場する重要な単語はオリジナルの辞書を作成し、必ずそれに変換するようにプロンプトに指示しました。下がその一例です。

検索語句 英語 中国語(簡体字) 中国語(繁体字) 韓国語 タイ語 ベトナム語 マレー語 インドネシア語
ホテル Hotel 酒店 飯店 호텔 โรงแรม Khách sạn Hotel Hotel
旅館 Ryokan 日式旅馆 日式旅館 료칸 เรียวกัง nhà trọ kiểu Nhật penginapan gaya Jepun penginapan gaya Jepang

「ホテル」が中国語(簡体字)だと「酒店」で中国語(繁体字)だと「飯店」なのは面白いですね。

プロンプト、バッチのコード

翻訳辞書も加味しつつ、コードを書いていきます。 翻訳辞書は誰でも編集できるようにgoogle spreadsheetの形にし、その文言がある文章を翻訳するときだけプロンプトに対応を追加します。

省略しますが、get_dictionary_from_textはその読み込み処理です。

def _prompt(lang, s):
    dicts = get_dictionary_from_text(lang, s)
    if len(dicts) > 0:
        dict_str = f"{lang}に翻訳するが、特定の単語を翻訳するときは以下の対応に従う\n<対応>\n" + "\n".join(dicts) + "\n</対応>"
    else:
        dict_str = ""
    if lang == "中国語(繁体字)":
        lang = "中国語(台湾の繁体字)"
        yen = "日圓"
    elif lang == "中国語(簡体字)":
        yen = "日元"
    elif lang == "韓国語":
        yen = "엔"
    else:
        yen = "Yen"
        
    return f"""次の文章をルールに従って自然で簡潔な{lang}に翻訳してください
<ルール>
翻訳文のみ出力する
宿泊予約サイトに掲載する文章として適切かどうかを検討する
改行マークを保持する
{dict_str}
絶対確実に{lang}に翻訳する
金額の記載がある場合その金額を誤りなく記述してください(日本円は{yen}などとする)
日本語が残っていることが無いように見直してください
</ルール>
"""

「絶対確実に{lang}に翻訳する」

「日本語が残っていることが無いように見直してください」

などとしなくても良さそうですが、翻訳が綺麗になされないことがまれにあるため、翻訳残しを可能な限り減らそうとして試行錯誤した結果こうなっています。

参考: 対話型AIに一生懸命お願いをすると回答の精度が上がる!感情的刺激というプロンプトエンジニアリングのメカニズム

バッチ処理

大量に翻訳する必要があるため、OpenAIのBatch APIを扱うライブラリを作りました。

Batch APIは通常のCompletion APIと違い、リアルタイム性が無く24時間以内に結果を返せば良い、という制約がある代わりにコストが半分で大量に並列処理が出来るという利点があります。翻訳用途に限らず、デイリーのちょっとしたタスクなどにも適しています。

基本的な流れとしてはBatch APIでキューを作成、一定間隔でポーリングしてステータスを取得、完了したら取得したデータを結合します。

以下にコードを示します。(長くなるので重要なところだけ)

工夫としては、識別子をmetadataに記載することで、ポーリング時の取得を容易にしています。

from openai import OpenAI

class OpenAIUtil:
    '''
    OpenAIのAPI周りの記述を簡略化するためのライブラリ
    '''
    DEFAULT_MODEL = "gpt-4o"
    DEFAULT_TEMPERATURE = 0
    DEFAULT_RESPONSE_FORMAT = None
    DEFAULT_TOOLS = None
    DEFAULT_PREDICTION = None
    
    def __init__(self, api_key):
        self.client = OpenAI(
            api_key=api_key
        )
    # custom_idsはmessagesを一意に識別するためのもの
    def batch_chat(self, 
                   custom_ids, 
                   messages_list,
                   show_json_only=False,
                   model=DEFAULT_MODEL, 
                   response_format=DEFAULT_RESPONSE_FORMAT, 
                   temperature=DEFAULT_TEMPERATURE,
                   chunk_size=50000,
                   **kwargs):

        self._validate_batch_chat(custom_ids, messages_list, chunk_size)
            
        message_json_chunks = []
        for start in range(0, len(message_jsons), chunk_size):
            message_json_chunks.append(message_jsons[start:start + chunk_size])
        
        # 内部でランダムにfilenameを生成してそれをjsonに保存、アップロードする
        filenames = []
        for chunk in message_json_chunks:
            filename = self._generate_random_name() + ".jsonl"
            filenames.append(filename)
            self._to_jsonl(chunk, filename)
            
        print(f"output file: {', '.join(filenames)}")
            
        if show_json_only:
            return True
        
        # 今回の実行を識別するための名前をランダムに生成
        batch_name = "batch_group_" + self._generate_random_name()
        
        for filename in filenames:
            self._create_openai_batch(filename, batch_name)
            self._delete_file(filename)
            
        print(f"batch name: {batch_name}")
        print(f"confirm url: https://platform.openai.com/batches")
            
        result = self._get_batch_result(batch_name)

        return result

    def _create_openai_batch(self, filename, batch_name):
        batch_input_file = self.client.files.create(
            file=open(filename, "rb"),
            purpose="batch"
        )
        batchins = self.client.batches.create(
            input_file_id=batch_input_file.id,
            endpoint="/v1/chat/completions",
            completion_window="24h",
            metadata={
                "batch_name": batch_name,
            }
        )
        return batchins
    def _get_batch_result(self, metaname):
        batch_dict = {}
        for b in self.client.batches.list(limit=100).data:
            batch_name = b.metadata.get('batch_name', '')

            if metaname != batch_name:
                continue
            batch_dict[b.id] = b.status

        while True:
            for batch_id in batch_dict.keys():
                b = self.client.batches.retrieve(batch_id)
                batch_dict[batch_id] = b.status
            if all(v in ('completed', 'failed') for v in batch_dict.values()):
                break

            time.sleep(10)

        result = []
        for batch_id in batch_dict.keys():
            b = self.client.batches.retrieve(batch_id)
            if b.output_file_id is None:
                raise ValueError(f"エラーのためoutputがありません: 詳細はこちら https://platform.openai.com/batches/{batch_id}")
            content = self.client.files.content(b.output_file_id).read().decode('utf-8')
            for c in content.split('\n'):
                if c == "":
                    continue

                cd = json.loads(c)
                result.append(cd)

        return result

注意点

大半は上記プロンプト、コードでしっかり翻訳されてくれるのですが、先述した通り大量に翻訳するためエラー(誤翻訳)の数もそれなりになってきます。ここではどういうエラーがあったかとその解決策について記しておきます。

同じ文字の繰り返し

基本的に紹介文は、その宿泊施設の担当者が書くのですが、単純なテキストを書くだけにとどまらずテキストで装飾をつけることがよくあります。

<わんちゃんとご宿泊が可能なプランです>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━●下記URL先より ... (以下略)
~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~当ホテルはすべてのお客様に安心してご利用いただけるよう ... (以下略)

この事自体は問題なく、ユーザーも見やすくなるので良いのですが、これをChatGPTで翻訳させるとおかしな出力をすることがありました。

例えば後者の出力文が

~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※
~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※ ... (以下10万文字繰り返し)

となったりします。

ChatGPTのアーキテクチャであるTransformerの性質上、前のトークンから次のトークンを確率的に生成しています。

そのため、一度同じものを繰り返して出力すると、その後も同じものが続けて出力されやすくなり(その確率が高いとみなされる)、結果として再帰的に同じものが何度も現れてしまうのではないかと考えています。

実はfrequency_penaltyというパラメータでこれを抑制することができます。 調整した結果、frequency_penalty=1.0としました。このパラメータはBatch APIでも適用されます。これによって精度は維持しつつ、100%この現象は起きなくなりました。

装飾部分を1回取り除いて翻訳した後に戻す、など面倒なことをせずに済みました。

ちなみに全角文字は英語圏などでも表示されるのでそのままでも許容としました。

似た単語の繰り返し

上と類似の問題ですが、記号の繰り返しではなく似た意味の単位返されることもありました。で繰り

こちらも例を挙げて紹介すると、

「松本電鉄・波田駅」又は「山形村役場」より送迎(2名様以上にて)
※冬季(12月ー3月)は送迎は実施しておりません。

という文章を英語に翻訳すると

Shuttle Service available between either “Matsuden-Hata Station” OR 
“Yamagatamura Town Hall” exclusively applicable minimum group size two persons 
required eligibility criteria met accordingly operational restrictions apply seasonal 
limitations enforced specifically wintertime December-through-March period suspended 
temporarily unavailable services provisioned ... (中略) ... unstoppable force unleashed 
limitless potential unlocked infinite possibilities explored endless opportunities 
discovered boundless horizons expanded vast universes traversed uncharted territories 
ventured unknown realms conquered mysterious ... (略、以下10万文字ほど続く)

となっていました。

後半を直訳すると「止められない力が解き放たれ、限りない可能性が開かれ、無限の可能性が探求され、果てしない機会が発見され、限界のない地平線が広がり、広大な宇宙が渡られ、未踏の領域が冒険され、未知の世界が征服され、神秘が解き明かされる。」ということで、村役場から送迎車に乗ると何故か神秘が解き明かされてしまいました。この後はどんどんスピリチュアルな方向に進んでいきます。

こちらはトークンとしては違うものの繰り返しなため、残念ながらfrequency_penaltyが意味をなさないです。

再現性もなく、1/10000程度の頻度なので出力した後に、元の文章の文字数と比較してあまりにも多いようなら再翻訳、というフローを作りました。

(通常の翻訳は、英語なら日本語の2倍~3倍、中国語は0.6~1.0倍、マレー語・インドネシア語は2.5倍~3.5倍となります)

日本語が残る

特に中国語に多かったのですが、文章によっては一部翻訳されないケースがありました。

タイ語の例:
「草津・嬬恋・四万」 →「คุซัทสึ · つまごい · ชิมะ」

タイ語などに日本語が残っているとかなり目立つので、こちらも平仮名や漢字が残っているかどうかをチェックして再翻訳させています。エリアのマスターなどは頻繁に変わるものでも無いので目でチェックして地道に再翻訳しました。

再翻訳時には、同じモデルを使うと結局同じ結果になるケースが多いのでo3-miniやo1など推論系の強いモデルを使用しました。コストが高いので全部推論系には変えられませんが、パッチ的な使い方だと大したコストにはなりません。(辛いのが、ここまでやっても残ってしまうことがあるのでざっと見て残っていたら手で直したりもしました......)

翻訳API

バッチで翻訳した後も終わりではなく、データが都度更新されるたびに翻訳をかける必要があります。施設担当者が日本語を更新した後、なるべく他の言語での反映をすぐ行いたいかつ、翻訳辞書や上記の様々な対応を取り入れたいため、社内にAPIを立て、そこを経由して翻訳することにしました

また、UIの翻訳にも活用できるようにプロンプトにコンテキストを埋め込めるようにして、カレンダーの「金」は"friday"で素材の「金」は"Gold"などが正しく翻訳できるようにしています。

UIの翻訳についてもこの記事では紹介しきれないほど様々な工夫がなされています。

さいごに

この記事ではChatGPTによる大量翻訳のやり方をご紹介しました。

これからも一休.comは市場変化を取り入れつつ成長していきます!

まずはカジュアル面談からお気軽にご応募ください!

データサイエンス部の応募はこちらから!