Полнотекстовый поиск в SQLite с FTS5

Когда в приложении накапливаются тысячи записей с текстом — заметки, статьи, описания товаров, логи — обычный LIKE '%слово%' быстро перестаёт устраивать. Он не использует индекс, сканирует таблицу целиком и ничего не знает о словах, словоформах и релевантности. SQLite решает эту задачу штатно: модуль FTS5 (Full-Text Search версии 5) даёт полноценный полнотекстовый индекс прямо внутри одного файла базы, без отдельного поискового сервера вроде Elasticsearch. В этой статье разберём, как создать FTS-таблицу, наполнить её, делать запросы через MATCH, ранжировать результаты функцией bm25(), подсвечивать совпадения через highlight() и snippet(), держать индекс в синхроне с основной таблицей через триггеры и обращаться ко всему этому из C#.
Что такое FTS5 и когда он нужен
FTS5 — это расширение SQLite, реализованное в виде виртуальной таблицы. Внешне она выглядит как обычная таблица со столбцами, но под капотом хранит инвертированный индекс: для каждого слова (токена) запоминается, в каких строках и столбцах оно встречается. Благодаря этому поиск по слову выполняется за время, близкое к логарифмическому, а не линейным сканированием.
FTS5 входит в состав официальных сборок SQLite (включая ту, что поставляется с Microsoft.Data.Sqlite и System.Data.SQLite), поэтому отдельно ничего ставить обычно не нужно. Проверить наличие модуля можно запросом:
SELECT sqlite_compileoption_used('ENABLE_FTS5');
-- вернёт 1, если FTS5 доступен
Использовать FTS5 стоит, когда нужен поиск по естественному тексту: по словам и фразам, с учётом релевантности, с подсветкой найденного. Если же вам нужны только точные совпадения по коротким полям (email, артикул), обычного индекса B-tree достаточно.
Создание виртуальной таблицы и индексация
Базовый синтаксис — CREATE VIRTUAL TABLE ... USING fts5. Перечисляем столбцы, по которым хотим искать. Все столбцы FTS5 текстовые; типы данных не указываются.
CREATE VIRTUAL TABLE articles_fts USING fts5(
title,
body,
tokenize = 'unicode61 remove_diacritics 2'
);
Параметр tokenize задаёт токенизатор — то, как текст режется на слова. По умолчанию используется unicode61, который корректно разбивает Unicode-текст (в том числе кириллицу) по границам слов и приводит буквы к нижнему регистру. Опция remove_diacritics 2 убирает диакритику, чтобы «кафе» и «кафе́» считались одним словом. Для многих языков базового unicode61 достаточно; для стемминга (поиска по основам слов) понадобятся внешние токенизаторы, но это уже за рамками штатной поставки.
Наполняется FTS-таблица обычным INSERT:
INSERT INTO articles_fts (title, body) VALUES
('Введение в SQLite', 'SQLite — встраиваемая база данных в одном файле.'),
('Полнотекстовый поиск', 'Модуль FTS5 строит инвертированный индекс по тексту.');
Внешняя таблица контента
Часто текст уже лежит в обычной таблице, и дублировать его в FTS не хочется. Для этого есть режим external content: FTS5 хранит только индекс, а сами строки берёт из «настоящей» таблицы по rowid.
CREATE TABLE articles (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL
);
CREATE VIRTUAL TABLE articles_fts USING fts5(
title,
body,
content = 'articles', -- источник данных
content_rowid = 'id' -- связь по столбцу id
);
Такой индекс занимает меньше места, но требует ручной синхронизации при изменениях — об этом ниже, в разделе про триггеры.
Запросы через MATCH
Поиск выполняется оператором MATCH (или эквивалентным синтаксисом с =). Слева — имя FTS-таблицы или конкретного столбца, справа — поисковый запрос.
-- поиск по всем индексированным столбцам
SELECT rowid, title FROM articles_fts
WHERE articles_fts MATCH 'индекс';
-- поиск только по столбцу title
SELECT rowid, title FROM articles_fts
WHERE title MATCH 'поиск';
Язык запросов FTS5 богаче, чем просто слово:
sqlite база— обе лексемы (неявное И);sqlite OR mysql— любая из них;"полнотекстовый поиск"— точная фраза (слова подряд);поис*— префиксный поиск (поиск, поисковый, поисковик);база NOT данных— есть «база», но нет «данных»;title : sqlite— ограничение по столбцу прямо в запросе;NEAR(sqlite индекс, 5)— слова на расстоянии не более 5 токенов.
Важно: спецсимволы из пользовательского ввода нужно экранировать, иначе он будет воспринят как синтаксис запроса. Самый надёжный приём — обернуть пользовательскую строку в двойные кавычки, удвоив кавычки внутри неё, и при необходимости добавить
*для префиксного поиска.
Ранжирование через bm25()
По умолчанию MATCH возвращает строки в порядке релевантности (внутренний rank). Чтобы управлять сортировкой явно и получать числовую оценку, используется функция bm25() — реализация известного алгоритма ранжирования Okapi BM25. Она учитывает частоту слова в документе и его редкость по всей коллекции.
Особенность: bm25() возвращает отрицательное число, и чем оно меньше (более отрицательное), тем релевантнее документ. Поэтому сортируем по возрастанию:
SELECT
rowid,
title,
bm25(articles_fts) AS rank
FROM articles_fts
WHERE articles_fts MATCH 'sqlite индекс'
ORDER BY rank -- лучшие совпадения сверху
LIMIT 20;
Функции bm25() можно передать веса столбцов — так заголовок будет важнее тела статьи:
-- title весит в 10 раз больше, чем body
SELECT rowid, title, bm25(articles_fts, 10.0, 1.0) AS rank
FROM articles_fts
WHERE articles_fts MATCH 'поиск'
ORDER BY rank;
Есть и удобный псевдостолбец rank: ORDER BY rank внутри FTS-таблицы делает то же, что сортировка по bm25() с весами по умолчанию.
Подсветка результатов: highlight() и snippet()
Чтобы показать пользователю, где именно нашлось совпадение, FTS5 даёт две функции. highlight() возвращает весь текст столбца, оборачивая найденные слова заданными маркерами:
-- highlight(таблица, индекс_столбца, открывающий, закрывающий)
SELECT highlight(articles_fts, 1, '<b>', '</b>') AS marked
FROM articles_fts
WHERE articles_fts MATCH 'индекс';
-- ... строит <b>инвертированный</b> <b>индекс</b> по тексту.
Здесь индекс столбца — порядковый номер, начиная с 0 (0 = title, 1 = body).
snippet() делает то же, но возвращает короткий фрагмент вокруг совпадения, а не весь текст — то, что обычно показывают в выдаче поиска:
-- snippet(таблица, столбец, откр, закр, многоточие, макс_токенов)
SELECT snippet(articles_fts, 1, '[', ']', '…', 10) AS preview
FROM articles_fts
WHERE articles_fts MATCH 'инвертированный'
ORDER BY rank;
Последний аргумент ограничивает длину фрагмента в токенах (до 64). Если найденный текст длиннее, по краям подставляется заданное многоточие.
Синхронизация с основной таблицей через триггеры
В режиме external content FTS5 не обновляется сам при изменении основной таблицы — это нужно делать вручную. Стандартный приём — повесить триггеры AFTER INSERT, AFTER DELETE и AFTER UPDATE. Удаление из FTS5 в этом режиме выполняется специальной командой: вставкой служебной строки со столбцом articles_fts равным 'delete' и тем же rowid и значениями, что были у удаляемой записи.
-- при вставке: добавить запись в индекс
CREATE TRIGGER articles_ai AFTER INSERT ON articles BEGIN
INSERT INTO articles_fts (rowid, title, body)
VALUES (new.id, new.title, new.body);
END;
-- при удалении: убрать запись из индекса
CREATE TRIGGER articles_ad AFTER DELETE ON articles BEGIN
INSERT INTO articles_fts (articles_fts, rowid, title, body)
VALUES ('delete', old.id, old.title, old.body);
END;
-- при обновлении: удалить старую версию и вставить новую
CREATE TRIGGER articles_au AFTER UPDATE ON articles BEGIN
INSERT INTO articles_fts (articles_fts, rowid, title, body)
VALUES ('delete', old.id, old.title, old.body);
INSERT INTO articles_fts (rowid, title, body)
VALUES (new.id, new.title, new.body);
END;
Если индекс создаётся для уже заполненной таблицы, его нужно один раз построить целиком. Для external content это делается так:
INSERT INTO articles_fts (articles_fts) VALUES ('rebuild');
Команда 'rebuild' отбрасывает текущий индекс и перестраивает его из таблицы-источника. Также полезна команда оптимизации, объединяющая внутренние сегменты индекса для более быстрого поиска:
INSERT INTO articles_fts (articles_fts) VALUES ('optimize');
Доступ из C#
Из .NET с FTS5 удобно работать через пакет Microsoft.Data.Sqlite — он использует нативный движок SQLite со встроенным FTS5, дополнительных нативных зависимостей не нужно. Запрос MATCH ничем не отличается от обычного: параметр передаётся как значение, а не вклеивается в строку, что закрывает SQL-инъекции.
using Microsoft.Data.Sqlite;
string userQuery = txtSearch.Text; // ввод пользователя
// экранируем: оборачиваем в кавычки, удваивая внутренние, и даём префиксный поиск
string ftsQuery = "\"" + userQuery.Replace("\"", "\"\"") + "\" *";
using var conn = new SqliteConnection("Data Source=app.db");
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
SELECT a.id,
a.title,
snippet(articles_fts, 1, '[', ']', '…', 12) AS preview,
bm25(articles_fts, 10.0, 1.0) AS rank
FROM articles_fts
JOIN articles a ON a.id = articles_fts.rowid
WHERE articles_fts MATCH $q
ORDER BY rank
LIMIT 50;";
cmd.Parameters.AddWithValue("$q", ftsQuery);
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
int id = reader.GetInt32(0);
string title = reader.GetString(1);
string preview = reader.GetString(2);
double rank = reader.GetDouble(3);
Console.WriteLine($"{rank:F2} {title} -> {preview}");
}
Несколько практических замечаний для .NET-приложений:
- Соединение к одному файлу базы лучше переиспользовать или применять пул — открытие нового подключения на каждый запрос недёшево.
- Для интерактивного поиска «по мере набора» добавляйте задержку (debounce) и отменяйте предыдущий запрос через
CancellationToken, иначе на каждый символ полетит обращение к БД. - Параметризуйте именно значение для
MATCH; имя таблицы и столбцов параметром передать нельзя. - Если данные правят несколько потоков, оберните запись и пересборку индекса в транзакцию, чтобы триггеры отработали атомарно.
Итого
- FTS5 — встроенный в SQLite полнотекстовый поиск; создаётся через
CREATE VIRTUAL TABLE ... USING fts5и не требует внешнего сервера. - Токенизатор
unicode61корректно работает с кириллицей;remove_diacriticsи префиксный поискслово*расширяют возможности. - Поиск идёт через
MATCHс поддержкой И/ИЛИ/НЕ, фраз, ограничения по столбцу и оператораNEAR. - Ранжирование даёт
bm25(): значения отрицательные, сортируемORDER BY rank, веса столбцов задаются аргументами. highlight()иsnippet()подсвечивают совпадения и формируют короткие превью для выдачи.- Для режима external content синхронизацию обеспечивают триггеры
AFTER INSERT/UPDATE/DELETE, а первичное построение — команда'rebuild'. - Из C# (
Microsoft.Data.Sqlite) запросMATCHвыполняется как обычный параметризованный SQL — не забудьте экранировать пользовательский ввод.