クラウド Mac で検証する iOS FTS5 検索:分割・クエリ処理・索引同期

クラウド Mac で検証する iOS FTS5 検索:分割・クエリ処理・索引同期

クラウド Mac で検証する iOS FTS5 検索:分割・クエリ処理・索引同期

顧客がチケットのタイトルを変更すると、一覧には新しいタイトルが表示されるのに、オフライン検索では古い語句が依然としてヒットする。この種の不具合は、制作チームがクラウド Mac を切り替えながらビルドを作成する際に見落とされがちです。プロジェクトはコンパイルでき、データベースも開けますが、全文索引と業務テーブルの同期は崩れています。納品前に検索仕様を検証するには、まず固定データを使った受け入れテストを用意するのが適切です。本番データでの実行は補足的な確認には役立ちますが、結果を安定して再現しにくいため、それだけを合否基準にはできません。

実際に使われる SQLite を確認する

クラウド Mac のターミナルで sqlite3 :memory: 'SELECT sqlite_version();' を実行し、検証に使用するバージョンを記録します。続いて FTS5 仮想テーブルの作成を試します。作成に失敗したら、そこで検証を止めてください。「検索結果が出ない」原因をトークン分割の問題と取り違えないためです。

コマンドラインツールで確認できるのは、あくまで検証環境です。iOS プロジェクトはシステムのライブラリにリンクしている場合も、独自の SQLite を同梱している場合もあります。そのため、アプリが実際に使う接続からも、同じテーブル作成文を実行する必要があります。この確認をテストターゲットに組み込み、対象 OS 上で実行してください。SQL だけのサンプルは、まず Mac 上で素早く試行錯誤できますが、モバイル側での実行結果の代わりにはなりません。

トークン分割とクエリの意味を固定する

最初に、「入力をリテラルなフレーズとして検索する」のか、「高度な検索構文を受け付ける」のかを決めます。前者は一般的なチケット検索欄に、後者はブール式への対応を明示したツールに向いています。何の案内もない同じ入力欄に、この 2 つの動作を混在させるべきではありません。

unicode61 remove_diacritics 2 は、ラテン文字でよく使われるアクセント記号を処理できますが、汎用的な中国語の分かち書き処理ではありません。サンプルには、少なくとも大文字と小文字、アクセント記号、ハイフン、引用符、区切りのない中国語を含めます。英語のテストが通っただけで、中国語でも任意の単語を検索できると判断してはいけません。

SQL のパラメータバインドと MATCH 用のエスケープは、別の層の処理です。パラメータバインドは、入力が SQL の構造として解釈されるのを防ぎますが、MATCH 独自の検索構文を無効にはしません。リテラルなフレーズとして扱うには、まず入力内の二重引用符を 2 つに重ね、その全体を一対の二重引用符で囲みます。

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 を文字列連結で組み立てないでください。ここでいう「リテラル」もトークン分割を経るため、バイト単位の部分文字列一致とは異なります。空の入力には、空の検索結果か、製品仕様で定めた既定の一覧をそのまま返します。

何を検索ヒットとするかは、製品仕様の一部です。前方一致、フレーズ検索、部分文字列検索は互いの代わりにはなりません。まして、構文エラーを握りつぶして「結果 0 件」に見せかけてはいけません。

トリガーで外部コンテンツ索引を同期する

外部コンテンツテーブルは、既存の業務テーブルがあり、本文を重複して保存したくないプロジェクトに向いています。一方、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 を切り替えるときや顧客プロジェクトを引き継ぐときにも、特定のマシンに偶然残っていた正しい索引ではなく、検索の動作そのものを検証できます。

よくある質問

Mac の sqlite3 で FTS5 が動けば iOS アプリでも使えますか?

それだけでは判断できません。異なる SQLite ライブラリを使う場合があるため、アプリの実際の接続で一時 FTS5 テーブルを作り、対象システムで同じデータを検証します。

unicode61 は中国語を単語に分割しますか?

汎用的な中国語の単語分割は提供しません。実際の文章で動作を確認し、単語検索が必要なら索引作成と検索で共通の分割方式を採用します。

プロジェクトのスケジュールに合わせたレンタル期間

一時的なビルド環境が必要なときや、納品前の増強、複数のクライアント案件で環境を分けたいときに便利です。チームで年間を通じて高負荷のビルドを行い、すでに自社の機材をお持ちの場合は、購入とレンタルを比較したうえでご検討ください。

VMSSDでは、仮想マシンではなく専有の物理Mac miniをご利用いただけます。日・週・月・四半期から期間を選べ、満了後は自動更新されず停止します。利用可能なモデルと拠点は、コントロールパネルに表示される最新情報をご確認ください。

記事を読んだら、次の準備へ

プロジェクトの期間とビルド負荷を確認してから、モデルとレンタル期間をお選びください。初めてご利用の方は、注文前のチェックリストから始められます。