Joyuno's Dev Blog

HyDE + Reranking + Query Expansion: 2026년 6월 기준 “진짜” RAG 성능을 끌어올리는 고급 검색 스택

AI RAG

들어가며

프로덕션 RAG에서 성능이 “거의 맞는데 결정적으로 틀리는” 케이스는 보통 retrieval 단계에서 top-k 안에 정답 근거가 못 들어오거나, 들어와도 상위에 못 올라와 LLM 프롬프트에 실리지 않아서 발생합니다. 2026년 기준 현업에서 가장 재현성 있게 먹히는 처방은 크게 3가지 축입니다:

  • Query Expansion: 사용자의 짧고 모호한 query를 여러 개로 “펼쳐” recall을 올림
  • HyDE (Hypothetical Document Embeddings): LLM이 “그럴듯한 답변 문서”를 먼저 써주고, 그 문서를 임베딩해 retrieval의 semantic gap을 줄임 (ocadefusion.fr)
  • Reranking (Cross-Encoder / ColBERT): 1차 후보(대개 50~200개)를 “질문-문서 쌍”으로 정밀 채점해 top-n을 재정렬 (presenc.ai)

언제 쓰면 좋나

  • 문서가 길고 다양하며(내부 위키/정책/기술문서), query가 짧고 모호한 편
  • “정답 문서가 아예 안 잡히는” recall 문제가 있거나
  • 하이브리드(BM25+dense)만으로는 domain mismatch가 자주 나는 경우

언제 쓰면 안 되나

  • 이미 1차 retrieval top-10에 정답이 안정적으로 들어오는데 “생성/프롬프트”가 문제인 경우(이때는 rerank/HyDE는 비용만 늘고 효과가 작을 수 있음) (callsphere.ai)
  • 초저지연(수십 ms) 요구가 강한 실시간 서비스에서 cross-encoder rerank를 무턱대고 넣는 경우(캐싱/게이팅 없으면 곧바로 비용 폭발)

🔧 핵심 개념

1) Query Expansion: “recall을 돈으로 사는” 전략

정의: 하나의 query를 여러 variant로 확장(재작성/분해/동의어/약어 풀기 등)하고, 각 query로 검색한 결과를 RRF(Reciprocal Rank Fusion) 같은 방식으로 합치는 접근입니다. SemEval/TREC 계열 시스템도 multi-stage에서 query rewriting + hybrid + rerank 조합을 반복적으로 사용합니다. (arxiv.org)

흐름

  1. LLM(또는 rule)로 query variant N개 생성
  2. 각 variant로 BM25 + dense(또는 둘 중 하나) 검색
  3. 결과를 RRF로 fuse → 후보 풀을 크게 확보
  4. (선택) reranker로 최종 상위만 정밀 선별

차이점

  • HyDE가 “문서 형태로 변환”이라면, Query Expansion은 “질문 자체를 여러 개로” 늘립니다.
  • Expansion은 coverage(포괄성), HyDE는 semantic gap 완화에 강점.

2) HyDE: “질문을 문서로 바꿔서” 임베딩 공간의 위치를 이동

정의: LLM이 query에 대해 답변에 가까운 hypothetical document를 생성하고, 그 문서를 임베딩해 검색합니다(원 query 임베딩 대신/또는 함께). 원 아이디어는 retrieval에서 query가 너무 짧아 임베딩이 불안정한 문제를 줄이는 것. (ocadefusion.fr)

내부 작동(실무 관점)

  • query → (LLM) hypothetical doc 생성
  • hypothetical doc → embedding
  • embedding으로 vector search 수행
  • (보통) 원 query vector search도 같이 수행하고 fuse(RRF)하는 편이 안전

2026년식 관찰 포인트

  • 임베딩/하이브리드가 강해진 환경에서는 HyDE의 평균 이득이 작아져서, “어려운 query에만 켠다(gating)”가 수익성이 좋다는 경험칙이 보고됩니다. (callsphere.ai)

3) Reranking: 2-stage RAG의 “결정타”

정의: 1차 retriever가 뽑은 top-K(보통 50~200)를 cross-encoder가 query-document를 직접 입력으로 받아 점수를 내고 재정렬합니다. embedding 기반 유사도(=bi-encoder)와 달리, cross-encoder는 질문과 문서를 함께 보며 미세한 관련성을 잡습니다. (bge-model.com)

대표 모델/트렌드(2026년 상반기)

  • 오픈 웨이트 쪽은 Qwen3-Reranker, BGE-Reranker-v2 계열이 자주 언급됩니다. (presenc.ai)
  • BGE 쪽 문서도 reranker가 “embedding이 아니라 query+doc → score”라는 점을 명확히 합니다. (bge-model.com)
  • 연구/실험에서는 retriever(HyDE/HyPE/Fusion) + reranker(BGE/MiniLM 등) 조합 평가가 계속 나오고 있습니다. (link.springer.com)

💻 실전 코드

아래는 현실적인 내부 문서 RAG를 가정한 파이프라인 예시입니다.

  • Vector DB: Qdrant
  • 1차 검색: Dense(top=80) + BM25(top=80) + RRF fuse
  • Query Expansion: LLM으로 3개 variant 생성
  • HyDE: LLM으로 hypothetical doc 1개 생성(어려운 query일 때만)
  • Rerank: BGE cross-encoder로 최종 top-12 선별
  • 출력: 상위 문서의 score/메타데이터

0) 의존성/실행 준비

pip install qdrant-client sentence-transformers transformers torch --upgrade
# OpenAI 사용 시:
pip install openai

1) 파이프라인 코드 (Python)

import os
from dataclasses import dataclass
from typing import List, Dict, Tuple
from qdrant_client import QdrantClient
from sentence_transformers import SentenceTransformer, CrossEncoder

# ---- 설정 ----
QDRANT_URL = os.getenv("QDRANT_URL", "http://localhost:6333")
COLLECTION = os.getenv("QDRANT_COLLECTION", "internal_docs")

# Dense embedder (예: BGE-M3 같은 계열을 가정)
EMBED_MODEL = os.getenv("EMBED_MODEL", "BAAI/bge-m3")

# Reranker: cross-encoder (2026년 현업에서 자주 쓰는 BGE reranker 계열)
RERANK_MODEL = os.getenv("RERANK_MODEL", "BAAI/bge-reranker-v2-m3")

# ---- 데이터 구조 ----
@dataclass
class Hit:
    doc_id: str
    text: str
    source: str
    score: float  # retrieval or rerank score
    meta: Dict

# ---- 유틸: RRF ----
def rrf_fuse(rank_lists: List[List[Hit]], k: int = 60) -> List[Hit]:
    # RRF score = sum(1 / (k + rank))
    # doc_id 기준으로 합산
    acc: Dict[str, Tuple[Hit, float]] = {}
    for hits in rank_lists:
        for rank, h in enumerate(hits, start=1):
            add = 1.0 / (k + rank)
            if h.doc_id not in acc:
                acc[h.doc_id] = (h, add)
            else:
                acc[h.doc_id] = (acc[h.doc_id][0], acc[h.doc_id][1] + add)

    fused = []
    for doc_id, (h, s) in acc.items():
        fused.append(Hit(doc_id=doc_id, text=h.text, source=h.source, score=s, meta=h.meta))
    fused.sort(key=lambda x: x.score, reverse=True)
    return fused

# ---- (선택) LLM 기반 Query Expansion / HyDE ----
# 여기서는 OpenAI를 예로 들되, 사내 LLM/로컬 LLM로 대체 가능하도록 함수로 분리
def llm_generate_variants(query: str) -> List[str]:
    """
    현실 팁: variant는 3~5개가 보통 sweet spot.
    - 약어 풀기
    - 동의어/제품명 변형
    - 한글/영문 혼용
    """
    try:
        from openai import OpenAI
        client = OpenAI()
        prompt = f"""
너는 검색 최적화 전문가다.
원문 질의: {query}
목표: 내부 기술문서 검색 recall을 올리기 위한 query variants 3개를 만들어라.
조건:
- 원문의 의도를 유지
- 약어/영문/한글 변형 포함
- 출력은 JSON 배열 문자열로만
"""
        r = client.responses.create(
            model="gpt-4.1-mini",
            input=prompt,
            temperature=0.2,
        )
        import json
        return json.loads(r.output_text)
    except Exception:
        # LLM 미구성 시 fallback
        return [query, query + " 가이드", query + " troubleshooting"]

def llm_generate_hyde_doc(query: str) -> str:
    """
    HyDE: '답변이 실린 것 같은 문서'를 생성하되,
    사실성보다 '검색용 표현 다양성'이 핵심.
    """
    try:
        from openai import OpenAI
        client = OpenAI()
        prompt = f"""
다음 질문에 답하는 내부 기술 문서의 한 섹션을 작성해라.
질문: {query}

조건:
- bullet/절차/키워드를 풍부하게 포함
- 특정 회사 비밀/가정 데이터는 만들지 말고 일반화
- 길이: 200~350자
"""
        r = client.responses.create(
            model="gpt-4.1-mini",
            input=prompt,
            temperature=0.3,
        )
        return r.output_text
    except Exception:
        return f"{query}\n\n키워드: 설정, 오류, 원인, 해결, 체크리스트"

def is_hard_query(query: str) -> bool:
    # 실무에서는 (1) 길이, (2) OOV 비율, (3) 과거 실패율, (4) 초기 top-k score gap 등으로 게이팅
    return len(query) < 12 or ("?" in query)

# ---- 검색기 ----
class RagRetriever:
    def __init__(self):
        self.qdrant = QdrantClient(url=QDRANT_URL)
        self.embedder = SentenceTransformer(EMBED_MODEL)
        self.reranker = CrossEncoder(RERANK_MODEL)

    def dense_search(self, query: str, limit: int = 80) -> List[Hit]:
        qv = self.embedder.encode(query, normalize_embeddings=True).tolist()
        res = self.qdrant.search(
            collection_name=COLLECTION,
            query_vector=qv,
            limit=limit,
            with_payload=True,
        )
        hits = []
        for p in res:
            payload = p.payload or {}
            hits.append(Hit(
                doc_id=str(p.id),
                text=payload.get("text", ""),
                source=payload.get("source", "unknown"),
                score=float(p.score),
                meta=payload
            ))
        return hits

    def bm25_search(self, query: str, limit: int = 80) -> List[Hit]:
        # Qdrant의 full-text 인덱싱/필터를 사용한다고 가정 (구현은 스키마에 따라 달라짐)
        # 여기서는 예시로 "text" 필드 full-text match를 사용했다고 가정한 pseudo.
        res = self.qdrant.search(
            collection_name=COLLECTION,
            query_filter={
                "must": [
                    {"key": "text", "match": {"text": query}}
                ]
            },
            limit=limit,
            with_payload=True
        )
        hits = []
        for p in res:
            payload = p.payload or {}
            hits.append(Hit(
                doc_id=str(p.id),
                text=payload.get("text", ""),
                source=payload.get("source", "unknown"),
                score=float(p.score),
                meta=payload
            ))
        return hits

    def rerank(self, query: str, candidates: List[Hit], top_n: int = 12) -> List[Hit]:
        pairs = [(query, h.text[:2000]) for h in candidates]  # 너무 길면 잘라서 비용/지연 관리
        scores = self.reranker.predict(pairs)
        reranked = []
        for h, s in zip(candidates, scores):
            reranked.append(Hit(doc_id=h.doc_id, text=h.text, source=h.source, score=float(s), meta=h.meta))
        reranked.sort(key=lambda x: x.score, reverse=True)
        return reranked[:top_n]

    def retrieve(self, query: str) -> List[Hit]:
        variants = llm_generate_variants(query)

        rank_lists = []
        for q in variants:
            rank_lists.append(self.dense_search(q, limit=80))
            rank_lists.append(self.bm25_search(q, limit=80))

        if is_hard_query(query):
            hyde_doc = llm_generate_hyde_doc(query)
            rank_lists.append(self.dense_search(hyde_doc, limit=80))  # HyDE는 doc를 query로 넣는 셈

        fused = rrf_fuse(rank_lists, k=60)[:200]   # rerank 입력 풀은 100~200 사이가 흔함
        reranked = self.rerank(query, fused, top_n=12)
        return reranked

if __name__ == "__main__":
    rr = RagRetriever()
    q = "HyDE를 RAG에 넣었는데 reranking까지 해야 하나?"
    top = rr.retrieve(q)
    for i, h in enumerate(top, 1):
        print(f"{i:02d}. score={h.score:.4f} source={h.source} id={h.doc_id}")
        print(h.text[:180].replace("\n", " "))
        print("-" * 80)

예상 출력(형태)

  • rerank score 기반으로 상위 문서가 재정렬되며, 동일 문서가 여러 query variant에서 걸려도 RRF로 합쳐진 뒤 1회만 rerank 됩니다.

⚡ 실전 팁 & 함정

Best Practice (효과 큰 순)

1) Rerank는 “후보 풀 품질”이 전제
reranker는 top-200 안에 정답이 있어야 이깁니다. 그래서 보통 “hybrid + expansion + (필요 시 HyDE)”로 후보 풀 recall을 먼저 올리고, 그 다음 rerank로 precision을 올립니다. (arxiv.org)

2) HyDE는 상시 ON보다 ‘게이팅’이 현실적
HyDE는 LLM 호출 + 추가 검색이므로 비용이 누적됩니다. 최근 글/경험 공유에서는 강한 baseline(좋은 embedding + hybrid) 위에서는 평균 이득이 작아 어려운 query에만 켜는 전략이 수익성이 좋다고 봅니다. (callsphere.ai)

3) 오픈 웨이트 reranker는 BGE 계열이 디폴트 옵션이 됨
cross-encoder 기반 reranker가 “프로덕션 RAG의 2nd stage”라는 점, 그리고 BGE reranker가 대표 오픈 모델로 널리 쓰인다는 점이 여러 자료에서 반복됩니다. (presenc.ai)

흔한 함정/안티패턴

  • “reranker 넣었더니 오히려 나빠짐”:
    • (a) reranker가 cross-encoder가 아니라 bi-encoder(임베딩 모델)를 잘못 쓴 경우
    • (b) 언어/도메인 mismatch(특히 비영어, 사내 용어)
    • (c) 후보 풀이 너무 작아 rerank가 의미 없음
      현업에서도 이런 케이스가 꽤 보고됩니다. (reddit.com)
  • rerank 입력 텍스트를 무제한으로 넣기: 토큰/지연/VRAM이 바로 터집니다. 상위 chunk의 앞부분만, 혹은 “query 주변 window”만 잘라 넣는 게 비용 대비 효율이 좋습니다.

비용/성능/안정성 트레이드오프

  • Query Expansion/HyDE는 LLM 호출 비용 + 지연이 추가되지만, retriever가 약한 구간에서 recall을 올리는 데 강합니다.
  • Cross-encoder rerank는 후보 K에 선형으로 비용이 늘어납니다(대개 K=100~200에서 타협). “항상 최고 모델”이 아니라, 트래픽 구간별로 MiniLM급(저가) vs BGE급(고품질) 이원화도 흔합니다. (presenc.ai)

🚀 마무리

정리하면, 2026년 6월 시점의 실전형 RAG 성능 최적화는 “한 방”이 아니라 스택 설계입니다.

  • Recall이 문제면: Query Expansion + (필요 시) HyDE로 후보 풀을 키우고 (ocadefusion.fr)
  • Precision이 문제면: cross-encoder reranking을 2nd stage로 고정하고, K/top-n/텍스트 길이로 비용을 제어 (presenc.ai)
  • HyDE는 강력하지만, 강한 baseline 위에서는 게이팅이 도입 판단의 핵심 (callsphere.ai)

다음 학습/실험 추천:

  • 내 데이터셋에서 “정답 문서가 top-200 안에 있나?”(oracle recall@200) 먼저 재고, 그 다음 rerank/HyDE/expansion을 단계적으로 추가하세요.
  • retriever–reranker 조합을 통제 실험으로 비교한 2026년 평가 연구(예: retriever/ reranker 페어링 비교)를 읽고, 본인 도메인에 맞게 K, fusion, reranker 모델 크기를 튜닝하는 게 가장 빠릅니다. (link.springer.com)