Dense, Sparse, Hybrid Retriever
이 시리즈는 RAG의 검색 부분만 집중해서 다룬다. RAG의 품질은 결국 검색이 결정하는데, 단순 벡터 검색만으로는 실무에서 한계가 빠르게 온다.
이번 글에서는 RAG 검색기를 부를 때 자주 나오는 세 가지 단어부터 정리한다.
Dense Retrieval (벡터 검색)
Sparse Retrieval (키워드 검색, BM25)
Hybrid Retrieval (둘을 같이)
Dense Retrieval: 의미로 찾기
Dense는 "조밀한" 벡터를 쓴다는 뜻이다. 앞 시리즈에서 다룬 임베딩 기반 벡터 검색이 여기에 해당한다.
질문: "연차 얼마나 있어?" → 벡터 (...)
문서: "휴가는 연 15일이다" → 벡터 (...)
좌표가 가까움 → 매칭
장점은 명확하다.
- 유의어, 다른 표현, 풀어쓴 질문에 강함
- "내용은 같은데 단어가 다른" 경우를 잡아냄
- 의도 기반 검색 가능 ("뭔가 결제가 안 되는데" → 결제 장애 문서)
단점도 있다.
- 정확한 식별자(에러코드, 사람 이름, 제품 코드)에 약함
- 임베딩 모델에 종속됨 (모델 교체하면 인덱스 다 다시 만들어야 함)
- 결과를 사람이 보고 직관적으로 디버깅하기 어려움
- 한국어 임베딩 모델이 영어보다 약한 편
Sparse Retrieval: 단어로 찾기
Sparse는 "희소한" 벡터다. 한 문서에서 등장하는 단어만 1, 나머지 단어는 0으로 두는 방식이다.
대표적인 알고리즘이 BM25다.
BM25 점수 = (단어가 문서에 얼마나 자주 나오는가)
× (그 단어가 전체 문서에서 얼마나 희귀한가)
× (문서 길이 보정)
쉽게 말해 이런 직관이다.
- 흔한 단어("은", "는", "이", "의")는 점수 낮음
- 특정 문서에만 자주 나오는 단어("4023", "결제한도")는 점수 높음
쓰는 도구는 보통 이렇다.
- Elasticsearch
- OpenSearch
- PostgreSQL 전문 검색 (tsvector / GIN 인덱스)
- 단독 라이브러리: rank_bm25, BM25Okapi
장점은 이렇다.
- 정확한 단어 매칭에 강함 (에러코드, 고유명사, 코드 식별자)
- 결과가 직관적이라 디버깅 쉬움
- 한국어도 형태소 분석기(노리, mecab)만 잘 쓰면 잘 됨
- 필터 검색(날짜, 권한)과 결합하기 좋음
단점은 의미 검색에 약한 거다.
질문: "연차 얼마나 있어?"
문서: "휴가는 연 15일이다"
→ "연차"라는 단어가 문서에 없음 → BM25 점수 낮음 → 못 찾음
한 줄 비교
Dense : "비슷한 뜻"을 찾는다. 표현이 달라도 매칭됨.
Sparse : "정확한 단어"를 찾는다. 표현이 다르면 못 찾음.
둘 다 강점과 약점이 정반대라, 같이 쓰는 게 답이라는 결론이 나온다.
Hybrid Retrieval: 둘을 합치는 법
Hybrid는 그냥 Dense 결과랑 Sparse 결과를 같이 쓰는 거다. 실무에서 가장 흔한 패턴은 이렇다.
글로 풀면 이렇다.
질문
├─→ Sparse (BM25)로 top-N 후보 추출
└─→ Dense (벡터 검색)로 top-N 후보 추출
↓
둘을 합쳐서 한 리스트로 만듦 (= Score Fusion)
↓
상위 top-k 반환
여기서 핵심은 점수를 어떻게 합치냐다. 점수 척도가 다르기 때문에 그냥 더할 수는 없다.
BM25 점수: 보통 5~50 사이의 정수 같은 값
Dense 점수: 보통 0~1 사이의 코사인 유사도
(또는 0~2 사이의 distance)
대표적인 합치는 방법이 두 가지 있다.
1. RRF (Reciprocal Rank Fusion)
가장 흔하고 가장 안정적인 방법.
RRF 점수 = Σ 1 / (k + rank_i)
(k는 보통 60 사용)
순위만 보기 때문에 점수 척도가 달라도 잘 동작한다.
def rrf(rank_lists, k=60):
scores = {}
for ranks in rank_lists:
for rank, doc_id in enumerate(ranks):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)2. Weighted Sum
각 점수를 0~1 사이로 정규화한 다음 가중치로 합친다.
final_score = α × normalized_sparse + (1 - α) × normalized_dense
α를 0.5에서 시작해서 evaluation 결과를 보고 조정한다.
α = 0.7 → 키워드 중심 (식별자/코드 검색 위주)
α = 0.3 → 의미 중심 (FAQ/일반 질문 위주)
실무 예시: Elasticsearch + pgvector + RRF
실제 회사에서 흔히 쓰는 구성 한 가지.
[문서 인덱싱]
원본 문서
├─→ Elasticsearch에 색인 (BM25 + 메타데이터)
└─→ pgvector에 임베딩 저장
[검색 시점]
질문
├─→ Elasticsearch BM25 top-50 (메타 필터 같이 적용)
└─→ pgvector ANN top-50
↓
RRF로 점수 합쳐서 top-20 추림
↓
Reranker로 다시 정렬 (다음 글)
↓
최종 top-5를 LLM에 넘김
이 구조의 좋은 점은 이렇다.
- BM25 쪽에서 메타 필터(권한, 날짜, 부서)를 같이 적용 가능
- 벡터 검색은 "유의어 잘 잡기" 책임만 짐
- 두 시스템 중 하나가 죽어도 다른 쪽으로 fallback 가능
- 디버깅할 때 "어느 쪽이 못 찾았는지" 분리 가능
코드 예시: 간단한 Hybrid Retriever
LangChain의 EnsembleRetriever로 간단히 만들 수도 있다.
from langchain_community.retrievers import BM25Retriever
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings
from langchain.retrievers import EnsembleRetriever
docs = [...] # langchain Document 리스트
# 1) Sparse
bm25 = BM25Retriever.from_documents(docs)
bm25.k = 50
# 2) Dense
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = FAISS.from_documents(docs, embeddings)
dense = vector_store.as_retriever(search_kwargs={"k": 50})
# 3) Hybrid (RRF 비슷한 동작)
hybrid = EnsembleRetriever(
retrievers=[bm25, dense],
weights=[0.5, 0.5],
)
results = hybrid.invoke("연차 얼마나 있어?")이 예제는 라이브러리에 다 의존하기 때문에 사이드 효과를 컨트롤하기 어렵다. 실무에서는 직접 RRF를 구현하고 점수를 노출하는 편이 디버깅이 훨씬 편하다.
from typing import Protocol
class Retriever(Protocol):
def search(self, query: str, k: int) -> list[tuple[str, float]]:
"""(doc_id, score) 리스트 반환"""
class HybridRetriever:
def __init__(self, sparse: Retriever, dense: Retriever):
self.sparse = sparse
self.dense = dense
def search(self, query: str, k: int = 20) -> list[str]:
sparse_ranks = [d for d, _ in self.sparse.search(query, k=50)]
dense_ranks = [d for d, _ in self.dense.search(query, k=50)]
fused = rrf([sparse_ranks, dense_ranks])
return [doc_id for doc_id, _ in fused[:k]]Retriever 인터페이스를 추상화해두면 나중에 검색기를 바꾸기도 쉽고, Langfuse에 점수와 doc_id를 그대로 로깅하기도 쉽다.
정리
- Dense는 의미, Sparse는 단어를 잡는다
- 둘은 강약이 정반대라 같이 쓰는 게 정답이다 (Hybrid)
- 합치는 방식은 보통 RRF 또는 Weighted Sum, 시작은 RRF가 안전하다
- 실무 구성: Elasticsearch(BM25 + 필터) + 벡터DB(pgvector/Pinecone) + RRF
Retriever인터페이스를 추상화해두면 디버깅, 교체, 관측이 다 쉬워진다
다음 글에서는 Hybrid가 가져온 top-20 후보를 Reranker로 다시 정렬하는 법과, 권한/날짜/문서타입 같은 Metadata Filter를 어떻게 같이 적용하는지 다룬다.