公開側のサイト内検索は、管理画面でAI下書き用の資料を集めるWeb検索とは別の機能です。このブログでは、公開済みの記事だけを対象に、検索語の長さに応じてFTS5 trigram、アプリ生成のbigram索引、LIKEを使い分けています。本稿は、その検索経路と索引を壊さず運用する方法に絞った実装記録です。実装と公式資料は2026年7月22日に確認しました。
公開検索とWeb検索を分ける
/searchが探すのは、D1のpostsに保存されたタイトル、要約、本文、タグです。外部サイトを探すSearXNGや本文抽出処理は呼びません。これにより、公開検索の応答は外部サービスの状態に左右されず、AI用Web検索の出典収集とも責務が混ざりません。
検索対象は正本列だけではありません。日本語のtitle_ja、excerpt_ja、content_jaと、英語のtitle_en、excerpt_en、content_enも含めます。英語ページで英語題名を検索しても、正本列が日本語だから見つからない、という状態を避けるためです。
文字数ごとに検索経路を選ぶ
searchPostsは検索語をtrimし、NFKC正規化後のcode point数で主経路を選びます。
- 1 code point:通常のFTS5では扱いにくく、10個の正本・日本語・英語・タグ列に対する
LIKEを使います。 - 2 code points:
posts_fts_bigramへMATCHします。索引の生成方法は別稿「D1とFTS5で日本語2文字検索を作る」で扱います。 - 3 code points以上:
tokenize='trigram'のposts_fts_jaへphraseとしてMATCHし、ORDER BY rankで最大30件を返します。
SQLite公式文書は、trigram tokenizerの全文検索では3 Unicode文字未満の部分文字列が行に一致しないと説明しています。短語をtrigramへ無理に流さず経路を分けるのは、この仕様に合わせるためです。
旧contentless索引を通常FTS5へ作り直した
初期migrationのposts_ftsとposts_fts_jaはcontent=''のcontentless表でした。一方、更新triggerは通常のDELETEを前提にしており、SQLite公式が説明するcontentless表の削除契約と整合していませんでした。また、索引対象は正本列だけで、英語列を検索できませんでした。
migrations/0010_rebuild_localized_fts.sqlでは、旧triggerと2つの仮想表を落とし、本文を保持する通常FTS5表として再作成します。各索引行には正本・日本語・英語の同種フィールドを連結して格納し、posts.rowidを共通キーにします。INSERT、UPDATE、DELETE triggerはすべて公開状態を確認し、更新時と削除時はrowidで古い索引行を消します。表名posts_fts_jaは互換性のため残していますが、内容は日英両方です。
障害時に結果を隠さない
主経路で結果がない、またはmigration未適用でFTS表を読めない場合は、次の順に退避します。
• 既存posts_ftsのprefix query
• 正本・日本語・英語・タグ列へのLIKE
• D1 binding自体がないローカル環境では、読み込んだ記事を同じ全フィールドで部分一致
fallbackは障害をなかったことにするためではありません。ログにはposts_fts_bigram_failed、posts_fts_ja_failed、posts_fts_fallback_failedのような限定した識別子を残し、読者には一致する記事を返せる可能性を保ちます。1文字LIKEは線形走査になり得るため、主経路として広げず、最大30件に制限しています。
記事と索引を同じ書込み単位にする
通常FTS5表はposts triggerで同期します。bigramはアプリでgramを生成するため、記事のwrite、索引の削除、索引の再挿入を同じD1Database.batchへ入れます。Cloudflare公式文書では、batch内のstatementは順番に実行され、途中で失敗するとシーケンス全体が中止またはrollbackされると説明されています。
さらに、管理画面の条件付きUPDATEが競合で0件だった場合に、未保存本文だけがbigram索引へ入らないよう、索引statementも保存後のupdated_at一致を条件にします。backup restoreも同じ索引builderを呼び、別実装を持ちません。
確認したことと限界
テストでは、日英列のLIKE、英語語句のtrigram検索、更新時の旧語削除、記事削除時の索引削除、bigram表がない場合のfallback、競合UPDATE時の旧索引保持を確認しました。ローカルD1 migrationと既存公開記事のbackfillも別コマンドに分けています。
この設計はブログ内の部分一致検索であり、意味検索ではありません。FTS5のrankも読者意図を完全に表すものではなく、1文字検索や記号中心の語はLIKEまで退避します。索引サイズ、検索語、fallback率は記事数が増えた段階で計測し直す必要があります。
参考資料
• SQLite FTS5 Extension — The Trigram Tokenizer
• SQLite FTS5 Extension — Contentless-Delete Tables
• Cloudflare D1 — D1 Database / batch
• Cloudflare D1 — Migrations