blog.mahoroi.com

tinysearchで日本語検索機能を実装する

-

tinysearch を使って日本語の検索機能を実装するという内容です。

tinysearch

https://github.com/tinysearch/tinysearch

TinySearch is a lightweight, fast, full-text search engine. It is designed for static websites.

TinySearch is written in Rust, and then compiled to WebAssembly to run in the browser. It can be used together with static site generators such as Jekyll, Hugo, zola, Cobalt, or Pelican.

静的サイト向けの軽量で高速な検索エンジンです。Rust で書かれていて、WebAssembly にコンパイルすることでブラウザ上で動作します。

日本語に対応させる

tinysearch は文章を空白で分割したものをインデックスに使用するため、空白を含まない日本語には対応していません。そこで日本語の文章に対して分かち書きを行い、得られた結果を空白で結合したものを使うことで tinysearch を日本語対応させます。

分かち書きツール

分かち書きを行うにあたり、Rust(tinysearch) と JavaScript (ブラウザ) の両方で同様の分かち書き結果が得られる必要があります。

JavaScript 製の分かち書きツールには TinySegmenter があります。純 JavaScript であり、別の方から Rust 実装版も公開されていたので今回は TinySegmenter を使います。

※ JavaScript 製と Rust 製の TinySegmenter の分かち書き結果は、数値の出力結果が異なるため正確には一致していません。

Rust 側のトークナイザー修正

tinysearch をクローンしてコード修正をしていきます。

$ git clone https://github.com/tinysearch/tinysearch.git
$ cd tinysearch
# $ git rev-parse --short HEAD
# e02fb59

はじめに Rust 製の TinySegmenter を Cargo でインストール。

bin/Cargo.toml
[dependencies]
tinysegmenter = "0.1.1"

tinysearch 内部で TinySegmenter を使うように修正。

bin/src/storage.rs
/// Remove non-ascii characters from string
fn cleanup(s: String) -> String {
    s.replace(|c: char| !c.is_alphabetic(), " ")
}

// TinySegmenter を使ったトークナイザを追加
fn tokenize(s: &str) -> impl Iterator<Item = String> {
    let tokens = tinysegmenter::tokenize(s);
    tokens.into_iter()
}

// Read all posts and generate Bloomfilters from them.
#[no_mangle]
pub fn generate_filters(
    posts: HashMap<PostId, Option<String>>,
) -> Result<Vec<(PostId, CuckooFilter<DefaultHasher>)>, Error> {
    // Create a dictionary of {"post name": "lowercase word set"}. split_posts =
    // {name: set(re.split("\W+", contents.lower())) for name, contents in
    // posts.items()}
    debug!("Generate filters");

    let bytes = include_bytes!("../assets/stopwords");
    let stopwords = String::from_utf8(bytes.to_vec())?;
    let stopwords: HashSet<String> = stopwords.split_whitespace().map(String::from).collect();

    let split_posts: HashMap<PostId, Option<HashSet<String>>> = posts
        .into_iter()
        .map(|(post, content)| {
            debug!("Generating {:?}", post);
            (
                post,
                content.map(|content| {
                    // cleanup(strip_markdown(&content))
                    //     .split_whitespace()
                    //     .map(str::to_lowercase)
                    //     .filter(|word| !stopwords.contains(word))
                    //     .collect::<HashSet<String>>()
                    // トークナイザを入れ替える
                    tokenize(&cleanup(strip_markdown(&content)))
                        .map(|x| x.to_lowercase())
                        .filter(|word| !stopwords.contains(word))
                        .collect::<HashSet<String>>()
                }),
            )
        })
        .collect();

これで tinysearch が TinySegmenter による分かち書きされたものをインデックス作成に使用するようになりました。

日本語テキストの準備

検索対象の日本語テキストを用意します。以下は Yahoo!ニュース記事から tinysearch 用インデックスファイルを作成するサンプルです。bin/fixtures/index.json にニュース内容が保存されます。

bin/fixtures/news.py
import json
import time
import feedparser
import pathlib

dic = []

urls = [
    'https://news.yahoo.co.jp/rss/topics/top-picks.xml',
    'https://news.yahoo.co.jp/rss/topics/domestic.xml',
    'https://news.yahoo.co.jp/rss/topics/world.xml',
    'https://news.yahoo.co.jp/rss/topics/business.xml',
    'https://news.yahoo.co.jp/rss/topics/entertainment.xml',
    'https://news.yahoo.co.jp/rss/topics/sports.xml',
    'https://news.yahoo.co.jp/rss/topics/it.xml',
    'https://news.yahoo.co.jp/rss/topics/science.xml',
    'https://news.yahoo.co.jp/rss/topics/local.xml'
]

for url in urls:
    print('GET:', url)
    feed = feedparser.parse(url)
    for entry in feed.entries:
        dic.append({
            'title': entry.title,
            'url': entry.link,
            'body': entry.summary
        })
    time.sleep(5)

output = pathlib.Path(__file__, '..', 'index.json').resolve()
with open(output, 'w') as f:
    json.dump(dic, f, indent=4, ensure_ascii=False)
print('SAVE:', output)

WebAssembly

wasm 出力します。ビルドには wasm-pack が必要です。

# wasm-pack をインストール
$ cargo install wasm-pack

# wasm 生成
$ cargo run fixtures/index.json

JavaScript 側のトークナイザー修正

wasm ファイルと一緒に生成された demo.html を修正して、JavaScript 側のトークナイザーでも TinySegmenter を使うようにします。

demo.html
<html>
  <head>
    <meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
    <!-- TinySegmenterを読み込む -->
    <script src="/tinysegmenter.js" type="text/javascript"></script>
  </head>

  <body>
    <script type="module">
      import { search, default as init } from './tinysearch_engine.js';
      window.search = search;
      async function run() {
        await init('./tinysearch_engine_bg.wasm');
      }
      run();
    </script>

    <script>
      const segmenter = new TinySegmenter();
      function doSearch() {
        let value = document.getElementById('demo').value;
        // 分かち書き
        value = segmenter.segment(value);
        // 空白で分かち書き結果を結合
        value = value.join(' ');
        // 検索結果
        const arr = search(value, 10);
        let ul = document.getElementById('results');
        ul.innerHTML = '';
        for (i = 0; i < arr.length; i++) {
          var li = document.createElement('li');
          let elem = arr[i];
          let elemlink = document.createElement('a');
          elemlink.innerHTML = elem[0];
          elemlink.setAttribute('href', elem[1]);
          li.appendChild(elemlink);
          ul.appendChild(li);
        }
      }
    </script>

    <h2>Search:</h2>
    <input type="text" id="demo" onkeyup="doSearch()" />
    <h2>Results:</h2>
    <ul id="results"></ul>
  </body>
</html>

以上で TinySegmenter を使った tinysearch の日本語対応ができました。実際の動作は demo.html から確認できます。

$ npx serve . -p 3000
# open localhost:3000/demo.html

Intl.Segmenter を分かち書きに使う

ブラウザ上で分かち書きが行える Intl.Segmenter があります。この API を使うと分かち書きツールを別途読み込む必要がなくなります。一方で多くのブラウザでは実装されていないというデメリットもあります。

対応ブラウザ、未対応ブラウザで別々の分かち書きツールを使用する wasm を用意して、動的に切り替えることが現段階では理想的な実装になるかと思います。

参考