Back

アプリにAI検索を実装する:Vector Search、Hybrid Search、Semantic Rankingをゼロから構築

ユーザーが「ログインのとき止まるやつの直し方」と検索しているのに、検索結果はゼロ件。「認証トークン期限切れの解決方法」というぴったりの記事があるのに、キーワードが一致しないため見つからない。

これが従来の検索の根本的な問題です。単語をマッチングしているだけで、意味をマッチングしていない。 2026年、ユーザーは「意図を理解してくれる検索」を当たり前に期待しています。

朗報です。AI検索の構築は、もはや大学院の研究テーマじゃありません。最新の埋め込みモデルとベクトルDB、そしていくつかのパターンを組み合わせれば、ユーザーが何を言いたいのかを本当に理解する検索が作れます。

この記事では、プロダクションで使えるAI検索をゼロから一緒に作っていきます。ベクトル検索の基本から、多くのアプリにベストなハイブリッド検索、精度を引き上げるセマンティックリランキング、そしてチュートリアルが教えてくれないプロダクションの罠まで。すぐに使えるTypeScriptコード付きです。

検索の3世代

コードに入る前に、現在地を整理しておきましょう。なぜ3つの世代があるのか。

第1世代:キーワード検索(BM25/TF-IDF)

いまだに大半のアプリが使っている方式です。PostgreSQLのtsvector、Elasticsearchのデフォルトモード、あるいはSQLのLIKEクエリまで。

-- クラシックなアプローチ SELECT * FROM articles WHERE to_tsvector('english', title || ' ' || body) @@ to_tsquery('english', 'authentication & token & expiry');

仕組み: クエリ語がドキュメント内に何回出現するかを数え、希少性で重み付け(IDF)し、関連度スコアでランキング。

うまくいく場面:

  • 正確な用語マッチング("ERROR 0x80070005")
  • 名前で特定のドキュメントを検索
  • ブール演算を使った構造化クエリ
  • 埋め込みモデルが知らない可能性のあるドメイン固有の専門用語

うまくいかない場面:

  • 同義語(「自動車」vs「車」vs「クルマ」)
  • 意図の理解(「サイトを速くする方法」→「Webパフォーマンス最適化」にマッチすべき)
  • タイプミスへの耐性(ファジーマッチングが一部カバー)
  • 多言語クエリ

第2世代:ベクトル検索(セマンティック)

ベクトル検索はテキストを意味を捉える数値表現(エンベディング)に変換します。似た概念は使用した単語に関係なく、ベクトル空間で近くに配置されます。

// 「ログイン問題の修正」と「認証エラーの解決」が // 近いベクトルに変換される const embedding1 = await embed("ログイン問題の修正"); const embedding2 = await embed("認証エラーの解決"); cosineSimilarity(embedding1, embedding2); // ~0.92(非常に類似!)

仕組み: 埋め込みモデル(OpenAIのtext-embedding-3-smallやオープンソースのnomic-embed-text)がテキストを高次元ベクトル(通常256〜1536次元)に変換します。検索はベクトル空間で最近傍を見つける問題になります。

得意なこと:

  • 曖昧なクエリの背後にある意図の把握
  • クロスリンガル検索(エンベディングは言語の壁を越える)
  • 単語の重複がゼロでも、意味的に関連するコンテンツの発見

苦手なこと:

  • 正確なキーワードマッチング(皮肉にも!)
  • モデルが見たことのない希少な技術用語
  • 新しさの判定——エンベディングは何が「最新」か知らない
  • フィルター/ファセットクエリ(「Reactタグで2025年以降の記事」)

第3世代:ハイブリッド検索 + リランキング(2026年のベストプラクティス)

ここでのポイント:キーワード検索とベクトル検索はお互いの弱点を補う方向で失敗します。つまり、組み合わせれば最強です。

ユーザークエリ
    ↓
┌──────────────────────┐
│  並列Retrieval        │
│  ┌─────────────────┐ │
│  │ BM25(キーワード)│──→ 上位20件 キーワード結果
│  └─────────────────┘ │
│  ┌─────────────────┐ │
│  │ ベクトル(意味)  │──→ 上位20件 セマンティック結果
│  └─────────────────┘ │
└──────────────────────┘
    ↓
Reciprocal Rank Fusion(マージ+重複排除)
    ↓
上位40件 候補(マージ済み)
    ↓
LLMリランカー(任意だが強力)
    ↓
最終 上位10件

これを作っていきましょう。

Step 1: pgvectorでベクトル検索をセットアップ

最初から専用のベクトルDBは要りません。PostgreSQLにpgvector拡張を入れるだけで、数百万ベクトルを十分な性能で処理できます。しかも全部1つのDBに収まるのが嬉しいところ。

データベースセットアップ

-- pgvectorを有効化 CREATE EXTENSION IF NOT EXISTS vector; -- エンベディングカラム付きドキュメントテーブル CREATE TABLE documents ( id SERIAL PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL, metadata JSONB DEFAULT '{}', embedding vector(1536), -- OpenAI text-embedding-3-small次元 created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- 高速な近似最近傍検索のためのHNSWインデックス -- 大規模環境での性能の鍵 CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64); -- BM25用の全文検索インデックスも作成 ALTER TABLE documents ADD COLUMN search_vector tsvector GENERATED ALWAYS AS ( setweight(to_tsvector('english', coalesce(title, '')), 'A') || setweight(to_tsvector('english', coalesce(content, '')), 'B') ) STORED; CREATE INDEX ON documents USING gin(search_vector);

エンベディング生成

import OpenAI from 'openai'; const openai = new OpenAI(); async function generateEmbedding(text: string): Promise<number[]> { // モデルの最大トークン数に合わせてトランケート const truncated = text.slice(0, 8000); const response = await openai.embeddings.create({ model: 'text-embedding-3-small', input: truncated, dimensions: 1536, }); return response.data[0].embedding; } // バッチエンベディング(1回で最大2048件) async function generateEmbeddings( texts: string[] ): Promise<number[][]> { const batchSize = 100; const allEmbeddings: number[][] = []; for (let i = 0; i < texts.length; i += batchSize) { const batch = texts.slice(i, i + batchSize); const response = await openai.embeddings.create({ model: 'text-embedding-3-small', input: batch.map(t => t.slice(0, 8000)), dimensions: 1536, }); allEmbeddings.push( ...response.data.map(d => d.embedding) ); // Rate limitを尊重 if (i + batchSize < texts.length) { await new Promise(r => setTimeout(r, 100)); } } return allEmbeddings; }

基本的なベクトル検索

import { Pool } from 'pg'; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); async function vectorSearch( query: string, limit: number = 10 ): Promise<SearchResult[]> { const queryEmbedding = await generateEmbedding(query); const result = await pool.query(` SELECT id, title, content, metadata, 1 - (embedding <=> $1::vector) AS similarity FROM documents WHERE embedding IS NOT NULL ORDER BY embedding <=> $1::vector LIMIT $2 `, [JSON.stringify(queryEmbedding), limit]); return result.rows; }

<=>演算子はコサイン距離を計算します。1から引けばコサイン類似度に変換されます(高いほど類似)。

パフォーマンスチューニング

HNSWインデックスで重要なパラメータがef_searchです。速度とRecall(精度)のトレードオフを制御します。

-- デフォルト: ef_search = 40(高速、~95% Recall) SET hnsw.ef_search = 40; -- 高精度: ef_search = 100(~99% Recall、2-3倍遅い) SET hnsw.ef_search = 100; -- プロダクションではユースケースに応じてクエリ単位で設定

100万ドキュメントでのベンチマーク(1536次元):

ef_searchRecall@10レイテンシ (p50)レイテンシ (p99)
4095.2%5ms15ms
10098.8%12ms30ms
20099.5%25ms55ms

ほとんどのアプリではef_search = 100が最適です。

Step 2: キーワード検索(BM25)の追加

ベクトル検索だけでは不十分です。ユーザーが「ERROR-4012」や「RFC 7519」を検索する場合、キーワード検索のほうが客観的に優れています。

async function keywordSearch( query: string, limit: number = 10 ): Promise<SearchResult[]> { const sanitized = query.replace(/[^\w\s]/g, ' ').trim(); const tsQuery = sanitized.split(/\s+/).join(' & '); const result = await pool.query(` SELECT id, title, content, metadata, ts_rank_cd(search_vector, to_tsquery('english', $1)) AS rank FROM documents WHERE search_vector @@ to_tsquery('english', $1) ORDER BY rank DESC LIMIT $2 `, [tsQuery, limit]); return result.rows; }

Step 3: Reciprocal Rank Fusionによるハイブリッド検索

ここが本領発揮です。キーワード結果とベクトル結果を統合します。標準的なアプローチは**Reciprocal Rank Fusion(RRF)**で、異なるシステムのスコアを正規化せずにランキングリストをマージできます。

RRFの仕組み

RRFスコア = Σ (1 / (k + rank_i))

kは定数(通常60)、rank_iは各結果リストでのドキュメントの順位です。両方のリストで1位のドキュメントは、片方だけで1位のものより高い合算スコアを得ます。

実装

interface SearchResult { id: number; title: string; content: string; metadata: Record<string, unknown>; score: number; } interface HybridSearchOptions { limit?: number; keywordWeight?: number; // 0-1、キーワード重み vectorWeight?: number; // 0-1、ベクトル重み rrfK?: number; // RRF定数、デフォルト60 } async function hybridSearch( query: string, options: HybridSearchOptions = {} ): Promise<SearchResult[]> { const { limit = 10, keywordWeight = 0.3, vectorWeight = 0.7, rrfK = 60, } = options; // 両方の検索を並列実行 const candidateCount = limit * 4; // 充分なマージのため多めに取得 const [keywordResults, vectorResults] = await Promise.all([ keywordSearch(query, candidateCount), vectorSearch(query, candidateCount), ]); // ランクマップの構築 const rrfScores = new Map<number, { score: number; doc: SearchResult; }>(); // キーワード結果のスコアリング keywordResults.forEach((doc, index) => { const rank = index + 1; const rrfScore = keywordWeight * (1 / (rrfK + rank)); rrfScores.set(doc.id, { score: rrfScore, doc }); }); // ベクトル結果のスコアリング(既存なら加算、なければ新規) vectorResults.forEach((doc, index) => { const rank = index + 1; const rrfScore = vectorWeight * (1 / (rrfK + rank)); const existing = rrfScores.get(doc.id); if (existing) { existing.score += rrfScore; // 両方に登場 — ブースト! } else { rrfScores.set(doc.id, { score: rrfScore, doc }); } }); // 合算スコアでソートして返却 return Array.from(rrfScores.values()) .sort((a, b) => b.score - a.score) .slice(0, limit) .map(({ doc, score }) => ({ ...doc, score })); }

重み調整ガイド

ユースケースキーワード重みベクトル重み理由
一般Q&A0.30.7意図が重要
コード検索0.60.4正確なシンボルが重要
エラー検索0.70.3エラーコードは完全一致
対話型0.20.8自然言語クエリ
多言語0.10.9エンベディングが言語を超える

Step 4: セマンティックリランキング(品質ブースター)

ハイブリッド検索で80%までいけます。リランキングが残りの20%を埋めますが、この20%が「良い検索」と「魔法のような検索」を分けることが多いです。

リランキングの役割

Retrieval(ベクトル+キーワード)はRecallの最適化です——広い網を投げること。リランキングはPrecisionの最適化——各候補を丁寧に見て、クエリとの真の関連度を評価します。

リランカーはクエリと各候補ドキュメントをペアとして受け取り、関連度スコアを出力します。エンベディング(クエリとドキュメントを独立にエンコード)と違い、リランカーは両方を同時に見るため、きめ細かい関連度を捉えられます。

クロスエンコーダーリランカー

import Anthropic from '@anthropic-ai/sdk'; const anthropic = new Anthropic(); async function rerank( query: string, candidates: SearchResult[], topK: number = 10 ): Promise<RerankedResult[]> { const candidateList = candidates .map((c, i) => `[${i}] Title: ${c.title}\nContent: ${c.content.slice(0, 500)}`) .join('\n\n'); const response = await anthropic.messages.create({ model: 'claude-sonnet-4-20250514', max_tokens: 2000, messages: [{ role: 'user', content: `You are a search relevance judge. Given a query and candidate documents, score each document's relevance from 0.0 to 1.0. Query: "${query}" Candidates: ${candidateList} Return JSON array: [{"index": 0, "score": 0.95, "reason": "directly answers the query"}, ...] Return ONLY the JSON array, no other text.`, }], }); const scores = JSON.parse( (response.content[0] as { text: string }).text ) as { index: number; score: number; reason: string }[]; return scores .sort((a, b) => b.score - a.score) .slice(0, topK) .map(s => ({ ...candidates[s.index], rerankerScore: s.score, relevanceReason: s.reason, })); }

専用リランカーモデル(低コスト代替)

LLMリランキングは強力ですが高価です。大量の検索には専用リランカーモデルを使いましょう:

import { CohereClient } from 'cohere-ai'; const cohere = new CohereClient({ token: process.env.COHERE_API_KEY }); async function cohereRerank( query: string, candidates: SearchResult[], topK: number = 10 ): Promise<RerankedResult[]> { const response = await cohere.v2.rerank({ model: 'rerank-v3.5', // または最新の 'rerank-v4.0-pro' query, documents: candidates.map(c => ({ text: `${c.title}\n${c.content.slice(0, 1000)}`, })), topN: topK, }); return response.results.map(r => ({ ...candidates[r.index], rerankerScore: r.relevanceScore, relevanceReason: '', })); }

1日1,000件のリランキングクエリのコスト比較(候補20件ずつ):

リランカーレイテンシ月額コスト
Claude Sonnet(LLM)~800ms~$90
Cohere Rerank v4.0~180ms~$6
Cohere Rerank v3.5~200ms~$5
Jina Reranker v2~150ms~$4
セルフホスト(cross-encoder)~100msサーバーコストのみ

大半のアプリには専用リランカーモデルが最善です。LLMリランキングは、なぜその結果が関連するかの説明が必要な場合に限定しましょう。

Step 5: 全体の統合

完全な検索パイプラインを1つのプロダクション関数にまとめます:

interface SearchConfig { limit: number; keywordWeight: number; vectorWeight: number; useReranker: boolean; rerankerType: 'llm' | 'cohere' | 'none'; candidateMultiplier: number; } const DEFAULT_CONFIG: SearchConfig = { limit: 10, keywordWeight: 0.3, vectorWeight: 0.7, useReranker: true, rerankerType: 'cohere', candidateMultiplier: 4, }; async function search( query: string, config: Partial<SearchConfig> = {} ): Promise<SearchResult[]> { const cfg = { ...DEFAULT_CONFIG, ...config }; const candidateCount = cfg.limit * cfg.candidateMultiplier; // ステージ1: 並列Retrieval const [keywordResults, vectorResults] = await Promise.all([ keywordSearch(query, candidateCount), vectorSearch(query, candidateCount), ]); // ステージ2: Reciprocal Rank Fusion const fused = reciprocalRankFusion(keywordResults, vectorResults, cfg); // ステージ3: リランキング(任意) if (cfg.useReranker && fused.length > 0) { const rerankerInput = fused.slice(0, cfg.limit * 2); if (cfg.rerankerType === 'llm') return rerank(query, rerankerInput, cfg.limit); if (cfg.rerankerType === 'cohere') return cohereRerank(query, rerankerInput, cfg.limit); } return fused.slice(0, cfg.limit); }

プロダクション上の考慮事項

パイプラインを組むのは簡単な方です。本当のエンジニアリングは、これを大規模で安定・高速・コスト効率よく回すところにあります。

1. エンベディングの鮮度

ドキュメントが変更されると、エンベディングが古くなります。戦略が必要です:

// オプション1: 書き込み時に同期生成(シンプル、書き込みレイテンシ増加) async function updateDocument(id: number, content: string) { const embedding = await generateEmbedding(content); await pool.query(` UPDATE documents SET content = $1, embedding = $2::vector, updated_at = NOW() WHERE id = $3 `, [content, JSON.stringify(embedding), id]); } // オプション2: 非同期エンベディングキュー(プロダクション推奨) import { Queue } from 'bullmq'; const embeddingQueue = new Queue('embeddings', { connection: { host: 'localhost', port: 6379 }, }); async function updateDocumentAsync(id: number, content: string) { // コンテンツは即座に更新 await pool.query( 'UPDATE documents SET content = $1, updated_at = NOW() WHERE id = $2', [content, id] ); // エンベディング生成はキューに await embeddingQueue.add('generate', { documentId: id, content, }, { attempts: 3, backoff: { type: 'exponential', delay: 1000 }, }); }

2. クエリ前処理

生のユーザークエリは、検索パイプラインに入れる前に前処理が必要な場合があります:

async function preprocessQuery(rawQuery: string): Promise<{ processedQuery: string; searchConfig: Partial<SearchConfig>; }> { // 1. 正確なコード/エラー検索か検出 const isExactMatch = /^[A-Z]+-\d+$|^ERROR|^0x|^HTTP \d{3}/.test(rawQuery); if (isExactMatch) { return { processedQuery: rawQuery, searchConfig: { keywordWeight: 0.9, vectorWeight: 0.1, useReranker: false }, }; } // 2. 省略クエリの展開(オプショナルのLLMステップ) // "k8s OOM pod restart" → "Kubernetes out of memory pod restart troubleshooting" // 3. 多言語サポート用の言語検出 // エンベディングはクロスリンガルを自然に処理するが、BM25は言語別設定が必要 return { processedQuery: rawQuery, searchConfig: {}, }; }

3. キャッシュ戦略

エンベディング生成が最も高価な処理です。積極的にキャッシュしましょう:

import { Redis } from 'ioredis'; const redis = new Redis(process.env.REDIS_URL); async function getCachedEmbedding(text: string): Promise<number[] | null> { const key = `emb:${simpleHash(text)}`; const cached = await redis.get(key); if (cached) return JSON.parse(cached); return null; } async function cacheEmbedding(text: string, embedding: number[]): Promise<void> { const key = `emb:${simpleHash(text)}`; await redis.set(key, JSON.stringify(embedding), 'EX', 86400); // 24時間TTL } // キャッシュ付きラッパー async function getEmbedding(text: string): Promise<number[]> { const cached = await getCachedEmbedding(text); if (cached) return cached; const embedding = await generateEmbedding(text); await cacheEmbedding(text, embedding); return embedding; }

4. モニタリングと品質測定

測定できなければ改善できません。以下のメトリクスを追跡しましょう:

interface SearchMetrics { // パフォーマンス totalLatencyMs: number; embeddingLatencyMs: number; retrievalLatencyMs: number; rerankLatencyMs: number; // 品質(ユーザーフィードバックまたは暗黙的シグナルが必要) clickThroughRate: number; // クリックのあった検索の割合 meanReciprocalRank: number; // 最初にクリックされた結果の1/順位の平均 noResultsRate: number; // 結果0件の検索の割合 // コスト embeddingTokensUsed: number; rerankerCallsMade: number; }

5. PostgreSQLの先へスケーリング

pgvectorは約500万ベクトルまで驚くほど快適に動作します。それ以上なら:

規模推奨理由
< 10万ベクトルpgvectorシンプルに同じDB
10万〜500万pgvector + HNSWチューニングまだ対応可、mとefを調整
500万〜5000万専用ベクトルDBPinecone, Weaviate, Qdrant
5000万以上分散ベクトルDBMilvus, Vespa, カスタム

pgvectorから専用ベクトルDBへのマイグレーションパスはシンプルです——エンベディング生成と検索APIはそのまま、ストレージ/クエリレイヤーだけを入れ替えればOKです。

エンベディングモデルの選択

検索システムにおいて最も重要な決定がエンベディングモデルの選択です:

モデル次元数最大トークン品質(MTEB)コスト/100万トークン推奨用途
OpenAI text-embedding-3-small1536819162.3$0.02コスパの良いデフォルト
OpenAI text-embedding-3-large3072819164.6$0.13API最高品質
Cohere embed-v4.0256–1536128,00066.2$0.10多言語、マルチモーダル
Voyage AI voyage-3256–204832,00067.1$0.06長文ドキュメント
nomic-embed-text(OSS)64–768819262.4無料(セルフホスト)プライバシー、APIコスト不要
BGE-M3(OSS)1024819263.0無料(セルフホスト)多言語、セルフホスト

重要: エンベディングモデルを一度選んだ後に変更する場合、コーパス全体の再エンベディングが必要です。慎重に選びましょう。

よくある落とし穴(とその回避法)

落とし穴1:チャンクを細かくしすぎる

ドキュメントを小さなチャンクに分割しすぎると、文脈が失われます:

// ❌ 非推奨: 200トークン固定チャンクは文脈を失う const chunks = splitByTokenCount(document, 200); // ✅ 推奨: オーバーラップ付きセマンティックチャンキング function semanticChunk(text: string): string[] { const paragraphs = text.split(/\n\n+/); const chunks: string[] = []; let current = ''; for (const para of paragraphs) { if (current.length + para.length > 1500) { if (current) chunks.push(current); current = para; } else { current += '\n\n' + para; } } if (current) chunks.push(current); return chunks.map((chunk, i) => { if (i === 0) return chunk; const prevLastSentence = chunks[i - 1].split(/\. /).pop(); return `${prevLastSentence}. ${chunk}`; }); }

落とし穴2:メタデータフィルタリングの無視

-- ❌ 非推奨: 全体から検索してからフィルター SELECT * FROM documents ORDER BY embedding <=> $1::vector LIMIT 10; -- ✅ 推奨: 先にフィルター、サブセット内で検索 SELECT * FROM documents WHERE metadata->>'category' = 'engineering' AND created_at > NOW() - INTERVAL '90 days' ORDER BY embedding <=> $1::vector LIMIT 10;

落とし穴3:実際のクエリでテストしない

ユーザーの実クエリ(検索ログ、サポートチケット、フィードバック)からテストセットを作りましょう。NDCGやMRRなどの自動メトリクスも有用ですが、上位50クエリの結果を自分の目で確認することが最も重要です。

// ゴールデンテストセットを作成 const testCases = [ { query: "ログインのとき止まるやつの直し方", expectedTopResult: "認証トークン期限切れの解決方法", expectedInTop5: ["認証トラブルシューティングガイド", "セッション管理"], }, // ... 実際の検索ログからの50件のクエリ ]; async function evaluateSearch() { let hits = 0; for (const tc of testCases) { const results = await search(tc.query, { limit: 5 }); if (results.some(r => r.title === tc.expectedTopResult)) { hits++; } } console.log(`Recall@5: ${(hits / testCases.length * 100).toFixed(1)}%`); }

落とし穴4:コールドスタートの無視

ローンチ時には検索ログがありません。ユーザーが何を検索するかわかりません。キーワード重みを多めに設定してスタートし(0.5/0.5ハイブリッド)、クエリデータが蓄積されたら段階的にベクトル寄りにシフトしていきましょう。

まとめ:検索スタック意思決定ガイド

AI検索の構築は1つの技術を選ぶことではなく、正しく積み重ねることです:

  1. ハイブリッド検索(BM25+ベクトル)から始める。 これだけで各単独アプローチを15〜25%上回ります。

  2. 精度が必要ならリランキングを追加。 Cohere Rerankの呼び出しは約200msで数セントのコストですが、上位3件の結果品質が劇的に向上します。

  3. 特別な理由がなければpgvectorを使う。 既存のPostgreSQLにベクトルを共存させれば、運用・トランザクション・バックアップ・JOINすべてがシンプルになります。

  4. 徹底的に計測する。 CTR、ゼロ結果率を追跡し、実クエリからゴールデンテストセットを作りましょう。

  5. 初日からエンベディングをオーバーエンジニアリングしない。 text-embedding-3-smallで始めてデプロイし、実ユーザークエリを収集してから、より強力な(より高価な)モデルが必要かどうかを判断しましょう。

「キーワード検索」と「AI検索」の間の溝は、もう博士論文のテーマではありません。このガイドのパターンがあれば、開発者1人が週末で構築できる検索システムは、5年前なら専任チームが四半期かけて作っていたレベルです。ツールは成熟しています。パターンは実証済みです。あとは作るだけです。

AIsearchvector-databaseembeddingspgvectorPostgreSQLTypeScriptRAGsemantic-searchproduction

関連ツールを見る

Pockitの無料開発者ツールを試してみましょう