Hugoのブログに全文検索をつけた

κeenです。数年ブログをやってきて記事が溜まったので目的の記事をさがしやすいように全文検索をつけてみました。

このページです。右上のsearchから飛べます。実装は至ってシンプルで、本当に全文を検索しているだけです。ですがここに至るまでにちょっと経緯があったので紹介します。

候補の検討

このissueでいくつか検討しています。私のブログは静的サイトジェネレータのHugoで作られているのでサーバで動作するコードはありません。全てをフロントエンドでやる必要があります。全文検索はフロントエンドでやるには少し重いタスクですが最近だとWebAssemlyがあるので徐々にフロントエンド全文検索が浸透してきているようです。

今回検討の元になったというか全文検索を載せようと思ったのは以下の記事が発端です。

ここで挙げられている検索エンジンを検討しました。

  • stork:普通の検索エンジン。スニペットを埋め込むだけで使える。
  • tinysearch:Bloomフィルタべースの検索エンジン。検索部分だけでUIは自分で作る。

false positiveがあるのは嫌だしStorkをまずは検討してみました。

Stork

公式サイトにインストールガイドがあるのでそれに従うだけでインストールできます。とりあえず動くものを試すだけならそれで簡単に動いてよかったのですが、色々試すうちにちょいちょい不便な部分がみつかりました。

  • UIがstork側持ちなので手を入れるのが少し面倒だった
  • インデックス対象を1つ1つコンフィグに書かないといけなかった
  • インデックスが大きすぎた

UIについてはフロントエンドに詳しくないくせにas-isで使わずに検索バーをいじろうとしたりブログのテーマに合わせてデザインを変えようとしたりした結果です。地味にブログテーマのCSSが適用されてちょっと表示がおかしくなったり、DOMノードが勝手に追加される影響で表示が崩れたりして苦戦しました。特に、私はinputの内容が空になったら検索結果ウィンドウを閉じてほしかったのにStork側が挿入したボタンを押さないとウィンドウが消えないなど、細かな点で不満が溜まりました。

インデックス対象については数百ある記事を1つ1についてURLとファイル名とタイトルをTOMLで書く必要がありました。まあ、Hugoで頑張って生成できるんですが、なんかTOMLにつらつらと記事情報を並べていくのは微妙だなという気持に。

Storkの一番ダメだった部分はインデックスの大きさですね。インデックスファイルが200MBくらいありました。当然、そんなものロードしてるとブラウザが固まるレベルで重くなりました。ということでこれはちょっと採用できないですね。

Tinysearch

TinysearchはBloomフィルタベースなのでインデックスが小さく(268KB!)、APIも search を提供しているのみでUIはこちら持ちなので完全に制御できます。インデックス対象はJSONにURL、タイトル、本文を並べる形式ですが、HugoはJSONの生成までは想定しているのでまあ許容範囲でしょう。

ただちょっと気になる点がありまして、昔同僚がtinysearchの検索結果が非常に悪いと言っていたんですよね。Bloomフィルタだし多少はそうかもねと思ってたんですが今回使ってみてようやく分かりました。

Tinysearchはインデックスが小さく、ブログに載せられそうだと思って色々試してみると日本語の検索結果が壊滅的なことに気付きます。対象のワードを含んだ記事が一切引っ掛からず、関係ない記事がいくつか結果に出てくるのみです。Bloomフィルタの仕組みからして関係ない記事が上がることはあっても関係ある記事が引っ掛からないことはないはずです。よくよく調べてみるとtinysearchは入力のトークナイズに split_whitespace を使ってるだけの簡素なものでした。これだと日本語は全く扱えませんね。

Tinysearchに形態素解析を使うかn-gramを導入するかの提案をしようか迷ったのですが開発もあまり活発でなさそうですし、Rustで動く(TinysearchはRust製です)形態素解析エンジンで絶対おすすめと言えるのが見当たらなかったのでやめておきました。

あと私はあんまり気にしなかったのですが、Tinysearchは容量削減のために一旦Bloomフィルタを埋め込んだRustのコードを生成してWebAssemblyにコンパイルする方式だったのでインデックスの生成に時間がかかります。その点で受け入れられない人もいるかもしれません。

全文検索…?

ところで、そもそも私のブログに対して全文検索をするのは正しいのでしょうか。転置インデックスを生成するとしたらどのくらいのサイズになるのでしょうか。

丁度、Tinysearchのときに全文入りのJSONを生成しているのでそのサイズを測ってみると3.5MBでした。ちょっと「うっ」となりますがこれだけならまあ読み込めないサイズじゃないですね。ですがここからbigramで転置インデックスを作ると、1byteあたりbiなので+1byte、インデックスが4byteとして最低でもサイズが6倍くらいになります。スマホユーザがそれなりにいることを考えると数十MBのデータの転送はちょっと許容できませんね。

Tinysearchは数百KBに収まっていたのでアプローチとしてはBloomフィルタとかの方向になりそうですが、ふと素のままのJSONファイルが数MBと転送できそうなサイズだったことを思い出します。

試しにTinysearchのときに生成したJSONファイルをブラウザにロードして、検索ワードを記事1つ1つに対して match するコードを書いていみたら思いの外軽快に動くことに気付きました。流石に1入力ごとにインクリメンタルに検索するのはちょっとつらいですが、入力して検索ボタンを押す方式であれば問題なく動きます。

ということで私が今回採用した検索アルゴリズムは以下です。

function search(word) {
    let result = [];
    for (i = 0; i < data.length; i++) {
        if (data[i].body.match(word)) {
            result.push(data[i]);
        }
    }
    return result;
}

個人の作るデータ量は大したことないので案外線形探索でもどうにかなってしまうんですね。スマホでもサクサク動きました。8年ブログやっててこの量なのであと10年くらいは同じコードでも問題ないはずです。10年後にはコンピュータのスペックも上がってるでしょうし、それ以前にもっと賢い方法が登場するでしょうからしばらくはこのまま運用できそうです。

まとめ

静的サイトに全文検索エンジンを導入しました。転置インデックス、Bloomフィルタを試してみて、結局個人のブログで使う量であれば線形探索で十分であることが分かりました。検索分野はかなり発達してる領域ですし転置インデックスの圧縮方法くらいいくらでもありそうですが、そういう実装ないんですかね?

Written by κeen
Older article
Search