ブログ内検索が「キーワード一致」から「意味で探す」に変わった — セマンティック検索をAgentと実装した記録

目次(6件)
「画像アップロード 方法」と検索して、本当に読みたかったのは「エディタに画像機能を組み込んだ話」だった。
キーワードが完全一致しないと出てこない検索は、記事が増えるほど不便になる。14記事を超えたあたりで「自分のブログなのに目的の記事が見つからない」という状態になり、セマンティック検索の導入を決めた。
BEFORE — 検索機能がない状態
もともと my-blog には検索機能そのものがなかった。記事を探すには、トップページをスクロールしてカテゴリで絞るか、ブラウザの Ctrl+F で本文を検索するしかない。
14記事くらいまでは「最近書いた記事だから場所を覚えている」で済んでいた。でも過去記事を引用したいとき、タイトルのキーワードを正確に覚えていないと見つけられない。「テーマ切替の記事どこだっけ」と思っても、タイトルに「テーマ」が入っているか「着せ替え」だったか、すぐには思い出せない。
AFTER — 意味で検索できる状態
検索窓に「見た目を変える方法」と入力すると、「着せ替え」の記事がヒットする。「画像を自動で作りたい」と入れれば、eyecatch 生成の記事が出てくる。キーワードが一致していなくても、意味が近ければ見つかる。
ホームページのサイドバー(モバイルでは最新記事の上)に検索窓を配置した。300ms の debounce が入っているので、入力のたびにリクエストが飛ぶことはない。検索結果にはタイトル・カテゴリ・日付が表示され、クリックで記事ページに遷移する。
Agent との協働プロセス
実装は 3つの Phase に分けて進めた。
- Embedding パイプライン構築 — Supabase に
pgvector拡張を有効化し、post_embeddingsテーブルとmatch_post_embeddingsRPC を作成。scripts/generate-embeddings.jsを既存スクリプトのパターン(loadEnvLocal→parseArgs→ JSON サマリー出力)に合わせて新規作成した - 全記事の Embedding 生成 — Gemini
gemini-embedding-001(768次元・無料枠)で公開14記事・35チャンクの embedding を一括生成・保存 - 検索 API 作成 —
POST /api/searchルートを新規作成。クエリ文字列を Gemini で embedding 化し、match_post_embeddingsRPC でコサイン類似度検索。Origin 検証・bot フィルタ・レート制限 30req/min/IP のセキュリティ対策を組み込んだ - 検索 UI 実装 —
SemanticSearch.tsxコンポーネントを作成。300ms debounce と AbortController で前のリクエストをキャンセルする設計にした - レビュー 2ラウンド — 1st で Blocking 4件(非アトミック insert、Origin 検証漏れ、エラーハンドリング、abort 時の loading フリッカー)を修正。2nd は Blocking 0件、Advisory 2件(古い結果のクリア、bot フィルタ追加)を修正
- a11y 改善 —
role="search"、aria-live="polite"、focus-visible、maxLength、日付ガードを追加
Agent には「ブログにセマンティック検索をつけたい。pgvector と Gemini Embedding を使って」と伝えたところ、DB スキーマから API ルート、UI コンポーネントまで一貫した設計を提案してくれた。ただし、レビューで出てきた問題は Agent だけでは気づけなかったものが多い。
困った具体例
| # | 状況 | 困りごと | 解消 |
|---|---|---|---|
| 1 | text-embedding-004 を指定 | 404 エラーで embedding 生成が動かない | 廃止済みモデルだった。gemini-embedding-001 に変更して解決 |
| 2 | 非アトミック insert | embedding 生成途中で失敗すると、先に delete した既存データが消えて復旧不能 | 全チャンク生成完了後に delete → insert する順序に修正 |
| 3 | 本番デプロイで検索が 500 エラー | SUPABASE_SERVICE_ROLE_KEY が Vercel に未設定 | 環境変数を追加して即解決。ローカルでは .env.local があるので気づけなかった |
| 4 | abort 時の loading フリッカー | 新しい検索を開始 → 前のリクエストを abort → abort 後にも loading 表示が残る | signal.aborted をチェックし、キャンセルされたリクエストでは state を更新しないよう修正 |
特に2番目は危なかった。既存の embedding を先に全削除してから新しい embedding を insert する設計にしていたため、API 呼び出しが途中で失敗すると「古いデータは消えたのに新しいデータは入っていない」という状態になる。レビューで指摘されて、全チャンク生成が成功してから delete + insert に順序を変えた。
結果
- 実装時間: Agent との協働で Phase 0〜2 まで合計約3時間
- 変更ファイル数: 新規5ファイル + 既存2ファイル修正
- Embedding 対象: 公開14記事・35チャンク
- API コスト: Gemini embedding は無料枠内で完結
- レビュー指摘: Blocking 4件 + Advisory 2件、全件修正済み
- 本番トラブル: 環境変数1件追加で解決
次の1歩
- 新規記事を公開したら
generate-embeddings.js --slug <slug>で embedding を追加するフローをルーティン化する - 検索結果にスニペット(本文の該当部分)を表示して、クリック前に内容がわかるようにする
- 検索ログを収集して「どんなキーワードで探されているか」を分析し、記事テーマの参考にする