メインコンテンツへスキップ
Agent実践2/28/2026, 1:00:31 PM

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

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

「画像アップロード 方法」と検索して、本当に読みたかったのは「エディタに画像機能を組み込んだ話」だった。

キーワードが完全一致しないと出てこない検索は、記事が増えるほど不便になる。14記事を超えたあたりで「自分のブログなのに目的の記事が見つからない」という状態になり、セマンティック検索の導入を決めた。

BEFORE — 検索機能がない状態

もともと my-blog には検索機能そのものがなかった。記事を探すには、トップページをスクロールしてカテゴリで絞るか、ブラウザの Ctrl+F で本文を検索するしかない。

14記事くらいまでは「最近書いた記事だから場所を覚えている」で済んでいた。でも過去記事を引用したいとき、タイトルのキーワードを正確に覚えていないと見つけられない。「テーマ切替の記事どこだっけ」と思っても、タイトルに「テーマ」が入っているか「着せ替え」だったか、すぐには思い出せない。

AFTER — 意味で検索できる状態

検索窓に「見た目を変える方法」と入力すると、「着せ替え」の記事がヒットする。「画像を自動で作りたい」と入れれば、eyecatch 生成の記事が出てくる。キーワードが一致していなくても、意味が近ければ見つかる。

ホームページのサイドバー(モバイルでは最新記事の上)に検索窓を配置した。300ms の debounce が入っているので、入力のたびにリクエストが飛ぶことはない。検索結果にはタイトル・カテゴリ・日付が表示され、クリックで記事ページに遷移する。

Agent との協働プロセス

実装は 3つの Phase に分けて進めた。

  1. Embedding パイプライン構築 — Supabase に pgvector 拡張を有効化し、post_embeddings テーブルと match_post_embeddings RPC を作成。scripts/generate-embeddings.js を既存スクリプトのパターン(loadEnvLocalparseArgs → JSON サマリー出力)に合わせて新規作成した
  2. 全記事の Embedding 生成 — Gemini gemini-embedding-001(768次元・無料枠)で公開14記事・35チャンクの embedding を一括生成・保存
  3. 検索 API 作成POST /api/search ルートを新規作成。クエリ文字列を Gemini で embedding 化し、match_post_embeddings RPC でコサイン類似度検索。Origin 検証・bot フィルタ・レート制限 30req/min/IP のセキュリティ対策を組み込んだ
  4. 検索 UI 実装SemanticSearch.tsx コンポーネントを作成。300ms debounce と AbortController で前のリクエストをキャンセルする設計にした
  5. レビュー 2ラウンド — 1st で Blocking 4件(非アトミック insert、Origin 検証漏れ、エラーハンドリング、abort 時の loading フリッカー)を修正。2nd は Blocking 0件、Advisory 2件(古い結果のクリア、bot フィルタ追加)を修正
  6. a11y 改善role="search"aria-live="polite"focus-visiblemaxLength、日付ガードを追加

Agent には「ブログにセマンティック検索をつけたい。pgvector と Gemini Embedding を使って」と伝えたところ、DB スキーマから API ルート、UI コンポーネントまで一貫した設計を提案してくれた。ただし、レビューで出てきた問題は Agent だけでは気づけなかったものが多い。

困った具体例

#状況困りごと解消
1text-embedding-004 を指定404 エラーで embedding 生成が動かない廃止済みモデルだった。gemini-embedding-001 に変更して解決
2非アトミック insertembedding 生成途中で失敗すると、先に delete した既存データが消えて復旧不能全チャンク生成完了後に delete → insert する順序に修正
3本番デプロイで検索が 500 エラーSUPABASE_SERVICE_ROLE_KEY が Vercel に未設定環境変数を追加して即解決。ローカルでは .env.local があるので気づけなかった
4abort 時の 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 を追加するフローをルーティン化する
  • 検索結果にスニペット(本文の該当部分)を表示して、クリック前に内容がわかるようにする
  • 検索ログを収集して「どんなキーワードで探されているか」を分析し、記事テーマの参考にする