セマンティック検索の実装パターン
作成日:
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)
}
関連トピック
- pgvector - PostgreSQLのベクトル検索拡張
- Supabase + pgvector - Supabaseでのセマンティック検索
- Embedding APIの選択 - 埋め込み生成APIの選択肢
- 検索技術の基礎 - 検索技術の基本概念
- 検索エンジン選定ガイド - 検索エンジンの技術選定