Room の全文検索:2026 年、ローカルファーストな Android アプリに即時検索を追加する
Room の FTS4 サポートを実践的に解説 — 検索用の仮想テーブルを構築し、トリガーで同期を保ち、なぜ FTS5 には手動マイグレーションが必要なのか。
検索は、遅くなるまで誰も気づかない機能だ。300 個のアイテムが入ったパントリーに「ヨー」と入力し、入力するたびにリストが絞り込まれることを期待する — 応答に 200 ミリ秒でもかかれば、アプリは壊れているように感じられる。Room ベースのほとんどのアプリはこれを LIKE '%query%' 句で処理しており、小さなテーブルでは問題なく動く。テーブルが大きくなったり、クエリが複数語になったり、タイプミスへの耐性が欲しくなったりした瞬間に機能しなくなる。SQLite にはこれに対する本物の答えが昔からある — 全文検索だ。Room では @Fts4 アノテーションとして公開されている。ここでは実際にどう動くか、どこで破綻するか、そしてほとんどのチュートリアルが前提としている FTS5 が、なぜ Room では無料で手に入らないのかを説明する。
LIKE がスケールしない理由
SELECT * FROM pantry_items WHERE name LIKE '%yogurt%' はインデックスを使えない。SQLite は全行をスキャンし、それぞれで部分文字列一致を実行しなければならない。数百行なら見えない。バーコードスキャンとレシート履歴を持つパントリーアプリが思ったより早く到達する数千行になると、それはキー入力ごとに目に見えるカクつきになる — 特にクエリが複数の列(名前、ブランド、カテゴリ)にわたって OR を実行する必要がある場合はなおさらだ。
全文検索はこれを逆転させる。行をスキャンする代わりに、SQLite は書き込み時に転置インデックスを構築する — 各単語は、それを含む行にマッピングされる。検索はスキャンではなくインデックス参照になるため、テーブルが大きくなってもパフォーマンスは一定に保たれる。
Room が実際に与えてくれるもの:FTS5 ではなく FTS4
ここが多くの人をつまずかせる部分だ。ウェブ上の FTS 関連コンテンツのほとんどは、SQLite の新しい FTS5 モジュールを前提としているからだ。Room の @Fts4 アノテーションが配線するのは FTS4 の仮想テーブルであり、@Fts5 に相当するものはない。FTS4 と FTS5 は重要な違いがある — FTS5 にはより理にかなったクエリ構文、組み込みの bm25() ランキング、そして前方一致クエリのより良い扱いがある。これらは Room が FTS4 で自動的に提供してくれるものではない。
FTS5 が必要な場合でも手に入れることはできる — 手動で行うだけだ。アノテーション付きエンティティの代わりに、Migration の中で生の SQL(CREATE VIRTUAL TABLE ... USING fts5(...))を使って仮想テーブルを自分で作成し、読み取りには単純な @DatabaseView または生のクエリでマッピングする。パントリーのアイテム名、サブスクリプション名、メモのタイトルといったほとんどのローカル検索のユースケースでは、FTS4 で十分すぎるほどで、アノテーション以上のコストはかからない。まずはそこから始めよう。手動の FTS5 ルートに手を出すのは、フレーズクエリや組み込みランキングが本当に必要な場合だけにすべきだ。
FTS4 テーブルを Room に配線する
FTS テーブルには、伴走する「コンテンツ」エンティティ — すでに他のすべてに使っている通常のテーブル — に加えて、検索可能な列をインデックスする仮想テーブルが必要だ。
@Entity(tableName = "pantry_items")
data class PantryItem(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val brand: String,
val category: String,
)
@Fts4(contentEntity = PantryItem::class)
@Entity(tableName = "pantry_items_fts")
data class PantryItemFts(
val name: String,
val brand: String,
val category: String,
)
contentEntity は Room に、pantry_items を真実の源として扱い、各行のコピーではなくインデックスだけを FTS テーブルに保存するよう指示する。DAO のクエリは、FTS テーブルの rowid に対して結合する点を除けば、通常のクエリとほぼ同じに見える。
@Query("""
SELECT pantry_items.* FROM pantry_items
JOIN pantry_items_fts ON pantry_items.id = pantry_items_fts.rowid
WHERE pantry_items_fts MATCH :query
""")
fun search(query: String): Flow<List<PantryItem>>
MATCH は FTS 演算子だ — これがクエリをスキャンではなくインデックス参照に変える。"yog" の代わりに "yog*" を渡すと前方一致になり、これがタイプするそばから検索する用途で欲しいものだ。
インデックスを同期させ続ける
contentEntity を使うと、Room は挿入・更新・削除の際に pantry_items_fts を pantry_items と同期させるトリガーを生成する — 自分で書く必要はない。注意すべき点はひとつだけ:この同期がカバーするのは、Room が生成した insert/update/delete メソッドを経由する書き込みだけだ。Room の DAO 層の外で実行される生の SQL UPDATE や、execSQL を使った一括インポートは、トリガーに支えられたコンテンツエンティティのマッピングを、微妙で見逃しやすい形で回避してしまう。バーコードスキャンによる挿入経路を含め、パントリーの変更は、「高速な」インポート用の別のraw-SQL近道ではなく、Room のスキーマ検証がすでに考慮している同じ DAO メソッドを経由させること。
ランキング:FTS4 の本当の限界
FTS4 は正しい一致結果を返すが、一致順以外の関連性ランキングは提供しない。誰かが「牛乳」で検索し、「全乳」と「ミルクチョコレートバー」の両方が一致した場合、FTS4 はユーザーがどちらを意図していたか教えてくれない — 得られるのはテーブル順の行であり、関連性順ではない。数百のパントリーアイテムであれば、実際にはこれはほとんど問題にならない — リストは一目で見渡せるほど短いからだ。これが問題になり始めたら — より大きなカタログ、あるいはより広いフィールドセットにわたる検索の場合 — FTS5 に飛びつかずに済む修正は、シンプルなクライアント側の並べ替えだ:一致結果を取得し、それ以外の何よりもクエリが名前の先頭と一致するかどうかで並べ替える。これはデータベースのマイグレーションではなく数行の Kotlin であり、実際にユーザーを苛立たせるケース — 完全一致や前方一致が無関係な部分一致の下に埋もれてしまうこと — を修正する。
まとめ
Room の FTS4 サポートは、1 つのアノテーションとわずかに異なる DAO クエリというコストで、線形スキャンをインデックス参照に変えてくれる — サーバーも、サードパーティの検索 SDK も、即時に感じられるべきもののためのネットワーク往復も不要だ。これはあらゆるローカルファーストな Android アプリの検索にとって正しいデフォルトだ。覚えておく価値がある 2 点:Room が話すのは FTS4 であって FTS5 ではないので、新しいモジュールにしか存在しないランキング機能を前提に設計しないこと。そして、検索可能であるべきすべての書き込み経路は Room の DAO 層を通らなければならない、さもないとインデックスは説明すべきテーブルから静かに同期がずれていく。この 2 点さえ正しくやれば、検索は心配する必要のある機能ではなくなる。
このパターンを実際に公開されているアプリで見たいなら、Stocky が数百アイテムまで成長しうるパントリーの検索にこれを使っている — バーコードスキャン、レシートインポート、手入力がすべて同じ検索可能なテーブルに集約される。
// 関連記事
ジャーナルの他の記事
2026年のRoom TypeConverters: enum・日付・リストをスキーマを壊さずに保存する
AndroidのRoom TypeConvertersに関する実践ガイド — enum、Instant/LocalDate、そしてリスト — さらに、コンバーターを静かなデータ破損バグに変えてしまう間違いについて。
2026年のRoomデータベースインデックス:本当に遅いクエリを見つけて直す
AndroidでRoom/SQLiteデータベースにインデックスを張る実践ガイド — EXPLAIN QUERY PLANの読み方、勘に頼らない@Indexの追加、インデックスを静かに無効化してしまう間違い。
RoomのRelationで一対多データをN+1クエリなしに取得する
Roomの@Relationアノテーションの実践ガイド — カテゴリとエントリのような一対多データを、N+1クエリや手動のjoinなしでモデリングする。