Проверка FTS5 в iOS: токенизация, запросы и синхронизация индекса

Проверка FTS5 в iOS: токенизация, запросы и синхронизация индекса

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

Проверьте, какая SQLite используется на самом деле

В терминале облачного Mac выполните sqlite3 :memory: 'SELECT sqlite_version();' и запишите версию, используемую в эксперименте. Затем попробуйте создать виртуальную таблицу FTS5. Если создать её не удалось, остановитесь: отсутствие результатов поиска в этом случае нельзя списывать на токенизацию.

Утилита командной строки проверяет только среду эксперимента. Проект iOS может использовать системную библиотеку или собственную сборку SQLite, поэтому тот же запрос создания таблицы нужно выполнить через реальное подключение приложения. Добавьте эту проверку в тестовый таргет и запустите её на целевой системе. Чисто SQL-примеры удобно сначала быстро отрабатывать на Mac, но они не заменяют результаты запуска на мобильной платформе.

Зафиксируйте правила токенизации и семантику запросов

Сначала определитесь, что поддерживает поиск: «буквальную фразу» или «расширенный синтаксис запросов». Первый вариант подходит для обычной строки поиска по заявкам, второй — для инструментов с явно заявленной поддержкой булевых выражений. Смешивать эти режимы в одном поле ввода без пояснений не стоит.

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

Привязка SQL-параметров и экранирование MATCH — разные уровни обработки. Привязка параметров не позволяет вводу стать частью структуры SQL, но не отключает собственный синтаксис запросов MATCH. Для поиска буквальной фразы можно сначала удвоить внутренние двойные кавычки, а затем заключить весь текст в двойные кавычки:

func literalFTSQuery(_ text: String) -> String? {
    let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
    guard !trimmed.isEmpty else { return nil }
    return "\"" + trimmed.replacingOccurrences(of: "\"", with: "\"\"") + "\""
}

Привяжите возвращённое значение к WHERE ticket_fts MATCH ?, не собирайте SQL конкатенацией. Здесь «буквальная» фраза всё равно проходит токенизацию: это не побайтовый поиск подстроки. Для пустого ввода сразу возвращайте пустой результат или список по умолчанию, предусмотренный правилами продукта.

Правила совпадения при поиске — часть контракта продукта. Поиск по префиксу, фразе и подстроке не взаимозаменяемы. Тем более нельзя превращать синтаксические ошибки в «ноль результатов», просто подавляя их.

Синхронизируйте индекс внешнего содержимого с помощью триггеров

Таблица FTS5 с внешним содержимым подходит для проектов, где уже есть основная таблица и не хочется хранить текст повторно. Она не подходит для реализации, которая рассчитывает, что FTS5 сам будет отслеживать изменения основной таблицы. Параметр content лишь задаёт связь: вставки, удаления и обновления всё равно нужно синхронизировать.

Следующий пример можно выполнить прямо в терминале. Все данные хранятся только в памяти; если хотя бы одна проверка не пройдёт, команда завершится с ненулевым кодом:

sqlite3 -bail :memory: <<'SQL'
CREATE TABLE ticket(id INTEGER PRIMARY KEY, title TEXT NOT NULL);
CREATE VIRTUAL TABLE ticket_fts USING fts5(
  title,
  content='ticket',
  content_rowid='id',
  tokenize='unicode61 remove_diacritics 2'
);

CREATE TRIGGER ticket_ai AFTER INSERT ON ticket BEGIN
  INSERT INTO ticket_fts(rowid, title) VALUES(new.id, new.title);
END;

CREATE TRIGGER ticket_ad AFTER DELETE ON ticket BEGIN
  INSERT INTO ticket_fts(ticket_fts, rowid, title)
    VALUES('delete', old.id, old.title);
END;

CREATE TRIGGER ticket_au AFTER UPDATE ON ticket BEGIN
  INSERT INTO ticket_fts(ticket_fts, rowid, title)
    VALUES('delete', old.id, old.title);
  INSERT INTO ticket_fts(rowid, title) VALUES(new.id, new.title);
END;

CREATE TEMP TABLE assertion(ok INTEGER NOT NULL CHECK(ok = 1));

INSERT INTO ticket VALUES(1, 'Café delivery');
INSERT INTO assertion
  SELECT count(*) = 1 FROM ticket_fts
  WHERE ticket_fts MATCH '"cafe"';

UPDATE ticket SET title = 'Invoice approved' WHERE id = 1;
INSERT INTO assertion
  SELECT count(*) = 0 FROM ticket_fts
  WHERE ticket_fts MATCH '"cafe"';
INSERT INTO assertion
  SELECT count(*) = 1 FROM ticket_fts
  WHERE ticket_fts MATCH '"invoice"';

BEGIN;
DELETE FROM ticket WHERE id = 1;
INSERT INTO assertion
  SELECT count(*) = 0 FROM ticket_fts
  WHERE ticket_fts MATCH '"invoice"';
ROLLBACK;

INSERT INTO assertion
  SELECT count(*) = 1 FROM ticket_fts
  WHERE ticket_fts MATCH '"invoice"';

DELETE FROM ticket WHERE id = 1;
INSERT INTO assertion
  SELECT count(*) = 0 FROM ticket_fts
  WHERE ticket_fts MATCH '"invoice"';
SQL

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

Проверьте существующие данные и сортировку

Если основная таблица появилась раньше индекса, установка триггеров не добавит в него существующие записи автоматически. Во время миграции выполните INSERT INTO ticket_fts(ticket_fts) VALUES('rebuild'); в условиях контролируемой записи, а затем проверьте поиск на фиксированных записях, созданных до миграции. Перестроение индекса — операция миграции, а не действие при каждом открытии экрана поиска.

Не оценивайте полноту индекса по SELECT count(*) FROM ticket_fts: без MATCH таблица с внешним содержимым может читать данные напрямую из таблицы содержимого. Нужны проверки реальных поисковых запросов, отдельно подтверждающие, что старые слова больше не находятся, а новые находятся.

При одинаковом ранге добавьте стабильную сортировку по первичному ключу основной таблицы, например ORDER BY rank, rowid. В наборе для проверки пагинации должно быть несколько результатов с одинаковым рангом. Иначе изменение порядка при повторных запусках легко принять за потерю данных.

Превратите сбои в пункты приёмки

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

При приёмке проверьте каждый пункт: новая база и база после миграции дают одинаковые результаты; после переименования старые слова больше не находятся; после удаления не остаётся совпадений; откат транзакции восстанавливает результаты; поведение поиска на китайском соответствует описанию. В завершение запустите тот же набор данных в тестовом таргете iOS. Тогда при смене облачного Mac или передаче клиентского проекта вы будете проверять само поведение поиска, а не случайно сохранившийся корректный индекс на конкретной машине.

Часто задаваемые вопросы

Если FTS5 работает в sqlite3 на Mac, будет ли он работать в iOS?

Не обязательно: приложение может использовать другую библиотеку SQLite. Создайте временную таблицу FTS5 через реальное подключение приложения и запустите примеры на целевой системе.

Разбивает ли unicode61 китайский текст на слова?

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

Срок аренды под график проекта

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

VMSSD предоставляет выделенные физические Mac mini — не виртуальные машины. Выберите срок аренды: день, неделю, месяц или квартал. По окончании срока аренда прекращается без продления. Актуальную доступность моделей и локаций смотрите в панели управления.

Превратите чтение в план действий

Оцените сроки проекта и нагрузку на сборки, а затем выберите модель и срок аренды. Если пользуетесь сервисом впервые, начните со списка перед оформлением заказа.