この記事の目次
タップすると同じページ内の見出しへ移動します
11 sections 01 索引の不変条件 ↓ 02 buildSearchBigramsの生成規則 ↓ 03 通常FTS5表を使う理由 ↓ 04 通常保存を1文で追従する ↓ 05 バックアップからの復元は別のbulk builderを使う ↓ 06 バックフィルは全件DELETEしない ↓ 07 クエリ ルーティングとフォールバック ↓ 08 ローカルとリモートを分けて実行する ↓ 09 制約 ↓ 10 参考資料 ↓ 11 関連記事 ↓ SQLite FTS5のtrigram tokenizerは部分一致に使えますが、公式文書どおり、3 Unicode文字未満の全文検索は一致しません。
このブログでは、正規化後にちょうど2コードポイントとなる検索語を毎回 LIKE '%...%'へ流さないため、アプリで隣接bigramを生成し、D1の専用FTS5表へ保存しています。
この記事では、グラム生成、表設計、保存競合のguard、バックアップからの復元、既存行のバックフィルを実装に沿って説明します。
実装と公式資料は2026年7月23日に確認しました。
日本語文書を正規化し、重なり合う2コードポイントのグラムからD1索引と検索結果を作る流れ *図: フィールドごとに正規化したコードポイント列からbigramを作り、記事単位の索引文書へまとめる流れ。*
索引の不変条件 この索引は、公開記事の正本、日本語、英語のタイトル、要約、本文、タグを対象にします。
生成規則は次のとおりです。
NFKC正規化してから小文字化する 文字、数字、結合文字の連続区間だけをグラム化する 空白、記号、フィールド境界をまたぐグラムを作らない 隣接する2コードポイントを1コードポイントずつずらして取り出す 同じ記事内で重複したグラムは1回だけ格納する 下書き、削除済み記事、保存競合で採用されなかった本文を検索結果へ出さない ここで数える単位は、NFKC正規化後に Array.fromで得るコードポイントです。
書記素クラスタではないため、画面上の「1文字」と常に一致するわけではありません。
buildSearchBigramsの生成規則 src/lib/search-bigrams.tsの buildSearchBigramsは、Unicode プロパティ escapeの [\p{L}\p{N}\p{M}]+で連続区間を取り出します。
各区間を別々に処理するため、空白や記号の両側を結んだ偽のpairは入りません。
text ⧉
日本語 -> 日本 / 本語
AI -> ai (NFKC + lowercase)
広告 枠 -> 広告 (空白をまたぐ「告枠」は作らない) buildPostBigramDocumentは、各フィールドから得たグラムを Setへ集約します。
検索側の toBigramFtsQueryは各グラムを二重引用符で囲み、複数ある場合は ANDで結びます。
ただし、公開ルートがbigram索引へ送るのは正規化後にちょうど2コードポイントとなる検索語だけです。
たとえば 日本語から2つのグラムを生成できても、3コードポイントの公開検索はtrigram経路を使います。
通常FTS5表を使う理由 migrations/0009_posts_fts_bigram.sqlは、生成済みグラムを保持する通常FTS5表を作ります。
sql ⧉
CREATE VIRTUAL TABLE posts_fts_bigram USING fts5(
grams,
post_id UNINDEXED,
tokenize='unicode61 remove_diacritics 0'
); post_idは所有者確認用なので UNINDEXEDです。
FTS5の rowidは posts.rowidと一致させます。
通常のcontentless FTS5表では、普通の DELETEを使えません。
SQLite 3.43以降にはcontentless-delete表もありますが、実行環境のバージョン条件が増えます。
この実装は、格納量よりも更新、削除、検証を同じSQLで再現できることを優先し、通常表を選びました。
通常保存を1文で追従する 通常保存では、記事書き込みの直後に buildPostBigramIndexStatementsが返す1つの文(1つのstatement)を置き、同じ D1Database.batchで実行します。
公開記事にグラム文書がある場合は INSERT OR REPLACEを返し、下書きまたは空文書の場合は DELETEを返します。
「毎回DELETEしてからINSERTする」という実装ではありません。
公開記事の文は、概念的には次のguardを持ちます。
sql ⧉
INSERT OR REPLACE INTO posts_fts_bigram(rowid, grams, post_id)
SELECT rowid, ?, id
FROM posts
WHERE changes() > 0
AND id = ?
AND status = 'published'
AND updated_at = ?; changes()は、同じ接続で直前に実行した記事書き込みが変更した行数です。
楽観的lockに負けた条件付きUPDATEは0行なので、後続の索引文も0行になります。
updated_atは、変更された行が予定した記事バージョンであることを追加確認します。
同じtimestampを持つ競合もあり得るため、 updated_atだけでは不十分で、直前書き込みの changes() > 0が必要です。
posts側のupdate トリガーは、検索対象列や公開状態が変わると旧bigram行を削除します。
採用された通常保存では、その後続文が新しいグラムを再挿入します。
競合で記事書き込みが0行ならトリガーも動かず、後続文もguardで止まるため、既存索引を保持します。
バックアップからの復元は別のbulk builderを使う バックアップからの復元は記事集合を置き換える処理なので、通常保存用builderを繰り返し呼びません。
buildBigramRestoreStatementsが復元 トランザクション内でbigram表を空にし、公開記事の文書をバインドパラメーターの上限内でまとめて再挿入します。
通常保存と復元が共有するのは、索引文ではなく buildPostBigramDocumentです。
この分離により、グラム生成規則は1箇所に保ちつつ、単一記事saveとカタログ復元で異なる書き込み形状を使えます。
バックフィルは全件DELETEしない scripts/rebuild-search-bigrams.mjsは、下書きを含む全posts行を読み、検索対象fieldを一時ステージ表へ保存します。
その後、次の処理を1つのSQLファイルで実行します。
postsに所有者がいないorphan索引を削除する ステージした完全snapshotと現在行が一致する下書き、または空文書の索引を削除する ステージした完全snapshotと現在行が一致する公開記事だけを INSERT OR REPLACEする 一時ステージ表を削除する 索引表全体を先に消す方式ではありません。
バックフィル中に記事が変わった場合は、 updated_atだけでなく、正本、日本語、英語、タグを含む完全snapshot比較が不一致となり、その古いステージ行は書き込みません。
実行後は、各公開記事から期待グラム文書を再計算します。
検証対象は件数だけではなく、missing、unexpected、staleの各件数と文書内容です。
どれかが残ればスクリプトは失敗します。
クエリ ルーティングとフォールバック searchPostsは、trim後の検索語をNFKC正規化してコードポイント数を数えます。
主経路は次の3つです。
1コードポイント、または2コードポイントでも有効なグラムがない語はローカライズした LIKE有効なグラムがある2コードポイントは posts_fts_bigram MATCH ?3コードポイント以上は posts_fts_ja MATCH ?bigram検索が失敗、または0件だった場合は、旧プレフィックス FTSを挟まずローカライズした LIKEへ直接進みます。
この順序により、部分一致する記事がプレフィックス結果に隠されません。
3コードポイント以上のtrigram経路だけは、0件または失敗時に旧 posts_ftsのプレフィックス検索を試し、その後 LIKEへ進みます。
D1側のLIKEまで失敗した場合は、同梱した正本JSONを同じ全fieldで部分一致検索します。
ローカルとリモートを分けて実行する スキーマ マイグレーションとバックフィルは別コマンドです。
既定の検証先はローカルです。
bash ⧉
pnpm run d1:migrate:local
pnpm run d1:bigram:local リモートは、バックアップ、対象データベース、適用予定を確認した後に明示します。
すでに適用済みの番号付きマイグレーション ファイルは編集せず、修正は新しい番号のマイグレーションとして追加します。
bash ⧉
pnpm run d1:migrate:remote
pnpm run d1:bigram:remote 制約 bigram文書は元本文より大きくなり得ます。
FTS5の rankは生成済みpairに対する評価であり、意味的な近さではありません。
記号中心の語や、正規化と小文字化の後に有効なpairが残らない2コードポイント入力は LIKEへ進みます。
アプリ外からpostsを直接更新した場合はトリガーが旧グラムを削除するだけなので、管理経路で再保存するか、snapshot バックフィルを明示的に実行する必要があります。
参考資料 関連記事