LESSON 40分

ストーリー

佐藤CTO
ベクトル検索だけで全ての検索ニーズをカバーできるか?
佐藤CTO
例えば”RFC-2024-0042の内容を教えて”という質問。これはベクトル検索よりキーワード検索の方が確実に見つかる。逆に”マイクロサービス間の認証パターン”はベクトル検索向きだ
あなた
両方を組み合わせるのがハイブリッド検索ですね
佐藤CTO
その通り。さらにキャッシュ戦略やクエリ拡張も組み合わせて、本番品質の検索パフォーマンスを実現する。今日はその実装テクニックを押さえよう

ハイブリッド検索

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

検索手法得意なクエリ苦手なクエリ
ベクトル検索意味的な質問(概念、パターン)固有名詞、ID、正確な用語
キーワード検索 (BM25)固有名詞、ID、技術用語言い換え、同義語、抽象的な質問
ハイブリッド両方に対応-

BM25 + ベクトル検索の組み合わせ

interface HybridSearchConfig {
  vectorWeight: number;   // ベクトル検索のウェイト (0-1)
  keywordWeight: number;  // キーワード検索のウェイト (0-1)
  topK: number;
}

class HybridSearchEngine {
  constructor(
    private readonly vectorStore: VectorStore,
    private readonly embedder: EmbeddingService,
    private readonly keywordIndex: KeywordSearchIndex,
  ) {}

  async search(
    query: string,
    config: HybridSearchConfig,
  ): Promise<RetrievedContext[]> {
    // 並列で両方の検索を実行
    const [vectorResults, keywordResults] = await Promise.all([
      this.vectorSearch(query, config.topK * 2),
      this.keywordSearch(query, config.topK * 2),
    ]);

    // Reciprocal Rank Fusion (RRF) でスコアを統合
    return this.reciprocalRankFusion(
      vectorResults,
      keywordResults,
      config,
    );
  }

  private async vectorSearch(
    query: string,
    topK: number,
  ): Promise<RetrievedContext[]> {
    const embedding = await this.embedder.embed(query);
    return this.vectorStore.search(embedding, topK);
  }

  private async keywordSearch(
    query: string,
    topK: number,
  ): Promise<RetrievedContext[]> {
    return this.keywordIndex.search(query, topK);
  }

  private reciprocalRankFusion(
    vectorResults: RetrievedContext[],
    keywordResults: RetrievedContext[],
    config: HybridSearchConfig,
  ): RetrievedContext[] {
    const k = 60; // RRFの定数。一般的に60が使われる
    const scoreMap = new Map<string, { context: RetrievedContext; score: number }>();

    // ベクトル検索結果のRRFスコア
    vectorResults.forEach((result, rank) => {
      const rrfScore = config.vectorWeight / (k + rank + 1);
      const existing = scoreMap.get(result.chunk.id);
      if (existing) {
        existing.score += rrfScore;
      } else {
        scoreMap.set(result.chunk.id, { context: result, score: rrfScore });
      }
    });

    // キーワード検索結果のRRFスコア
    keywordResults.forEach((result, rank) => {
      const rrfScore = config.keywordWeight / (k + rank + 1);
      const existing = scoreMap.get(result.chunk.id);
      if (existing) {
        existing.score += rrfScore;
      } else {
        scoreMap.set(result.chunk.id, { context: result, score: rrfScore });
      }
    });

    // スコア順にソートしてTop-Kを返す
    return Array.from(scoreMap.values())
      .sort((a, b) => b.score - a.score)
      .slice(0, config.topK)
      .map(item => ({ ...item.context, score: item.score }));
  }
}

pgvector + PostgreSQL全文検索のハイブリッド

class PgHybridSearch {
  constructor(private readonly pool: Pool) {}

  async initialize(): Promise<void> {
    // 全文検索インデックス(日本語対応)
    await this.pool.query(`
      CREATE INDEX IF NOT EXISTS embeddings_fts_idx
      ON embeddings USING gin (
        to_tsvector('simple', content)
      )
    `);
  }

  async hybridSearch(
    query: string,
    queryEmbedding: number[],
    topK: number = 5,
    vectorWeight: number = 0.7,
  ): Promise<RetrievedContext[]> {
    const result = await this.pool.query(
      `WITH vector_search AS (
        SELECT id, content, metadata,
               1 - (embedding <=> $1::vector) AS vector_score,
               ROW_NUMBER() OVER (ORDER BY embedding <=> $1::vector) AS vector_rank
        FROM embeddings
        ORDER BY embedding <=> $1::vector
        LIMIT $3
      ),
      keyword_search AS (
        SELECT id, content, metadata,
               ts_rank(to_tsvector('simple', content), plainto_tsquery('simple', $2)) AS keyword_score,
               ROW_NUMBER() OVER (
                 ORDER BY ts_rank(to_tsvector('simple', content), plainto_tsquery('simple', $2)) DESC
               ) AS keyword_rank
        FROM embeddings
        WHERE to_tsvector('simple', content) @@ plainto_tsquery('simple', $2)
        LIMIT $3
      )
      SELECT
        COALESCE(v.id, k.id) AS id,
        COALESCE(v.content, k.content) AS content,
        COALESCE(v.metadata, k.metadata) AS metadata,
        COALESCE($4::float / (60 + v.vector_rank), 0) +
        COALESCE((1 - $4::float) / (60 + k.keyword_rank), 0) AS rrf_score
      FROM vector_search v
      FULL OUTER JOIN keyword_search k ON v.id = k.id
      ORDER BY rrf_score DESC
      LIMIT $3`,
      [`[${queryEmbedding.join(',')}]`, query, topK * 2, vectorWeight],
    );

    return result.rows.map(row => ({
      chunk: {
        id: row.id,
        documentId: row.metadata?.documentId ?? '',
        content: row.content,
        metadata: row.metadata,
      },
      score: row.rrf_score,
    })).slice(0, topK);
  }
}

セマンティックキャッシュ

概要

類似のクエリに対して以前の結果を再利用するキャッシュ戦略です。LLM API呼び出しのコストとレイテンシを大幅に削減できます。

class SemanticCache {
  constructor(
    private readonly embedder: EmbeddingService,
    private readonly vectorStore: VectorStore,
    private readonly similarityThreshold: number = 0.95,
    private readonly ttlMs: number = 3600_000, // 1時間
  ) {}

  async get(query: string): Promise<CacheEntry | null> {
    const queryEmbedding = await this.embedder.embed(query);
    const results = await this.vectorStore.search(queryEmbedding, 1);

    if (results.length === 0) return null;

    const topResult = results[0];

    // 類似度が閾値を超えていればキャッシュヒット
    if (topResult.score >= this.similarityThreshold) {
      const entry = JSON.parse(topResult.chunk.content) as CacheEntry;

      // TTLチェック
      if (Date.now() - entry.createdAt < this.ttlMs) {
        return entry;
      }

      // TTL切れの場合は削除
      await this.vectorStore.delete([topResult.chunk.id]);
    }

    return null;
  }

  async set(query: string, answer: string, contexts: string[]): Promise<void> {
    const queryEmbedding = await this.embedder.embed(query);

    const entry: CacheEntry = {
      query,
      answer,
      contexts,
      createdAt: Date.now(),
    };

    await this.vectorStore.upsert([{
      id: `cache-${Date.now()}-${Math.random().toString(36).slice(2)}`,
      documentId: 'cache',
      content: JSON.stringify(entry),
      embedding: queryEmbedding,
      metadata: { type: 'cache', createdAt: Date.now() },
    }]);
  }
}

interface CacheEntry {
  query: string;
  answer: string;
  contexts: string[];
  createdAt: number;
}

// RAGパイプラインにキャッシュを統合
class CachedRAGPipeline {
  constructor(
    private readonly cache: SemanticCache,
    private readonly pipeline: RAGPipeline,
  ) {}

  async generate(query: string): Promise<{ answer: string; cached: boolean }> {
    // キャッシュチェック
    const cached = await this.cache.get(query);
    if (cached) {
      return { answer: cached.answer, cached: true };
    }

    // キャッシュミス: 通常のRAGパイプライン
    const answer = await this.pipeline.generate(query);
    const contexts = await this.pipeline.retrieve(query);

    // 結果をキャッシュ
    await this.cache.set(
      query,
      answer,
      contexts.map(c => c.chunk.content),
    );

    return { answer, cached: false };
  }
}

クエリ拡張

Synonym Expansion(同義語展開)

class QueryExpander {
  private synonymMap: Map<string, string[]> = new Map([
    ['デプロイ', ['デプロイメント', 'リリース', '本番投入', 'deploy']],
    ['認証', ['authentication', 'auth', 'ログイン', '認可']],
    ['DB', ['データベース', 'database', 'RDS', 'PostgreSQL']],
    ['k8s', ['Kubernetes', 'クバネティス', 'コンテナオーケストレーション']],
    ['CI/CD', ['継続的インテグレーション', 'パイプライン', 'GitHub Actions']],
  ]);

  expand(query: string): string[] {
    const queries = [query];
    for (const [term, synonyms] of this.synonymMap) {
      if (query.includes(term)) {
        for (const synonym of synonyms) {
          queries.push(query.replace(term, synonym));
        }
      }
    }
    return [...new Set(queries)];
  }
}

Multi-Query Retrieval

複数のクエリで検索して結果を統合するパターンです。

class MultiQueryRetriever {
  constructor(
    private readonly embedder: EmbeddingService,
    private readonly vectorStore: VectorStore,
    private readonly expander: QueryExpander,
  ) {}

  async retrieve(query: string, topK: number = 5): Promise<RetrievedContext[]> {
    const expandedQueries = this.expander.expand(query);

    // 全クエリで並列検索
    const allResults = await Promise.all(
      expandedQueries.map(async q => {
        const embedding = await this.embedder.embed(q);
        return this.vectorStore.search(embedding, topK);
      }),
    );

    // 重複除去とスコア集約
    const scoreMap = new Map<string, RetrievedContext>();
    for (const results of allResults) {
      for (const result of results) {
        const existing = scoreMap.get(result.chunk.id);
        if (!existing || existing.score < result.score) {
          scoreMap.set(result.chunk.id, result);
        }
      }
    }

    return Array.from(scoreMap.values())
      .sort((a, b) => b.score - a.score)
      .slice(0, topK);
  }
}
検索パフォーマンスのモニタリング
interface SearchMetrics {
  queryLatencyMs: number;
  embeddingLatencyMs: number;
  vectorSearchLatencyMs: number;
  keywordSearchLatencyMs: number;
  totalLatencyMs: number;
  resultCount: number;
  cacheHit: boolean;
  topScore: number;
}

class InstrumentedSearch {
  async search(query: string): Promise<{
    results: RetrievedContext[];
    metrics: SearchMetrics;
  }> {
    const start = performance.now();

    const embeddingStart = performance.now();
    const embedding = await this.embedder.embed(query);
    const embeddingLatency = performance.now() - embeddingStart;

    const searchStart = performance.now();
    const results = await this.vectorStore.search(embedding, 5);
    const searchLatency = performance.now() - searchStart;

    const totalLatency = performance.now() - start;

    const metrics: SearchMetrics = {
      queryLatencyMs: totalLatency,
      embeddingLatencyMs: embeddingLatency,
      vectorSearchLatencyMs: searchLatency,
      keywordSearchLatencyMs: 0,
      totalLatencyMs: totalLatency,
      resultCount: results.length,
      cacheHit: false,
      topScore: results[0]?.score ?? 0,
    };

    // メトリクスの送信
    await this.metricsCollector.record(metrics);

    return { results, metrics };
  }
}

まとめ

ポイント内容
ハイブリッド検索ベクトル + BM25 を RRF で統合。両方の強みを活かす
セマンティックキャッシュ類似クエリの結果を再利用してコストとレイテンシを削減
クエリ拡張同義語展開やMulti-Queryで検索のRecallを向上
モニタリング各段階のレイテンシとスコアを計測して継続的に改善

チェックリスト

  • ハイブリッド検索(ベクトル + BM25)の仕組みとRRFを理解した
  • セマンティックキャッシュの設計パターンを理解した
  • クエリ拡張のテクニックを理解した
  • 検索パフォーマンスのモニタリング方法を理解した

次のステップへ

検索の最適化テクニックを学びました。次は演習でベクトルDBを実際に構築してみましょう。

ベクトル検索だけでは不十分。ハイブリッド + キャッシュ + クエリ拡張の組み合わせが本番品質への道。


推定読了時間: 40分