この記事の目次
タップすると同じページ内の見出しへ移動します
7 sections 01 日本語全文検索の何が難しいのか ↓ 02 スキーマ定義 ↓ 03 検索クエリとエスケープ ↓ 04 落とし穴: trigramは3文字未満を検索できない ↓ 05 3段フォールバックの全体像 ↓ 06 マイグレーションでの注意 ↓ 07 まとめ ↓ 日本語全文検索の何が難しいのか
英語はスペースで単語が区切れますが、日本語は区切りがありません。通常はMeCabのような形態素解析器で単語分割しますが、Cloudflare Workers + D1 の環境には形態素解析器を持ち込めません(辞書が巨大すぎる)。
そこでこのブログでは FTS5のtrigramトークナイザ を使いました。文字列3文字ずつをトークンにする方式で、辞書不要で日本語が検索できます。
スキーマ定義
sql ⧉
CREATE VIRTUAL TABLE posts_fts_ja USING fts5( title, excerpt, content, tags, slug, tokenize = 'trigram' ); -- postsテーブルと同期させるトリガー CREATE TRIGGER posts_fts_ja_insert AFTER INSERT ON posts BEGIN INSERT INTO posts_fts_ja(rowid, title, excerpt, content, tags, slug) VALUES (NEW.rowid, NEW.title, NEW.excerpt, NEW.content, NEW.tags, NEW.slug); END; CREATE TRIGGER posts_fts_ja_update AFTER UPDATE ON posts BEGIN DELETE FROM posts_fts_ja WHERE slug = OLD.slug; INSERT INTO posts_fts_ja(rowid, title, excerpt, content, tags, slug) VALUES (NEW.rowid, NEW.title, NEW.excerpt, NEW.content, NEW.tags, NEW.slug); END;
ポイントはトリガーで同期すること。アプリ側で「保存したらFTSも更新」を書くと、書き忘れで検索インデックスが必ず腐ります。DB側で保証するのが鉄則です。
検索クエリとエスケープ
FTS5のMATCH構文は Next.js のような記号入りの語で構文エラーになります。クエリは必ずダブルクォートで囲みます:
ts ⧉
function toFtsQuery(query: string) { const clean = query.replace(/["']/g, " ").replace(/\s+/g, " ").trim(); if (!clean) return ""; // Next.js のような記号入りの語はMATCH用にクォート必須 return `"${clean.replace(/"/g, '""')}"`; }
検索本体は rank 順で:
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;
落とし穴: trigramは3文字未満を検索できない
trigramは「3文字の窓」でトークン化するので、「AI」「検索」のような1、2文字のクエリはヒットしません。これは実際に運用して初めて気づく罠です。
対策はクエリ長で分岐すること:
ts ⧉
const useBigramLike = Array.from(clean).length < 3; // サロゲートペア考慮でArray.from const sql = useBigramLike ? `SELECT * FROM posts WHERE status = 'published' AND (title LIKE ? OR excerpt LIKE ? OR content LIKE ? OR tags LIKE ?) ORDER BY datetime(created_at) DESC LIMIT 30` : `SELECT posts.* FROM posts_fts_ja ... MATCH ?`;
短いクエリは LIKE '%xx%' で十分です。記事数百件のフルスキャンは数msで終わります。「全部FTSでやらねば」と思い込まないこと。
3段フォールバックの全体像
最終的な検索フローはこうなりました:
1. **クエリ3文字未満** → LIKE検索
2. **3文字以上** → trigram FTS(日本語メイン)
3. **ヒット0件** → 英単語向けのprefix検索(`token*` 形式の別FTSテーブル)
4. **それでも0件** → LIKE検索で最終フォールバック
各段はtry/catchで囲み、FTSの構文エラーで検索機能全体が死なないようにします。検索は「精度が落ちても動き続ける」ことが一番大事です。
マイグレーションでの注意
既存データがある状態でFTSテーブルを追加する場合、初期データの流し込みもマイグレーションに含めます:
sql ⧉
INSERT INTO posts_fts_ja(rowid, title, excerpt, content, tags, slug) SELECT rowid, title, excerpt, content, tags, slug FROM posts WHERE NOT EXISTS (SELECT 1 FROM posts_fts_ja WHERE posts_fts_ja.slug = posts.slug);
NOT EXISTSを付けて冪等にしておくと、マイグレーションの再実行で二重登録されません。
まとめ
• 形態素解析が使えない環境の日本語検索は trigram FTS5 が現実解
• FTSテーブルの同期はアプリではなくDBトリガーで保証する
• trigramは3文字未満を拾えないので、短クエリはLIKEに分岐
• 検索は多段フォールバックで「死なない」設計にする
Turnstile site key が未設定のため、このフォームは送信できません。管理者は NEXT_PUBLIC_TURNSTILE_SITE_KEY を設定してください。