Qwen3のトークナイザーは日本語をどう扱うか
前の記事
でPyxisにトークナイザーを組み込んだ。その過程でQwen3の
tokenizer.json
の中身をあれこれ覗いていたのだが、日本語の扱いが思っていたのと違っていて面白かったので、そこだけ切り出して書く。
前提として、BPEやbyte-level BPE、pre-tokenizerといった用語は前の記事で説明しているので、そちらを読んでいる前提で進める。分割の実験にはHuggingFaceの tokenizers を使っている。
日本語ではほぼ区切られない
トークナイザーはBPEでマージする前に、pre-tokenizerという処理で正規表現を使ってテキストを大まかに区切る。単語の境界をまたいだマージが起きないようにするための仕組みで、Qwen3では次の正規表現が使われている。
(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+
これが日本語に対してどう働くのか、実際にかけてみた。
'I like this' -> ['I', ' like', ' this']
'こんにちは、世界' -> ['こんにちは', '、世界']
'私はラーメンを食べた' -> ['私はラーメンを食べた']
英語は3つに区切られるのに、日本語は文が丸ごと1つの塊になっている。
理由は主力パターンの
\p{L}+
にある。
\p{L}
はUnicodeの「文字」カテゴリで、漢字もひらがなもカタカナもすべて含まれる。英語は単語の間にスペースがあり、スペースは
\p{L}
ではないのでそこで連続が途切れて区切りになるが、日本語は分かち書きしないので全部つながったままマッチする。
こんにちは、世界
が2つに割れたのは読点
、
が文字ではないためで、つまり日本語で区切りが入るのは句読点や記号の位置だけ。
ということは、pre-tokenizerの「マージが単語境界をまたがないようにする」という働きが、日本語ではほとんど効いていない。文全体が1つの塊としてBPEに渡され、その中で自由にマージが起きる。実際にトークン化してみるとこうなる。
'これはペンです' -> ['これは', 'ペン', 'です']
'今日はいい天気ですね' -> ['今日は', 'いい', '天', '気', 'ですね']
これは
や
今日は
のように名詞と助詞がくっついたトークン、
ですね
のような活用語尾のトークンができている。英語なら
like this
が1トークンにならないよう防いでいたのと同じ種類のマージが、日本語では普通に起きているということ。
文字の途中で切れるトークン
もうひとつ面白い例。
'私はラーメンを食べた' -> ['私は', 'ラ', 'ー�', '�', 'ン', 'を', '食べた']
ラーメン
の部分が文字化けしている。これはトークンの境界が「メ」という1文字の
途中
で切れているため。「メ」はUTF-8で
E3 83 A1
の3バイトだが、前半
E3 83
が
ー
と一緒に1つのトークンになり、残りの
A1
が単独で次のトークンになっている。どちらも単体では正しいUTF-8にならないので、個別にデコードすると
�
(U+FFFD、不正なバイト列を表す置換文字)が出てくる。byte-level BPEはバイト単位でマージするので、こういう文字の途中で切れるトークンが生まれることがある。もちろん全部繋げてデコードすればちゃんと「私はラーメンを食べた」に戻る。
これは生成のときに効いてくる。トークンを1個ずつ生成しながら順次表示していく場合、
ー�
のような中途半端なトークンをその時点で表示すると文字化けする。生成が進んで次のトークンが来ればバイトが揃って正しい文字になるので、ストリーミング表示するにはデコードできないバイトを次まで持ち越す必要がある。
tokenizers
にはこのための
decode_stream
というAPIがある。
これは他人事ではなく、実は次回書く生成ループが
decode(&[next_token_id])
とトークンを1個ずつ単独でデコードして表示していて、まさにこの問題を踏んでいる。英語のプロンプトで動かしている限りは表面化しないので、そのまま気づかず書いていた。
トークン効率
ついでにトークン効率も測ってみた。同じ内容の文で比べるとこうなる。
| 文 | 文字数 | トークン数 | 文字/トークン |
|---|---|---|---|
| The quick brown fox jumps over the lazy dog. | 44 | 10 | 4.40 |
| 素早い茶色の狐が怠けた犬を飛び越える。 | 19 | 16 | 1.19 |
英語は1トークンで4文字ぶん運べるのに、日本語は1文字ちょっと。ここだけ見ると日本語がかなり不利に見える。
ただしこの指標はフェアではない。分母の「文字数」の重みが言語によって違うからだ。
kitchen
は7文字だが「台所」は2文字で、同じ意味を運ぶのに必要な文字数がそもそも3倍以上違う。日本語は少ない文字数で書ける分、文字数で割ると値が小さく出る。1.19という数字は「日本語は1トークンで1文字ぶんの意味しか運べない」という意味ではない。
そこで、意味の対応する単語同士で、何トークンになるかを比べてみた。
| 英語 | トークン数 | 日本語 | トークン数 |
|---|---|---|---|
| kitchen | 2 | 台所 | 2 |
| government | 1 | 政府 | 1 |
| yesterday | 2 | 昨日 | 1 |
| understand | 2 | 理解 | 1 |
| difficult | 2 | 難しい | 1 |
意外なことに、日本語のほうが少ないくらいだった。
yesterday
や
difficult
が2トークンなのに「昨日」「難しい」は1トークンで収まっている。Qwen3は中国語圏のモデルということもあってか、漢字を含む語彙はかなり充実している。
一方で明確に弱いのがカタカナ語で、
トークナイザー
は4トークンに割れる(
tokenizer
は1トークン)。日本語のトークン数が増えるのは、語彙が貧弱だからというより、こうした外来語や、送り仮名・活用語尾のような細かい単位が積み重なるからだと思う。
文単位でも測ってみたが、こちらは文によって傾向がかなり違った。
| 日本語の文 | 英語 | 日本語 |
|---|---|---|
| 私は駅に行って切符を買った。 | 10 | 9 |
| ご質問があればお知らせください。 | 10 | 9 |
| このライブラリは高速なBPEトークナイザーを提供する。 | 9 | 17 |
素の和文なら英語より少ないこともあるが、カタカナ語が入ると一気に膨らむ。技術文書は外来語だらけなので、体感として日本語は不利、というのはこのあたりが効いていそう。APIの課金はトークン単位なので、そのままコスト差になる。
カタカナを漢語にすると縮む
カタカナの弱さが分かりやすく出るのが「日本語のトークン化」という文字列。9文字で7トークンに割れる。
日本 / 語 / の / トー / ク / ン / 化
「日本」は2文字で1トークンにまとまっているのに、「トークン」の4文字は
トー
ク
ン
と3トークンに分解されている。
試しに同じ意味を漢語で書き換えてみると、差がはっきりする。
日本語の字句解析 -> 日本 / 語 / の / 字 / 句 / 解析
8文字で6トークン。文字数はほとんど変わらないのに、カタカナを漢語にするだけでトークンが1つ減る。「解析」が2文字で1トークンに収まる一方、「トークン」は4文字使って3トークンかかっている。
LLMに投げるプロンプトを短くしたいとき、カタカナ語を漢語に言い換えると効く場面がありそうだが、そこまでして削る価値があるかは場合によりそう。
トークナイザーの実装そのものについては 前の記事 に書いた。次回は出力ヘッドとサンプリングを実装して、テキストからテキストへの推論をつなげる予定。