RAG Retriever 실무

Dense, Sparse, Hybrid Retriever

2026. 06. 29.
AIRAGRetrieverSearch

이 시리즈는 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를 어떻게 같이 적용하는지 다룬다.