FTS5は、SQLiteに同梱されている全文検索用の仮想テーブルモジュールです。検索サーバーを立てずに、CREATE VIRTUAL TABLE の1文で転置インデックス付きのテーブルを作れます。ただし既定の設定では日本語の文中の語を部分一致で拾えないことがあり、trigramに替えてもMATCHでは2文字の語に一致しません。この記事では、テーブル作成とMATCH構文、bm25()での並べ替え、日本語と2文字語への対処、Cloudflare D1での制約までを、SQLite 3.51.0(macOS 26.6.2 同梱)で実行した結果とともに説明します。SQL例は同じデータベースで順に実行できますが、エラーを示す文は個別に実行してください。Python例は独立したインメモリデータベースを作ります。
まとめ:FTS5で最初に押さえる6点
- FTS5はSQLite本体に同梱の拡張です。macOS同梱のsqlite3もPython標準のsqlite3も有効化済みで、
pragma_compile_optionsに ENABLE_FTS5 が出れば使えます。 - 既定のunicode61トークナイザーは日本語を分割しません。文中の語への部分一致はできませんが、トークン全体との一致や前方一致は可能です。3文字以上の部分一致には
tokenize='trigram'を使えます。 - trigramは3文字未満の語に一致しません。2文字の語は、同じtrigramテーブルへの
LIKE '%語%'なら正しく返ります(ただし全件走査)。 - 関連度順は bm25() の昇順で並べます。値は負で、小さいほど上位です。
ORDER BY rankも同じ結果になります。 - offsets() と matchinfo() はFTS5にありません。FTS3/4向けの記事を写すとエラーになります。一致箇所の表示は highlight() と snippet() です。
- Cloudflare D1でも使えますが、FTS5の仮想テーブルを含むデータベースはエクスポートできず、LIKEのパターンは50バイトまでです。
FTS5の仕組みとLIKE検索との速度差
転置インデックスによる検索とLIKEの全件走査
通常のテーブルに LIKE '%キーワード%' を投げると、SQLiteは全行の文字列を先頭から照合します。FTS5は挿入時に文字列をトークンへ分割し、トークンから行を引ける転置インデックスを保持します。検索時は該当トークンの行リストを読むだけです。
合成した日本語文50,000行(平均50.2字)で、通常テーブルへのLIKEとtrigram構成のFTS5を比べました。SQLite 3.51.0、Intel Core i9-9880H、20回平均・ウォームキャッシュの値です。rowidの全件取得が完了するまでをPython側で計測しており、LIMIT付き検索や本文取得の時間とは異なります。
| 検索語 | ヒット件数 | 通常表 LIKE | FTS5 LIKE | FTS5 MATCH |
|---|---|---|---|---|
| ロールバック | 40 | 47.19ms | 0.67ms | 1.21ms |
| 障害の報告 | 126 | 44.47ms | 2.18ms | 1.33ms |
| 議事録 | 15,003 | 60.92ms | 208.55ms | 16.17ms |
| 障害(2文字) | 14,747 | 60.17ms | 78.80ms | 0件 |
この測定では、ヒットが少ない上の2語で、MATCHは通常表のLIKEより30倍以上速くなりました。注目すべきは3行目です。全体の3割にヒットする語では、trigramテーブルへのLIKEが通常表のLIKEの3.4倍遅くなりました。索引で候補を絞ったあと、候補行の本文を読み直して照合する分のコストが、ヒット件数に比例して膨らむためです。FTS5に移したら、検索は原則MATCHで書きます。
代償はディスクです。optimizeとVACUUM後の大きさは、通常テーブル8.2MBに対しtrigram構成のFTS5が16.6MBでした。検索時間を、容量と書き込みコストで買う構造です。
自分の環境でFTS5が有効かの確認
FTS5はコンパイルオプションで有効化される拡張です。次のクエリで ENABLE_FTS5 が返れば、追加の導入なしで使えます。
SELECT compile_options FROM pragma_compile_options WHERE compile_options LIKE 'ENABLE_FTS%';
-- ENABLE_FTS3 / ENABLE_FTS3_PARENTHESIS / ENABLE_FTS3_TOKENIZER / ENABLE_FTS4 / ENABLE_FTS5
SELECT sqlite_version();
-- 3.51.0
Pythonでは、言語の版ではなくリンクされているSQLiteの版を見ます。Python公式ドキュメントのとおり sqlite3.sqlite_version が実行時のSQLiteライブラリの版で、macOS同梱のPython 3.9.6では 3.51.0 が返り、FTS5も有効でした。sqlite3モジュールは既定で拡張の読み込みに対応していないため、FTS5が無効なビルドに後から足すのは手間がかかります。FTS5には組み込みのICUトークナイザーがなく、ICUを有効にしたビルドだけでは利用できません。macOS同梱のsqlite3でも tokenize='icu' は no such tokenizer: icu で失敗します。
仮想テーブルの作成とMATCH検索の基本構文
CREATE VIRTUAL TABLEの書き方と主なオプション
FTS5のテーブルは USING fts5 を付けた仮想テーブルとして作ります。列に型や制約は書けません。a TEXT と書くと unrecognized column option: TEXT、a NOT NULL は parse error で失敗します。
CREATE VIRTUAL TABLE docs_u61 USING fts5(
title,
body,
tokenize='unicode61', -- 既定なので省略できる
prefix='2 3' -- 2文字・3文字の前方一致用の索引を追加
);
INSERT INTO docs_u61(title, body) VALUES
('release note', 'sqlite full text index with fts5'),
('sqlite tips', 'near queries and column filters in sqlite');
検索対象にしない列は author UNINDEXED のように UNINDEXED を付けます。prefix は sear* のような前方一致を速くしますが、指定した文字数ごとに索引が増えるので、実際に使う長さだけを指定します。
MATCHクエリで使える検索構文
検索は テーブル名 MATCH 'クエリ' で書きます。WHERE docs_u61 = 'fts5' や FROM docs_u61('fts5') も同じ意味です。クエリ言語はFTS5独自で、構文の全体はSQLite公式のFTS5ドキュメントにあります。下表は上の2行で実行した結果です。
| 目的 | クエリ | 返った行 |
|---|---|---|
| AND・括弧 | sqlite AND (index OR fts5) |
1 |
| フレーズ | "full text" または full + text |
1 |
| 近接 | NEAR(full fts5, 3) |
1 |
| 近接(距離不足) | NEAR(full fts5, 2) |
なし |
| 前方一致 | fil* |
2 |
| 列指定 | title:sqlite |
2 |
| 複数列指定 | {title body}:tips |
2 |
| 列の除外 | - title:sqlite |
1と2 |
| 列の先頭語 | body:^near |
2 |
NEARの数値は、2語の間に挟まってよいトークン数です。1行目の本文では full と fts5 の間に text・index・with の3語があるため、3なら一致し、2では外れます。- title:sqlite は「title以外の列にsqliteがある行」の意味で、両行とも本文にsqliteを含むので2行返ります。
AND・OR・NOT・NEARは大文字でなければ演算子になりません。sqlite and fts5 と書くと and が普通の語として扱われ、0件になります。優先順位はNOT、AND、ORの順です。
検索フォームの入力値で起きる構文エラーと対策
入力値をそのままMATCHへ渡すと、記号を含む入力で例外が出ます。コロンは列指定、ハイフンやプラスは演算子として解釈されるためです。
CREATE VIRTUAL TABLE q USING fts5(b);
INSERT INTO q VALUES('foo-bar C++ test');
SELECT count(*) FROM q WHERE q MATCH 'foo-bar'; -- Error: no such column: bar
SELECT count(*) FROM q WHERE q MATCH 'C++'; -- Error: fts5: syntax error near "+"
SELECT count(*) FROM q WHERE q MATCH 'sqlite:'; -- Error: no such column: sqlite
SELECT count(*) FROM q WHERE q MATCH '"foo-bar"'; -- 1
SELECT count(*) FROM q WHERE q MATCH '"C++"'; -- 1
入力値は二重引用符で囲み、フレーズとして渡すのを既定の実装にします。入力中の二重引用符は2つ重ねてエスケープします。これを省くと、製品名やメールアドレスで検索した利用者だけにエラーが出る、再現しにくい不具合になります。後述のPython実装に組み込んだ形を載せています。
日本語が1件もヒットしない原因とtrigramへの切り替え
組み込みトークナイザー4種と日本語への適性
| 名前 | 分割規則 | 日本語 | 備考 |
|---|---|---|---|
| unicode61 | Unicode文字分類 | 単語分割なし | 既定 |
| ascii | ASCII範囲で分割 | 単語分割なし | 非ASCIIは語の一部扱い |
| porter | 英語の語幹化 | 日本語解析なし | 他のトークナイザーに重ねる |
| trigram | 3文字ずつ | 可 | 3.34.0で追加 |
porterは他のトークナイザーの出力を語幹化し、単独で指定した場合は既定でunicode61を使います。running と run を同一視する英語向けの機能で、日本語の分割には関係しません。
unicode61が日本語を分割できない仕組み
unicode61は空白と句読点で語を切ります。分かち書きをしない日本語では、文がまるごと1トークンになります。
CREATE VIRTUAL TABLE u USING fts5(body); -- 既定のunicode61
INSERT INTO u VALUES('日本語のテキスト検索');
SELECT count(*) FROM u WHERE u MATCH '検索'; -- 0
SELECT count(*) FROM u WHERE u MATCH '日本語*'; -- 1(文頭からの前方一致だけは通る)
SELECT count(*) FROM u WHERE u MATCH '日本語のテキスト検索'; -- 1(文全体と完全一致)
文の途中にある語では検索できません。不具合ではなく仕様で、「FTS5を入れたのに日本語が引っかからない」という症状の大半はこれが原因です。
trigramへの切り替えと3文字未満の制約
SQLite 3.34.0(2020年12月1日)で追加されたtrigramトークナイザーは、文字列を3文字ずつずらして索引します。「データベース」なら「データ」「ータベ」「タベー」…と分かれるため、日本語でも部分一致が成立します。
CREATE VIRTUAL TABLE docs_tri USING fts5(title, body, tokenize='trigram');
INSERT INTO docs_tri VALUES('請求書の検索','請求書と見積書を全文検索する運用手順をまとめます。');
INSERT INTO docs_tri VALUES('ログ検索','アクセスログを検索する方法をまとめます。');
INSERT INTO docs_tri VALUES('全文検索の入門','SQLiteのFTS5で日本語を検索する方法。転置インデックスを使います。');
SELECT rowid, title FROM docs_tri WHERE docs_tri MATCH '日本語';
-- 3|全文検索の入門 (3文字: 一致する)
SELECT count(*) FROM docs_tri WHERE docs_tri MATCH '検索';
-- 0 (2文字: 一致しない)
公式ドキュメントのtrigramの節には「3 unicode文字未満の部分文字列は全文検索クエリでどの行にも一致しない」とあり、実行結果もそのとおりです。日本語の検索語は「東京」「請求」「障害」のような2文字が多いので、導入前に想定クエリのうち2文字の語がどれだけあるかを数えてください。対処は次の章で扱います。
3文字以上でも、語の境界をまたいだ誤ヒットは避けられません。
CREATE VIRTUAL TABLE d USING fts5(b, tokenize='trigram');
INSERT INTO d VALUES('東京都市計画の概要'),('京都市の観光案内');
SELECT rowid FROM d WHERE d MATCH '京都市';
-- 1 と 2 の両方が返る('東京都市計画' の中の '京都市' にも一致)
長い自然文のクエリが0件になる理由
trigramではクエリも3文字トークンの連続として扱われ、その文字列が連続して存在する行だけが返ります。文をそのまま渡すと、言い換えや語の省略が1か所あるだけで0件になります。
CREATE VIRTUAL TABLE l USING fts5(b, tokenize='trigram');
INSERT INTO l VALUES('障害の報告書を作成する手順をまとめます');
SELECT count(*) FROM l WHERE l MATCH '障害の報告書の作成手順'; -- 0
SELECT count(*) FROM l WHERE l MATCH '"報告書" "作成する"'; -- 1(語を分けてAND)
SELECT count(*) FROM l WHERE l MATCH '報告書 AND 手順'; -- 0(手順が2文字のため)
3行目が落とし穴です。2文字の語をANDに混ぜると、その語が一致しないため全体も0件になります。ORで結合した場合は2文字の語だけが黙って無視されます。自然文を受ける検索窓では、アプリ側でキーワードを切り出し、3文字以上の語だけをMATCHに渡す処理が要ります。
形態素解析トークナイザーとアプリ側分かち書きの選択
2文字の語と境界の誤ヒットを両方なくしたいなら、trigram以外の手段になります。選択肢は次の3つです。
- アプリ側で分かち書きしてから投入:MeCabやSudachi、Janomeで形態素に分け、空白区切りの文字列をunicode61で索引する。検索語にも同じ処理をかける。
- 拡張トークナイザーを組み込む:形態素解析器をFTS5のトークナイザーとして登録する拡張を読み込む。
- ICUを使うカスタム実装を用意する:ICUによる分割処理をFTS5のカスタムトークナイザーとして実装・登録する。
どれを選ぶかは実行環境で決まります。Cloudflare D1のように任意の拡張を読み込めない環境では、アプリ側で分かち書きします。配布アプリでは、実行環境が許せばカスタムトークナイザーを組み込む選択肢もあります。解析器ごとの分割の違いはMeCabとSudachiなど形態素解析ツールの実測比較にまとめています。
2文字の検索語を拾う方法:MATCHとLIKEの使い分け
trigramテーブルへのLIKEによる2文字語の検索
trigramテーブルには、MATCHのほかにLIKEとGLOBを索引で処理する機能があります。2文字のパターンは索引を使えず全件走査に落ちますが、結果は正しく返ります。
SELECT rowid FROM docs_tri WHERE body LIKE '%検索%';
-- 1 / 2 / 3 (MATCH '検索' は0件だった)
EXPLAIN QUERY PLAN SELECT * FROM docs_tri WHERE body LIKE '%請求書%';
-- SCAN docs_tri VIRTUAL TABLE INDEX 0:L1 (L=LIKE、数字は列番号。title列なら 0:L0)
実行計画の L1 は、LIKEの条件が仮想テーブル側へ渡されたことを示します。公式ドキュメントによれば、remove_diacritics=1 を付けるとLIKEとGLOBの索引処理が外れ、case_sensitive=1 ではGLOBだけが索引の対象になります。LIKEを使う前提なら、この2つのオプションは付けません。
速度は冒頭の表のとおりです。2文字の「障害」は、FTS5へのLIKEが78.80ms、通常表のLIKEが60.17msで、速くはなりません。3文字以上の稀少語ではLIKEも索引で速くなりますが、頻出語では逆転します。そこで、3文字以上の語はMATCH、2文字以下の語を含むときだけLIKEに切り替える実装にします。
Python標準のsqlite3による検索関数の実装
入力値のクォートとLIKEの特殊文字のエスケープを含めた実装例です。Python 3.9.6(SQLite 3.51.0)で実行しました。
import sqlite3
con = sqlite3.connect(":memory:")
con.execute("CREATE VIRTUAL TABLE docs USING fts5(title, body, tokenize='trigram')")
con.executemany("INSERT INTO docs(title, body) VALUES (?, ?)", [
("障害報告の書き方", "サーバー障害の報告書を作成する手順"),
("東京オフィス移転", "東京の新オフィスへの移転スケジュール"),
("C++ビルド手順", "C++とCMakeでビルドする方法"),
])
def fts_quote(term):
# 入力中の " は "" に重ねてから全体を " で囲み、フレーズとして渡す
return '"' + term.replace('"', '""') + '"'
def search(q, limit=20):
terms = q.split()
if not terms:
return []
if all(len(t) >= 3 for t in terms):
# 3文字以上の語だけなら trigram の転置インデックスを使う
sql = ("SELECT rowid, title, bm25(docs, 10.0, 1.0) FROM docs "
"WHERE docs MATCH ? ORDER BY bm25(docs, 10.0, 1.0) LIMIT ?")
return con.execute(sql, (" AND ".join(fts_quote(t) for t in terms), limit)).fetchall()
# 2文字以下の語を含むときは LIKE に切り替える(結果は正しいが全件走査になる)
def like_pat(t):
# LIKE の特殊文字 % と _ は \ でエスケープする
return "%" + t.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
where = " AND ".join("(title LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')" for _ in terms)
args = [p for t in terms for p in (like_pat(t),) * 2]
return con.execute(f"SELECT rowid, title, NULL FROM docs WHERE {where} LIMIT ?", (*args, limit)).fetchall()
print(search("障害報告")) # [(1, '障害報告の書き方', -1.0046689178132628)]
print(search("東京")) # [(2, '東京オフィス移転', None)]
print(search("C++")) # [(3, 'C++ビルド手順', -1.0144463727256912)]
print(search("報告書 手順")) # [(1, '障害報告の書き方', None)]
「C++」はそのままMATCHに渡すと構文エラーでしたが、クォートすれば一致します。LIKE側の結果にはbm25()のスコアが付かないため、関連度で並べたい画面では、2文字語の検索を「タイトル一致を先に出す」など別の並べ方で補います。2文字語の検索が多い場合は、想定するデータ量と同時検索数で全件走査の応答時間を測り、要件を満たせなければ前の章の分かち書き方式へ移ります。
bm25()で関連度順に並べる方法と列の重み付け
bm25の値が負になる理由とrankの使い方
MATCHの結果は既定では関連度順に並びません。並べるには補助関数bm25()を使います。公式ドキュメントのbm25()の節のとおり、FTS5は通常のBM25スコアに-1を掛けた値を返すため、値は負で、小さいほど上位です。
SELECT rowid, bm25(docs_tri) AS score
FROM docs_tri WHERE docs_tri MATCH '請求書'
ORDER BY bm25(docs_tri);
-- 1|-0.716278566950994
同じ並べ替えは ORDER BY rank とも書けます。rankはFTS5テーブルの隠し列で、既定では引数なしのbm25()と同じ値になり、上のクエリでも -0.716278566950994 が返ります。DESCにすると関連度の低い行が先頭に来るので、向きを間違えないでください。
検索語が全行に出る場合、スコアはほぼ0に潰れます。docs_tri の3行すべてに含まれる「検索する」で並べると、3行とも -1.1e-06 から -8.7e-07 の範囲に収まり、順序は文書の長さの差だけで決まります。並べ替えが効かないと感じたら、その語がどれだけの行に出ているかを先に疑います。BM25の式そのものと、k1・bの意味はBM25の計算式とPython実装の解説で扱っています。
列ごとの重み付けによるタイトル一致の優先
bm25()は2つ目以降の引数で列の重みを取ります。並びはテーブル定義の列順です。
SELECT rowid, bm25(docs_tri, 10.0, 1.0) AS score
FROM docs_tri WHERE docs_tri MATCH '請求書'
ORDER BY bm25(docs_tri, 10.0, 1.0);
-- 1|-1.01845858738344 (titleを10倍。既定の -0.716… より上位側へ動く)
重みを0にすると、その列は検索対象に残したまま順位付けからだけ外れます。
snippet()とhighlight()による該当箇所の表示
highlight()は列の全文に開始・終了の印を挟み、snippet()は一致箇所の前後を切り出します。snippet()の最後の引数はトークン数で、1から64の範囲です。
SELECT snippet(docs_tri, 1, '<b>', '</b>', '...', 8)
FROM docs_tri WHERE docs_tri MATCH '日本語';
-- ...S5で<b>日本語</b>を検索す...
SELECT highlight(docs_tri, 0, '[', ']')
FROM docs_tri WHERE docs_tri MATCH '請求書';
-- [請求書]の検索
SELECT offsets(docs_tri) FROM docs_tri WHERE docs_tri MATCH '請求書';
-- Error: unable to use function offsets in the requested context
2つ目の引数は列番号(0始まり)です。最後の例のとおり、FTS3/4にあった offsets() と matchinfo() はFTS5にはありません。FTS3/4の解説がFTS5として出回っているので、関数名は公式ドキュメントで確かめてください。
本体テーブルと同期するexternal contentとcontentless
external contentとトリガーによる同期
FTS5テーブルは既定で本文の複製を持ちます。業務テーブルが別にあると二重保存になるので、content に元テーブル、content_rowid に主キー列を指定するexternal content構成にします。
CREATE TABLE t_ext(id INTEGER PRIMARY KEY, note TEXT);
CREATE VIRTUAL TABLE t_fts USING fts5(note, content='t_ext', content_rowid='id');
INSERT INTO t_ext VALUES(1, 'external content table test');
SELECT count(*) FROM t_fts WHERE t_fts MATCH 'external'; -- 0
INSERT INTO t_fts(t_fts) VALUES('rebuild');
SELECT count(*) FROM t_fts WHERE t_fts MATCH 'external'; -- 1
本体へINSERTしただけでは索引は更新されず、rebuildで初めて1件になります。継続して同期するにはトリガーを3本作ります。DELETEとUPDATEでは、’delete’ コマンドに更新前の値を渡します。古い値を渡さないと索引に残骸が残り、消した行が検索に出続けます。
CREATE TRIGGER t_ai AFTER INSERT ON t_ext BEGIN
INSERT INTO t_fts(rowid, note) VALUES (new.id, new.note);
END;
CREATE TRIGGER t_ad AFTER DELETE ON t_ext BEGIN
INSERT INTO t_fts(t_fts, rowid, note) VALUES('delete', old.id, old.note);
END;
CREATE TRIGGER t_au AFTER UPDATE ON t_ext BEGIN
INSERT INTO t_fts(t_fts, rowid, note) VALUES('delete', old.id, old.note);
INSERT INTO t_fts(rowid, note) VALUES (new.id, new.note);
END;
INSERT INTO t_ext VALUES(2, 'trigger sync works');
UPDATE t_ext SET note='renamed row' WHERE id=1;
SELECT count(*) FROM t_fts WHERE t_fts MATCH 'external'; -- 0(更新前の語は消えた)
SELECT rowid FROM t_fts WHERE t_fts MATCH 'renamed'; -- 1
DELETE FROM t_ext WHERE id=2;
SELECT count(*) FROM t_fts WHERE t_fts MATCH 'trigger'; -- 0
INSERT INTO t_fts(t_fts, rank) VALUES('integrity-check', 1); -- 本体と索引が一致していればエラーなし
最後の integrity-check に rank=1 を付けると、索引が元テーブルの内容と一致しているかまで照合し、ずれていればエラーを返します。トリガーを後から足した場合や、移行バッチの後に流しておくと、同期漏れを早く見つけられます。
contentless・contentless-deleteテーブル
content='' を指定すると、索引だけを持ち本文を保存しないcontentlessテーブルになります。冒頭の5万行で作ると8.3MBで、本文を持つ構成の16.6MBのほぼ半分でした。代わりに列を読むとNULLが返り、snippet()もエラーではなくNULLを返します。
CREATE VIRTUAL TABLE c_less USING fts5(x, content='');
INSERT INTO c_less(rowid, x) VALUES(1, 'hello world');
SELECT quote(x), quote(snippet(c_less, 0, '[', ']', '...', 5)) FROM c_less WHERE c_less MATCH 'hello';
-- NULL|NULL
DELETE FROM c_less WHERE rowid=1;
-- Error: cannot DELETE from contentless fts5 table: c_less
CREATE VIRTUAL TABLE c_del USING fts5(x, content='', contentless_delete=1);
INSERT INTO c_del(rowid, x) VALUES(1, 'hello world');
DELETE FROM c_del WHERE rowid=1;
SELECT count(*) FROM c_del WHERE c_del MATCH 'hello'; -- 0
従来のcontentlessテーブルはDELETE文を使えず、削除にはFTS5の’delete’コマンドが必要でしたが、SQLite 3.43.0(2023年8月24日)で追加された contentless_delete=1 で削除と置き換えができます。さらに3.47.0(2024年10月21日)の contentless_unindexed=1 を使うと、UNINDEXED列の値だけは保存されます。検索でrowidを取り、本文は別のストレージから引く設計に向いた構成です。
インデックスの保守とサイズの制御
optimize・merge・rebuild・secure-deleteの使い分け
FTS5の索引には更新でセグメントが増えますが、既定では自動統合も行われます。手動の統合は、大量投入後や検索性能の低下を確認した場合に検討します。保守は、テーブル名と同じ名前の隠し列へのINSERTという独特の構文で行います。
| コマンド | 構文(ftはテーブル名) | 用途 |
|---|---|---|
| optimize | INSERT INTO ft(ft) VALUES(‘optimize’); | 全セグメントを1つに統合 |
| merge | INSERT INTO ft(ft, rank) VALUES(‘merge’, 500); | 指定量だけ段階的に統合 |
| rebuild | INSERT INTO ft(ft) VALUES(‘rebuild’); | 元の内容から索引を作り直す |
| integrity-check | INSERT INTO ft(ft, rank) VALUES(‘integrity-check’, 1); | 索引と内容の整合を検査 |
| secure-delete | INSERT INTO ft(ft, rank) VALUES(‘secure-delete’, 1); | 削除した語を索引から即時に消す |
大量投入の直後にoptimizeを1回実行し、通常の更新は既定の自動統合に任せるのが扱いやすい運用です。mergeは、検索の遅れを確認したときに少しずつ統合する手段です。optimizeは索引全体を書き直すので、稼働中に実行すると長い書き込みロックがかかります。secure-deleteはSQLite 3.42.0で追加されたオプションで、既定では削除した語の痕跡がセグメント統合まで索引に残るのを避けたいときに使います。個人情報を検索対象に入れる場合に検討する設定です。ただし、DBファイルの直接解析による復元にも対処するには、SQLite本体の PRAGMA secure_delete = 1 も必要です。
detailオプションによる索引サイズの削減
索引が保持する情報量を減らす設定の1つが detail オプションです。既定の detail=full は語の出現位置まで持ち、column は列単位、none は行単位までしか持ちません。冒頭の5万行(trigram・1列)で比べると、full 16.6MB、column 16.5MB、none 11.7MBでした。1列のテーブルでは column にしてもほとんど縮みません。
代わりに検索機能が減ります。公式ドキュメントのとおり、trigramでdetail=columnまたはnoneを指定すると、3文字を超える文字列を1つの検索語として渡すMATCHは使えません。ただしLIKEやGLOBは引き続き使えます。
CREATE VIRTUAL TABLE t_none USING fts5(b, tokenize='trigram', detail=none);
INSERT INTO t_none VALUES('障害報告書の作成');
SELECT count(*) FROM t_none WHERE t_none MATCH '障害報'; -- 1(3文字は通る)
SELECT count(*) FROM t_none WHERE t_none MATCH '障害報告'; -- Error: fts5: phrase queries are not supported (detail!=full)
SELECT count(*) FROM t_none WHERE b LIKE '%障害報告%'; -- 1(LIKEは動く)
trigramで detail を落としてよいのは、検索をLIKEとGLOBだけで行うと決めた場合です。MATCHを使う設計なら full のままにします。
fts5vocabによる索引の中身の確認
ヒットが0件のとき、原因がトークナイザーにあるのか語が無いのかは、fts5vocab仮想テーブルで索引の中身を見ると切り分けられます。
CREATE VIRTUAL TABLE vocab_u61 USING fts5vocab(docs_u61, 'row');
SELECT term, doc, cnt FROM vocab_u61 ORDER BY term LIMIT 5;
-- and|1|1
-- column|1|1
-- filters|1|1
-- fts5|1|1
-- full|1|1
term は語、doc はその語を含む行数、cnt は出現回数です。trigram構成の docs_tri に作れば、term に 5で日 や fts のような3文字の並びが出るので、日本語の部分文字列が意図どおり索引されているかを確認できます。
Cloudflare D1でFTS5を使うときの制約
Cloudflare D1は対応するSQLite拡張の一覧にFTS5(fts5vocabを含む)を挙げており、D1でもFTS5を利用できます。ただし、各SQLで使うオプションの対応状況はD1上で確認してください。ローカルのSQLiteと異なる制約として、次の3点があります。
- FTS5の仮想テーブルを含むデータベースはエクスポートできません。D1のインポート・エクスポートの説明では、仮想テーブルを削除してからエクスポートし、後で作り直す手順が案内されています。バックアップや環境の複製の手順に、FTS5テーブルの再作成とrebuildを組み込んでおく必要があります。
- LIKEとGLOBのパターンは50バイトまでです(D1の制限一覧)。各文字がUTF-8で3バイトなら、前後の%を除いて16文字が上限です。補助漢字など4バイトの文字やエスケープ文字を含む場合は、パターン全体のバイト数で判定します。前章のLIKEへの切り替えは、2文字語なら問題ありません。
- 使える拡張は一覧にあるものだけです。形態素解析のトークナイザー拡張を読み込む手段は案内されていないため、日本語の精度を上げるならアプリ側で分かち書きした文字列を投入します。
FTS5を採用すべきでない場面
FTS5の価値は、SQLiteだけで検索が完結する点にあります。その利点が消える条件では、別の手段を選ぶべきです。
1つ目は、2文字の語が検索の主役で、LIKEの全件走査では必要な応答時間を満たせない日本語サービスです。全件走査の時間は行数に比例して伸びるため、分かち書きも導入できない環境では打つ手がなくなります。2つ目は、表記ゆれや同義語の吸収が要件にある場合です。FTS5には標準の同義語辞書はありません。unicode61の大小文字・ダイアクリティカルマークの処理やporterの語幹化で足りない場合は、クエリ展開やカスタムトークナイザーを実装します。Rust製の全文検索エンジンMeilisearchのように、タイポ許容や同義語を標準で持つ製品と比べると開発量の差は大きくなります。
3つ目は、書き込みが絶えず多いワークロードです。SQLiteの書き込みは1本に直列化されるため、更新のたびに索引も書くFTS5は競合を悪化させます。4つ目は、複数のアプリケーションサーバーから同じ検索索引を共有したい構成です。これらに当てはまるなら、全文検索エンジンの仕組みと主要OSSの比較を見て専用エンジンへの分離を検討してください。日本語の形態素解析を前提にした構成ならElasticsearchのkuromojiによる日本語全文検索が比較対象になります。既存の業務データベースの設計と合わせて検索基盤を見直す場合は、データベース設計・移行支援でご相談を受けています。
逆に、単一プロセスのデスクトップアプリ、モバイルアプリの端末内検索、実測で応答時間の要件を満たす管理画面の検索、エッジでSQLite互換のDBを動かすTursoのような構成では、外部サービスを増やさずに検索を作れるFTS5の利点がそのまま効きます。
よくある質問
FTS5はSQLiteに標準で入っていますか?
拡張機能ですが、macOS同梱のsqlite3、Python標準のsqlite3、Cloudflare D1では有効になっていました。SELECT compile_options FROM pragma_compile_options WHERE compile_options LIKE 'ENABLE_FTS%'; で ENABLE_FTS5 が返れば追加の導入は要りません。
FTS5で日本語を検索しても0件になるのはなぜですか?
既定のunicode61トークナイザーが日本語を語に分けず、文の途中にある語を索引していないためです。tokenize='trigram' を指定したテーブルを作り直し、データを入れ直してください。トークナイザーはテーブル定義に固定されるので、既存テーブルへのrebuildでは変わりません。
trigramで2文字の語を検索するにはどうすればよいですか?
MATCHでは一致しないので、同じtrigramテーブルに WHERE body LIKE '%東京%' のようにLIKEで問い合わせます。結果は正しく返りますが全件走査になるため、3文字以上の語はMATCH、2文字以下の語を含むときだけLIKEに切り替えます。
bm25()の値がマイナスになるのはなぜですか?
FTS5がBM25のスコアに-1を掛けて返す仕様のためです。関連度が高い行ほど小さい値になるので、ORDER BY bm25(t) か ORDER BY rank の昇順で並べます。
Cloudflare D1でFTS5は使えますか?
使えます。D1は対応拡張としてFTS5とfts5vocabを挙げています。ただしFTS5の仮想テーブルを含むデータベースはエクスポートできず、LIKEとGLOBのパターンは50バイトまでです。任意の拡張を読み込む手段は案内されていないので、日本語はtrigramかアプリ側の分かち書きで扱います。