HyDE + Reranking + Query Expansion: 2026년형 RAG 성능을 “끝까지” 끌어올리는 검색 스택 설계 가이드
들어가며
RAG를 운영해보면 결국 같은 벽에 부딪힙니다.
- Recall 부족: “정답이 인덱스에 있는데” 못 찾는다.
- Precision 부족: 찾긴 찾는데 “그럴듯한” 문서가 섞여 LLM이 헛소리를 만든다.
- Query-Document style gap: 유저 질문은 짧고 추상적인데, 문서는 길고 구체적이라 dense embedding만으로는 매칭이 자주 빗나간다(HyDE가 겨냥하는 핵심 문제). (arxiv.org)
2026년 8월 기준, 실무에서 성능을 확 올리는 조합은 보통 (1) Query Expansion(다중 쿼리/rewriting)로 recall을 올리고 → (2) Fusion(RRF)로 결과를 안정적으로 합치고 → (3) Cross-Encoder Reranking으로 precision을 끌어올리는 3단 스택입니다. SemEval-2026 RAG 계열 시스템들이 비슷한 파이프라인(쿼리 재작성 → 하이브리드+fusion → cross-encoder rerank)을 반복해서 채택한 것도 같은 이유입니다. (arxiv.org)
언제 쓰면 좋은가
- 질문이 짧거나 모호하고(“권한 에러 해결”), 문서가 길고 구조화된 경우(사내 위키/런북/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로 쓰는 기법입니다. (arxiv.org)
왜 먹히나(구조/흐름)
일반 dense retrieval은 embed(query)와 embed(chunk)를 비교합니다. 문제는 query가 짧을수록 embedding이 “의도”만 담고, chunk embedding은 “내용”을 담아서 벡터 공간에서 직접 맞붙으면 스타일/길이 차이로 손해를 봅니다. HyDE는 LLM이 query를 문서 스타일로 확장해 줌으로써 query → pseudo-doc → embedding으로 매칭 분포를 문서쪽에 더 가깝게 끌어옵니다(질문-문서 갭을 줄임). (arxiv.org)
트레이드오프: 쿼리마다 LLM 호출이 추가되어 query-time 비용/지연 증가가 가장 큰 단점입니다(후속 연구들도 HyDE의 오버헤드를 계속 지적). (arxiv.org)
그래서 2026년엔 “HyDE를 무조건”이 아니라, difficulty-based gating(어려운 질문일 때만 HyDE/다중쿼리 활성화) 같은 운영 패턴이 자주 언급됩니다. (reddit.com)
참고: 2026년 7월 말에는 HyDE의 query-time 오버헤드를 줄이려는 방향으로, 런타임 생성 대신 미리 프리컴퓨트해 retrieval을 question-question 형태로 바꾸는 접근(HyPE)이 제안되었습니다. 즉, HyDE 계열은 “갭을 줄이되 비용을 줄이는” 방향으로 진화 중입니다. (arxiv.org)
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을 올리되 안정적으로 합치는” 방법으로 반복적으로 다뤄집니다. (arxiv.org)
3) Fusion (RRF: Reciprocal Rank Fusion)
정의: 서로 다른 검색기/쿼리에서 나온 랭킹 리스트를 스코어 스케일 정규화 없이 합치는 랭크 기반 방법입니다. (colab.ws)
대표 공식은 다음 형태입니다:
RRF(d) = Σ_i w_i / (k + rank_i(d))(arc-labs.ai)
왜 중요한가: BM25 점수, cosine 유사도 점수, 다른 모델 점수는 스케일이 달라 단순 합이 위험합니다. RRF는 “순위”만 쓰므로 스케일 문제를 회피하고, 다중 쿼리/하이브리드(희소+밀집) 조합에서 특히 안정적입니다. (arc-labs.ai)
4) Reranking (Cross-Encoder / ColBERT 류)
정의: 1차 검색(topK) 결과에 대해, query와 passage를 함께 넣고 정밀한 relevance score로 재정렬합니다. SemEval-2026에서도 cross-encoder reranking(BGE reranker 계열)을 파이프라인 후단에 붙이는 구성이 반복됩니다. (arxiv.org)
- Cross-Encoder 장점: precision 상승, “lost in the middle” 완화(상위 문서의 진짜 관련도 정렬)
- 단점: 비용/지연(topK에 비례)
2026년 실무/가이드 문서에서도 bge-reranker-v2-m3 같은 경량 cross-encoder reranker가 “쉽게 배치 가능한 기본 선택지”로 자주 언급됩니다. (huggingface.co)
💻 실전 코드
아래는 현실적인 시나리오(사내 런북/장애 대응 문서 RAG) 기준입니다.
- 코퍼스: Markdown/PDF에서 추출된 “장애 코드/증상/원인/조치” 문서
- 목표: 짧고 애매한 질문에도 조치(runbook) 문서를 안정적으로 상위로 올리기
- 구성: Hybrid(BM25 + Dense) → (선택) HyDE/Query Expansion → RRF Fusion → Cross-Encoder Rerank → 상위 문서 컨텍스트로 생성
주의: 아래 코드는 “toy 데이터 5줄”이 아니라, 실제 서비스에서 흔한 구성요소(벡터DB, BM25, reranker, fusion, gating)를 한 파일에서 재현 가능하게 구성했습니다. 모델/DB는 환경에 맞게 바꿔 끼우면 됩니다.
0) 설치/환경
pip install -U qdrant-client sentence-transformers transformers torch rank-bm25 numpy
# 선택: OpenAI/사내 LLM을 HyDE에 쓰려면 해당 SDK 추가
1) 인덱싱(오프라인) + 하이브리드 검색기 준비
# 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']}")
예상 출력(예)
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 구조는 실제 평가/대회/가이드에서도 반복 등장합니다. (arxiv.org)
⚡ 실전 팁 & 함정
Best Practice 1) HyDE/Query Expansion은 “항상”이 아니라 “게이트”로 운영
HyDE/다중쿼리는 recall을 올려주지만 query-time 비용이 큽니다(연산/지연/토큰비). HyDE의 오버헤드 문제는 2026년 연구에서도 계속 지적되며, 이를 줄이려는 HyPE 같은 변형도 등장했습니다. (arxiv.org)
따라서 실무에선:
- 짧은 질문, 에러코드, 낮은 1차 retrieval confidence 때만 확장
- 나머지는 기본 hybrid + rerank만
Best Practice 2) Fusion은 점수 합이 아니라 RRF로(특히 hybrid/멀티쿼리)
BM25 점수와 dense cosine 점수는 분포가 다릅니다. RRF는 랭크 기반이라 스케일 이슈 없이 합치기 좋고, 다중 시스템/쿼리 결합에서 검증된 고전적 기법입니다. (colab.ws)
파라미터 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를 별도로 두고(즉, 후보군을 먼저 좁힌 다음 정밀 모델을 태움) 리소스를 아낍니다. (arxiv.org)
흔한 함정/안티패턴
- 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 등) (arxiv.org)
🚀 마무리
정리하면, 2026년형 “성능 지향 RAG”는 보통 다음 순서로 설계하면 실패 확률이 확 줄어듭니다.
1) Hybrid retrieval(BM25 + dense)로 후보군을 넓히고
2) (필요할 때만) Query Expansion / HyDE로 recall을 추가로 끌어올린 뒤 (arxiv.org)
3) RRF Fusion으로 결과를 스케일 문제 없이 합치고 (arc-labs.ai)
4) Cross-Encoder reranker로 최종 precision을 “확정”한다(특히 bge-reranker-v2-m3 같은 경량 모델이 배치 난이도를 낮춤). (huggingface.co)
도입 판단 기준(프로젝트 적용 체크리스트)
- “정답 문서가 있는데 못 찾는” 비율이 높다 → Query Expansion/HyDE 우선
- “찾긴 찾는데 상위에 이상한 문서가 섞인다” → Reranking 우선
- hybrid/멀티쿼리를 섞을 계획이다 → Fusion은 RRF 기본값
- p95 지연/비용이 빡빡하다 → HyDE는 게이트로, reranker topK 예산부터 튜닝
다음 학습 추천
- HyDE 원 논문(질문-문서 갭을 어떻게 다루는지) (arxiv.org)
- RAG-Fusion/산업 적용에서의 fusion 교훈(멀티쿼리+RRF 운영 관점) (arxiv.org)
- HyDE의 오버헤드를 줄이려는 최신 변형(HyPE) (arxiv.org)
원하시면, 위 코드에 (A) 실제 HyDE LLM 호출(OpenAI/Vertex/사내 vLLM)을 붙이고, (B) 평가셋으로 “HyDE on/off, rerank on/off, topK sweep” 자동 벤치 스크립트까지 확장해 드릴게요.