セマンティック検索の実装パターン

作成日:
semantic-search vector-search search implementation-pattern

概要

セマンティック検索(意味的検索)は、キーワードの完全一致ではなく、意味的な類似性に基づいて検索結果を返す技術です。この記事では、セマンティック検索を実装する際の代表的なパターンを解説します。

セマンティック検索の利点:

  • 「Docker」で検索 → 「コンテナ仮想化」関連記事もヒット
  • 表記ゆれに強い(「リアクト」→「React」)
  • 同義語を自動的にカバー

関連する基礎知識については以下を参照:

実装パターン

パターン1: クライアントサイド検索

最もシンプルな実装。小規模サイト向け。

┌─────────────────────────────────────────────────────────────────┐
│ ビルド時                                                         │
├─────────────────────────────────────────────────────────────────┤
│  全記事 → Embedding生成 → JSONファイルとして出力                   │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│ 検索時(ブラウザ)                                                 │
├─────────────────────────────────────────────────────────────────┤
│  1. JSONファイルを読み込み(キャッシュ)                            │
│  2. 検索クエリ → Embedding API → ベクトル                         │
│  3. コサイン類似度を計算                                          │
│  4. 上位N件を表示                                                 │
└─────────────────────────────────────────────────────────────────┘

利点:

  • サーバー不要
  • 実装がシンプル
  • 静的サイトと相性が良い

欠点:

  • JSONファイルが大きくなる(記事数 × ベクトル次元数)
  • クライアントでEmbedding APIを呼ぶとAPIキー漏洩リスク

適用条件:

  • 記事数が数百件以下
  • リアルタイム性が不要

パターン2: Edge Functions / サーバーレス

APIキーを安全に管理しつつ、サーバー管理を最小限に。

┌─────────────────────────────────────────────────────────────────┐
│ ビルド時                                                         │
├─────────────────────────────────────────────────────────────────┤
│  全記事 → Embedding生成 → ベクトルDBに保存                         │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│ 検索時                                                           │
├─────────────────────────────────────────────────────────────────┤
│  クライアント                                                     │
│      ↓ 検索クエリ                                                │
│  Edge Function (Cloudflare Workers / Vercel Edge / Supabase)    │
│      ↓ Embedding API呼び出し                                     │
│      ↓ ベクトルDB検索                                            │
│      ↓ 結果を返却                                                │
│  クライアント                                                     │
└─────────────────────────────────────────────────────────────────┘

利点:

  • APIキーがサーバーサイドに保持される
  • スケーラブル
  • 低レイテンシ(エッジで実行)

欠点:

  • Edge Functionの実行時間制限
  • 若干の複雑さ

適用条件:

  • 中規模サイト(数百〜数千記事)
  • APIキーを安全に管理したい

パターン3: 専用バックエンド

大規模サイトや高度な要件向け。

┌─────────────────────────────────────────────────────────────────┐
│ インデックス更新                                                  │
├─────────────────────────────────────────────────────────────────┤
│  CMS / Git Webhook                                              │
│      ↓                                                          │
│  バックエンドサーバー                                              │
│      ↓ Embedding生成                                             │
│      ↓ ベクトルDB更新                                            │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│ 検索時                                                           │
├─────────────────────────────────────────────────────────────────┤
│  クライアント                                                     │
│      ↓ 検索クエリ                                                │
│  バックエンドサーバー                                              │
│      ↓ Embedding生成                                             │
│      ↓ ベクトルDB検索                                            │
│      ↓ リランキング / フィルタリング                               │
│      ↓ 結果を返却                                                │
│  クライアント                                                     │
└─────────────────────────────────────────────────────────────────┘

利点:

  • 高度なカスタマイズが可能
  • リランキング、フィルタリング等の追加処理
  • 大規模データに対応

欠点:

  • サーバー運用が必要
  • コストが高い

適用条件:

  • 大規模サイト(数万記事以上)
  • 高度な検索要件

埋め込み更新のタイミング

記事の生成・更新時に更新

最も一般的なパターン。記事が変更されたときのみ埋め込みを更新。

┌─────────────────────────────────────────────────────────────────┐
│ トリガー: 記事の追加/更新/削除                                     │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  1. 新規記事作成                                                 │
│     └─→ Embedding生成 → DBに保存                                │
│                                                                 │
│  2. 記事更新(内容変更)                                          │
│     └─→ ハッシュ比較 → 変更あり → Embedding再生成 → DB更新        │
│                                                                 │
│  3. 記事削除                                                     │
│     └─→ DBから該当レコード削除                                   │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

差分更新の実装

コンテンツのハッシュを保存し、変更があった場合のみ埋め込みを再生成:

import crypto from 'crypto'

interface Article {
  slug: string
  title: string
  content: string
}

interface StoredArticle extends Article {
  embedding: number[]
  contentHash: string
}

function hashContent(article: Article): string {
  const text = `${article.title}\n\n${article.content}`
  return crypto.createHash('sha256').update(text).digest('hex')
}

async function syncArticle(article: Article, db: Database) {
  const newHash = hashContent(article)
  
  // 既存レコードを取得
  const existing = await db.getBySlug(article.slug)
  
  // ハッシュが同じなら更新不要
  if (existing?.contentHash === newHash) {
    console.log(`Skip: ${article.slug} (unchanged)`)
    return
  }
  
  // 埋め込みを生成
  const text = `${article.title}\n\n${article.content}`
  const embedding = await generateEmbedding(text)
  
  // DBに保存
  await db.upsert({
    ...article,
    embedding,
    contentHash: newHash,
  })
  
  console.log(`Updated: ${article.slug}`)
}

GitHub Actions での自動同期

記事ファイルが変更されたときのみ実行:

# .github/workflows/sync-embeddings.yml
name: Sync Embeddings

on:
  push:
    branches: [main]
    paths:
      - 'src/content/**/*.md'  # Markdownファイルの変更時のみ

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2  # 差分検出のため
      
      - name: Get changed files
        id: changed
        run: |
          echo "files=$(git diff --name-only HEAD~1 HEAD -- 'src/content/**/*.md' | tr '\n' ' ')" >> $GITHUB_OUTPUT
      
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          
      - run: npm ci
      
      - name: Sync changed articles
        if: steps.changed.outputs.files != ''
        run: npx tsx scripts/sync-embeddings.ts ${{ steps.changed.outputs.files }}
        env:
          SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
          SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }}
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

ハイブリッド検索

キーワード検索とセマンティック検索を組み合わせることで、より良い検索体験を提供。

なぜハイブリッド検索が必要か

検索タイプ得意なケース苦手なケース
キーワード検索正確な用語、固有名詞同義語、概念的な検索
セマンティック検索意味的な類似性、同義語正確なマッチが必要なケース

例:

  • 「RFC 7231」で検索 → キーワード検索が適切
  • 「HTTPのステータスコード」で検索 → セマンティック検索が適切

ハイブリッド検索の実装

-- PostgreSQL + pgvector でのハイブリッド検索
CREATE OR REPLACE FUNCTION hybrid_search(
  query_text TEXT,
  query_embedding VECTOR(1536),
  keyword_weight FLOAT DEFAULT 0.3,
  semantic_weight FLOAT DEFAULT 0.7,
  match_count INT DEFAULT 10
)
RETURNS TABLE (
  id INT,
  slug TEXT,
  title TEXT,
  score FLOAT
)
LANGUAGE plpgsql
AS $$
BEGIN
  RETURN QUERY
  WITH 
  -- キーワード検索(PostgreSQL全文検索)
  keyword_results AS (
    SELECT 
      a.id,
      a.slug,
      a.title,
      ts_rank(
        to_tsvector('simple', a.title || ' ' || a.content),
        plainto_tsquery('simple', query_text)
      ) AS keyword_score
    FROM articles a
    WHERE to_tsvector('simple', a.title || ' ' || a.content) 
          @@ plainto_tsquery('simple', query_text)
  ),
  -- セマンティック検索(pgvector)
  semantic_results AS (
    SELECT 
      a.id,
      a.slug,
      a.title,
      1 - (a.embedding <=> query_embedding) AS semantic_score
    FROM articles a
    WHERE 1 - (a.embedding <=> query_embedding) > 0.5
  )
  -- 結果を統合
  SELECT DISTINCT
    COALESCE(k.id, s.id) AS id,
    COALESCE(k.slug, s.slug) AS slug,
    COALESCE(k.title, s.title) AS title,
    (
      COALESCE(k.keyword_score, 0) * keyword_weight + 
      COALESCE(s.semantic_score, 0) * semantic_weight
    ) AS score
  FROM keyword_results k
  FULL OUTER JOIN semantic_results s ON k.id = s.id
  ORDER BY score DESC
  LIMIT match_count;
END;
$$;

Reciprocal Rank Fusion (RRF)

異なる検索手法のランキングを統合する手法:

interface SearchResult {
  id: string
  rank: number
}

function reciprocalRankFusion(
  keywordResults: SearchResult[],
  semanticResults: SearchResult[],
  k: number = 60  // 定数(通常60)
): Map<string, number> {
  const scores = new Map<string, number>()
  
  // キーワード検索結果のスコア
  keywordResults.forEach((result, index) => {
    const rrf = 1 / (k + index + 1)
    scores.set(result.id, (scores.get(result.id) ?? 0) + rrf)
  })
  
  // セマンティック検索結果のスコア
  semanticResults.forEach((result, index) => {
    const rrf = 1 / (k + index + 1)
    scores.set(result.id, (scores.get(result.id) ?? 0) + rrf)
  })
  
  return scores
}

// 使用例
const keywordResults = [{ id: 'a', rank: 1 }, { id: 'b', rank: 2 }]
const semanticResults = [{ id: 'b', rank: 1 }, { id: 'c', rank: 2 }]

const fusedScores = reciprocalRankFusion(keywordResults, semanticResults)
// b が最も高スコア(両方に出現)

キャッシュ戦略

検索クエリのキャッシュ

同じ検索クエリに対する結果をキャッシュ:

import { LRUCache } from 'lru-cache'

const searchCache = new LRUCache<string, SearchResult[]>({
  max: 1000,  // 最大1000クエリ
  ttl: 1000 * 60 * 60,  // 1時間
})

async function search(query: string): Promise<SearchResult[]> {
  // キャッシュをチェック
  const cached = searchCache.get(query)
  if (cached) return cached
  
  // 検索を実行
  const results = await performSearch(query)
  
  // キャッシュに保存
  searchCache.set(query, results)
  return results
}

埋め込みのキャッシュ

検索クエリの埋め込みをキャッシュ:

const embeddingCache = new LRUCache<string, number[]>({
  max: 10000,
  ttl: 1000 * 60 * 60 * 24,  // 24時間
})

async function getQueryEmbedding(query: string): Promise<number[]> {
  const cacheKey = `embedding:${query}`
  
  const cached = embeddingCache.get(cacheKey)
  if (cached) return cached
  
  const embedding = await generateEmbedding(query)
  embeddingCache.set(cacheKey, embedding)
  return embedding
}

パフォーマンス最適化

1. 埋め込みの次元削減

次元数を減らすことでストレージと計算コストを削減:

// OpenAI text-embedding-3-small は次元数を指定可能
const response = await fetch('https://api.openai.com/v1/embeddings', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${OPENAI_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    model: 'text-embedding-3-small',
    input: text,
    dimensions: 512,  // 1536 → 512 に削減
  }),
})

2. プリフィルタリング

ベクトル検索の前にカテゴリ等でフィルタリング:

-- カテゴリで絞ってからベクトル検索
SELECT *
FROM articles
WHERE category = 'topics'  -- プリフィルタ
ORDER BY embedding <=> query_embedding
LIMIT 10;

3. バッチ処理

複数の記事を一度にEmbedding生成:

// 複数テキストを一度に処理
async function batchGenerateEmbeddings(texts: string[]): Promise<number[][]> {
  const response = await fetch('https://api.openai.com/v1/embeddings', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${OPENAI_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'text-embedding-3-small',
      input: texts,  // 配列で渡す
    }),
  })
  
  const data = await response.json()
  return data.data.map((d: any) => d.embedding)
}

関連トピック

参考リンク