하이브리드 AI 검색: Dense + Sparse 벡터로 최고의 정확도

AI 기술

하이브리드 검색벡터 검색BM25Qdrant검색 최적화

이 글은 누구를 위한 것인가

  • 벡터 검색만으로 부족한 정확도를 개선하려는 팀
  • Dense + Sparse 하이브리드 검색을 구현하려는 개발자
  • RAG 시스템의 검색 품질을 높이려는 팀

들어가며

Dense 벡터(시맨틱 검색)는 의미를 이해하지만 정확한 키워드 매칭이 약하다. Sparse 벡터(BM25)는 키워드에 강하지만 동의어나 의미 이해가 없다. 둘을 결합한 하이브리드 검색이 실제 프로덕션에서 가장 좋은 성능을 낸다. 임베딩 모델 선택은 MTEB 리더보드를 기준선으로 삼되 자체 데이터로 재검증하는 것이 좋다.

이 글은 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의 정확도를 더 높일 수 있다.