rzaripov.kz

Полнотекстовый поиск в приложении на Kotlin

Поиск по строкам часто начинается с простого сравнения через LIKE. Такой подход подходит для небольшого списка настроек или нескольких десятков записей, но быстро теряет эффективность при работе с заметками, каталогом документов, сообщениями и другой текстовой информацией. Пользователь ожидает найти документ по отдельному слову, фрагменту фразы или нескольким терминам, а приложение должно вернуть результаты без заметных задержек.

Полнотекстовый индекс решает эту задачу иначе. Вместо последовательного просмотра каждой строки база заранее разбирает текст на отдельные слова и сохраняет структуру, по которой можно быстро находить совпадения. При добавлении, изменении или удалении записи индекс обновляется, а запрос использует уже подготовленные данные.

В экосистеме Android на Kotlin удобной основой для такого решения является SQLite с расширением FTS5. Оно поддерживает поиск по нескольким колонкам, логические операторы, фразовые запросы, сортировку по релевантности и формирование фрагментов с найденными словами. При этом данные могут оставаться локальными, что особенно важно для офлайн-приложений и приватных заметок.

Архитектура поиска должна учитывать не только SQL-запрос. Важны нормализация пользовательского ввода, задержка перед выполнением запроса, синхронизация индекса с основной таблицей, отображение состояния загрузки и корректная обработка пустых или специальных символов. Такой подход делает полнотекстовый поиск предсказуемым и удобным для дальнейшего развития.

Выбор структуры полнотекстового индекса

В SQLite FTS5 индекс создаётся как виртуальная таблица. Допустим, приложение хранит заметки в таблице notes с полями id, title и content. Для отдельного индекса можно использовать следующую схему:

CREATE VIRTUAL TABLE notes_search USING fts5(
    title,
    content,
    content='notes',
    content_rowid='id',
    tokenize='unicode61 remove_diacritics 2'
);

Параметр content='notes' создаёт внешний индекс: полный текст остаётся в основной таблице, а FTS-таблица хранит поисковую структуру. Это уменьшает дублирование данных, но требует аккуратно поддерживать соответствие между записями. Идентификатор строки указывается через content_rowid, поэтому значение notes.id должно быть стабильным и уникальным.

Токенизатор unicode61 корректно работает с Unicode и подходит для русского, английского и смешанного текста. Опция удаления диакритических знаков полезна для языков, где встречаются символы с надстрочными обозначениями. Для русского языка она не заменяет морфологический анализ: формы «разработка» и «разработки» всё ещё могут считаться разными словами. Если нужен поиск по основам слов, потребуется отдельная лемматизация перед сохранением данных либо специализированный поисковый движок.

При небольшом объёме информации допустимо хранить текст прямо внутри FTS-таблицы. В таком случае схема проще, но изменение записи требует обновлять две копии текста. Для прикладного проекта с заметками или документами внешний индекс обычно удобнее, потому что источником истины остаётся обычная таблица SQLite.

Синхронизация данных и индекса

При использовании внешнего контента индекс нужно обновлять после каждой операции с основной таблицей. Для добавления записи применяется специальная команда:

INSERT INTO notes_search(rowid, title, content)
VALUES (:id, :title, :content);

Изменение существующего текста выполняется через удаление старой индексной строки и добавление новой. FTS5 поддерживает специальную команду delete, которая сообщает индексу, какую запись необходимо исключить:

INSERT INTO notes_search(notes_search, rowid, title, content)
VALUES ('delete', :id, :title, :content);

Для корректного удаления требуется передать прежние значения индексируемых колонок. Поэтому на практике операцию часто выполняют внутри транзакции: сначала получают старую версию записи, затем удаляют её из индекса, изменяют основную таблицу и добавляют новые значения. Если приложение использует Room, эту последовательность удобно скрыть в одном методе репозитория.

Ещё один вариант — триггеры SQLite. Триггер после вставки добавляет строку в FTS-индекс, триггер после изменения заменяет старую версию, а триггер после удаления очищает индекс. Такой способ снижает риск забыть синхронизацию в одном из участков кода, однако усложняет миграции и диагностику. Для Android-проекта с понятным слоем доступа к данным транзакции репозитория часто оказываются прозрачнее.

При первоначальном запуске или восстановлении базы индекс можно построить заново:

INSERT INTO notes_search(notes_search) VALUES ('rebuild');

Перестроение следует выполнять в фоновом потоке, поскольку на большом наборе заметок оно может занять заметное время. После миграции схемы полезно проверять количество индексированных строк и обрабатывать ситуацию, когда основная таблица и поисковый индекс временно расходятся.

Запрос к FTS5 из Kotlin

В FTS5 поиск выполняется через оператор MATCH. Для получения идентификатора, заголовка и текста заметки запрос может выглядеть так:

SELECT n.id, n.title, n.content
FROM notes_search AS s
JOIN notes AS n ON n.id = s.rowid
WHERE notes_search MATCH :query
ORDER BY bm25(notes_search);

Функция bm25 возвращает оценку релевантности, поэтому результаты можно расположить от наиболее подходящих к менее подходящим. В отличие от обычной сортировки по дате или алфавиту такой порядок учитывает частоту совпадений и объём документа. При необходимости вес заголовка можно увеличить, передав дополнительные коэффициенты в bm25, чтобы совпадение в названии было важнее совпадения в длинном тексте.

В Room запрос можно описать в DAO:

@Query("""
    SELECT n.id, n.title, n.content
    FROM notes_search AS s
    JOIN notes AS n ON n.id = s.rowid
    WHERE notes_search MATCH :query
    ORDER BY bm25(notes_search)
""")
fun search(query: String): Flow<List<Note>>

Возвращаемый Flow хорошо сочетается с Kotlin Coroutines и StateFlow в модели представления. При изменении текста поиска интерфейс получает новый набор результатов, а слой данных остаётся изолированным от экранной логики. На Android для наблюдения за вводом удобно применять debounce, например задержку в 250–400 миллисекунд. Она предотвращает запуск отдельного запроса на каждый символ при быстром наборе.

Важно не передавать в MATCH необработанный пользовательский текст без проверки. Кавычки, скобки, символы *, - и логические операторы имеют специальное значение в синтаксисе FTS5. Если приложению нужен простой поиск отдельных слов, строку следует разбить на токены, очистить от недопустимых символов и заключить каждый токен в кавычки. Для расширенного режима можно явно разрешить фразы, префиксы и операторы AND, OR, NOT.

Фразы, префиксы и подсветка совпадений

FTS5 умеет искать точную фразу, если запрос заключён в двойные кавычки. Запрос "мобильное приложение" найдёт последовательное сочетание слов, а не документы, где эти термины встречаются далеко друг от друга. Для автодополнения можно использовать префиксный поиск: прилож* найдёт слова, начинающиеся с указанной основы. Однако такой режим может давать много результатов, поэтому его лучше включать только после двух или трёх введённых символов.

Для управления результатами используются логические операторы. Запрос kotlin AND sqlite требует наличия обоих слов, kotlin OR swift допускает любое из них, а kotlin NOT android исключает документы с указанным термином. Перед построением такого запроса необходимо разделять пользовательский ввод и системные операторы, иначе случайный символ способен изменить смысл поиска или вызвать ошибку SQLite.

Отображение результата становится понятнее, если показывать не весь документ, а короткий фрагмент вокруг найденного текста. Для этого используется функция snippet:

SELECT snippet(notes_search, 1, '<b>', '</b>', '…', 24)
FROM notes_search
WHERE notes_search MATCH :query;

Она возвращает часть индексируемого поля с маркерами начала и конца совпадения. В Android эти маркеры нельзя бездумно вставлять в HTML или текстовый компонент: содержимое должно быть безопасно экранировано, а подсветка — формироваться средствами интерфейса. Для Compose можно преобразовать найденные диапазоны в AnnotatedString, не смешивая пользовательский текст с HTML-разметкой.

Производительность и проверка качества

Полнотекстовый индекс особенно полезен при росте объёма данных, но он не отменяет базовые правила оптимизации. Индексируемые поля должны быть действительно нужны для поиска: добавление больших технических колонок увеличивает размер базы и стоимость обновлений. Сложные запросы лучше проверять через EXPLAIN QUERY PLAN, а время ответа измерять на реальных данных, включая длинные документы и большое количество совпадений.

Нормализация текста должна быть одинаковой при индексации и формировании запроса. Если приложение удаляет лишние пробелы, приводит символы к единому регистру или заменяет нестандартные кавычки, те же правила должны применяться во всех точках записи. Полезно отдельно протестировать кириллицу, латиницу, цифры, дефисы, смешанные слова, пустой запрос и строки с несколькими пробелами.

Для интерфейса важно разделить состояние поиска и состояние результата. Пока пользователь вводит запрос, можно показывать предыдущие результаты или индикатор обработки; при пустой строке — возвращать исходный список либо не выполнять запрос вовсе. Ошибка синтаксиса FTS должна превращаться в понятное состояние приложения, а не приводить к падению экрана.

Набор автоматических тестов должен проверять добавление, изменение и удаление заметок, восстановление индекса, поиск по заголовку и содержимому, сортировку по релевантности и формирование фрагмента. Если приложение поддерживает импорт данных, после массовой загрузки стоит выполнять операции в одной транзакции или пакетами. В результате SQLite FTS5, Kotlin Coroutines и аккуратный слой репозитория образуют быстрый локальный поиск, который сохраняет работоспособность и при увеличении базы.