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

AI 기술

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

이 글은 누구를 위한 것인가

  • 벡터 검색만으로 부족한 정확도를 개선하려는 팀
  • 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의 정확도를 더 높일 수 있다.