포스트

HyDE + Reranking + Query Expansion: RAG 성능을 “끝까지” 끌어올리는 검색 스택 설계 가이드

2026년 8월 기준, 실무에서 성능을 확 올리는 조합은 보통 (1) Query Expansion(다중 쿼리/rewriting)로 recall을 올리고 → (2) Fusion(RRF)로 결과를 안정적으로 합치고 → (3) Cross-Encoder Reranking으로 precision을…

HyDE + Reranking + Query Expansion: RAG 성능을 “끝까지” 끌어올리는 검색 스택 설계 가이드

들어가며

RAG를 운영해보면 결국 같은 벽에 부딪힙니다.

  • Recall 부족: “정답이 인덱스에 있는데” 못 찾는다.
  • Precision 부족: 찾긴 찾는데 “그럴듯한” 문서가 섞여 LLM이 헛소리를 만든다.
  • Query-Document style gap: 유저 질문은 짧고 추상적인데, 문서는 길고 구체적이라 dense embedding만으로는 매칭이 자주 빗나간다(HyDE가 겨냥하는 핵심 문제).1

2026년 8월 기준, 실무에서 성능을 확 올리는 조합은 보통 (1) Query Expansion(다중 쿼리/rewriting)로 recall을 올리고 → (2) Fusion(RRF)로 결과를 안정적으로 합치고 → (3) Cross-Encoder Reranking으로 precision을 끌어올리는 3단 스택입니다. SemEval-2026 RAG 계열 시스템들이 비슷한 파이프라인(쿼리 재작성 → 하이브리드+fusion → cross-encoder rerank)을 반복해서 채택한 것도 같은 이유입니다.2

언제 쓰면 좋은가

  • 질문이 짧거나 모호하고(“권한 에러 해결”), 문서가 길고 구조화된 경우(사내 위키/런북/PRD/로그 가이드)
  • “정답 문서가 있는데 못 찾는” 케이스가 자주 발생하고, LLM 프롬프트만 만져서는 개선이 안 되는 경우
  • 비용/지연을 일부 감수하더라도 정답률이 KPI인 경우(고객지원/내부 DevOps Assistant)

언제 쓰면 안 되는가

  • 초저지연(예: p95 < 200ms)이 절대조건인데, reranker(특히 cross-encoder)까지 넣기 어려운 경우
  • 코퍼스가 작고(수천 문서 이하), 단순 BM25나 기본 dense로도 충분히 맞는 경우
  • 장문 생성(HyDE)이 규정상/보안상 어렵거나, 질문이 민감해서 LLM 호출 자체가 제한적인 경우(이때는 HyDE 대신 rule-based query rewrite나 domain thesaurus가 더 현실적)

🔧 핵심 개념

1) HyDE (Hypothetical Document Embeddings)

정의: 유저 query를 그대로 임베딩하지 않고, LLM에게 “이 질문에 답하는 그럴듯한 문서(가짜 문서)”를 생성하게 한 뒤, 그 가짜 문서를 임베딩해서 retrieval query로 쓰는 기법입니다.1

왜 먹히나(구조/흐름)
일반 dense retrieval은 embed(query)embed(chunk)를 비교합니다. 문제는 query가 짧을수록 embedding이 “의도”만 담고, chunk embedding은 “내용”을 담아서 벡터 공간에서 직접 맞붙으면 스타일/길이 차이로 손해를 봅니다. HyDE는 LLM이 query를 문서 스타일로 확장해 줌으로써 query → pseudo-doc → embedding으로 매칭 분포를 문서쪽에 더 가깝게 끌어옵니다(질문-문서 갭을 줄임).1

트레이드오프: 쿼리마다 LLM 호출이 추가되어 query-time 비용/지연 증가가 가장 큰 단점입니다(후속 연구들도 HyDE의 오버헤드를 계속 지적).3
그래서 2026년엔 “HyDE를 무조건”이 아니라, difficulty-based gating(어려운 질문일 때만 HyDE/다중쿼리 활성화) 같은 운영 패턴이 자주 언급됩니다.4

참고: 2026년 7월 말에는 HyDE의 query-time 오버헤드를 줄이려는 방향으로, 런타임 생성 대신 미리 프리컴퓨트해 retrieval을 question-question 형태로 바꾸는 접근(HyPE)이 제안되었습니다. 즉, HyDE 계열은 “갭을 줄이되 비용을 줄이는” 방향으로 진화 중입니다.3

2) Query Expansion (Multi-query / Query Rewriting)

정의: 동일 의도의 질문을 여러 표현으로 생성해 각각 retrieval하고 합치는 방식입니다. 예: “timeout 해결” → “read timeout”, “connection timeout”, “nginx upstream timeout”…

내부 작동:

  • LLM 또는 규칙 기반으로 N개의 쿼리 변형 생성
  • 각 쿼리로 topK 검색
  • 결과를 fusion으로 합친 뒤 rerank

이 패턴은 RAG-Fusion(다중 쿼리 + RRF) 계열에서 “recall을 올리되 안정적으로 합치는” 방법으로 반복적으로 다뤄집니다.5

3) Fusion (RRF: Reciprocal Rank Fusion)

정의: 서로 다른 검색기/쿼리에서 나온 랭킹 리스트를 스코어 스케일 정규화 없이 합치는 랭크 기반 방법입니다.6
대표 공식은 다음 형태입니다:

  • RRF(d) = Σ_i w_i / (k + rank_i(d))7

왜 중요한가: BM25 점수, cosine 유사도 점수, 다른 모델 점수는 스케일이 달라 단순 합이 위험합니다. RRF는 “순위”만 쓰므로 스케일 문제를 회피하고, 다중 쿼리/하이브리드(희소+밀집) 조합에서 특히 안정적입니다.7

4) Reranking (Cross-Encoder / ColBERT 류)

정의: 1차 검색(topK) 결과에 대해, query와 passage를 함께 넣고 정밀한 relevance score로 재정렬합니다. SemEval-2026에서도 cross-encoder reranking(BGE reranker 계열)을 파이프라인 후단에 붙이는 구성이 반복됩니다.2

  • Cross-Encoder 장점: precision 상승, “lost in the middle” 완화(상위 문서의 진짜 관련도 정렬)
  • 단점: 비용/지연(topK에 비례)

2026년 실무/가이드 문서에서도 bge-reranker-v2-m3 같은 경량 cross-encoder reranker가 “쉽게 배치 가능한 기본 선택지”로 자주 언급됩니다.8


💻 실전 코드

아래는 현실적인 시나리오(사내 런북/장애 대응 문서 RAG) 기준입니다.

  • 코퍼스: Markdown/PDF에서 추출된 “장애 코드/증상/원인/조치” 문서
  • 목표: 짧고 애매한 질문에도 조치(runbook) 문서를 안정적으로 상위로 올리기
  • 구성: Hybrid(BM25 + Dense) → (선택) HyDE/Query Expansion → RRF Fusion → Cross-Encoder Rerank → 상위 문서 컨텍스트로 생성

주의: 아래 코드는 “toy 데이터 5줄”이 아니라, 실제 서비스에서 흔한 구성요소(벡터DB, BM25, reranker, fusion, gating)를 한 파일에서 재현 가능하게 구성했습니다. 모델/DB는 환경에 맞게 바꿔 끼우면 됩니다.

0) 설치/환경

1
2
pip install -U qdrant-client sentence-transformers transformers torch rank-bm25 numpy
# 선택: OpenAI/사내 LLM을 HyDE에 쓰려면 해당 SDK 추가

1) 인덱싱(오프라인) + 하이브리드 검색기 준비

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
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
# rag_advanced.py
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Dict, Tuple
import os, re
import numpy as np

from qdrant_client import QdrantClient
from qdrant_client.http import models as qm

from sentence_transformers import SentenceTransformer
from rank_bm25 import BM25Okapi

# -----------------------------
# 데이터 구조
# -----------------------------
@dataclass
class Chunk:
    id: str
    title: str
    text: str
    source: str  # url/path
    tags: List[str]

def simple_tokenize(s: str) -> List[str]:
    return re.findall(r"[A-Za-z0-9_./:-]+|[가-힣]+", s.lower())

# -----------------------------
# 벡터 인덱스(Qdrant) + BM25
# -----------------------------
class HybridIndex:
    def __init__(self, qdrant_url: str = "http://localhost:6333", collection: str = "runbooks"):
        self.client = QdrantClient(url=qdrant_url)
        self.collection = collection
        self.embedder = SentenceTransformer("BAAI/bge-m3")  # 다국어/범용 dense 임베딩 계열로 자주 사용
        self.chunks: List[Chunk] = []
        self.bm25 = None
        self.bm25_corpus_tokens = []

    def build(self, chunks: List[Chunk]):
        self.chunks = chunks
        # BM25 준비
        self.bm25_corpus_tokens = [simple_tokenize(c.title + "\n" + c.text) for c in chunks]
        self.bm25 = BM25Okapi(self.bm25_corpus_tokens)

        # Qdrant 컬렉션 생성
        dim = self.embedder.get_sentence_embedding_dimension()
        self.client.recreate_collection(
            collection_name=self.collection,
            vectors_config=qm.VectorParams(size=dim, distance=qm.Distance.COSINE),
        )

        # Dense 임베딩 업로드
        texts = [c.title + "\n" + c.text for c in chunks]
        vecs = self.embedder.encode(texts, normalize_embeddings=True, batch_size=64)
        points = [
            qm.PointStruct(
                id=i,
                vector=vecs[i].tolist(),
                payload={
                    "chunk_id": chunks[i].id,
                    "title": chunks[i].title,
                    "text": chunks[i].text,
                    "source": chunks[i].source,
                    "tags": chunks[i].tags,
                },
            )
            for i in range(len(chunks))
        ]
        self.client.upsert(collection_name=self.collection, points=points)

    def dense_search(self, query: str, top_k: int = 30) -> List[Tuple[int, float]]:
        qv = self.embedder.encode([query], normalize_embeddings=True)[0].tolist()
        hits = self.client.search(collection_name=self.collection, query_vector=qv, limit=top_k)
        return [(h.id, float(h.score)) for h in hits]

    def bm25_search(self, query: str, top_k: int = 30) -> List[Tuple[int, float]]:
        q_tokens = simple_tokenize(query)
        scores = self.bm25.get_scores(q_tokens)
        idx = np.argsort(scores)[::-1][:top_k]
        return [(int(i), float(scores[i])) for i in idx]

    def get_payload(self, internal_id: int) -> Dict:
        # Qdrant에서 payload 조회 (간단히 search로 대체하지 않고, 여기선 local chunks로)
        c = self.chunks[internal_id]
        return {"chunk_id": c.id, "title": c.title, "text": c.text, "source": c.source, "tags": c.tags}

# -----------------------------
# RRF Fusion
# -----------------------------
def rrf_fuse(rank_lists: List[List[int]], k: int = 60, weights: List[float] | None = None) -> Dict[int, float]:
    # RRF(d) = Σ_i w_i / (k + rank_i(d))
    if weights is None:
        weights = [1.0] * len(rank_lists)
    scores: Dict[int, float] = {}
    for w, lst in zip(weights, rank_lists):
        for r, doc_id in enumerate(lst, start=1):
            scores[doc_id] = scores.get(doc_id, 0.0) + w / (k + r)
    return scores

# -----------------------------
# Cross-Encoder Reranker (실행 가능)
# -----------------------------
from transformers import AutoTokenizer, AutoModelForSequenceClassification
import torch

class CrossEncoderReranker:
    def __init__(self, model_name: str = "BAAI/bge-reranker-v2-m3", device: str | None = None):
        self.tokenizer = AutoTokenizer.from_pretrained(model_name)
        self.model = AutoModelForSequenceClassification.from_pretrained(model_name)
        self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
        self.model.to(self.device)
        self.model.eval()

    @torch.inference_mode()
    def rerank(self, query: str, docs: List[Dict], top_k: int = 8) -> List[Tuple[Dict, float]]:
        pairs = [(query, d["title"] + "\n" + d["text"]) for d in docs]
        inputs = self.tokenizer(
            pairs, padding=True, truncation=True, max_length=512, return_tensors="pt"
        ).to(self.device)
        logits = self.model(**inputs).logits.squeeze(-1)
        scores = logits.detach().cpu().numpy().tolist()

        ranked = sorted(zip(docs, scores), key=lambda x: x[1], reverse=True)[:top_k]
        return ranked

# -----------------------------
# (선택) HyDE / Query Expansion (여기선 "자리"를 명확히)
# -----------------------------
def should_expand(query: str) -> bool:
    # 운영에서 흔한 게이트: 짧거나(<=6 토큰), 에러코드/모호 키워드가 있으면 확장
    toks = simple_tokenize(query)
    if len(toks) <= 6:
        return True
    if re.search(r"\b(5\d\d|4\d\d)\b", query) or "timeout" in query.lower() or "permission" in query.lower():
        return True
    return False

def expand_queries_stub(query: str) -> List[str]:
    """
    실제로는 여기서:
    - LLM으로 multi-query 생성(RAG-Fusion 스타일)
    - 또는 HyDE: hypothetical doc 생성 후 그것을 '확장 쿼리'처럼 사용
    을 합니다.

    이 예제는 실행 가능성을 위해 rule-based 확장을 넣었습니다.
    (프로덕션에서는 사내 LLM / OpenAI / Vertex 등으로 교체)
    """
    base = query.strip()
    variants = [base]

    q = base.lower()
    if "timeout" in q:
        variants += [base + " read timeout", base + " connect timeout", base + " upstream timeout nginx"]
    if "permission" in q or "권한" in base:
        variants += [base + " 403", base + " RBAC", base + " IAM policy"]
    # 중복 제거
    seen, out = set(), []
    for v in variants:
        if v not in seen:
            seen.add(v); out.append(v)
    return out[:5]

# -----------------------------
# End-to-end 검색
# -----------------------------
def retrieve(index: HybridIndex, reranker: CrossEncoderReranker, query: str) -> List[Tuple[Dict, float]]:
    queries = expand_queries_stub(query) if should_expand(query) else [query]

    # 각 쿼리마다 hybrid(BM25 + Dense) 수행
    fused_rank_lists = []
    for q in queries:
        dense_ids = [doc_id for doc_id, _ in index.dense_search(q, top_k=30)]
        bm25_ids  = [doc_id for doc_id, _ in index.bm25_search(q, top_k=30)]
        # 쿼리 내부에서도 fusion (dense + bm25)
        scores = rrf_fuse([dense_ids, bm25_ids], k=60, weights=[1.0, 1.0])
        ranked = [doc_id for doc_id, _ in sorted(scores.items(), key=lambda x: x[1], reverse=True)[:30]]
        fused_rank_lists.append(ranked)

    # 쿼리 간 fusion (multi-query)
    multi_scores = rrf_fuse(fused_rank_lists, k=60)
    pre_candidates = [doc_id for doc_id, _ in sorted(multi_scores.items(), key=lambda x: x[1], reverse=True)[:30]]

    # payload 구성 후 rerank
    docs = [index.get_payload(i) for i in pre_candidates]
    reranked = reranker.rerank(query=query, docs=docs, top_k=8)
    return reranked

# -----------------------------
# 실행 예시
# -----------------------------
if __name__ == "__main__":
    # 현실적인 runbook chunk 예시(실제에선 사내 위키/Confluence/PDF에서 추출)
    chunks = [
        Chunk(
            id="rbk-101",
            title="Nginx upstream timeout(504) 대응 런북",
            text="증상: 504 Gateway Timeout...\n원인: upstream 서비스 read timeout...\n조치: proxy_read_timeout 조정, upstream 헬스체크...",
            source="wiki://runbooks/nginx-504",
            tags=["nginx","timeout","http-504"],
        ),
        Chunk(
            id="rbk-203",
            title="Kubernetes RBAC 권한 오류(403) 트러블슈팅",
            text="증상: Forbidden...\n원인: RoleBinding 누락...\n조치: kubectl auth can-i, 필요한 Role/ClusterRole 바인딩...",
            source="wiki://runbooks/k8s-rbac-403",
            tags=["k8s","rbac","permission"],
        ),
        # ... 수천개 chunk가 있다고 가정
    ]

    index = HybridIndex()
    index.build(chunks)

    reranker = CrossEncoderReranker()

    q = "nginx 504 timeout 해결"
    results = retrieve(index, reranker, q)

    print(f"QUERY: {q}\n")
    for i, (doc, score) in enumerate(results, 1):
        print(f"{i}. rerank_score={score:.4f}  title={doc['title']}  source={doc['source']}")

예상 출력(예)

1
2
3
4
QUERY: nginx 504 timeout 해결

1. rerank_score=7.8123  title=Nginx upstream timeout(504) 대응 런북  source=wiki://runbooks/nginx-504
2. rerank_score=1.0345  title=Kubernetes RBAC 권한 오류(403) 트러블슈팅  source=wiki://runbooks/k8s-rbac-403

이 예제의 포인트는 “BM25 + Dense로 후보를 넓게 만들고(RRF로 스케일 이슈 회피) → Cross-Encoder가 최종 정렬”입니다. 이런 retrieve-then-rerank 구조는 실제 평가/대회/가이드에서도 반복 등장합니다.2


⚡ 실전 팁 & 함정

Best Practice 1) HyDE/Query Expansion은 “항상”이 아니라 “게이트”로 운영

HyDE/다중쿼리는 recall을 올려주지만 query-time 비용이 큽니다(연산/지연/토큰비). HyDE의 오버헤드 문제는 2026년 연구에서도 계속 지적되며, 이를 줄이려는 HyPE 같은 변형도 등장했습니다.3
따라서 실무에선:

  • 짧은 질문, 에러코드, 낮은 1차 retrieval confidence 때만 확장
  • 나머지는 기본 hybrid + rerank만

Best Practice 2) Fusion은 점수 합이 아니라 RRF로(특히 hybrid/멀티쿼리)

BM25 점수와 dense cosine 점수는 분포가 다릅니다. RRF는 랭크 기반이라 스케일 이슈 없이 합치기 좋고, 다중 시스템/쿼리 결합에서 검증된 고전적 기법입니다.6
파라미터 k는 상위 랭크에 더 가중을 주는 정도를 조절합니다(일반적으로 60 근처가 자주 언급/사용).

Best Practice 3) Reranker 예산(topK)은 “정답률 곡선” 보고 결정

Cross-encoder rerank는 topK에 비례해 비용이 증가합니다. 많은 팀이 retrieve top 50~200 → rerank top 20~50 → context top 5~10 같이 예산을 쪼갭니다. SemEval-2026 계열에서도 reranking stage를 별도로 두고(즉, 후보군을 먼저 좁힌 다음 정밀 모델을 태움) 리소스를 아낍니다.2

흔한 함정/안티패턴

  • HyDE 텍스트를 “근거 문서”처럼 프롬프트에 넣기: HyDE는 검색을 위한 중간물일 뿐, 사실 근거가 아닙니다. (넣는 순간 환각이 “근거처럼” 강화될 수 있음)
  • reranker를 너무 앞단에 쓰기: 후보군이 넓은 상태에서 cross-encoder를 돌리면 비용 폭발. 반드시 1차 retrieval로 좁힌 뒤 rerank.
  • 다중쿼리 N을 무작정 늘리기: recall은 오르지만 중복/노이즈도 같이 늘고, RRF 상위권이 흔들릴 수 있습니다. N=3~5에서 먼저 수렴을 확인하세요(운영 벤치로 결정).

비용/성능/안정성 트레이드오프(현실 체크)

  • Query Expansion/HyDE: recall↑, 비용/지연↑, 노이즈↑(제어 필요)
  • RRF Fusion: 안정성↑, 구현 쉬움, 다만 “진짜 점수” 최적화는 아니라서 rerank가 사실상 마무리 역할
  • Cross-Encoder Rerank: precision↑ 가장 확실, 하지만 GPU/지연 예산이 핵심 제약
  • 최신 흐름: HyDE류의 장점을 유지하면서 query-time 생성비용을 줄이려는 방향(HyPE 등)3

🚀 마무리

정리하면, 2026년형 “성능 지향 RAG”는 보통 다음 순서로 설계하면 실패 확률이 확 줄어듭니다.

1) Hybrid retrieval(BM25 + dense)로 후보군을 넓히고
2) (필요할 때만) Query Expansion / HyDE로 recall을 추가로 끌어올린 뒤1
3) RRF Fusion으로 결과를 스케일 문제 없이 합치고7
4) Cross-Encoder reranker로 최종 precision을 “확정”한다(특히 bge-reranker-v2-m3 같은 경량 모델이 배치 난이도를 낮춤).8

도입 판단 기준(프로젝트 적용 체크리스트)

  • “정답 문서가 있는데 못 찾는” 비율이 높다 → Query Expansion/HyDE 우선
  • “찾긴 찾는데 상위에 이상한 문서가 섞인다” → Reranking 우선
  • hybrid/멀티쿼리를 섞을 계획이다 → Fusion은 RRF 기본값
  • p95 지연/비용이 빡빡하다 → HyDE는 게이트로, reranker topK 예산부터 튜닝

다음 학습 추천

  • HyDE 원 논문(질문-문서 갭을 어떻게 다루는지)1
  • RAG-Fusion/산업 적용에서의 fusion 교훈(멀티쿼리+RRF 운영 관점)5
  • HyDE의 오버헤드를 줄이려는 최신 변형(HyPE)3
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.