Полнотекстовый поиск в приложении не требует отдельного сервера. Встроенный в SQLite модуль FTS5 создаёт виртуальную таблицу с инвертированным индексом, умеет ранжировать результаты и возвращать подсвеченные фрагменты. Для небольшого каталога статей или заметок этого достаточно, а база остаётся одним файлом, который легко скопировать.
Когда хватает FTS5
FTS5 подходит, если данные помещаются на один диск, поиск выполняет один процесс и нет требований к распределённой работе. Для демонстрации хватит одного файла и нескольких тысяч записей. Типичные сценарии: личная база знаний, каталог товаров с описаниями, поиск по тикетам, встроенная справка десктопного приложения. Модуль поддерживает фразовый поиск, префиксы и булевы операторы, а токенизаторы настраивают разбиение текста.
- Индекс хранится рядом с данными и не требует демона.
- Запросы выполняются в том же соединении, что и обычный SQL.
- Есть ранжирование BM25 и функция snippet для выдачи фрагментов.
- Поддерживаются внешние таблицы, чтобы не дублировать текст.
Ограничения тоже важны. FTS5 не заменяет поисковый сервер с шардированием, не умеет сложную морфологию русского языка «из коробки» и не индексирует бинарные форматы.
Проверка поддержки
Модуль может быть отключён при сборке SQLite. Проверьте опции компиляции и версию библиотеки. Если строки нет, используйте системный sqlite3 или пересоберите зависимость с нужным флагом.
sqlite3 app.db "PRAGMA compile_options;" | grep -i fts5
python3 -c "import sqlite3; print(sqlite3.sqlite_version)"
sqlite3 --version
Python может использовать собственную сборку SQLite, поэтому проверяйте именно окружение приложения. Для большинства современных дистрибутивов FTS5 включён по умолчанию.
Схема с внешним содержимым
Хранить текст дважды не нужно: content=’docs’ указывает на обычную таблицу, а FTS5 держит только индекс. Параметр content_rowid связывает строки с первичным ключом.
CREATE TABLE docs (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE VIRTUAL TABLE docs_fts USING fts5(
title,
body,
content='docs',
content_rowid='id',
tokenize='unicode61 remove_diacritics 2'
);
Токенизатор unicode61 делит текст на слова и убирает диакритику. Для русского языка это разумный выбор: полноценной морфологии он не делает, но приводит регистр и разделители к предсказуемому виду. Porter рассчитан на английский и для смешанных текстов не подходит.
Синхронизация через триггеры
Внешняя таблица не обновляет индекс сама: нужны триггеры на вставку, удаление и изменение. При удалении FTS5 требует старые значения колонок, иначе индекс разойдётся с данными.
CREATE TRIGGER docs_ai AFTER INSERT ON docs BEGIN
INSERT INTO docs_fts(rowid, title, body)
VALUES (new.id, new.title, new.body);
END;
CREATE TRIGGER docs_ad AFTER DELETE ON docs BEGIN
INSERT INTO docs_fts(docs_fts, rowid, title, body)
VALUES ('delete', old.id, old.title, old.body);
END;
CREATE TRIGGER docs_au AFTER UPDATE ON docs BEGIN
INSERT INTO docs_fts(docs_fts, rowid, title, body)
VALUES ('delete', old.id, old.title, old.body);
INSERT INTO docs_fts(rowid, title, body)
VALUES (new.id, new.title, new.body);
END;
Без триггеров индекс быстро устареет, а рассинхронизацию покажет integrity-check.
Запросы и ранжирование
Поиск идёт через MATCH: поддерживаются AND, OR, NOT, фразы, префиксы и фильтры по колонкам. bm25 возвращает отрицательное число: чем лучше совпадение, тем оно меньше, поэтому сортировка идёт по возрастанию.
SELECT d.id, d.title,
snippet(docs_fts, 1, '<b>', '</b>', '…', 16) AS fragment,
bm25(docs_fts, 1.0, 0.5) AS rank
FROM docs_fts
JOIN docs d ON d.id = docs_fts.rowid
WHERE docs_fts MATCH 'индекс AND (поиск OR sqlite)'
ORDER BY rank
LIMIT 20;
В snippet сначала указывается колонка, затем маркеры подсветки, строка усечения и число токенов. Веса bm25 задают приоритет колонок, а пользовательский запрос нужно экранировать или оборачивать в кавычки.
Фразовый поиск задаётся как MATCH ‘»полнотекстовый поиск»‘, префиксный — MATCH ‘поиск*’, фильтр по колонке — MATCH ‘title:linux’. Скобки помогают комбинировать условия, но сложный запрос труднее отлаживать.
Пример на Python
Параметры в MATCH передаются через заполнитель, как и обычные значения. Не собирайте SQL конкатенацией: это открывает путь к инъекции и ломает запросы с кавычками.
import sqlite3
con = sqlite3.connect('app.db')
con.execute('PRAGMA journal_mode=WAL')
rows = con.execute(
"SELECT d.title, snippet(docs_fts, 1, '<b>', '</b>', '…', 12) "
"FROM docs_fts JOIN docs d ON d.id = docs_fts.rowid "
"WHERE docs_fts MATCH ? ORDER BY bm25(docs_fts) LIMIT ?",
('поиск*', 10),
).fetchall()
for title, fragment in rows:
print(title, fragment)
Если маркеры подсветки вставляются как HTML, результат нужно экранировать при выводе: FTS5 лишь расставляет указанные строки вокруг совпадений.
Обслуживание индекса
После массовой загрузки или большого числа удалений полезно оптимизировать индекс. Команда rebuild полностью перестраивает его из внешней таблицы и помогает, если триггеры были отключены на время импорта.
INSERT INTO docs_fts(docs_fts) VALUES('optimize');
INSERT INTO docs_fts(docs_fts) VALUES('integrity-check');
INSERT INTO docs_fts(docs_fts) VALUES('rebuild');
Optimize запускайте при низкой нагрузке: операция временно увеличивает файл. Integrity-check показывает расхождение индекса и таблицы, rebuild возвращает согласованность.
Подстрочный поиск и ограничения
Обычный unicode61 ищет по словам, поэтому «индекс» не найдётся внутри «индексирование». Если нужен поиск по подстроке, помогает токенизатор trigram, но он требует запросы длиной не меньше трёх символов и занимает больше места.
CREATE VIRTUAL TABLE docs_trgm USING fts5(body, tokenize='trigram');
SELECT rowid FROM docs_trgm WHERE docs_trgm MATCH 'индек';
Trigram иногда ускоряет LIKE ‘%…%’, но не заменяет индекс по словам. Для больших каталогов можно держать два индекса, если интерфейсу нужен поиск по подстроке.
Чек-лист внедрения
- FTS5 включён в той сборке SQLite, которая работает в приложении.
- Схема использует внешнюю таблицу и не дублирует текст.
- Триггеры покрывают вставку, изменение и удаление.
- Пользовательский запрос передаётся параметром, а не склейкой строк.
- Настроены веса bm25 и проверена выдача на реальных данных.
- Есть регламент optimize и integrity-check после массовых операций.
Итог
SQLite FTS5 — практичный способ добавить полнотекстовый поиск без отдельного сервера и сложной инфраструктуры. Он даёт индекс, ранжирование и подсветку фрагментов, а внешняя таблица избавляет от дублирования данных. Если правильно настроить токенизатор, триггеры и экранирование запросов, решение выдерживает десятки тысяч документов и остаётся простым в сопровождении.