AI/Natural language processing

RAG 구축하기 - 3.2 성능 최적화 : Hybrid Search(CC& RRF) 와 Rerank

hjjummy 2025. 8. 19. 14:49

지난 글에서는 청킹(Chunking) 전략을 통해 검색 품질을 높이는 방법을 다뤘다.

2025.08.19 - [인공지능/자연어처리] - RAG 구축하기 - 3.1 성능 최적화 : Chunking 전략 제대로 파헤치기

 

RAG 구축하기 - 3.1 성능 최적화 : Chunking 전략 제대로 파헤치기

앞선 두 편에서 우리는 RAG의 큰 그림을 살펴봤다.1탄에서는 데이터 파이프라인 — 문서를 불러오고(Load), 파싱하고(Parsing), 청크로 나누고(Chunking), 문맥을 보강(Contextualize), 임베딩(Embedding), 벡터DB

hjjummy.tistory.com

 


이번에는 성능 최적화의 두 번째 포인트, 바로 Hybrid Search와 Re-Rank 기법에 대해 이야기해보려 한다.

RAG를 실제로 운영하다 보면 Dense Search(임베딩, 의미 기반)와 Sparse Search(BM25 같은 키워드 기반) 중 하나만 사용했을 때 생기는 한계를 자주 마주하게 된다. 그래서 두 방법을 Hybrid하는 게 실무에서 가장 많이 쓰이는 접근이다.

 

그 후 일반적으로 “Hybrid Search로 넓게 후보 확보 → Cross-Encoder로 Top-N 재정렬” 패턴이 가장 안정적이다.

헷갈릴 수도 있으므로 RAG의 파이프라인을 다시 정리해보면 

Data 준비 단계: Load → Parsing → Chunking → Contextualize → Embedding → Upsert

Retrieval 단계: Dense Search + Sparse Search → Hybrid Search (CC, RRF)

Re-Rank 단계: Cross-Encoder 기반 정밀 정렬 (Top-N 후보 재정렬)

Generation 단계: 최종적으로 선택된 문맥을 LLM에 전달해 답변 생성

 

이고 우리는 지금 Retrieval & Re-Rank 단계에서 사용하는 기법에 대해서 알아보는 시간이다. 

 


Hybrid Search

1. 왜 Hybrid Search가 필요한가?

  • Dense Search (임베딩, 의미 기반)
    • 장점: 의미 기반으로 유사한 내용을 잘 찾는다.
    • 단점: 숫자, 고유명사, 도메인 특화 용어에는 약하다.
    • 예: “자동차 연비 개선” ↔ “연료 효율 최적화” (잘 매칭) / “제34조” ↔ “법 조항” (놓칠 수 있음).
  • Sparse Search (BM25, 키워드 기반)
    • 장점: 정확한 키워드, 숫자, 법령 조항 같은 경우 강력하다.
    • 단점: 동의어나 표현이 달라지면 검색이 약해진다.
    • 예: “보트 충돌 회피” ↔ “선박 자율항해 회피 알고리즘” (못 잡음).

 즉, Dense는 semantic recall, Sparse는 lexical precision에 강하다.
따라서 두 가지를 결합해 서로의 약점을 보완하는 방식이 Hybrid Search다.


2. Hybrid Search 결합 방식

Hybrid Search의 핵심은 두 결과를 어떻게 합칠 것인가다. 대표적인 방법은 두 가지다. 

 

1. Convex Combination ( 가중 평균, CC)

Dense와 Sparse 각각의 점수를 정규화한 뒤, 가중치를 적용해 합산한다.

α = 0.7 → Dense 비중 ↑

α = 0.3 → Sparse 비중 ↑

 

장점: 계산이 단순하고 빠르다.
단점: 각각의 점수 스케일이 크면 normalization 필요

from langchain.retrievers import EnsembleRetriever
from langchain_community.vectorstores import OpenSearchVectorSearch

vector_db = OpenSearchVectorSearch(
            index_name=index_name,
            opensearch_url=oss_endpoint,
            embedding_function=embeddings,
            http_auth=http_auth, 
            is_aoss =False,
            engine="faiss",
            space_type="l2"
        )

# Sementic Retriever(유사도 검색, 3개의 결과값 반환)
opensearch_semantic_retriever = vector_db.as_retriever(
    search_type="similarity",
    search_kwargs={
        "k": 3
    }
)

search_semantic_result = opensearch_semantic_retriever.get_relevant_documents(query)

opensearch_lexical_retriever = OpenSearchLexicalSearchRetriever(
    os_client=os_client,
    index_name=index_name
)

# Lexical Retriever(키워드 검색, 3개의 결과값 반환)
opensearch_lexical_retriever.update_search_params(
    k=3,
    minimum_should_match=0
)

search_keyword_result = opensearch_lexical_retriever.get_relevant_documents(query)

# Ensemble Retriever(앙상블 검색)
ensemble_retriever = EnsembleRetriever(
    retrievers=[opensearch_lexical_retriever, opensearch_semantic_retriever],
    weights=[0.50, 0.50]
)

search_hybrid_result = ensemble_retriever.get_relevant_documents(query)

 

2. Reciprocal Rank Fusion (RRF)

Dense와 Sparse 각각을 순위(rank)로 변환한 뒤, 순위의 역수를 합산하는 방식이다.

  • k: 안정화 파라미터(일반적으로 60)
  • 두 검색 결과 모두 상위권에 등장한 문서에 큰 점수를 부여
def rrf_fuse(rank_lists, k=60):
    score = {}
    for ranks in rank_lists:               # ranks: [doc_id0, doc_id1, ...]
        for r, doc_id in enumerate(ranks):
            score[doc_id] = score.get(doc_id, 0) + 1.0 / (k + r + 1)
    return sorted(score.items(), key=lambda x: x[1], reverse=True)

# 1) 각각 검색
lex_docs = opensearch_lexical_retriever.get_relevant_documents(query)   # k개
sem_docs = opensearch_semantic_retriever.get_relevant_documents(query)  # k개

# 2) 문서 ID의 순위 리스트 만들기
lex_ids = [d.metadata.get("id", d.page_content[:50]) for d in lex_docs]
sem_ids = [d.metadata.get("id", d.page_content[:50]) for d in sem_docs]

# 3) RRF 결합
fused = rrf_fuse([lex_ids, sem_ids], k=60)

# 4) 최종 문서 재구성
id2doc = {d.metadata.get("id", d.page_content[:50]): d for d in (lex_docs + sem_docs)}
final_docs = [id2doc[doc_id] for doc_id, _ in fused]

 

장점: 점수 스케일이 다르더라도 순위만 있으면 결합 가능.
단점: 단순 순위 기반이라 세밀한 가중치 조정은 어렵다.


 

3. Hybrid Search 실무 최적화 팁

  • 도메인별 비중 조정
    • 법률/매뉴얼 → 키워드 중요 → Sparse 비중 ↑ (CC에서 α 낮춤).
    • 일반 QA/지식 검색 → 의미 중요 → Dense 비중 ↑ (CC에서 α 높임).
  • 평가 지표 기반 검증
    • Precision@k, Recall@k, MRR 같은 IR 지표로 비교.
    • 사용자 피드백을 함께 반영해 조정.
  • 다단계 Retrieval 전략
    1. Hybrid Search(CC 또는 RRF)로 Top-N 후보 검색
    2. Cross-Encoder 기반 Re-Ranker로 상위 k개 재정렬
      → Recall과 Precision을 동시에 확보

Re-Rank 

Hybrid Search로 상위 후보 문서를 뽑았다고 해서 그것이 곧 “최종 답변에 가장 유리한 문맥”이라고 보장되지는 않는다. 실제 서비스에선 아래와 같은 문제가 자주 발생한다.

  • 상위에 잡힌 문서 중 일부는 질문과 간접적으로만 관련이 있다.
  • 숫자·날짜·법 조항처럼 중요한 정보가 뒤쪽에 묻혀버린다.
  • Dense Search와 Sparse Search가 각자 잘못 잡은 문서가 섞여 들어온다.

이때 필요한 것이 Re-Rank(재정렬) 단계다.

 

최근 논문에 따르면 RAG의 정확도는 관련 정보의 컨텍스트 내 존재 유무가 아니라 순서라는 것을 발견했다고 한다.

즉, RAG 성능은 단순히 “얼마나 많은 관련 문서를 가져왔는가(Recall)”보다도, 그 중에서 상위권에 얼마나 정답이 배치되었는가(Precision@k, MRR)가 더 중요하다는 것이다. (출처: AWS)

 

LLM은 컨텍스트 윈도우가 제한되어 있기 때문에, 상위 몇 개 문서만 활용할 수 있다.

따라서 Re-Rank는 “검색된 후보 중 진짜 핵심 문서를 맨 위로 올리는 과정”이라 할 수 있다.


1. Bi-Encoder vs Cross-Encoder

Re-Rank를 구현하는 대표적인 두 가지 접근법은 다음과 같다.

출처: https://aws.amazon.com/ko/blogs/tech/korean-reranker-rag/

 

1. Bi-Encoder

Bi-Encoder는 흔히 Dense Retriever라고도 부른다. Dense Search와 사실상 동일 구조이다.

문서정보와 질의를 각각 임베딩 후 코사인 유사도를 계산하여 점수화한다.
즉, 질의와 문서를 독립적으로 임베딩해두고, 검색 시에는 단순한 벡터 연산으로 빠르게 계산한다.  

 

  • 장점: 매우 빠르다. 질의는 한 번만 인코딩하면 되고, 문서 임베딩은 사전에 계산·저장 가능.
    또한 수백만 건 문서라도 FAISS, Milvus 같은 ANN(근사 최근접 탐색) 라이브러리로 빠른 검색 가능.
             → 실시간 검색에서 매우 유리.
  • 단점: 질의와 문서를 독립적으로 처리하기 때문에 세밀한 문맥 비교가 어렵다. 질문과 문서가 같은 벡터 공간에 맵핑되긴 하지만, "질문 맥락에 따라 문서의 특정 부분이 중요해지는 경우"를 잘 반영하지 못한다.

    예를 들어, 문서에 “Python은 객체지향 언어이다” 라는 구절이 있고, 질의가 “Python이 절차적 언어인가요?” 라면 단순 유사도로는 세밀한 구분이 어렵다.
    따라서 Re-Rank 단계에서 Bi-Encoder는 거의 쓰이지 않고, 검색 단계(Recall 확보)에서 주로 사용된다.

2. Cross-Encoder

실제 핵심 Rerank에서 많이 쓰이는 것은 cross encoder이다. 질의와 문서를 하나의 입력 시퀀스로 합쳐서 모델에 넣고, 모델이 직접 relevance score를 출력한다.

  • 예시 입력: [CLS] 질의 [SEP] 문서 [SEP] 형태 입력.
  • 예시 출력: relevance score (예: 0.0 ~ 1.0 사이)
        여기서 중요한 점은, 모델이 질의와 문서 토큰 간의 어텐션(attention)을 직접 학습한다는 것.
        즉, "질문이 어떤 부분과 연결되는가"를 모델이 이해할 수 있다.

 

  • 장점: 질의와 문서를 토큰 수준에서 상호작용시켜서 의미적 차이를 세밀하게 반영. 미묘한 의미 차이나 문맥 기반 매칭에서 강력하다.

    예: "Python is an object-oriented language" vs "Python is not an object-oriented language" → Bi-Encoder는 거의 동일한 벡터를 만들 수 있지만, Cross-Encoder는 not의 의미 차이를 정확히 반영할 수 있다.

 

  • 단점: 계산량이 많아 실시간 검색에선 부담이 크다.
    질의마다 문서와 쌍으로 묶어 다시 인코딩해야 하므로 O(N) 시간이 든다. (문서 수 * 질의 수)
    실시간 검색 어려움: 수천 개 문서를 Re-Rank하면 GPU 부하가 크다. 따라서 보통 "상위 50~100개 문서"까지만 Cross-Encoder에 넣고, 상위 Top-10으로 다시 추린다.

2. 실제 Re-Rank 파이프라인에서의 역할 분담

RAG에서 Bi-Encoder와 Cross-Encoder는 경쟁 관계라기보다 협업 관계로 보는 게 맞다.

  1. 1차 검색 (Bi-Encoder, Dense Retriever)
    • 수십만/수백만 개 문서 중에서 Top-100 후보를 빠르게 가져옴.
    • Recall ↑ (빠르게 넓게 긁어오기).
  2. 2차 정렬 (Cross-Encoder, Reranker)
    • 가져온 100개 문서를 질의와 함께 넣고 세밀하게 평가.
    • Precision ↑ (진짜 중요한 문서만 추려내기).

이 구조를 흔히 “Dual-Stage Retrieval” 또는 “Retrieve & Rerank”라고 부른다


3. 지표 관점 비교

  • Bi-Encoder
    • Recall@100 ↑ (정답을 후보에 넣을 확률 높음).
    • 하지만 Precision@5 낮음 (상위에 꼭 정답이 있진 않음).
  • Cross-Encoder
    • Precision@k ↑ (정답을 위쪽으로 잘 올림).
    • 하지만 Recall 전체는 건드리지 않음 (애초에 후보에 없으면 못 살림).

즉, 둘을 조합해야 Recall + Precision을 동시에 잡을 수 있음.

 

 

정리하면,

  • Bi-Encoder: 빠르지만 대충(Recall 담당)
  • Cross-Encoder: 느리지만 정확(Precision 담당)
  • 그래서 Bi로 후보 확보 → Cross로 정렬이 가장 안정적인 RAG Re-Rank 구조이다.

 

다음 글에서는 여기서 한 발 더 나아가, 프롬프트 엔지니어링과 컨텍스트 관리 전략에 대해 다뤄볼 예정이다. 같은 검색 결과를 주더라도 LLM이 어떻게 답을 생성하느냐에 따라 품질이 크게 달라지기 때문이다. 어떤 식으로 컨텍스트를 구성하고, 어떻게 프롬프트를 설계해야 RAG가 안정적으로 동작하는지 구체적인 방법을 살펴보겠다.