이 글은 누구를 위한 것인가
- 벡터 검색만으로 부족한 정확도를 개선하려는 팀
- Dense + Sparse 하이브리드 검색을 구현하려는 개발자
- RAG 시스템의 검색 품질을 높이려는 팀
들어가며
Dense 벡터(시맨틱 검색)는 의미를 이해하지만 정확한 키워드 매칭이 약하다. Sparse 벡터(BM25)는 키워드에 강하지만 동의어나 의미 이해가 없다. 둘을 결합한 하이브리드 검색이 실제 프로덕션에서 가장 좋은 성능을 낸다.
이 글은 bluefoxdev.kr의 하이브리드 Dense Sparse 검색 가이드 를 참고하여 작성했습니다.
1. 하이브리드 검색 아키텍처
[Dense vs Sparse 비교]
Dense (시맨틱):
표현: 연속 벡터 (1536차원)
모델: Voyage AI, OpenAI embeddings
강점: 동의어, 문맥 이해
약점: 정확한 키워드 누락 가능
예: "노트북" 검색 → "랩탑" 결과도 반환
Sparse (키워드):
표현: 희소 벡터 (단어 빈도)
알고리즘: BM25, TF-IDF
강점: 정확한 키워드 매칭
약점: 동의어, 문맥 이해 없음
예: "GPT-4" 검색 → 정확히 GPT-4 포함 문서만
[RRF (Reciprocal Rank Fusion)]
두 순위 목록을 결합하는 방법
RRF_score(d) = Σ 1/(k + rank(d))
k = 60 (기본값)
장점: 정규화 불필요, 스코어 분포 무관
단점: 스코어 절대값 활용 불가
[재랭킹 (Reranking)]
1단계: 하이브리드 검색 (Top 100)
2단계: Cross-encoder 재랭킹 (Top 10)
Cross-encoder: 쿼리-문서 쌍을 직접 평가
2. 하이브리드 검색 구현
import { QdrantClient } from '@qdrant/js-client-rest';
import Anthropic from '@anthropic-ai/sdk';
const qdrant = new QdrantClient({ url: process.env.QDRANT_URL });
const claude = new Anthropic();
// BM25 스코어 계산 (간단 구현)
function bm25Score(query: string, document: string, k1 = 1.5, b = 0.75): number {
const queryTerms = query.toLowerCase().split(/\s+/);
const docTerms = document.toLowerCase().split(/\s+/);
const docLength = docTerms.length;
const avgDocLength = 150; // 평균 문서 길이 (사전 계산)
return queryTerms.reduce((score, term) => {
const tf = docTerms.filter(t => t === term).length;
if (tf === 0) return score;
const idf = Math.log((10000 + 0.5) / (1 + 1)); // 단순화 (실제로는 말뭉치 전체에서 계산)
return score + idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * docLength / avgDocLength));
}, 0);
}
// RRF 점수 계산
function rrfScore(ranks: number[], k = 60): number {
return ranks.reduce((sum, rank) => sum + 1 / (k + rank), 0);
}
async function hybridSearch(query: string, topK = 10) {
// 쿼리 임베딩 생성
const embeddingResponse = await fetch('https://api.voyageai.com/v1/embeddings', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.VOYAGE_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ input: query, model: 'voyage-3' }),
});
const { data } = await embeddingResponse.json();
const denseVector = data[0].embedding;
// Dense 검색 (상위 50개)
const denseResults = await qdrant.search('documents', {
vector: denseVector,
limit: 50,
with_payload: true,
});
// Sparse 검색 (BM25 시뮬레이션)
// 실제로는 Qdrant의 sparse vector 또는 Elasticsearch BM25 사용
const allDocs = denseResults.map(r => ({ id: r.id as string, payload: r.payload }));
const sparseScores = allDocs.map(doc => ({
id: doc.id,
score: bm25Score(query, (doc.payload?.content as string) ?? ''),
})).sort((a, b) => b.score - a.score);
// RRF 결합
const denseRanks = new Map(denseResults.map((r, i) => [r.id as string, i + 1]));
const sparseRanks = new Map(sparseScores.map((r, i) => [r.id, i + 1]));
const allIds = new Set([...denseRanks.keys(), ...sparseRanks.keys()]);
const rrfScores = Array.from(allIds).map(id => ({
id,
score: rrfScore([denseRanks.get(id) ?? 51, sparseRanks.get(id) ?? 51]),
payload: denseResults.find(r => r.id === id)?.payload,
}));
const hybridTop = rrfScores.sort((a, b) => b.score - a.score).slice(0, topK);
// Cross-encoder 재랭킹
return await rerank(query, hybridTop);
}
// Claude로 관련성 재랭킹
async function rerank(query: string, candidates: { id: string; score: number; payload: any }[]) {
const response = await claude.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 200,
messages: [{
role: 'user',
content: `질문: "${query}"
다음 ${candidates.length}개 문서를 관련성 순으로 정렬하세요. ID만 쉼표로 나열하세요.
${candidates.map((c, i) => `${i + 1}. ID:${c.id}\n${String(c.payload?.content ?? '').slice(0, 200)}`).join('\n\n')}`,
}],
});
const text = response.content[0].type === 'text' ? response.content[0].text : '';
const orderedIds = text.split(',').map(s => s.trim());
return orderedIds.map(id => candidates.find(c => c.id === id)).filter(Boolean);
}
마무리
하이브리드 검색의 핵심은 RRF(Reciprocal Rank Fusion)다. 두 검색 결과의 순위를 결합하면 Dense의 의미 이해와 Sparse의 키워드 정확도를 모두 활용한다. Qdrant는 dense + sparse 벡터를 네이티브로 지원하며, Cross-encoder 재랭킹으로 최종 Top-K의 정확도를 더 높일 수 있다.