Полнотекстовый поиск в Room: добавляем мгновенный поиск в local-first Android-приложение в 2026
Практическое руководство по поддержке FTS4 в Room на Android — создание виртуальной таблицы поиска, синхронизация через триггеры и почему FTS5 требует ручной миграции.
Поиск — это функция, которую никто не замечает, пока она не начинает тормозить. Введите “йог” в кладовую из трёхсот товаров и ждите, что список фильтруется по мере ввода — если ответ занимает хотя бы 200 мс, приложение ощущается сломанным. Большинство приложений на Room решают это условием LIKE '%запрос%', и на небольших таблицах это работает отлично. Работать перестаёт, как только таблица разрастается, запрос состоит из нескольких слов или нужна устойчивость к опечаткам. У SQLite давно есть настоящий ответ на это: полнотекстовый поиск, доступный в Room через аннотацию @Fts4. Вот как это работает на самом деле, где ломается и почему FTS5 — версия, которую предполагает большинство туториалов — не даётся Room бесплатно.
Почему LIKE не масштабируется
SELECT * FROM pantry_items WHERE name LIKE '%йогурт%' не может использовать индекс. SQLite приходится сканировать каждую строку и выполнять на ней сопоставление подстроки. На нескольких сотнях строк это незаметно. На нескольких тысячах — а приложение для кладовой со сканированием штрихкодов и историей чеков добирается до них быстрее, чем кажется, — это превращается в заметное подтормаживание при каждом нажатии клавиши, особенно если запросу ещё и нужно делать OR по нескольким колонкам (название, бренд, категория).
Полнотекстовый поиск переворачивает это. Вместо сканирования строк SQLite строит инвертированный индекс во время записи: каждое слово сопоставляется со строками, в которых оно встречается. Поиск превращается в обращение к индексу, а не в сканирование, поэтому производительность остаётся стабильной по мере роста таблицы.
Что Room на самом деле даёт: FTS4, а не FTS5
Именно на этом моменте многие спотыкаются, потому что большая часть материалов об FTS в сети предполагает более новый модуль FTS5 в SQLite. Аннотация @Fts4 в Room подключает виртуальную таблицу 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 выглядит почти как обычный, за исключением join по rowid таблицы FTS:
@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: именно он превращает запрос в обращение к индексу, а не в сканирование. Передача "йог*" вместо "йог" даёт совпадение по префиксу — то, что нужно для поиска по мере ввода.
Поддержание индекса в синхронизации
С contentEntity Room генерирует триггеры, которые синхронизируют pantry_items_fts с pantry_items при вставке, обновлении и удалении — писать их вручную не нужно. Единственное, за чем стоит следить: эта синхронизация покрывает только записи, проходящие через сгенерированные Room методы insert/update/delete. Сырой UPDATE на SQL, выполненный в обход слоя DAO Room, или массовый импорт через execSQL обходят сопоставление контентной сущности, опирающееся на триггеры, тонкими и легко упускаемыми способами. Изменения в кладовой — включая путь вставки через сканирование штрихкода — стоит проводить через те же методы DAO, которые уже учитывает валидация схемы Room, а не через отдельный “быстрый” путь на сыром SQL для импорта.
Ранжирование: реальное ограничение FTS4
FTS4 даёт корректные совпадения, но никакого ранжирования по релевантности сверх порядка совпадений. Если кто-то ищет “молоко” и совпадают и “Цельное молоко”, и “Молочный шоколадный батончик”, FTS4 не подскажет, что вероятнее имел в виду пользователь — строки возвращаются в порядке таблицы, а не по релевантности. Для нескольких сотен товаров в кладовой на практике это редко имеет значение — список достаточно короткий, чтобы бегло его просмотреть. Если это начинает иметь значение — более крупный каталог или поиск по более широкому набору полей — решение без перехода на FTS5 простое: клиентская пересортировка. Получите совпадения, затем отсортируйте по тому, совпадает ли запрос с началом названия, прежде чем учитывать всё остальное. Это несколько строк на Kotlin, а не миграция базы данных, и она исправляет тот случай, который реально раздражает пользователей: точные и префиксные совпадения, погребённые под несвязанными частичными совпадениями.
Вывод
Поддержка FTS4 в Room превращает линейное сканирование в обращение к индексу ценой одной аннотации и чуть иначе выглядящего запроса DAO — без сервера, без стороннего SDK поиска, без сетевого round-trip для того, что должно ощущаться мгновенным. Это правильное значение по умолчанию для поиска в любом local-first Android-приложении. Две вещи, которые стоит запомнить: Room говорит на FTS4, а не на FTS5, поэтому не проектируйте систему вокруг функций ранжирования, существующих только в новом модуле; и каждый путь записи, который должен быть доступен для поиска, обязан проходить через слой DAO Room — иначе индекс незаметно рассинхронизируется с таблицей, которую он должен описывать. Сделайте эти две вещи правильно, и поиск перестанет быть функцией, о которой нужно беспокоиться.
Если хотите увидеть этот паттерн в выпущенном приложении, Stocky использует его для поиска по кладовой, которая может разрастись до сотен товаров — сканирование штрихкодов, импорт чеков и ручной ввод попадают в одну и ту же доступную для поиска таблицу.
// По теме
Ещё из журнала
Room TypeConverters в 2026 году: как хранить enum'ы, даты и списки, не повреждая схему
Практическое руководство по Room TypeConverters на Android — enum'ы, Instant/LocalDate и списки — а также ошибки, которые превращают конвертер в незаметный баг повреждения данных.
Индексы базы данных Room в 2026 году: находим по-настоящему медленный запрос и исправляем его
Практическое руководство по индексированию базы Room/SQLite на Android — чтение EXPLAIN QUERY PLAN, добавление @Index без угадывания и ошибки, которые незаметно сводят индекс на нет.
@Relation в Room: запрос данных «один ко многим» на Android без N+1-запросов
Практическое руководство по аннотации @Relation в Room — моделирование данных «один ко многим», таких как категории и записи, без N+1-запросов и ручных join'ов.