この記事の目次
タップすると同じページ内の見出しへ移動します
10 sections 01 日本語検索を3経路へ分ける ↓ 02 現行trigram表のschema ↓ 03 MATCH queryのescape ↓ 04 1、2、3 code pointsのrouting ↓ 05 fallbackは主経路ごとに異なる ↓ 06 trigramとbigramの同期責務 ↓ 07 forward-only migrationの運用 ↓ 08 制約 ↓ 09 公式参考資料 ↓ 10 関連記事 ↓ 日本語検索を3経路へ分ける 2026年7月23日更新: 現在のrouteは、1 code pointをlocalized LIKE、有効なgramを持つ2 code pointsを専用bigram索引、3 code points以上をtrigram FTS5へ送ります。 2 code pointsでも記号中心でgramを作れない場合は LIKEへ進みます。 日本語検索では、空白だけを使って語を区切れません。
形態素解析器と辞書をWorkerへ同梱する方法もありますが、このブログのbundleと運用条件では採用しませんでした。
代わりに、D1が提供するSQLite FTS5のtrigram tokenizerと、アプリ生成bigram、localized LIKEをqueryの形に応じて使い分けています。
検索語の長さと文字種に応じてLIKE、FTS5、重なりgram索引へ振り分けるquery routing *図: 正規化後のqueryから1つの主経路を選び、0件または失敗時だけ次のfallbackへ進む。結果集合はmergeしない。*
現行trigram表のschema 初期migrationの posts_ftsと posts_fts_jaは content=''のcontentless表でした。
一方、当時のupdate triggerは普通の DELETEを使っており、SQLiteが定めるcontentless表の削除契約と一致していませんでした。
英語列も索引文書へ含まれていませんでした。
migrations/0010_rebuild_localized_fts.sqlは、旧triggerと仮想表を落とし、本文を保持する通常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 triggerも公開記事だけを追加します。
UPDATE triggerは OLD.rowidの索引を削除してから、更新後の記事が公開状態なら新しい文書を挿入します。
DELETE triggerは OLD.rowidを削除します。
slugではなく rowidで同期するため、slug変更後も旧索引を確実に消せます。
MATCH queryのescape FTS5のMATCH構文へ記号をそのまま渡すと、 Next.jsのような検索語が構文として解釈される場合があります。
toFtsQueryは引用符を除去して空白を正規化し、query全体を二重引用符で囲みます。
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 code pointsのrouting SQLite公式文書は、trigram tokenizerの全文検索で3 Unicode文字未満の部分文字列が一致しないと説明しています。
そのため、 searchPostsはqueryをtrimした後、NFKC正規化後のcode point数と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 code point :正本、日本語、英語、タグの10 fieldへ LIKE '%...%'有効なgramがある2 code points : posts_fts_bigram MATCH ?gramを作れない2 code points :localized LIKE3 code points以上 : posts_fts_ja MATCH ?Array.from(clean.normalize("NFKC"))はrouteを選ぶ長さを数えます。
bigram生成側はNFKCに加えて小文字化し、文字、数字、結合文字の区間だけをpair化するため、2 code pointsでも有効なgramが空になる場合があります。
fallbackは主経路ごとに異なる この実装は結果をmergeしません。
主経路が返した最初の非空resultを使い、0件または例外のときだけ次へ進みます。
1 code point、gramless 2 code points :最初からlocalized LIKEbigram 2 code points :bigramが0件または失敗なら、旧prefix FTSを挟まずlocalized LIKE3 code points以上 :trigramが0件または失敗なら posts_ftsのprefix query、その後localized LIKED1側のLIKEも失敗 :bundled canonical JSONを正本、日本語、英語、タグで部分一致 bigramからprefix FTSを飛ばすのは、prefixに一致した一部の記事が、本来必要なsubstring結果を隠さないようにするためです。
失敗logは posts_fts_bigram_failed、 posts_fts_ja_failed、 posts_fts_fallback_failedのような限定した識別子だけを残します。
trigramとbigramの同期責務 通常trigram表はDB triggerで同期します。
bigram文書はTypeScriptで生成するため、記事writeの直後にguard付きのbigram statementを置き、同じ D1Database.batchで実行します。
通常保存用builderが返すのは1 statementです。
公開記事なら INSERT OR REPLACE、下書きまたは空文書なら DELETEを返します。
どちらも直前の記事writeに対する changes() > 0と、対象行の updated_atを確認します。
これにより、楽観的lockに負けて0行だったwriteから、未保存本文のgramだけが索引へ入ることを防ぎます。
backup restoreは別のbulk builderを使います。
共有するのは索引statementではなく、正本、日本語、英語、タグからgram文書を作る buildPostBigramDocumentです。
forward-only migrationの運用 D1 migrationは、未適用の番号付きfileを順番に適用します。
すでに適用済みのmigration fileを編集して再実行する運用にはしません。
修正が必要なら、新しい番号のmigrationと検証手順を追加します。
localでschemaとbackfillを確認するコマンドは次のとおりです。
bash ⧉
pnpm run d1:migrate:local
pnpm run d1:bigram:local remote適用では、先にexportまたは管理画面backupを取得し、対象databaseと適用予定を確認します。
その後、remoteを明示したscriptを使います。
bash ⧉
pnpm run d1:migrate:remote
pnpm run d1:bigram:remote 番号付きmigrationの履歴行を削除して再適用する方法は、通常の修正手順には使いません。
制約 この検索はblog内の部分一致であり、意味検索ではありません。
FTS5の rankも読者の意図を完全には表しません。
D1 queryは先に最大30件へ絞り、その後applicationでtag filterを適用するため、tag付き検索ではglobal上位30件の外にある一致記事を取りこぼす可能性があります。
この点は記事数が増える前にSQL側filterへ移す余地があります。
公式参考資料 関連記事