この記事の目次
タップすると同じページ内の見出しへ移動します
8 sections 01 要件を先に固定する ↓ 02 buildSearchBigramsで生成する ↓ 03 専用FTS5表は通常表にした ↓ 04 保存競合で索引だけ先行させない ↓ 05 既存記事は明示コマンドでbackfillする ↓ 06 検索とテスト ↓ 07 残る制約 ↓ 08 参考資料 ↓ SQLite FTS5のtrigram tokenizerは部分一致に便利ですが、公式文書どおり、3 Unicode文字未満の全文検索は一致しません。「広告」「日記」「AI」のような2 code pointの検索を毎回 LIKE '%...%'へ流さずに済むよう、このブログではアプリ側で重なり合うbigramを生成し、D1の別FTS5表へ保存する方式を実装しました。本稿はgram生成、表設計、書込み整合性、既存記事のbackfillに限定した実装記録です。確認日は2026年7月22日です。
要件を先に固定する
今回の索引には次の条件を置きました。
• 対象は公開記事の正本・日本語・英語のタイトル、要約、本文、タグ
• NFKC正規化と小文字化を行う
• 文字・数字・結合文字の連続区間だけをgram化する
• フィールド境界や空白・記号をまたいだgramを作らない
• 隣り合う2 code pointsを1文字ずつずらして取り出す
• 同じ記事内で重複したgramは1回だけ格納する
• 下書き、削除済み記事、競合で保存されなかった本文を検索結果へ出さない
ここで数えるのは書記素クラスタではなく、NFKC正規化後に Array.fromで得るcode pointです。ユーザーが見た「1文字」と常に同じとは限らないため、実装と記事の表現も「2 code points」としています。
buildSearchBigramsで生成する
src/lib/search-bigrams.tsの buildSearchBigramsは、Unicode property escapeの [\p{L}\p{N}\p{M}]+で区間を取り出します。各区間を1 code pointずつずらして隣接pairにします。
text ⧉
日本語 -> 日本 / 本語 AI -> ai (NFKC + lowercase) 広告 枠 -> 広告 (空白をまたいで「告枠」は作らない)
記事全体のgram文書は buildPostBigramDocumentが作ります。各フィールドを別々に処理してから Setで統合するため、タイトル末尾と本文先頭をつないだ偽のgramも入りません。検索側の toBigramFtsQueryは生成したgramを引用し、複数ある場合は ANDで結びます。
専用FTS5表は通常表にした
migrations/0009_posts_fts_bigram.sqlは次の専用表を作ります。
sql ⧉
CREATE VIRTUAL TABLE posts_fts_bigram USING fts5( grams, post_id UNINDEXED, tokenize='unicode61 remove_diacritics 0' );
gramsには空白区切りの生成済みpairを入れ、 post_idは照合用なので UNINDEXEDです。 rowidは posts.rowidと一致させます。
当初は本文を持たないcontentless表も検討しました。しかし、通常のcontentless表は普通のDELETEを使えず、SQLite 3.43以降のcontentless-delete表にもruntime互換性の条件があります。このリポジトリでは更新・削除の再現性を優先し、生成済み gramsを保持する通常FTS5表を選びました。記事数が小さい段階では、格納量の削減よりも削除と復元を同じSQLで検証できることを優先しています。
保存競合で索引だけ先行させない
通常保存では、記事writeの後ろに buildPostBigramIndexStatementsが返すDELETEとINSERTを並べ、同じD1 batchで実行します。公開記事でgram文書がある場合だけ再挿入し、下書きは削除だけです。backup restoreも同じbuilderを呼びます。
重要なのは updated_at条件です。管理画面は楽観的ロックを使うため、古い画面からのUPDATEは0件になることがあります。索引statementが記事IDだけを条件にすると、記事保存は失敗したのに、未保存本文のgramだけが有効な索引へ入ります。そこでDELETEとINSERTの両方に、新しい updated_atと一致するposts行が存在する条件を付けました。競合UPDATEが0件なら索引statementも0件になり、旧索引を保持します。
DB triggerは、アプリ経路外で検索対象列が更新された場合に古いbigram行を削除します。TypeScriptでしか同じgramを再生成できないため、triggerは新規gramを推測して挿入しません。管理画面の通常保存とbackup restoreでは同じbatch内で再生成し、外部SQL更新後は明示的な再構築を行う設計です。
既存記事は明示コマンドでbackfillする
migrationは表とcleanup triggerを作りますが、既存記事のgramは自動では入りません。 scripts/rebuild-search-bigrams.mjsは公開記事を読み、全索引を削除してから生成済みgramを再挿入し、最終件数を確認します。
安全のため、既定はlocal D1です。本番は --remoteを明示した場合だけ対象になり、package scriptもlocalとremoteを分けています。SQL文字列はシングルクォートを二重化し、下書きは除外します。migrationとbackfillを分離したことで、schema適用後に件数を確認してから公開検索を切り替えられます。
検索とテスト
正規化後にちょうど2 code pointsで有効なgramができる検索語だけが posts_fts_bigram MATCH ?へ進みます。表が未適用、または検索に失敗した場合は既存FTS5とLIKEへ退避します。1 code pointはLIKE、3 code points以上はtrigramという役割分担です。
回帰テストでは、Unicode正規化、フィールド境界、重複除去、引用したMATCH query、下書き除外、通常保存とbackup restoreの共通builder、SQLエスケープ、更新・削除cleanup、競合UPDATE時の旧索引保持を確認しています。ローカルD1ではmigration後に既存公開記事を索引化し、件数一致を確認します。
残る制約
bigram文書は元本文より大きくなり得ます。 rankは生成済みpairに対するFTS5評価であり、自然言語の意味的近さではありません。記号だけの検索や、正規化後に有効なpairができない語はfallbackへ進みます。また、アプリ外からpostsを直接更新した場合は古い索引を消すだけなので、backfillまたは管理画面経由の再保存が必要です。
参考資料
• SQLite FTS5 Extension — The Trigram Tokenizer
• SQLite FTS5 Extension — Contentless and Contentless-Delete Tables
• Cloudflare D1 — D1 Database / batch
• Cloudflare D1 — Migrations