Gosuke Miyashita archive about feed

Qwen3のトークナイザーは日本語をどう扱うか

2 August 2026

前の記事 で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

意外なことに、日本語のほうが少ないくらいだった。 yesterdaydifficult が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に投げるプロンプトを短くしたいとき、カタカナ語を漢語に言い換えると効く場面がありそうだが、そこまでして削る価値があるかは場合によりそう。


トークナイザーの実装そのものについては 前の記事 に書いた。次回は出力ヘッドとサンプリングを実装して、テキストからテキストへの推論をつなげる予定。