Oramaという検索エンジンでブログ検索を作ってみた

Posted by johtani on Friday, December 15, 2023

目次

この記事は、情報検索・検索技術 Advent Calendar 2023の15日目の記事です。

昨年に続き2回目の情報検索のAdvent Calendar参戦です。

今年は、夏にオンライン参加したBerlin Buzzwords「The Debate Returns (with more vectors) Which Search Engine?」というセッションでPhilippさんが話題に出したOrama searchという検索エンジンを紹介してみようと思います(Oramaが正式名称なのかな?)。

Oramaという検索エンジン

公式サイトのトップにも記載がありますが、エッジで動作する全文検索&ベクトル検索エンジンというもののようです。 なにそれ?という感じがしますが、オープンソース版のドキュメントを見るともう少しわかりやすい説明になっています。

Orama is a fast, batteries-included, full-text and vector search engine entirely written in TypeScript, with zero dependencies.

OramaはTypeScriptで書かれた、依存なしで利用できる検索エンジンということです(batteries-includedって初めて見たかも。「必要なものがそろってる」とかいう意味みたい(参考:英語のWikipedia))。

以下は公式サイトにあるサンプルです。

  • createでスキーマの定義をした検索エンジンのインスタンスを生成します。
  • insertで先ほど生成した検索エンジンにデータをインデックスします(後で紹介しますが、一括でインデックスするメソッドも用意されています)。
  • searchで検索し、検索した結果が返ってきます。

以上です。あとは、必要に応じてGUIを用意するだけです。

import { create, insert, search } from '@orama/orama'

const db = await create({
  schema: {
    title: 'string',
    director: 'string',
    isFavorite: 'boolean',
    year: 'number'
  }
})

await insert(db, {
  title: 'The Prestige',
  director: 'Christopher Nolan',
  isFavorite: true,
  year: 2006
})

const searchResults = await search(db, {
  term: 'prestige',
  where: {
    isFavorite: true,
    year: {
      between: [2000, 2008]
    }
  }
})

サクッと動きそうですよね?依存関係がなくTypeScriptで書かれているということは静的なサイトなどでコンテンツを検索するのに使えるのでは?

ということで、自分のブログに組み込んでみました。 今回は組み込んだ時の流れと気になった点などを紹介します。 Hugoに組み込む際になるべく簡単にしたかったので、unpkg.comで公開されているJavaScriptだけを利用する形で検索ページを作ってみました。 JavaScriptやTypeScriptは不慣れなので効率がよくなかったり、間違っている点があればコメントなどをいただけると嬉しいです。

構成

私のブログサイトはHugoで生成(参考:Hugo導入時の記事)して、GitHub Pagesとして公開しています。 現在のブログ検索にはAlgoliaを利用しているのですが、テスト目的としてOramaも使えるようにしてみました(右上のメニューでOramaをクリックすると以下で説明するOramaによる検索を試すことができます。

今回実装してみたのは次のような流れです(日本語検索対応については後で説明します)。

  1. HugoでJSONのリストを生成(これまでと一緒)
  2. 1.で生成したリストをもとにNodeのスクリプトでOramaのインデックスファイルを作成
  3. 2.で作成したファイルを公開
  4. 画面で3.のファイルをダウンロードして検索

1. HugoでJSONのリストを生成(これまでと一緒)

HugoでJSONのリストを生成します。Algoliaに記事を登録するのにも利用している仕組みです。 検索に利用する記事の情報を1記事を1つのオブジェクトとして出力します。1つの配列に記事ごとのオブジェクトが入っているJSONファイルになります。

少しだけ特殊なことをしています。 ブログ記事にTagsというフィールドのありなしで処理を分岐しています。 Oramaが配列にNullを受け取った場合にうまく動かなかったので出力するJSON側で調整しています(あとで再確認してIssueあげておかないと)

2. 1.で生成したリストをもとにNodeのスクリプトでOramaのインデックスファイルを作成

最初に紹介した公式のサンプルの場合、毎回Oramaのインスタンスを生成した後にインデックス登録の処理を行わなければいけません。 ただ、Hugoで生成したコンテンツは特に動的に更新があるわけでもないので、検索画面を表示するたびにインデックス登録するのも無駄が多い気がします。 Orama単体で最初に実装した時には、1.で作成した記事一覧のJSONをOramaで実装した検索ページのスクリプトが呼ばれたタイミングでフェッチし、insertMultiple(複数登録用のメソッド)を呼び出す実装にしていました。

Oramaでは、あらかじめ作成したOramaのデータベースを永続化・リストアするためのData Persistenceプラグインが提供されています。 インデックスに登録する処理コストはHugoでコンテンツを生成した直後に行い、検索画面では作成済みのインデックスファイルをフェッチしてリストアする形にすることにしました。インデックスと記事のJSONデータを含んだファイルになるので、元の記事一覧のJSONよりはファイルサイズが大きくなってしまいますが、処理時間も含めて考えると許容できるサイズではないかなと。

次のスクリプトは1.で生成したJSONファイルを読み込んで、Oramaでインデックスした後にdpackというバイナリ形式でファイルに保存しています。 こののプラグインで選択できる形式は現時点ではjsonbinary(デフォルト=msgpack)、dpackの3種類になります。 残念ながら今回試したところ、binaryではエラーが発生したので、dpackを使用しました。

3. 2.で作成したファイルを公開

単にGitHub Pagesに公開しているだけです。 ブラウザが2.で作成したインデックスファイルをフェッチしてから利用するためです。

4. 画面で3.のファイルをダウンロードして検索

あとは検索画面です(長すぎるので、styleの部分は省略しています)。

まず、必要なライブラリなどを読み込みます(下のソースコードの1行目から14行目)。

Oramaだけであれば問題ないのですが、OramaのプラグインがOramaをモジュールとして参照しているため、importmapとして定義しています。 Oramaのプラグインがいくつか利用しているものがあったので、まとめてimportmapに定義してあります。

次に、検索窓(search-searchbar)や検索結果(search-hits)などのHTMLタグを用意します。 ヒット件数と検索にかかった時間も取れるので、表示する場所としてstatsも用意してあります。

22行目からが実際の処理になります。

28行目で、手順2.で作成したインデックスのファイルをフェッチします。 32行目でフェッチしたファイルから取り出した文字列をリストアして、Oramaのインスタンスを生成します。 34行目のtokenizerに関しては日本語対応で説明します。

48行目のquery関数がクエリの組み立て+検索の処理です。search-inputで入力されたテキストをtermとして渡して検索をしています(56行目)。 検索した結果のリストをもとに検索結果のHTMLタグを組み上げていきます。 検索結果には後述する@orama/highlightのモジュールを利用してスニペットを追加しています。

日本語対応

残念ながらOramaは公式には日本語をサポートしていません(サポートしているスキーマに設定できるlanguageの一覧(=STEMMERSにあるものだけ指定可能)。一覧と実装分けたほうがいい気がするけど?)。 ただ、tokenize関数を変更できるようにしてくれています。これを利用して、少しトリッキーな形で日本語対応しました。

先ほどの手順2.でOramaのcreate関数として、componentsに次のtokenizerを渡します。

languageenglishですが、日本語をtokenizeする処理に次のように書き換えました。 stemmingは今回は行わない(=用意されているstemmerを使えない)のでfalsenormalizationCacheは空のMapを用意しておかないとエラーが出るので、new Map()しています。 この設定を使ってcreateすることで、

  components: {
      tokenizer: {
        language: "english",
        stemming: false,
        normalizationCache: new Map(),
        tokenize: (raw) => {
              return tokenize(raw)
        },
      },
    },

少しわかりにくいですが、tokenizertokenize関数の中で呼び出しているreturn tokenize(raw)の部分はwakachigakiというライブラリのtokenize関数となっています。

wakachigakiというライブラリ

Elasticsearchなどのサーバー上で動作させる検索エンジンではkuromojiなどの辞書を内包した形態素解析器を利用するのが通常です。 ただ、今回はブラウザだけで完結した簡単な検索を行う仕組みをOramaを使って提供しようと考えました(kuromoji.jsというのもありますが、辞書のサイズがそこそこのサイズです)。 ですので、辞書ファイルのない軽量の形態素解析ライブラリとして、wakachigakiというライブラリ(=6.2Kbの軽量日本語分かち書きライブラリ)を利用してみました。

最初はTinySegmenterの利用をしていたのですが、wakachigakiのサイトにもあるように、TypeScriptやES Moduleでも利用できる形式となっていたのでこちらに切り替えました。

辞書もっていないため、品詞でのフィルタリングや読みを利用した処理はできませんが、日本語の文章を分かち書きしてくれます。

tokenize関数の設定

OramaのTokenizerインタフェースのtokenizeは入力の文字列をstring[]として返すことを期待しています。 wakachigakiのtokenizeも文字列を引数にとり、string[]を返すので、そのまま呼び出すだけにしてあります。

ただ、今回のブログ検索の実装では、検索窓に入力された文字列も特に何も処理せずにtokenizeに渡す形にしています。 クエリパーサーなどを考える場合は、検索窓に入力された文字列を前処理したりする必要がありますが今回はいったんこれで。。。

data-persistenceはcomponentsはpersistしない

手順2.で導入した、data-persistenceプラグインですが、現在の実装ではOramaが生成したデータベース(転置インデックス)だけを永続化しています。 独自に設定したtokenizerは残念ながら含まれていませんでした。 手順4.の検索画面のコードの34行目の部分(下記)で、フェッチしたインデックスデータからrestoreした後のOramaのインスタンスのtokenizerに手順2.と同じ設定のtokenizerを設定することで検索時にも同じトークナイズがされるようになります(今の仕組みだと修正した場合には、両方のソースコードを修正しないといけないのがちょっと面倒かも?)。

db.tokenizer = {
      language: "english",
      stemming: false,
      normalizationCache: new Map(),
      tokenize: (raw) => {
            return tokenize(raw)
      },
    }

tokenizerはOramaの検索エンジンのインスタンスに紐づく設定となっているので、フィールドごとに切り替えるといったことも現在では想定されていない気がします。

ハイライト

ハイライトのために、インデックスデータに単語のポジションを保存するためのプラグインも提供されています。 このポジションは検索の単語がヒットしドキュメントのどの位置に出てくるのか?をあらかじめデータとして保存しておくことで、ハイライトのたびに単語の位置を毎回計算しなくてもよくするためのものと思われます。

これを利用してもよかったのですが、今回は@orama/highlightというモジュールを利用することにしました。 検索にヒットしたドキュメントの元の文章と検索のキーワードを入力すると検索キーワードの周りにハイライトのHTMLタグを埋め込んでくれるライブラリを利用しました。これは普通に他でも便利かも?

余力があればプラグインを利用した時のインデックスのサイズの違いや検索時の速度の違いを調べてみるのもいいかもしれないです。

そのほかの機能

今回は利用していませんが、Oramaにはそのほかの機能も用意されています(公式ドキュメントの検索の紹介のページ)。詳細は公式ドキュメントをご覧ください。

  • Typo tolerance
  • Facet/Filter
  • Vector Search
  • Geo Search
  • Grouping
  • Threshold
  • Preflight

Oramaで(まだ?)できないこと

今回触ってみて、現在のOramaの実装ではできなそうなことがいくつかあったので書き出しておきます。 あくまでも現時点のことです。今後実装される可能性もあります。

デフォルトでは、searchtermに渡した文字列をtokenizeし、出力されたトークン列(単語の配列)のそれぞれのトークン(単語)にヒットしたドキュメントが全て検索結果に含まれます。 検索結果のドキュメントはBM25でスコアリングされるので、多くのトークンが含まれるものが上位に来る可能性が高いです。

ただ、特定の単語を含まない検索、ANDやOR検索など凝ったクエリは現在は書けないようです(Issueがある)。

filterは別途用意されているので、例えばファセットで表示したカテゴリで絞り込んだ状態にするといったことはできそうです。

残課題

とりあえず日本語をなんとなく検索できるようにすることはできました。 が、気になることもいっぱいありました。 時間を見つけてTypeScriptなどを勉強しながら探っていく感じでしょうか。

インデックスを小さくする方法

今回はブログ記事のサマリー(Hugoで生成されるSummary)でインデックスを作ったので、インデックスのサイズはまだ小さくなっています(といっても1MB超えてるけど)。ブログ記事全体を検索できるようにする場合は、インデックスファイルが大きくなります。

これは、OramaがデフォルトではインデックスしたJSON自体もインデックス(ドキュメントストア)に保存しているためです。 ですので、Oramaに登録したJSONデータ+検索用のインデックスがインデックスファイルとなります。 ただ、検索だけができればよい場合は、出来上がったインデックスがあればドキュメントのIDは取得できます。

公式ドキュメントでもそのことに触れているページがあります(参考:Remote document storing)。

まだちゃんと実装を見ていませんが、ブログ記事本体の文字列はドキュメントストアに保存しないようにすれば検索はできるが、容量が大きくならないといった工夫ができそうです。

ただ、検索結果にハイライトを含んだスニペットを表示したい場合は、コンテンツ文字列が必要になってくるので、代わりに何かしらの仕組みを考える必要が出てきます(例えばサマリー部分の文字列だけ保存しておいて、ハイライトはそこだけを対象とするなど)。

そのほか

そのほかに思いつくのはこの辺でしょうか?

  • オートコンプリート:Oramaというよりも、JavaScriptやTypeScriptでうまく実装できるのか?という話のような気がしますが。。。
  • ファセット対応:機能としては存在していますが、今回は実装が間に合いませんでした。
  • Vector Search:検索キーワードのベクトルをどうやって作るのか?という問題が残りそう。コンテンツのベクトルだけで関連ページの表示みたいなことはできるかも?(動的にやる必要がないので、事前計算して各ページに埋め込むほうがよさそうだけど)
  • Geo Search:緯度経度があるデータを探し出してこないと?
  • relevanceの処理の変更:BM25以外に切り替えることができるかどうか(Oramaのソースとにらめっこしないといけなそう)
  • tokenizeの改良:辞書を利用する形態素解析ライブラリやAPIを利用してみたり、「てにをは」といった1文字のひらがなを削ることで、無駄な検索ヒットを減らしてみたり。大文字小文字の正規化も必要。
  • 検索ログの解析:検索ログを保存する仕組みなどはないので、Google AnalyticsやほかのAPIなどを用意する?

まとめ

ということで、Oramaという検索エンジン?ライブラリ?を利用して日本語のブログ検索を実装してみました。 まだ、機能が少ない部分もありますが、少数のデータをブラウザだけで簡単に検索できる仕組みをGitHub Pagesだけで提供することができそうです。 今回は静的なファイルだけで完結する形にしましたが、TypeScriptで利用できる簡単な検索エンジンライブラリとして使ってみるとまた別の面白さもあるかもしれないです。 まだ触っていない機能は思いついたこと(=残課題に書いたこと)もあるので、ちょっと試行錯誤してみようかなぁ。


comments powered by Disqus

See Also by Hugo


Related by prelims-cli