【JavaScript】日本語の文章から単語を分割する Intl.Segmenter

前回の記事で日本語の文字を正しく分割する方法に Intl.Segmenter を使いましたが、Intl.Segmenter は文章を単語で分割することもできます。以前は形態素解析のオンラインサービスに問い合わせないと実現できなかった日本語の単語の分割ですが、現在はローカルで実現できるようになりました。しかし、私たちが期待する形態素解析をベースにした単語分割とは少し事情が違うようです。今回のそのあたりの事情を深堀してみたいと思います。

日本語の単語分割の実行例

では JavaScript で日本語の文章の単語分割をしてみましょう。まずは、「隣の客はよく柿食う客だ」を単語に分割してみます。

const string = '隣の客はよく柿食う客だ';
const segmenter = new Intl.Segmenter('ja-JP', { granularity: 'word' });
const segments = segmenter.segment(string);

for (const segment of segments) {
    console.log(JSON.stringify(segment));
}

Intl.Segmenter コンストラクタのパラメータ granularity"word" を指定すると、文章を単語で分割する Intl.Segmenter オブジェクト (変数 segmenter) が生成されます。このオブジェクトの segment() メソッドに文章を引き渡すと、指定の文章を単語に分割した結果を表す Intl.Segments インスタンス (変数 segments) が得られます。

Intl.Segments インスタンス (変数 segments) は Array オブジェクトではありませんが、イテレータープロトコルを実装していますので、for 文を使ってリスト内の要素を走査することができます。その一つ一つの要素は通常のオブジェクト (変数 segment) で、その内容を出力すると次のようになります。

{"segment":"隣","index":0,"input":"隣の客はよく柿食う客だ","isWordLike":true}
{"segment":"の","index":1,"input":"隣の客はよく柿食う客だ","isWordLike":true}
{"segment":"客","index":2,"input":"隣の客はよく柿食う客だ","isWordLike":true}
{"segment":"は","index":3,"input":"隣の客はよく柿食う客だ","isWordLike":true}
{"segment":"よく","index":4,"input":"隣の客はよく柿食う客だ","isWordLike":true}
{"segment":"柿","index":6,"input":"隣の客はよく柿食う客だ","isWordLike":true}
{"segment":"食う","index":7,"input":"隣の客はよく柿食う客だ","isWordLike":true}
{"segment":"客","index":9,"input":"隣の客はよく柿食う客だ","isWordLike":true}
{"segment":"だ","index":10,"input":"隣の客はよく柿食う客だ","isWordLike":true}

うまく分解できたように見えますね。では、Intl.Segmenter の単語分割機能について、もう少し深く踏み込んでみましょう。

うまく単語に分割できない文章

少し意地悪をしてみましょう。漢字ばかりの「東京特許許可局」を単語分割すると次の結果が得られました。

{"segment":"東京","index":0,"input":"東京特許許可局","isWordLike":true}
{"segment":"特許","index":2,"input":"東京特許許可局","isWordLike":true}
{"segment":"許可","index":4,"input":"東京特許許可局","isWordLike":true}
{"segment":"局","index":6,"input":"東京特許許可局","isWordLike":true}

うまくいっているように見えます。では、さらに意地悪をして、形態素解析で良く例として挙げられる「すもももももももものうち」を単語分割してみましょう。すると、次のような結果が得られます。

{"segment":"すもも","index":0,"input":"すもももももももものうち","isWordLike":true}
{"segment":"も","index":3,"input":"すもももももももものうち","isWordLike":true}
{"segment":"も","index":4,"input":"すもももももももものうち","isWordLike":true}
{"segment":"も","index":5,"input":"すもももももももものうち","isWordLike":true}
{"segment":"も","index":6,"input":"すもももももももものうち","isWordLike":true}
{"segment":"も","index":7,"input":"すもももももももものうち","isWordLike":true}
{"segment":"もの","index":8,"input":"すもももももももものうち","isWordLike":true}
{"segment":"うち","index":10,"input":"すもももももももものうち","isWordLike":true}

もはや、単語ではなく文字で分解したのと変わらない状態になってしまいました。そして「もの」は単語の区切りですらありません。

ちなみに形態素解析ツールとして有名な「MeCab」で解析すると、次のような結果が得られます。

すもも  名詞,一般,*,*,*,*,すもも,スモモ,スモモ
も      助詞,係助詞,*,*,*,*,も,モ,モ
もも    名詞,一般,*,*,*,*,もも,モモ,モモ
も      助詞,係助詞,*,*,*,*,も,モ,モ
もも    名詞,一般,*,*,*,*,もも,モモ,モモ
の      助詞,連体化,*,*,*,*,の,ノ,ノ
うち    名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ

さすがですね。人が見てもすぐには区切りが分かりにくそうな「ひらがな」だけの文章でも期待通りに分割してくれます。そして、分割されたセグメントの品詞を推定してくれるところが便利です。

Intl.Segmenter の単語分割は何をしているのか

Intl.Segmenter の単語分割は、私たちが期待する形態素解析をベースにした単語分割ではありません。

Intl.Segmenter は ECMAScript 仕様で規定されているのですが、単語の区切りの発見については「18.8 Abstract Operations for Segmenter Objects」の節で言及しています。そこには、区切りの発見は実装依存とあります。つまり、ウェブブラウザー次第ということになります。

実際には各ウェブブラウザーは Intl.Segmenter の単語分割では辞書による分解をベースにしており、知らない単語が出現するとうまく分解できないようです。また、品詞の推定も行っていないようです。そのため、頻出頻度が低い専門用語や、ひらがなばかりの文章を分割するのは非常に苦手なのです。

ECMAScript 仕様およびブラウザーの実装の詳細に興味があれば、こちらのブログ記事をご覧になると良いでしょう。

Intl.Segmenter の単語分割の使いどころ

一見、Intl.Segmenter の日本語の単語分割は中途半端なクオリティのように感じますが、そもそも Intl.Segmenter の単語分割の目的とは何なのでしょうか。

前述の通り、Intl.Segmenter は ECMAScript 仕様で規定されてますが、そのアルゴリズムに関しては Unicode Standard Annex #29 を想定しているようです。そして、その「4 Word Boundaries」には、マウスのダブルクリックなどで単語を選択状態にする、矢印キーなどで次の単語にカーソルを移動する、単語の検索および置換、といったユースケースが挙げられています。

文字コンテンツの一か所をマウスでクリックしただけで、その単語そのものが選択状態になったり、その単語の意味を表示するなどのコンテキストメニューが表示されると便利に感じるシーンはありそうです。

しかし、句読点や句点やカッコも一つの単語としてみなされるというのも困ります。もちろん、それを区別する仕組みが用意されています。次の例では、意図的に句読点や句点やカッコを含んだ文章を分解しています。

const string = '東京(江戸)は、日本の首都です。';
const segmenter = new Intl.Segmenter('ja-JP', { granularity: 'word' });
const segments = segmenter.segment(string);

for (const segment of segments) {
    console.log(JSON.stringify(segment));
}

このコードの実行結果は次の通りです。

{"segment":"東京","index":0,"input":"東京(江戸)は、日本...","isWordLike":true}
{"segment":"(","index":2,"input":"東京(江戸)は、日本...","isWordLike":false}
{"segment":"江戸","index":3,"input":"東京(江戸)は、日本...","isWordLike":true}
{"segment":")","index":5,"input":"東京(江戸)は、日本...","isWordLike":false}
{"segment":"は","index":6,"input":"東京(江戸)は、日本...","isWordLike":true}
{"segment":"、","index":7,"input":"東京(江戸)は、日本...","isWordLike":false}
{"segment":"日本","index":8,"input":"東京(江戸)は、日本...","isWordLike":true}
{"segment":"の","index":10,"input":"東京(江戸)は、日本...","isWordLike":true}
{"segment":"首都","index":11,"input":"東京(江戸)は、日本...","isWordLike":true}
{"segment":"です","index":13,"input":"東京(江戸)は、日本...","isWordLike":true}
{"segment":"。","index":15,"input":"東京(江戸)は、日本...","isWordLike":false}

ここでは、isWordLike プロパティに注目してください。句読点や句点やカッコのとき、その値は false になっています。それ以外は true です。

もし前述のユースケースを実現したいなら、isWordLike プロパティの値が false のセグメントを無視することで、無用な選択を避けることができるようになります。

想定されたユースケースを見てお分かりの通り、単語分割の精度が低かったとしても、機能提供が致命的に損なわれるわけではない点が重要です。とりわけ Intl.Segmenter の日本語の単語分割は、私たちが期待するような形態素解析ベースの単語分割のクオリティとは大きく違います。形態素解析ベースの単語分割のクオリティを必要とするようなユースケースで Intl.Segmenter の日本語の単語分割を使うべきではないでしょう。その場合は、形態素解析のクラウドサービスを使うべきでしょう。

最後に

Intl.Segmenter は Chrome、Edge、Safari にはかなり以前から実装されていましたが、Firefox がなかなか実装してくれませんでした。そのため以前は Intl.Segmenter の利用がためらわれました。しかし、2024 年に Firefox が Intl.Segmenter を実装したことで、すべてのメジャーブラウザーで Intl.Segmenter が利用できるようになりました。いまは迷うことなく Intl.Segmenter が使えます。

今回は以上で終わりです。最後まで読んでくださりありがとうございました。それでは次回の記事までごきげんよう。

Share