FTS5とは?SQLite全文検索の使い方と日本語対応・bm25
FTS5は、SQLiteに同梱されている全文検索用の仮想テーブルモジュールです。追加のサーバーもインデクサも立てずに、CREATE VIRTUAL TABLE 一行で転置インデックスを持つ検索用テーブルを作れます。ただし既定設定のままでは日本語が1件もヒットせず、旧来のFTS3/4向けの情報を持ち込むと存在しない関数を呼んでエラーになります。テーブル作成からMATCH構文、bm25()による並べ替え、日本語対応、本体テーブルとの同期までを、SQLite 3.51.0(macOS 26.6 同梱)で実行を確認したSQLとともに整理しました。
まとめ:FTS5で最初に押さえる5点
- FTS5はSQLite本体に同梱の拡張で、多くの配布バイナリで有効。
pragma_compile_optionsに ENABLE_FTS5 があれば使えます。 - 既定のunicode61トークナイザーは日本語を分割できないため、日本語文書に対する MATCH は0件になります。
tokenize='trigram'への切り替えが最短の解決策です。 - trigramは3文字未満のクエリに一致しません。「東京」「検索」のような2文字語が主要な検索語になる用途では、そのままでは実用に耐えません。
- 関連度順は bm25() で並べ替えます。値は負で、小さいほど上位です。
ORDER BY rankが同じ意味の省略記法です。 - offsets() と matchinfo() はFTS5に存在しません。ハイライトは highlight() と snippet() を使います。FTS3/4時代の記事を参照する際の最大の落とし穴です。
FTS5の正体:SQLiteに同梱された全文検索用の仮想テーブル
LIKEの逐次走査とMATCHの転置インデックスの差
通常のテーブルに LIKE '%キーワード%' を投げると、SQLiteは全行のテキストを先頭から走査します。行数に比例して遅くなり、インデックスも効きません。FTS5は挿入時にテキストをトークンへ分割し、トークンから行番号を引ける転置インデックスを維持するため、検索時は該当トークンの行リストを引くだけで済みます。
SQLite 3.51.0で、日本語の文(平均45.8字)50,000行に対し、8,265件がヒットする語を20回平均(ウォームキャッシュ)で計測すると、通常テーブルへのLIKE部分一致が32.8ms、trigram構成のFTS5へのMATCHが2.56msでした。約13倍の差です。ヒット件数が多いほど結果の取り出しが支配的になって倍率は縮み、稀少語ほど広がります。
一方でファイルサイズは通常テーブルの6.7MBに対しFTS5が17.6MBと約2.6倍に増えます(trigram+日本語の場合。トークナイザーと言語で倍率は変わります)。FTS5の採用は、ディスクと更新コストを払って検索時間を買う判断だと理解しておくと設計を誤りません。
自分の環境でFTS5が有効かの確認
FTS5はコンパイルオプションで有効化される拡張のため、まず利用可否を確認します。次のクエリが1行返せば有効です。
SELECT * FROM pragma_compile_options WHERE compile_options LIKE 'ENABLE_FTS%';
-- ENABLE_FTS5 が返れば利用可能
macOS 26.6 同梱のsqlite3 3.51.0とPython標準ライブラリのsqlite3で実行すると、いずれもENABLE_FTS5が返ります。Cloudflare D1もFTS5をサポート対象として明記しており、fts5vocabまで利用できます。一方、ICUトークナイザーは別のコンパイルオプション(SQLITE_ENABLE_ICU)が必要で、macOS同梱のsqlite3では有効になっていません(no such tokenizer: icu になります)。日本語対応でICUを前提にした手順を見かけたら、まず自環境のビルドを確認してください。
仮想テーブルの作成からMATCH検索までの最小手順
CREATE VIRTUAL TABLEの書き方と主なオプション
FTS5のテーブルは通常のCREATE TABLEではなく、USING fts5 を付けた仮想テーブルとして作ります。列に型や制約は指定できません。型名を書くと unrecognized column option: TEXT、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');
非索引列が必要な場合は列名の後ろに UNINDEXED を付けます(例: author UNINDEXED)。prefix オプションは前方一致検索を高速化しますが、指定した文字数ぶんインデックスが増えるため、必要な長さだけ指定します。
MATCHクエリで使える検索構文
検索は テーブル名 MATCH 'クエリ' で書きます。クエリ言語はFTS5独自で、SQLのLIKEとは別物です。以下はすべてSQLite 3.51.0で動作を確認した書式です。
| 目的 | 書き方 | 意味 |
|---|---|---|
| AND検索 | full AND text | 両方を含む行(空白区切りも同義) |
| OR検索 | full OR text | いずれかを含む行 |
| 除外 | full NOT text | 前者を含み後者を含まない行 |
| フレーズ | “full text” | 連続した語順で一致 |
| 近接 | NEAR(full text, 3) | 3トークン以内に共起 |
| 前方一致 | sear* | searchなどに一致 |
| 列指定 | title:sqlite | title列だけを対象 |
注意すべきは、AND・OR・NOT・NEARが大文字必須という点です。小文字で書くと通常のトークンとして扱われ、意図せず0件になります。演算子の優先順位はNOT、AND、ORの順で、括弧でのグルーピングができます。
SELECT rowid, title FROM docs_u61 WHERE docs_u61 MATCH 'sqlite AND (index OR fts5)';
-- rowid=1, title='release note' が返る
ユーザー入力をそのまま渡した場合の構文エラー
検索フォームの入力値をMATCHへ直接渡す実装は、記号を含む入力で例外を投げます。FTS5のクエリ言語では、コロンが列指定、ハイフンやプラスが演算子・区切りとして解釈されるためです。
CREATE VIRTUAL TABLE q USING fts5(b);
INSERT INTO q VALUES('foo-bar C++ test');
SELECT * FROM q WHERE q MATCH 'foo-bar'; -- Error: no such column: bar
SELECT * FROM q WHERE q MATCH 'C++'; -- Error: fts5: syntax error near "+"
SELECT * FROM q WHERE q MATCH 'sqlite:'; -- Error: no such column: sqlite
SELECT * FROM q WHERE q MATCH '"foo-bar"'; -- 二重引用符で囲めば正常に検索できる
対策は、入力値を二重引用符で囲んでフレーズとして渡すことです。入力中の二重引用符自体は2つ重ねてエスケープします。演算子を使わせたい場合を除き、アプリ側でクォートするのを既定の実装にしてください。この処理を省くと、製品名やメールアドレスを検索した利用者にだけエラーが出る、再現しにくい不具合になります。
日本語が1件もヒットしない原因とtrigramへの切り替え
組み込みトークナイザー4種の使い分け
FTS5にはトークナイザーが4種類同梱されています。日本語で使えるのは実質trigramだけです。
| 名前 | 分割規則 | 日本語 | 備考 |
|---|---|---|---|
| unicode61 | 空白・句読点 | 不可 | 既定 |
| ascii | ASCII範囲のみ | 不可 | 非ASCIIは語の一部扱い |
| porter | 英語の語幹化 | 不可 | 他トークナイザーに重ねる |
| trigram | 3文字ずつ | 可 | 3.34.0で追加 |
porterは単独のトークナイザーではなく、unicode61などの出力を語幹に丸めるラッパーとして働きます。英語のrunningとrunを同一視したい場合に使う機能で、日本語の分割には関与しません。
unicode61が日本語を分割できない仕組み
既定のunicode61トークナイザーは、Unicodeの文字種に基づいて空白と句読点で語を切ります。英語なら単語境界と一致しますが、分かち書きをしない日本語では文がまるごと1トークンになります。結果として、部分文字列での検索は成立しません。
CREATE VIRTUAL TABLE u USING fts5(body); -- 既定のunicode61
INSERT INTO u VALUES('日本語のテキスト検索');
SELECT count(*) FROM u WHERE u MATCH '検索';
-- 0 が返る(部分文字列として索引されていない)
不具合ではなく仕様です。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文字: 一致しない)
公式ドキュメントは「3 unicode文字未満の部分文字列はどの行にも一致しない」と明記しており、実行しても0件になります。日本語の検索語は「東京」「請求」「障害」のような2文字が非常に多いため、社内文書検索やサイト内検索にそのまま載せると、検索できない語があるという問い合わせにつながります。導入前に、想定クエリのうち2文字語がどれだけを占めるかを必ず数えてください。
3文字以上でも、n-gramである以上は語境界をまたいだ誤ヒットが起きます。
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文字トークンの連続として扱われ、全トークンが連続一致する行だけが返ります。実質的にフレーズ検索なので、文をそのまま投げると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 '障害 OR 報告書 OR 手順'; -- 1
検索窓に自然文が入る想定なら、アプリ側でキーワードを切り出してOR結合し、後述のbm25()で並べ替える構成にします。この一手間を入れないと、丁寧に入力した利用者ほど結果が出ないという逆転が起きます。
trigramでLIKEとGLOBが索引化される条件
trigram構成のテーブルは、MATCHだけでなくLIKEとGLOBの中間一致もインデックス経由で処理できます。実行計画を見ると、パターン照合が仮想テーブル側へ渡されていることが分かります。
EXPLAIN QUERY PLAN SELECT * FROM docs_tri WHERE body LIKE '%障害%';
-- SCAN docs_tri VIRTUAL TABLE INDEX 0:L1 (L=LIKE、数字は列番号。title列なら 0:L0)
ただし remove_diacritics=1 または case_sensitive=1 を指定するとLIKEの最適化は外れ、実行計画から L が消えます(case_sensitive指定時もGLOBの G1 は残ります)。既存のLIKE検索を書き換えずに高速化したい場合は、これらのオプションを付けないでください。
形態素解析トークナイザーとアプリ側分かち書きの選択
2文字語の取りこぼしと語境界の誤ヒットを許容できないなら、trigram以外の手を打ちます。現実的な選択肢は4つです。
- アプリ側で分かち書きしてから投入:MeCabやkuromoji、Janomeなどで形態素に分割し、空白区切りの文字列としてINSERTしてunicode61で索引する。検索時も同じ処理をクエリに適用する。
- 拡張トークナイザーを組み込む:sqlite-vaporettoなど、外部の形態素解析器をFTS5のトークナイザーとして登録する拡張を使う。
- ICU有効ビルドを用意する:SQLITE_ENABLE_ICU付きでビルドし直し、ICUトークナイザーを使う。
- FTS5をやめる:検索要件が中心なら専用エンジンへ分離する。
判断を分けるのは運用形態です。Cloudflare D1のような拡張をロードできないマネージド環境や、利用者の端末で動く配布アプリでは、拡張の追加もカスタムビルドも現実的でないため、アプリ側分かち書きが唯一の解になります。逆にサーバー側でビルドを管理できるなら、拡張トークナイザーが精度と索引サイズの両面で有利です。
bm25()で関連度順に並べる方法と列の重み付け
bm25の値が負になる理由とrankの使い方
FTS5はMATCHの結果を関連度順には返しません。既定の順序はrowid順です。関連度で並べるには補助関数bm25()を使います。FTS5のbm25()は標準的なBM25スコアの符号を反転させた値を返すため、値は負になり、小さいほど上位です。前節の docs_tri でそのまま実行できます。
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()と同値です。上のクエリを rank に置き換えても同じ -0.716278566950994 が返ります。降順(DESC)にすると関連度の低い行が先頭に来るので、並べ替えの向きを間違えないでください。
なお、検索語が全行に出現する場合はIDF項がほぼ0となり、スコアが潰れます。docs_tri の3行すべてに含まれる「検索する」で並べ替えると、3行とも -1e-06 前後まで縮み、順序は文書長の差だけで決まります。ランキングが効かないと感じたら、まずその語の文書頻度を疑ってください。
列ごとの重み付けによるタイトル一致の優先
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()による該当箇所の表示
検索結果に一致箇所を見せる関数は2つあります。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 '請求書';
-- [請求書]の検索
第1引数は列番号(0始まり)です。ここで重要なのは、FTS3/4にあった offsets() と matchinfo() はFTS5には存在しない点です。公式の補助関数一覧に記載がなく、呼び出すと「unable to use function offsets in the requested context」というエラーになります。FTS3/4の内容をFTS5として書いた解説が混在しているため、関数名は公式のfts5.htmlで確認するのが安全です。FTS5側の補助関数はbm25()、highlight()、snippet()が中心で、ほかにロケール関連のfts5_get_locale()などがあります。
本体テーブルと同期するexternal contentとcontentless
content指定とトリガーによる同期
FTS5テーブルは既定で本文の複製を持つため、業務テーブルが別にあると同じテキストの二重保存になります。これを避けるのがexternal content構成で、content に元テーブル名、content_rowid に主キー列を指定します。
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だけではインデックスは更新されません。上の実行結果が示すとおり0件です。既存データを一括で取り込むには rebuild を実行します。
INSERT INTO t_fts(t_fts) VALUES('rebuild');
SELECT count(*) FROM t_fts WHERE t_fts MATCH 'external'; -- 1 が返る
継続的な同期にはトリガーを3本用意します。UPDATEとDELETEでは、削除を表す ‘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;
contentlessテーブルとcontentless_delete
content='' を指定すると、索引だけを持ち本文を保存しないcontentlessテーブルになります。サイズは最小になりますが、SELECTで列を読み出してもNULLが返ります。snippet()やhighlight()もエラーにはならずNULLを返すため、不具合に気づきにくい点に注意してください。検索でrowidだけを取得し、本文は別のストレージから引く設計向けです。
CREATE VIRTUAL TABLE c_less USING fts5(x, content='');
INSERT INTO c_less(rowid, x) VALUES(1, 'hello world');
SELECT x, snippet(c_less, 0, '[', ']', '...', 5) FROM c_less WHERE c_less MATCH 'hello';
-- 両方とも NULL が返る
従来のcontentlessテーブルはDELETEもUPDATEもできませんでしたが、SQLite 3.43.0(2023年8月24日)で追加された contentless-delete オプションにより、削除と置換が可能になりました。組み込み機器やモバイルなど容量制約が厳しく、削除も発生する用途に向きます。
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; -- 3.43.0 以降で成功する
インデックスの肥大化と保守
optimize・merge・rebuildの使い分け
FTS5のインデックスはセグメントが複数レベルに蓄積される構造で、更新を重ねると断片化して検索が遅くなります。保守は関数呼び出しではなく、テーブル名と同名の隠し列へのINSERTという独特の構文で行います。
| コマンド | 構文 | 用途 |
|---|---|---|
| optimize | INSERT INTO ft(ft) VALUES(‘optimize’); | 全階層を1つに統合/高コスト |
| merge | INSERT INTO ft(ft, rank) VALUES(‘merge’, 500); | 指定ページ分だけ段階的に統合 |
| rebuild | INSERT INTO ft(ft) VALUES(‘rebuild’); | content元から索引を作り直す |
大量投入の直後に一度optimizeを実行し、以降は夜間バッチでmergeを回す運用が扱いやすくなります。optimizeはインデックス全体を書き直すため、稼働中のデータベースで無計画に実行すると長時間ロックがかかります。
detailオプションによるインデックスサイズの制御
索引サイズを削る最大のレバーは detail オプションです。既定の detail=full は各トークンの出現位置まで保持し、column は列単位、none は行単位までしか持ちません。unicode61構成の1列テーブルに5万行(1行40語)を投入して比較すると、21.4MB(full)、18.6MB(column)、15.8MB(none)でした。
代償は検索機能です。full以外ではフレーズ検索とNEAR検索が「fts5: phrase queries are not supported (detail!=full)」で失敗し、detail=none では title:sqlite のような列指定クエリも「column queries are not supported」で失敗します。trigramと組み合わせた場合はさらに影響が大きく、クエリ自体が3文字トークンの連続一致になるため、4文字以上の検索がすべて失敗します。
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: phrase queries are not supported
trigramで detail を落とすのは、検索語を3文字に限定できる特殊な用途だけです。なおLIKEの索引最適化は detail=none でも有効なままです。
fts5vocabによる索引内容の確認
索引の中身を確認したいときは fts5vocab 仮想テーブルを作ります。どの語が何文書に何回現れるかが読めるため、ヒット0件の原因がトークナイザーにあるのか語の不在にあるのかを切り分けられます。
CREATE VIRTUAL TABLE vocab_u61 USING fts5vocab(docs_u61, 'row');
SELECT term, doc, cnt FROM vocab_u61 ORDER BY term LIMIT 5;
-- fts5|1|1
-- full|1|1
-- index|1|1
-- note|1|1
-- release|1|1
trigram構成の docs_tri に対して実行すると、termは 5で日 eのf fts のような3文字のn-gramとして並びます。日本語で意図した部分文字列が索引されているかの確認に使えます。
FTS5を採用すべきでない場面
FTS5はSQLiteで完結することが最大の価値であり、その利点が消える条件では素直に他の手段を選ぶべきです。
第一に、2文字の検索語が主役になる日本語サービスです。trigramの3文字制約は設定で回避できず、形態素解析トークナイザーもアプリ側分かち書きも導入できない環境なら要件を満たせません。第二に、表記ゆれや同義語の吸収が要件に含まれる場合です。FTS5にはシノニム辞書もステミング(英語のporterを除く)もなく、アプリ側でクエリを展開する実装を自作することになります。Rust製の全文検索エンジンMeilisearchのように、タイポ許容や同義語設定を標準機能として持つ製品と比べると開発量の差は大きくなります。
第三に、書き込みが継続的に多いワークロードです。SQLiteの書き込みは単一ライターに直列化されるため、更新のたびに索引へ書き込むFTS5は競合を悪化させます。第四が、複数のアプリケーションサーバーから同じ検索インデックスを共有したい構成で、これはSQLiteの前提から外れます。分析用途で大量データを集計しながら検索したいなら、列指向で分析に振ったDuckDBのような別系統のエンジンが噛み合います。要件がこれらに該当するなら、全文検索エンジンの仕組みと主要OSSの比較を確認して、専用エンジンへの分離を検討してください。
逆に、単一プロセスのデスクトップアプリ、モバイルアプリのローカル検索、数十万件規模の管理画面内検索、そしてエッジ環境でSQLiteを動かすTursoのような構成では、外部サービスを増やさずに検索を実装できるFTS5の利点がそのまま効きます。
よくある質問
Q. FTS5はSQLiteに標準で入っていますか。
A. 拡張機能ですが、公式配布のsqlite3、Python標準ライブラリのsqlite3、Cloudflare D1など主要な環境では有効化済みです。SELECT * FROM pragma_compile_options WHERE compile_options LIKE 'ENABLE_FTS%'; でENABLE_FTS5が返れば追加インストールは不要です。
Q. 日本語で検索してもヒットしません。
A. 既定のunicode61トークナイザーが日本語を語に分割できないためです。tokenize='trigram' を指定したテーブルを作り直し、データを再投入してください。トークナイザーはテーブル定義に固定されるため、既存テーブルへの rebuild では変更できません。
Q. bm25()の戻り値がマイナスになるのはなぜですか。
A. FTS5が符号を反転して返す仕様のためです。関連度の高い行ほど小さい(負に大きい)値になります。ORDER BY bm25(t) または ORDER BY rank の昇順で上位から並びます。
Q. offsets()やmatchinfo()が使えません。
A. どちらもFTS3/FTS4の関数で、FTS5には実装されていません。highlight()とsnippet()で置き換えます。
Q. 元のテーブルを更新したのに検索結果が変わりません。
A. external content構成では本体テーブルへの変更が自動では索引に反映されません。AFTER INSERT・AFTER UPDATE・AFTER DELETEのトリガーを設定し、既存データには INSERT INTO ft(ft) VALUES('rebuild'); を一度実行してください。
Q. FTS4からFTS5へ移行すべきですか。
A. 新規実装ならFTS5が既定の選択です。ただし移行時はoffsets()・matchinfo()が使えなくなるため、ハイライト処理とスコアリングの書き換えが必要になります。trigramトークナイザーとcontentless-deleteはFTS5にしかないので、日本語検索や容量制約がある場合は移行の動機になります。