はじめに
サイト内検索やアプリ内のインクリメンタル検索を実装しようとして、「Elasticsearchを立てるほどの規模じゃない」「でもArray.filterとインデックスなしの文字列一致では遅すぎる」と悩んだ経験はないでしょうか。特にSPAやドキュメントサイトのように、数千〜数万件のデータをブラウザ側だけで検索したいケースでは、サーバーを介さず動く軽量な全文検索エンジンが欲しくなります。
そこで候補に挙がるのがFlexSearchです。README曰く「他ライブラリと比較して最大100万倍高速なクエリ処理」を謳う、ブラウザとNode.jsの両方で動く全文検索ライブラリです。数字だけ見ると眉唾ものに感じますが、実際にはインメモリの転置インデックスを徹底的にチューニングすることで、この速度を実現しています。今回はその仕組みと使い方を、実際に手を動かしながら見ていきます。
FlexSearchとは
FlexSearchは「Next-Generation full-text search library for Browser and Node.js」を掲げる、ゼロ依存の全文検索ライブラリです。単純なid-テキストの組を検索するIndexと、複数フィールドを持つJSONドキュメントを検索できるDocumentという2つのAPIを軸に構成されています。GitNationで「Best Technology of the Year」にノミネートされるなど、パフォーマンス面での評価が高いのが特徴です。
主な特徴
- 圧倒的な検索速度 - 独自ベンチマークでは、他の主要な全文検索ライブラリと比較して最大100万倍高速なクエリ処理を達成したとされています
- ゼロ依存・軽量 -
light・compact・bundleと用途別のビルドが用意されており、必要な機能だけを選んで読み込めます - Web Worker / Node.jsワーカー対応 -
Workerインデックスを使えば、インデックスの更新や検索処理をメインスレッドから切り離して並列実行できます - 多言語の文字セットに対応 - ラテン文字はもちろん、日本語・中国語・韓国語(CJK)、アラビア語、ヘブライ語、キリル文字などを標準サポート
- 永続化インデックス - IndexedDB・Redis・SQLite・Postgres・MongoDB・Clickhouseなど、様々なデータストアにインデックスを永続化できます
インストール
npmを使う場合は以下のコマンドでインストールできます。
npm install flexsearch
Yarnやpnpmでも同様にインストールできます。
yarn add flexsearch
pnpm add flexsearch
ESM環境では次のように名前付きインポートで各クラスを読み込みます。
import { Index, Document, Worker, Charset } from "flexsearch";
FlexSearchの基本的な使い方
もっともシンプルな使い方は、IndexにID付きの文字列をadd(id, text)で登録し、search(query)で検索するパターンです。ブログ記事の一覧から、キーワードに一致するものだけを絞り込んでみましょう。要点だけを抜き出すと次のようになります。
import { Index, Charset } from 'flexsearch'
const index = new Index({ encoder: Charset.CJK })
const docs = [
'JavaScriptで作る全文検索エンジン',
'軽量で高速なインメモリ検索ライブラリの選び方',
]
// idと文字列の組を登録していく
docs.forEach((text, id) => index.add(id, text))
// 一致したdocsのインデックス番号が配列で返る
const result = index.search('検索')
実際にブラウザ上で動かせるサンプルが以下です。入力欄にキーワードを打つたびにindex.search()が呼ばれ、一致した記事だけが一覧に残ります。
日本語のように単語間にスペースがない言語では、素のIndexでは単語の切れ目をうまく認識できません。encoder: Charset.CJKを指定することでCJK向けの分かち書きが行われ、トークナイズの設定を変えなくても単語の途中の文字列で検索にヒットするようになります。試しにnew Index({ encoder: Charset.CJK })のencoderを外してnew Index()にすると、日本語の部分一致がヒットしにくくなる違いを確認できます。
実践的なユースケース
FlexSearchのDocumentで複数フィールドを横断検索する(Document Index)
実際のアプリでは、タイトルと本文のように複数のフィールドを持つデータをまとめて検索したい場面が多くあります。Documentインデックスを使えば、フィールドごとにインデックスを構築しつつ、1回のsearch呼び出しで横断的に検索できます。store: trueを指定すると、検索結果からIDを引くまでもなく元データをそのまま復元(enrich)できます。要点だけを抜き出すと次のようになります。
import { Document, Charset } from 'flexsearch'
const index = new Document({
encoder: Charset.CJK,
document: {
id: 'id',
index: ['title', 'content'], // 横断検索したいフィールドを列挙する
store: true, // 検索結果に元データをそのまま含める(enrich)
},
})
index.add({ id: 0, title: 'FlexSearchの基本', content: '全文検索エンジンです' })
// enrich: true で doc(元データ)付きの結果が返る
const result = index.search('検索', { enrich: true })
実際にブラウザ上で動かせるサンプルが以下です。
index: ['title', 'content']と指定するだけで、両方のフィールドが個別にインデックス化され、どちらかにマッチすれば結果に含まれます。フィールドごとにtokenizeやencoderを変えたい場合は、文字列の代わりにオブジェクトの配列を渡すことで細かく制御できます。
FlexSearchのtagオプションで絞り込む(Multi-Tag Search)
キーワード検索に加えて、カテゴリやステータスといったタグで絞り込みたいケースも多いはずです。Documentインデックスのtagオプションを使うと、キーワード検索とタグフィルタを組み合わせられます。要点だけを抜き出すと次のようになります。
import { Document, Charset } from 'flexsearch'
const index = new Document({
encoder: Charset.CJK,
document: {
id: 'id',
tag: 'category', // タグとして使うフィールド名
index: 'name',
store: true,
},
})
index.add({ id: 0, name: 'ワイヤレスキーボード', category: 'PC周辺機器' })
// search()にtagオプションを渡すとキーワード×タグで絞り込める
const result = index.search('ワイヤレス', {
tag: { category: 'PC周辺機器' },
enrich: true,
})
実際にブラウザ上で動かせるサンプルが以下です。
同じキーワード「ワイヤレス」でも、search()のtagオプションに渡すカテゴリによって結果が絞り込まれる様子が確認できます。ECサイトの商品検索や、ブログ記事のカテゴリ絞り込みなど、キーワード×属性の組み合わせが必要な場面でそのまま応用できるパターンです。tag: 'category'を配列にしてtag: ['category', 'status']のように指定すると、複数のタグフィールドを組み合わせた絞り込みにも拡張できます。
FlexSearchのtokenizeオプションで部分一致検索する(Tokenizer)
デフォルトのstrictトークナイザは完全一致に近く、入力途中の文字列ではヒットしません。オートコンプリートやインクリメンタルサーチを実装する場合は、tokenizeオプションをforward(前方一致)やfull(部分一致)に変更することで、タイプ中でも結果が更新されるUIを作れます。要点だけを抜き出すと次のようになります。
import { Index } from 'flexsearch'
// strictは完全一致寄り、fullは部分一致まで拾う
const strictIndex = new Index({ tokenize: 'strict' })
const fullIndex = new Index({ tokenize: 'full' })
const words = ['FlexSearch', 'Elasticsearch']
words.forEach((w, id) => {
strictIndex.add(id, w)
fullIndex.add(id, w)
})
strictIndex.search('search') // => [] (完全一致しないためヒットしない)
fullIndex.search('search') // => [0, 1] (部分一致でヒットする)
実際にブラウザ上で動かせるサンプルが以下です。
searchという単語の一部を入力しただけで、fullトークナイザ側は「Search」を含む3件(FlexSearch・Elasticsearch・MeiliSearch)をヒットさせています。ただしfullは転置インデックスのサイズが大きくなりやすいトレードオフがあるため、データ量や用途に応じてforward(前方一致のみ)と使い分けるのが実践的です。
まとめ
FlexSearchは、Indexによるシンプルなid-テキスト検索から、Documentによる複数フィールド・タグ検索、さらにはWorkerや永続化ストレージへの対応まで、全文検索に必要な機能を一通り備えたライブラリです。サーバーを立てずにブラウザだけで高速な検索体験を作りたい場合や、Node.js側で軽量な検索エンジンが欲しい場合に、まず検討する価値があります。
今回紹介したIndex・Document・tokenizeの3つを押さえておけば、多くの検索UIはカバーできるはずです。より大規模なデータセットを扱う場合は、Web Workerによる並列化や、IndexedDB・Redisなどへの永続化もあわせて検討してみてください。