RustとWasmで静的ウェブページに日本語検索機能を追加する


概要

静的ウェブページ向け検索エンジンtinysearchrust_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.jselasticlunrよりもインデックスファイルサイズが遥かに小さくなることが特長です。
しかし、残念なことに日本語検索に対応していません。以下のように文章を空白区切りでトークナイズし、インデックスに単語を登録しているからです:

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.BreakIteratorrust_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には実装されており、webkitFirefoxの方でも開発が進んでいるようです。
Intl.Segmenterが実装されれば、これを用いてトークナイズ処理を実装することで、V8依存をなくすことができます。

各JSエンジンごとにそれぞれのトークナイズ処理を実装する

V8でIntl.v8BreakIteratorを使ったように、それぞれのJSエンジンで同じように実装すれば動くかと思います。ただし、他のJSエンジンがV8と同じようにicu::BreakIteratorをAPIとして公開していればの話ですが。(ちゃんと調べてません)

トークナイズをngramにする

トークナイズ処理をngramにすれば、V8への依存を消すことができます。
しかし、検索精度の面などで新たに問題が生じそうです。

ということで、Intl.Segmenterが実装されるのを気長に待ちましょう。

Reference