概要
静的ウェブページ向け検索エンジンtinysearchをrust_icuのトークナイザ(icu::BreakIterator
)を使って日本語対応させてみた。
また、これをmdBookに組み込み、The Rust Programming Language 日本語版へ適用してみた (chromiumのみ対応。その他は従来どおりの検索性能)
実装: https://github.com/tamuhey/tinysearch/tree/japanese
mdBookへの適用: https://github.com/tamuhey/mdBook/tree/tiny_search
The Rust Programming Language 日本語版への適用例: https://tamuhey.github.io/book-ja/
tinysearch
tinysearchは静的ウェブページ向け検索エンジンです。Rust製であり、lunr.jsやelasticlunrよりもインデックスファイルサイズが遥かに小さくなることが特長です。
しかし、残念なことに日本語検索に対応していません。以下のように文章を空白区切りでトークナイズし、インデックスに単語を登録しているからです:
cleanup(strip_markdown(&content))
.split_whitespace()
英語なら大体動きますが、日本語だとほとんど動きません。今回はこの部分を改良していき、日本語に対応させてみます。
トークナイザ: icu::BreakIterator
では日本語を分割できるトークナイザを適当に選んでインデックスを生成すれば良いかというと、そうではありません。インデックス生成時と、実際に検索するときに用いるトークナイザの分割結果を一貫させる必要があるからです。そうでなければ単語分割結果が異なってしまい、うまくインデックスにヒットしなくなり検索精度が落ちます。(注:日本語の単語分割は文脈に大きく依存するので、同じトークナイザを使ったとしても、分割結果を完全に一致させるのは難しいです。)
例えばelasticlunr-rsではトークナイザにlinderaを使っているので、同じ挙動のトークナイザをフロントエンドで使おうとすると、linderaの辞書であるipadic
をフロントエンドに持ってくる必要があります。辞書はサイズがとても大きいので、ウェブページのサイズのほとんどすべてをトークナイザ用のファイルが占めることになってしまいます。これはあまりいい方法ではなさそうです。
ではフロントエンドで簡単に単語分割をするにはどうすればいいでしょうか?
実は主要なブラウザにはデフォルトで単語分割機能が入っています。試しにこの文章をダブルクリックしてみてください。空白で区切られていないにもかかわらず、文全体ではなく単語がハイライトされたはずです。
例えば、Chromiumではこの単語分割機能をIntl.v8BreakIterator
から使うことができます(参考)。Intl.v8BreakIterator
はunicode-orgのICUにあるicu::BreakIteratorのラッパーで、UAX#29に基づいて単語分割をします。
そして嬉しいことに、rust_icuというCrateにこのicu::BreakIterator
が最近実装されました。つまりインデックス生成時にはrust_icu
を、フロントではIntl.v8BreakIterator
を使えば、インデックス生成時と検索時で一貫した単語分割結果を得られるのです。
V8依存になってしまいますが、今回はこれを使います。
インデックス生成時のトークナイザの置き換え
まずはインデックス生成時のトークナイザをrust_icuで置き換えていきます。
こんな感じでテキストを分割する関数を作ればよいです:
use rust_icu::brk;
use rust_icu::sys;
pub fn tokenize(text: &str) -> impl Iterator<Item = &str> {
let iter =
brk::UBreakIterator::try_new(sys::UBreakIteratorType::UBRK_WORD, "en", text).unwrap();
let mut ids = text.char_indices().skip(1);
iter.scan((0, 0), move |s, x| {
let (l, prev) = *s;
let x = x as usize;
if let Some((r, _)) = ids.nth(x - prev - 1) {
*s = (r, x);
Some(&text[l..r])
} else {
Some(&text[l..])
}
})
}
rust_icu::brk::UBreakIterator
は文字境界位置のインデックスを生成するイテレータです。インデックスはバイト数ではなく文字数で返されるので、そのままtextをスライスすることはできません。char_indices
を使い、バイト境界を求めてから部分文字列を返します。
ちなみにこれをビルドするには、nightlyのrustcとicuの開発環境をインストールする必要があります(参考: https://github.com/google/rust_icu#required )。
検索時のトークナイザの置き換え
次にフロントエンドのトークナイザを置き換えます(参考):
function tokenize(text) {
text = text.toLowerCase()
let it = Intl.v8BreakIterator([], { type: 'word' })
it.adoptText(text)
let words = []
let cur = 0, prev = 0
while (cur < text.length) {
cur = it.next()
words.push(text.substring(prev, cur))
prev = cur
}
return words.join(" ")
}
先程と同様、Intl.v8BreakIterator
は文字境界位置を返すので、それをもとに部分文字列を抽出します。
最後に空白で結合し、tinysearchに渡します。ここはtinysearchの方を改造して、入力に単語列を取るようにしても良いかもしれません。
Demo
以上でtinysearchの改造は終わりです。tinysearchコマンドの使い方自体は変えていないので、元のコマンドと同じ方法で検索用アセットを生成できます。
こちらが日本語対応版のデモサイトです。「日本」と打つと、ちゃんと検索結果が表示されているのがわかります。
mdBookに日本語対応tinysearchを導入
mdBookは日本語に対応していません。検索機能にはelasticlunr-rsが使われていますが、これにパッチを当てていく方針はかなり大変そうです(参考: mdBookを日本語検索に対応させたかった)。
そこでmdBookの検索機能をelasticlunrからtinysearchに置き換え、日本語対応させてみました。(repo)
試しにRustの日本語ドキュメントをビルドしたものがこちらです: https://tamuhey.github.io/book-ja/
日本語、英語両方ともうまく検索できているように見えます。
また、インデックスとwasm moduleのファイルサイズの合計が1.3MBになりました。オリジナルのmdBookで生成されたインデックスファイルのサイズは5.8MBなので、約1/4程度です。tinysearchの効果が発揮されたようです。
まとめと課題
v8.BreakIterator
とrust_icu::BreakIterator
を使って、tinysearchを日本語対応させてみました。
また、mdBookの検索機能をelasticlunrから今回改造したtinysearchに置き換え、試しに日本語Rustドキュメントを生成してみました。うまく日本語検索できているようです。
しかし、いくつか課題があります。
1. mdBookの検索結果に本文の対応箇所が表示されない
これは面倒でやっていません。インデックスファイルに本文を登録しておき、wasm module側で適当に該当箇所を返すような改造が必要です。
2. V8依存
今回のやりかたの一番大きな問題です。SafariやFirefoxでは完全には動きません。(検索ワードが単語分割結果と偶然一致していれば検索されます)
これについては、3つの解決策があると思います。
Intl.Segmenter
の実装を待つ
ECMAScriptにIntl.Segmenterが提案されています。v8BreakIterator
とAPIは異なりますが、ベースはicu::BreakIterator
であり分割結果は同じです。既にChrome 87には実装されており、webkitやFirefoxの方でも開発が進んでいるようです。
Intl.Segmenter
が実装されれば、これを用いてトークナイズ処理を実装することで、V8依存をなくすことができます。
各JSエンジンごとにそれぞれのトークナイズ処理を実装する
V8でIntl.v8BreakIterator
を使ったように、それぞれのJSエンジンで同じように実装すれば動くかと思います。ただし、他のJSエンジンがV8と同じようにicu::BreakIterator
をAPIとして公開していればの話ですが。(ちゃんと調べてません)
トークナイズをngramにする
トークナイズ処理をngramにすれば、V8への依存を消すことができます。
しかし、検索精度の面などで新たに問題が生じそうです。
ということで、Intl.Segmenter
が実装されるのを気長に待ちましょう。
Reference
- tinysearch
- lunr
- elasticlunr
- elasticlunr-rs
- lindera
- icu
- Unicode® Standard Annex #29 UNICODE TEXT SEGMENTATION
- icu::BreakIterator
- rust_icu
- How does Chrome decide what to highlight when you double-click Japanese text? - StackOverflow
- tinysearch japanese demo
- tinysearch japanese branch
- mdBook
- mdBook with tinysearch
- Rust book
- Rust book-ja with tinysearch
- How to use ICU
- Intl.Segmenter: Unicode segmentation in JavaScript