RAG 성능 고도화 — HyDE, 가상의 답을 지어내 검색하기
이 글은 RAG 검색 정확도를 끌어올리는 'HyDE(가상 문서 임베딩)'를 탐구하며 정리한 기록이다. 책과 Gemini의 도움을 빌려 궁금증을 해소해가며 쌓았다.
질문을 그대로 검색하지 말고, '답변'을 먼저 지어내서 검색한다
앞 글에서 다중 질의로 질문을 여러 갈래로 늘려 검색망을 넓히는 방법을 정리했는데, 같은 '질의 변형' 계열인데도 발상이 완전히 다른 기법이 하나 더 있다. HyDE(Hypothetical Document Embeddings), 우리말로 옮기면 '가상 문서 임베딩'이다. 질문을 더 잘 다듬어서 검색하는 게 아니라, 아예 그 질문에 대한 가상의 답변을 LLM에게 먼저 지어내게 한 다음, 그 답변으로 DB를 서칭하는 방식이다.
처음 이 아이디어를 봤을 때는 좀 이상하게 들렸다. 답을 모르니까 검색하는 건데, 답을 먼저 만든다니 앞뒤가 안 맞는 것 같았다. 그런데 곱씹어보면 꽤 영리한 우회다. 핵심은 질문과 답변은 생긴 게 다르다는 점에 있다. 우리가 찾으려는 대상 문서는 결국 '답변에 가까운 글'인데, 정작 검색에 넣는 사용자의 질문은 형태가 답변과 딴판이다. 짧고 막연한 의문문 한 줄로 길고 구체적인 설명문 더미를 찾으려니 임베딩 공간에서 거리가 멀 수밖에 없다. HyDE는 이 어긋남을 정면으로 공략한다. 질문보다 답변이 대상 문서와 훨씬 닮았으니, 차라리 그럴듯한 가짜 답변을 만들어 그걸로 검색하면 진짜 정답 문서와 더 가까이 붙는다는 직관이다. 이 발상은 Luyu Gao 등이 쓴 논문 Precise Zero-Shot Dense Retrieval without Relevance Labels(2022년 12월 arXiv, ACL 2023 발표)에서 제안됐다.
전체 흐름은 다음과 같다.
flowchart TD
A["사용자 질문 입력"] --> B["LLM이 가상의 답변<br/>(Hypothetical Document) 생성"]
B --> C["가상 답변 임베딩"]
C --> D["벡터 DB에서 유사 문서 검색<br/>(질문이 아닌 가상 답변으로 검색)"]
D --> E["검색된 문서 + 원래 질문을<br/>LLM에 전달"]
E --> F["최종 답변 생성"]
주식 질문 하나로 따라가 보는 HyDE
말로만 들으면 감이 안 오니 원문에서 든 예시를 그대로 따라가 보자. 사용자가 "주식시장 변동성이 높을 때 투자 전략은 무엇인가요?"라고 물었다고 하자. 이걸 단순 키워드 검색에 그대로 넣으면, 시스템이 건질 수 있는 단서는 '주식시장', '변동성', '투자전략' 정도가 전부다. 너무 앙상해서 정작 관련성 낮은 문서가 딸려 올 여지가 크다. 질문 문장에 들어 있는 키워드 자체가 빈약한 게 문제다.
여기서 HyDE는 검색에 들어가기 전에 LLM에게 이 질문의 가상 답변을 먼저 받아낸다. 가령 "주식시장의 변동성이 높을 때는 분산 투자, 달러 코스트 애버리징, 안전 자산 비중 확대 등의 전략을 세울 수 있다" 정도의 답변이 나온다. 이게 사실인지 아닌지는 중요하지 않다. 어차피 사용자에게 보여줄 최종 답이 아니라 검색용 미끼니까. 중요한 건 이 가상 답변에 '분산 투자', '달러 코스트 애버리징', '안전 자산', '비중 확대'처럼 원래 질문에는 없던 풍부한 키워드가 잔뜩 박혀 있다는 점이다. 질문 한 줄을 던졌을 때와 이 가상 답변을 던졌을 때, 검색에 쓰이는 단서의 밀도가 확 차이 난다. 이 풍부해진 단서로 DB를 뒤지면 훨씬 정확하고 관련성 높은 문서를 찾아낼 가능성이 크게 올라간다.
쉽고 빠른 대신, 공짜는 아니다
HyDE의 가장 큰 매력은 적용이 쉽고 빠르다는 데 있다. 기존 검색 파이프라인 앞에 '가상 답변 생성' 단계 하나만 끼워 넣으면 끝이라, 적은 손으로 검색 품질을 끌어올릴 수 있다. 앞 글의 다중 질의가 그랬듯, 이 계열 기법들은 대체로 투입 대비 효과가 좋아서 먼저 손대볼 만하다.
다만 공짜는 아니다. 검색을 한 번 돌리자고 LLM을 한 번 더 호출해 가상 답변을 만들어야 하니, 그만큼 비용이 추가되고 응답 시간도 늘어난다. 결국 다중 질의 때와 똑같은 저울질이다. 가상 답변을 만드는 리소스와 지연을 내주고, 그 대가로 검색 적중률을 사는 거래다. 질문과 답변의 생김새가 크게 어긋나는 도메인일수록 이 거래가 남는 장사라고 판단했다.
실습 예제
코드는 개념 자체가 한눈에 들어오도록 라이브러리 없이 순수 파이썬으로, 도메인도 주식 대신 '건강 상담'으로 바꿔 새로 짰다. 질문을 그대로 검색했을 때와 가상 답변으로 검색했을 때를 나란히 찍어, 차이를 눈으로 확인하게 했다.
"""
HyDE (Hypothetical Document Embeddings) 실습 예제
도메인: 건강/증상 상담
핵심 교육 포인트: 같은 질문을 (A) 그대로 검색 vs (B) 가상답변으로 검색 비교
"""
import math
import re
from collections import Counter
# ─────────────────────────────────────────────
# 1. 간이 문서 DB (건강·증상 관련 지식 조각)
# ─────────────────────────────────────────────
DOCUMENTS = [
{"id": 1, "content": "충분한 수분 섭취는 두통 완화에 효과적이다. 성인은 하루 약 2리터의 물을 마셔야 한다."},
{"id": 2, "content": "긴장성 두통은 목·어깨 근육의 과도한 수축으로 발생한다. 스트레칭과 온찜질이 도움이 된다."},
{"id": 3, "content": "편두통은 박동성 통증, 빛·소리 과민, 구역감을 동반하는 경우가 많다. 어두운 방에서 안정을 취하면 증상이 완화된다."},
{"id": 4, "content": "마그네슘 결핍은 두통과 근육 경련을 유발할 수 있다. 견과류, 통곡물, 녹색 채소를 통해 보충할 수 있다."},
{"id": 5, "content": "과음 후 두통(숙취 두통)은 알코올의 이뇨 작용으로 인한 탈수가 주요 원인이다. 전해질 음료 섭취가 권장된다."},
{"id": 6, "content": "만성 두통이 주 3회 이상 지속되면 신경과 전문의 진료가 필요하다. CT나 MRI 검사로 기질적 원인을 배제한다."},
{"id": 7, "content": "카페인 금단 증상으로 두통이 나타날 수 있다. 평소 커피를 즐기다가 갑자기 중단하면 24~48시간 내에 두통이 발생한다."},
{"id": 8, "content": "혈압이 갑자기 상승하면 후두부에 두통이 발생할 수 있다. 심한 두통과 함께 시야 이상·구토가 있으면 즉시 응급실을 방문해야 한다."},
{"id": 9, "content": "수면 부족은 두통의 흔한 원인이다. 규칙적인 수면 패턴을 유지하고 하루 7~9시간을 자는 것이 권장된다."},
{"id": 10, "content": "목 디스크(경추 추간판 탈출증)는 후두부와 어깨·팔로 방사되는 통증을 유발할 수 있다. X-ray 또는 MRI로 진단한다."},
]
# ─────────────────────────────────────────────
# 2. 간이 TF-IDF 유사도 계산 (외부 라이브러리 없음)
# 실제 RAG에서는 임베딩 벡터의 코사인 유사도를 쓴다.
# ─────────────────────────────────────────────
def tokenize(text: str) -> list[str]:
"""한국어·영문 단어 단위 토큰화 (공백·구두점 기준)"""
return re.findall(r"[가-힣a-zA-Z0-9]+", text.lower())
def build_idf(docs: list[dict]) -> dict[str, float]:
"""역문서빈도(IDF) 계산"""
n = len(docs)
df: Counter = Counter()
for doc in docs:
df.update(set(tokenize(doc["content"])))
return {term: math.log((n + 1) / (count + 1)) + 1 for term, count in df.items()}
def tf_idf_vector(text: str, idf: dict) -> dict[str, float]:
"""텍스트의 TF-IDF 벡터 반환"""
tokens = tokenize(text)
if not tokens:
return {}
tf = Counter(tokens)
total = len(tokens)
return {term: (count / total) * idf.get(term, 0) for term, count in tf.items()}
def cosine_similarity(vec_a: dict, vec_b: dict) -> float:
"""두 TF-IDF 벡터의 코사인 유사도"""
common = set(vec_a) & set(vec_b)
if not common:
return 0.0
dot = sum(vec_a[t] * vec_b[t] for t in common)
norm_a = math.sqrt(sum(v ** 2 for v in vec_a.values()))
norm_b = math.sqrt(sum(v ** 2 for v in vec_b.values()))
if norm_a == 0 or norm_b == 0:
return 0.0
return dot / (norm_a * norm_b)
def search(query: str, idf: dict, top_k: int = 3) -> list[dict]:
"""쿼리와 가장 유사한 문서 top_k개 반환"""
q_vec = tf_idf_vector(query, idf)
scored = []
for doc in DOCUMENTS:
d_vec = tf_idf_vector(doc["content"], idf)
scored.append({"id": doc["id"], "score": cosine_similarity(q_vec, d_vec), "content": doc["content"]})
scored.sort(key=lambda x: x["score"], reverse=True)
return scored[:top_k]
# ─────────────────────────────────────────────
# 3. HyDE 핵심: 가상답변 생성기
# [실제로는 LLM(ChatGPT·Claude 등)이 생성]
# 여기서는 규칙 기반 템플릿으로 흉내 낸다.
# ─────────────────────────────────────────────
HYPOTHETICAL_TEMPLATES = {
"두통": (
"두통이 자주 발생할 때는 먼저 수분 섭취가 충분한지 확인해야 한다. "
"긴장성 두통이라면 목·어깨 스트레칭과 온찜질이 도움이 되며, "
"편두통은 어두운 방에서 안정을 취하는 것이 권장된다. "
"마그네슘 결핍이 원인일 수 있으므로 견과류·녹색 채소 섭취도 고려한다. "
"수면 부족이나 카페인 금단도 흔한 원인이며, 만성화되면 신경과 진료가 필요하다."
),
}
def generate_hypothetical_answer(question: str) -> str:
"""
[실제로는 LLM이 생성]
질문 키워드를 기반으로 가상의 전문가 답변을 생성한다.
실제 서비스에서는 ChatGPT·Claude 등 LLM API 호출로 대체한다.
"""
for keyword, template in HYPOTHETICAL_TEMPLATES.items():
if keyword in question:
return template
return question # 매칭 실패 시 질문을 그대로 반환 (폴백)
# ─────────────────────────────────────────────
# 4. (A) vs (B) 비교 실행
# ─────────────────────────────────────────────
def print_results(label: str, query_used: str, results: list[dict]) -> None:
print(f"\n{'='*60}")
print(f" {label}")
shown = query_used if len(query_used) <= 60 else query_used[:60] + "..."
print(f" 검색에 사용된 텍스트: \"{shown}\"")
print(f"{'='*60}")
for rank, r in enumerate(results, start=1):
print(f" [{rank}위 | 유사도={r['score']:.4f}] {r['content'][:50]}...")
def main():
idf = build_idf(DOCUMENTS)
user_question = "두통이 자꾸 생겨요"
print("\n" + "★" * 60)
print(" HyDE 비교 데모: 건강/증상 상담 도메인")
print(" 사용자 질문:", user_question)
print("★" * 60)
# (A) 질문 그대로 검색
results_a = search(user_question, idf, top_k=3)
print_results("(A) 질문 원문으로 직접 검색", user_question, results_a)
# (B) 가상답변 생성 후 그 답변으로 검색
hypothetical_answer = generate_hypothetical_answer(user_question)
results_b = search(hypothetical_answer, idf, top_k=3)
print_results("(B) HyDE — 가상답변을 검색 쿼리로 사용", hypothetical_answer, results_b)
# 비교 요약
print("\n" + "-" * 60)
print(" [비교 요약]")
print(f" (A) 검색된 문서 ID: {[r['id'] for r in results_a]}")
print(f" (B) 검색된 문서 ID: {[r['id'] for r in results_b]}")
avg_a = sum(r["score"] for r in results_a) / len(results_a)
avg_b = sum(r["score"] for r in results_b) / len(results_b)
print(f" (A) 평균 유사도: {avg_a:.4f}")
print(f" (B) 평균 유사도: {avg_b:.4f}")
if avg_b > avg_a:
print(" → HyDE(B)가 더 높은 평균 유사도로 관련 문서를 더 잘 찾았습니다.")
else:
print(" → 결과 동일 또는 (A) 우위. 가상답변 품질 개선이 필요합니다.")
print("-" * 60)
if __name__ == "__main__":
main()
이렇게 (A)와 (B)를 나란히 돌려보면 HyDE의 값어치가 '질문과 답변 사이의 거리'를 좁히는 데서 나온다는 게 눈에 들어온다. 앞 글의 다중 질의가 질문을 여러 개로 늘리는 길이었다면, HyDE는 질문을 답의 모습으로 바꿔치기하는 길이다. 방향은 달라도 둘 다 '검색 앞단에서 LLM에게 일을 한 번 더 시켜, 검색을 쉽게 만든다'는 같은 줄기에 있다.
[참고자료]
RAG 마스터 레시피 - 브라이스 유 , 조경아 , 박수진 , 김재웅
https://product.kyobobook.co.kr/detail/S000216240484
테디노트의 랭체인을 활용한 RAG 비법노트
'AI Engineering > RAG' 카테고리의 다른 글
| RAG 성능 고도화2 — 질의 변형과 다중 질의 생성 (1) | 2026.06.21 |
|---|---|
| RAG 성능 고도화1 — 청킹 전략 깊게 파기 (0) | 2026.06.21 |