포스트

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

HyDE + Reranking + Query Expansion: 2026년 6월 기준 “진짜” 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) 의존성/실행 준비

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
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)
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.