ストーリー
ハイブリッド検索
なぜハイブリッドが必要か
| 検索手法 | 得意なクエリ | 苦手なクエリ |
|---|---|---|
| ベクトル検索 | 意味的な質問(概念、パターン) | 固有名詞、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分