LIKE ではスケールしない
SQLite でテキスト検索をしたことがあるなら、まず LIKE '%word%' に手が伸びたはずです。小さなテーブルなら問題なく動きますが、大きくなった途端に破綻します。インデックスが効かないので、SQLite は全行をスキャンして小文字化し、部分文字列を地道にチェックするしかありません。単語の区切り、ランキング、複数キーワード、前方一致——どれも自前で実装する羽目になります。
その答えとして組み込まれているのが FTS5 です。指定したテキスト列に対して転置インデックスを張り、独自のクエリ構文を解釈し、BM25 で結果をランキングしてくれる仮想テーブルの一種です。SQLite に標準で同梱されているので、追加の拡張をインストールする必要はありません。
FTS5 仮想テーブルを作る
FTS5 テーブルは CREATE VIRTUAL TABLE ... USING fts5(...) で作成し、インデックスを張りたいテキスト列を列挙します。
注目してほしいポイントが3つあります。まず、カラムに型がありません。FTS5 はすべてをテキストとして扱うからです。次に、MATCH 演算子はカラムではなくテーブル名に対して使います(posts MATCH ... のように書く)。そして、クエリは大文字小文字を区別せず、トークナイズされて処理されるため、'sqlite' で検索すれば各行の SQLite もちゃんとヒットします。
MATCH クエリ言語の文法
MATCH には単語1つだけでなく、もっと複雑な式も渡せます。クエリ文字列には独自の小さな文法があります。
それぞれの動きはこんな感じです。
'fts5 AND prefix'— 両方のトークンが含まれている必要あり(順序や位置は問わない)。'"keep fts"'— フレーズ一致。この順序通りに並んでいること。'trig*'— 前方一致検索。trigger、triggers、trigonometryなどにマッチします。'index NOT trigger'—indexを含み、かつtriggerを含まないもの。
特定のカラムだけを対象にしたい場合は、'title:sqlite' のように column:term の形で書けます。文法としては括弧によるグループ化や OR での候補指定にも対応していて、検索エンジンでよく見るあの書き心地そのままです。
BM25 によるランキング
FTS5 は、デフォルトで各行に rank という隠しカラムを付けてくれます。中身は BM25 の関連度スコアで、値が小さいほどマッチ度が高いという仕様です。これで並べ替えれば、関連度の高い順に結果を取り出せます。
特定のカラムを他より重く評価したい場合は、bm25() に重みを渡します。テーブル宣言の順番にカラムごとの重みを指定してください:
最初の投稿が上位に来るのは、sqlite が body(重み 1×)だけでなく title(重み 10×)にも登場するからです。重みは、自分のアプリで実際にどうランク付けしたいかに合わせて決めましょう。
インデックスを元テーブルと同期させる
一番シンプルな FTS5 テーブルは、検索対象のテキストを自分自身の中に丸ごとコピーして持ちます。INSERT しかしないログ系のデータならこれで十分ですが、たいていのアプリにはすでに本物のテーブルがあって、FTS にはそれを追いかけてほしいはずです。そんなときの定番パターンが、external content の FTS テーブル + 3 つのトリガーという組み合わせです。
content='articles' を指定すると、FTS5 はテキスト本体を保存せず、必要になったときに articles テーブルから取りに行くようになります。トリガーは書き込みを FTS インデックスにも反映させる役割です。こうすると articles が正本(source of truth)となり、articles_fts はその脇に置かれた検索用の構造でしかなくなります。
ちょっと変わった見た目の INSERT INTO articles_fts(articles_fts, ...) VALUES ('delete', ...) は、FTS5 にインデックスからその行を削除させるための専用コマンド構文です。
スニペットとハイライト表示
検索結果には、ヒットした語を強調したプレビューを添えたいことがほとんどです。FTS5 にはそのための関数が2つ用意されています。
highlight(table, column_index, open, close)はマッチしたトークンを指定タグで囲んだ、その列の全文を返します。snippet(table, column_index, open, close, ellipsis, token_count)はマッチ箇所を中心とした短い抜粋を返します。
カラムのインデックスは宣言順に0始まりです。検索UIではお馴染みの「ヒットした語を黄色でハイライト」を実装するための基本パーツが、このあたりの関数になります。
ハマりどころまとめ
意外とつまずくポイントをいくつか挙げておきます。
MATCH演算子はFTSテーブル専用です。 普通のカラムに対してMATCHは使えません。既存テーブルに対して全文検索を効かせたい場合は、前述の external-content パターンを使ってください。rankでのソートを忘れずに。 これを付けないと、FTS5は保存順で返してくるだけで、関連度とは無関係な並びになります。- トークナイザの選択は重要です。 デフォルトの
unicode61はUnicodeの単語境界で区切って小文字化します。runでrunningもヒットさせたい(ステミング)場合はporterを指定します:USING fts5(body, tokenize='porter')。 - FTS5はあいまい検索エンジンではありません。 やってくれるのは前方一致であって、ファジー検索ではありません。「もしかして:〇〇」のような挙動が欲しければ、FTS5の上に別途レイヤーを積む必要があります。
- コンテンツレスなテーブル(
content='')はサイズが小さい代わりに元テキストが取り出せません。 検索はできますが、取得できるのは rowid だけで、元の本文は復元できません。本文を別の場所に保存している構成では便利です。
次回:ウィンドウ関数
ここまででテキスト検索はひと通りカバーしました。次のページでは毛色の違う高度なクエリ — ウィンドウ関数を扱います。行を集約で潰すことなく、累計・ランキング・グループ単位の集計を計算できる機能です。
よくある質問
SQLiteのFTS5とは何ですか?
FTS5はSQLiteに標準搭載されている全文検索拡張機能です。CREATE VIRTUAL TABLE ... USING fts5(...) で専用の仮想テーブルを作成し、MATCH 演算子で検索します。INSERT時にテキストを自動でトークン化し、転置インデックスとして保持。デフォルトではBM25でスコアリングされます。
MATCHとLIKEはどう違いますか?
LIKE は単純な部分一致のリニアスキャンで、語の区切りも考慮しません。一方 MATCH はFTS5の転置インデックスを使うため、大規模なテーブルでも高速です。トークン単位の検索、前方一致 (term*)、ブール演算子 (AND / OR / NOT)、フレーズ検索 ("exact phrase") にも対応します。ただし MATCH はFTSの仮想テーブルにしか使えない点に注意してください。
FTS5のインデックスを元テーブルと同期させるには?
代表的なやり方は2つあります。1つ目は外部コンテンツ(external-content)またはコンテンツレスのFTS5テーブルを使い、content='posts' のように元テーブルを参照させる方法。これならテキストを二重に保存せずに済みます。2つ目は AFTER INSERT / AFTER UPDATE / AFTER DELETE のトリガーを定義して、変更をFTSテーブルへミラーリングする方法です。
全文検索の結果をスコア順に並べるには?
FTS5には rank という隠しカラムがあり、BM25スコア(値が小さいほど関連度が高い)を返します。ORDER BY rank でそのまま並べ替えられます。明示的にスコアを取りたい場合は bm25(table) を呼び出し、bm25(posts, 10.0, 1.0) のようにカラムごとの重みも指定可能です。たとえばタイトルを本文より重視する、といった調整ができます。