この記事の目次
タップすると同じページ内の見出しへ移動します
10 sections 01 日本語検索を3経路へ分ける ↓ 02 現行trigram表のスキーマ ↓ 03 MATCH クエリのescape ↓ 04 1、2、3コードポイントのルーティング ↓ 05 フォールバックは主経路ごとに異なる ↓ 06 trigramとbigramの同期責務 ↓ 07 前進のみ マイグレーションの運用 ↓ 08 制約 ↓ 09 公式参考資料 ↓ 10 関連記事 ↓ 日本語検索を3経路へ分ける 2026年7月23日更新: 現在のルートは、1コードポイントをローカライズした LIKE、有効なグラムを持つ2コードポイントを専用bigram索引、3コードポイント以上をtrigram FTS5へ送ります。 2コードポイントでも記号中心でグラムを作れない場合は LIKEへ進みます。 日本語検索では、空白だけを使って語を区切れません。
形態素解析器と辞書をWorkerへ同梱する方法もありますが、このブログのbundleと運用条件では採用しませんでした。
代わりに、D1が提供するSQLite FTS5のtrigram tokenizerと、アプリ生成bigram、ローカライズした LIKEをクエリの形に応じて使い分けています。
検索語の長さと文字種に応じてLIKE、FTS5、重なりグラム索引へ振り分けるクエリ ルーティング *図: 正規化後のクエリから1つの主経路を選び、0件または失敗時だけ次のフォールバックへ進む。結果集合はmergeしない。*
現行trigram表のスキーマ 初期マイグレーションの posts_ftsと posts_fts_jaは content=''のcontentless表でした。
一方、当時のupdate トリガーは普通の DELETEを使っており、SQLiteが定めるcontentless表の削除契約と一致していませんでした。
英語列も索引文書へ含まれていませんでした。
migrations/0010_rebuild_localized_fts.sqlは、旧トリガーと仮想表を落とし、本文を保持する通常FTS5表として作り直します。
trigram表の定義は次の形です。
sql ⧉
CREATE VIRTUAL TABLE posts_fts_ja USING fts5(
title,
excerpt,
content,
tags,
slug UNINDEXED,
tokenize='trigram'
); 初期投入では、正本、日本語、英語の同種fieldを連結し、公開記事だけを格納します。
sql ⧉
INSERT INTO posts_fts_ja(rowid, title, excerpt, content, tags, slug)
SELECT
rowid,
COALESCE(title, '') || ' ' || COALESCE(title_ja, '') || ' ' || COALESCE(title_en, ''),
COALESCE(excerpt, '') || ' ' || COALESCE(excerpt_ja, '') || ' ' || COALESCE(excerpt_en, ''),
COALESCE(content, '') || ' ' || COALESCE(content_ja, '') || ' ' || COALESCE(content_en, ''),
COALESCE(tags, ''),
slug
FROM posts
WHERE status = 'published'; INSERT トリガーも公開記事だけを追加します。
UPDATE トリガーは OLD.rowidの索引を削除してから、更新後の記事が公開状態なら新しい文書を挿入します。
DELETE トリガーは OLD.rowidを削除します。
slugではなく rowidで同期するため、slug変更後も旧索引を確実に消せます。
MATCH クエリのescape FTS5のMATCH構文へ記号をそのまま渡すと、 Next.jsのような検索語が構文として解釈される場合があります。
toFtsQueryは引用符を除去して空白を正規化し、クエリ全体を二重引用符で囲みます。
ts ⧉
function toFtsQuery(query: string) {
const clean = query.replace(/["']/g, " ").replace(/\s+/g, " ").trim();
if (!clean) return "";
return `"${clean.replace(/"/g, '""')}"`;
} trigram主経路は、公開記事へ限定し、FTS5の rank順で最大30件を取得します。
sql ⧉
SELECT posts.*
FROM posts_fts_ja
JOIN posts ON posts.rowid = posts_fts_ja.rowid
WHERE posts.status = 'published'
AND posts_fts_ja MATCH ?
ORDER BY rank
LIMIT 30; 1、2、3コードポイントのルーティング SQLite公式文書は、trigram tokenizerの全文検索で3 Unicode文字未満の部分文字列が一致しないと説明しています。
そのため、 searchPostsはクエリをtrimした後、NFKC正規化後のコードポイント数とbigram生成結果で分岐します。
ts ⧉
const characterCount = Array.from(clean.normalize("NFKC")).length;
const bigramQuery = toBigramFtsQuery(clean);
const useBigramFts = characterCount === 2 && Boolean(bigramQuery);
const useDirectLike = characterCount < 2 || (characterCount === 2 && !bigramQuery); 主経路は次のとおりです。
1コードポイント :正本、日本語、英語、タグの10 fieldへ LIKE '%...%'有効なグラムがある2コードポイント : posts_fts_bigram MATCH ?グラムを作れない2コードポイント :ローカライズした LIKE3コードポイント以上 : posts_fts_ja MATCH ?Array.from(clean.normalize("NFKC"))はルートを選ぶ長さを数えます。
bigram生成側はNFKCに加えて小文字化し、文字、数字、結合文字の区間だけをpair化するため、2コードポイントでも有効なグラムが空になる場合があります。
フォールバックは主経路ごとに異なる この実装は結果をmergeしません。
主経路が返した最初の非空結果を使い、0件または例外のときだけ次へ進みます。
1コードポイント、gramless 2コードポイント :最初からローカライズした LIKEbigram 2コードポイント :bigramが0件または失敗なら、旧プレフィックス FTSを挟まずローカライズした LIKE3コードポイント以上 :trigramが0件または失敗なら posts_ftsのプレフィックス クエリ、その後ローカライズした LIKED1側のLIKEも失敗 :同梱した正本JSONを正本、日本語、英語、タグで部分一致 bigramからプレフィックス FTSを飛ばすのは、プレフィックスに一致した一部の記事が、本来必要なsubstring結果を隠さないようにするためです。
失敗logは posts_fts_bigram_failed、 posts_fts_ja_failed、 posts_fts_fallback_failedのような限定した識別子だけを残します。
trigramとbigramの同期責務 通常trigram表はDB トリガーで同期します。
bigram文書はTypeScriptで生成するため、記事書き込みの直後にguard付きのbigram 文を置き、同じ D1Database.batchで実行します。
通常保存用builderが返すのは1文です。
公開記事なら INSERT OR REPLACE、下書きまたは空文書なら DELETEを返します。
どちらも直前の記事書き込みに対する changes() > 0と、対象行の updated_atを確認します。
これにより、楽観的lockに負けて0行だった書き込みから、未保存本文のグラムだけが索引へ入ることを防ぎます。
バックアップからの復元は別のbulk builderを使います。
共有するのは索引文ではなく、正本、日本語、英語、タグからグラム文書を作る buildPostBigramDocumentです。
前進のみ マイグレーションの運用 D1 マイグレーションは、未適用の番号付きファイルを順番に適用します。
すでに適用済みのマイグレーション ファイルを編集して再実行する運用にはしません。
修正が必要なら、新しい番号のマイグレーションと検証手順を追加します。
ローカルでスキーマとバックフィルを確認するコマンドは次のとおりです。
bash ⧉
pnpm run d1:migrate:local
pnpm run d1:bigram:local リモート適用では、先にexportまたは管理画面バックアップを取得し、対象データベースと適用予定を確認します。
その後、リモートを明示したスクリプトを使います。
bash ⧉
pnpm run d1:migrate:remote
pnpm run d1:bigram:remote 番号付きマイグレーションの履歴行を削除して再適用する方法は、通常の修正手順には使いません。
制約 この検索はブログ内の部分一致であり、意味検索ではありません。
FTS5の rankも読者の意図を完全には表しません。
D1 クエリは先に最大30件へ絞り、その後アプリケーション側でタグフィルターを適用するため、タグ付き検索では全体の上位30件の外にある一致記事を取りこぼす可能性があります。
この点は記事数が増える前にSQL側filterへ移す余地があります。
公式参考資料 関連記事