[RAG Playground] Quest 6: Query Rewriting으로 검색 질의 똑똑하게 바꾸기
RAG Playground (9부작)
- 1
- 2
- 3
- 4
- 5
- 6
- 7 [RAG Playground] Quest 6: Query Rewriting으로 검색 질의 똑똑하게 바꾸기 (현재 글)
- 8
- 9
지난 Quest 5에서 Re-ranker를 붙이고 나서 검색 결과가 눈에 띄게 좋아지긴 하더라구요 “동래구 목욕탕” 같은 명확한 질의에는 relevance 0.99가 찍히는 걸 보니 확실히 이게 맞다 라고 생각했는데 문제는 사용자가 항상 명확하게 검색하지 않는다는 거죠
예를 들어 “이번 주말에 아이들이랑 갈 만한 데” 같은 질의는 벡터 검색이 뭔가를 찾기는 하겠지만, 정확히 무엇을 원하는지 파악하기 어렵습니다. Re-ranker가 아무리 좋아도, 검색 자체에 좋은 후보가 안 들어오면 소용이 없다는 거죠 🤔
이번 퀘스트에서는
Query Rewriting이 무엇인가???
질의 재작성은 말 그대로 사용자의 원본 질의를 검색에 더 유리한 형태로 바꾸는 기법입니다. 방향은 크게 두 가지예요.
HyDE (
Multi-Query — 같은 의도를 표현하는 대안 질의를 여러 개 생성합니다. 각 질의로 검색한 결과를 합쳐 중복을 제거하면, 원본 질의 하나로는 놓쳤을 문서들도 건져낼 수 있습니다.
[원본 질의] "해운대 근처 가족 외식"
HyDE: LLM이 가상 답변 문서 생성
→ "해운대구에 위치한 ○○ 레스토랑은 가족 단위 손님을 위한..."
→ 그 문서 임베딩으로 Dense 검색
Multi-Q: LLM이 대안 질의 3개 생성
→ "해운대구 가족 식당"
→ "해운대 아이와 함께 갈 수 있는 음식점"
→ "해운대 단체 외식 가능한 식당"
→ 4개 질의 각각 검색 → 중복 제거 → 통합 후보
핵심 원칙: 재작성된 질의는 검색(retrieval)에만 씁니다. 리랭킹과 LLM 답변 생성에는 원본 질의를 유지합니다. 재작성 질의로 답변을 생성하면 의도가 흐려질 수 있거든요 !
Step 1: Query Rewriter 어댑터 생성
기존 adapters/reranker/novita.py 패턴과 동일하게 adapters/query_rewriter/ 디렉토리를 추가했습니다.
src/rag_playground/
├── adapters/
│ ├── data_go_kr/
│ ├── llm/
│ ├── vectorstore/
│ ├── reranker/
│ └── query_rewriter/ ← 신규
│ ├── __init__.py
│ └── openai_rewriter.py
openai_rewriter.py에는 함수 두 개를 담았습니다. 창의성이 필요한 작업이니까 temperature=0.7 정도로 설정했습니다. 모델은 gpt-5.4-mini를 사용합니다. 이전까지는 gpt-4o-mini로 사용했는데 gpt-5.4-mini로 교체했습니다. 좋은 게 좋은거니까 ㅎㅎ
HyDE: 가상 문서 생성
OPENAI_API_KEY = os.environ["OPENAI_API_KEY"]
_client = openai.OpenAI(api_key=OPENAI_API_KEY)
def generate_hypothetical_document(query: str) -> str:
"""HyDE: 질의에 대한 가상의 답변 문서를 생성한다."""
prompt = (
"당신은 부산 가족사랑카드 가맹업체 정보를 작성하는 전문가입니다.\n"
"아래 질의에 대해, 실제 가맹업체 정보처럼 보이는 가상의 답변 문서를 한 문단으로 작성하세요.\n"
"업체명, 주소, 업종, 할인 혜택 등의 정보를 포함하되 자연스럽게 서술하세요.\n\n"
f"질의: {query}"
)
response = _client.chat.completions.create(
model="gpt-5.4-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0.7,
max_completion_tokens=300,
)
return response.choices[0].message.content.strip()
프롬프트에서 “부산 가족사랑카드 가맹업체” 형식을 명시한 게 포인트입니다. 도메인에 맞는 가상 문서를 생성해야 임베딩이 실제 데이터에 가까워지거든요 일반적인 가상 문서면 오히려 노이즈가 될 수 있다는 점
Multi-Query: 대안 질의 생성
def generate_multi_queries(query: str, n: int = 3) -> list[str]:
"""Multi-Query: 같은 의도를 표현하는 대안 질의 n개를 생성한다."""
prompt = (
f"다음 검색 질의와 동일한 의도를 가진 다른 표현의 검색 질의를 {n}개 생성하세요.\n"
"번호나 기호 없이, 각 질의를 한 줄씩 작성하세요.\n\n"
f"원본 질의: {query}"
)
response = _client.chat.completions.create(
model="gpt-5.4-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0.7,
max_completion_tokens=200,
)
raw = response.choices[0].message.content.strip()
return [line.strip() for line in raw.splitlines() if line.strip()]
“번호나 기호 없이, 각 질의를 한 줄씩”이라고 명시한 덕분에 파싱이 단순합니다. splitlines()로 줄 단위로 자르면 바로 리스트가 완성됩니다 👍
Step 2: search_hybrid 확장
HyDE는 재작성된 가상 문서의 임베딩을 Dense 검색에 써야 합니다. 하지만 BM25 Sparse 검색은 여전히 ! 당연히 ! 원본 질의 문자열로 해야 키워드 매칭이 유지됩니다.
기존 search_hybrid() 시그니처에 dense_query_vector 파라미터를 추가했습니다:
def search_hybrid(
query: str,
collection_name: str = HYBRID_COLLECTION_NAME,
n_results: int = 5,
prefetch_limit: int = 20,
dense_query_vector: list[float] | None = None, # 추가
) -> list[dict[str, Any]]:
"""Dense + BM25 하이브리드 검색을 RRF로 결합한다."""
# dense_query_vector가 주어지면 그걸 쓰고, 아니면 query를 임베딩
query_vector = (
dense_query_vector if dense_query_vector is not None
else embed_texts([query])[0]
)
results = client.query_points(
collection_name=collection_name,
prefetch=[
Prefetch(
query=query_vector, # Dense: 재작성 임베딩 or 원본 임베딩
using=DENSE_VECTOR_NAME,
limit=prefetch_limit,
),
Prefetch(
query=Document(text=query, model="qdrant/bm25", options=BM25_OPTIONS),
using=SPARSE_VECTOR_NAME, # BM25: 항상 원본 질의 문자열
limit=prefetch_limit,
),
],
query=FusionQuery(fusion=Fusion.RRF),
limit=n_results,
with_payload=True,
)
...
기존 호출부는 dense_query_vector를 전달하지 않으므로 동작 변경이 없습니다 — 하위 호환 유지 ✅
Step 3: Answer 오케스트레이터 추가
application/answer.py에 두 함수를 추가했습니다.
answer_query_hyde_rerank
원본 질의
→ generate_hypothetical_document() # GPT-5.4-mini로 가상 문서 생성
→ embed_texts([hypothetical_doc])[0] # 가상 문서 임베딩
→ search_hybrid(query, # BM25는 원본, Dense는 HyDE 벡터
dense_query_vector=hyde_vector,
n_results=20)
→ rerank_hits(query, hits, top_n=5) # 리랭킹: 원본 질의 기준
→ generate_answer(query, reranked) # 답변: 원본 질의 기준
def answer_query_hyde_rerank(
query: str,
collection_name: str,
n_results: int = 5,
) -> tuple[list[dict], str]:
"""HyDE + Hybrid 검색 → Re-rank 파이프라인."""
hypothetical_doc = generate_hypothetical_document(query)
hyde_vector = embed_texts([hypothetical_doc])[0]
hits = search_hybrid(
query,
collection_name=collection_name,
n_results=n_results * 4,
dense_query_vector=hyde_vector,
)
reranked = rerank_hits(query, hits, top_n=n_results)
answer = generate_answer(query, reranked)
return reranked, answer
answer_query_multi_rerank
원본 질의
→ generate_multi_queries(n=3) # 대안 질의 3개 생성
→ [원본 + 대안 3개] = 4개 질의
→ 각 질의별 search_hybrid(q, n_results=20) # 최대 80건 수집
→ document 텍스트 기준 중복 제거
→ rerank_hits(query, unique_hits, top_n=5) # 리랭킹: 원본 질의 기준
→ generate_answer(query, reranked) # 답변: 원본 질의 기준
def answer_query_multi_rerank(
query: str,
collection_name: str,
n_results: int = 5,
) -> tuple[list[dict], str]:
"""Multi-Query + Hybrid 검색 → Re-rank 파이프라인."""
alt_queries = generate_multi_queries(query, n=3)
all_queries = [query] + alt_queries # 원본 포함 총 4개
seen, unique_hits = set(), []
for q in all_queries:
hits = search_hybrid(q, collection_name=collection_name, n_results=20)
for hit in hits:
doc_text = hit["document"]
if doc_text not in seen:
seen.add(doc_text)
unique_hits.append(hit)
reranked = rerank_hits(query, unique_hits, top_n=n_results)
answer = generate_answer(query, reranked)
return reranked, answer
중복 제거는 document 텍스트를 키로 쓰는 seen set으로 처리합니다. 간단하지만 요정도면 충분합니다
Step 4: CLI & Compare 모드 추가
CLI 모드 5, 6 추가
SEARCH_MODES = {
"1": ("naive", "Naive RAG (벡터 검색)"),
"2": ("bm25", "BM25 (키워드 검색)"),
"3": ("hybrid", "Hybrid (벡터 + 키워드 RRF)"),
"4": ("rerank", "Hybrid + Re-rank (BGE-reranker-v2-m3)"),
"5": ("hyde_rerank", "HyDE + Hybrid + Re-rank"), # 신규
"6": ("multi_rerank", "Multi-Query + Hybrid + Re-rank"), # 신규
}
함수 라우팅도 함께 추가했습니다:
answer_fn = {
"naive": answer_query,
"bm25": answer_query_bm25,
"hybrid": answer_query_hybrid,
"rerank": answer_query_rerank,
"hyde_rerank": answer_query_hyde_rerank, # 신규
"multi_rerank": answer_query_multi_rerank, # 신규
}[mode]
Compare 모드 추가
def _search_hyde_rerank(query: str, collection: str) -> list[dict]:
hypothetical_doc = generate_hypothetical_document(query)
hyde_vector = embed_texts([hypothetical_doc])[0]
hits = search_hybrid(query, collection_name=collection,
n_results=20, dense_query_vector=hyde_vector)
return rerank_hits(query, hits, top_n=5)
def _search_multi_rerank(query: str, collection: str) -> list[dict]:
alt_queries = generate_multi_queries(query, n=3)
seen, unique_hits = set(), []
for q in [query] + alt_queries:
for hit in search_hybrid(q, collection_name=collection, n_results=20):
if hit["document"] not in seen:
seen.add(hit["document"])
unique_hits.append(hit)
return rerank_hits(query, unique_hits, top_n=5)
MODE_SEARCH = {
"naive": lambda q, c: search(q, collection_name=c),
"bm25": lambda q, c: search_bm25(q, collection_name=c),
"hybrid": lambda q, c: search_hybrid(q, collection_name=c),
"rerank": lambda q, c: rerank_hits(q, search_hybrid(q, collection_name=c, n_results=20), top_n=5),
"hyde_rerank": _search_hyde_rerank, # 신규
"multi_rerank": _search_multi_rerank, # 신규
}
이제 compare.py 실행 한 번으로 6개 모드를 한꺼번에 비교할 수 있습니다 ✌️
uv run python -m rag_playground.application.compare
Step 5: 비교 테스트
5개 기본 쿼리로 6개 모드를 전부 돌려봤습니다. hyde_rerank와 multi_rerank는 LLM API를 한 번 더 호출하기 때문에 당연하게도 응답 시간이 늘어납니다 실제 서비스면 캐싱이나 비동기 처리가 필요할듯
Rerank vs HyDE vs Multi-Query 비교
질의별 최고 relevance score를 시각화하면 각 모드의 강약점이 한눈에 보입니다.
질의별 최고 Relevance Score 비교
| 질의 | Rerank | HyDE+Rerank | Multi-Query+Rerank |
|---|---|---|---|
| 부산진구 한식 맛집 | 음식점 5건 (0.74~0.94) | 음식점 5건 (0.77~0.94) | 음식점 5건, 더 다양한 업체 발굴 (0.81~0.94) |
| 해운대구에서 양식 할인 | 미용실 혼입 (0.35) | 한정식·그릇점 등 새로운 결과 발굴 | 식육점·식당 중심 정렬 (0.59~0.78) |
| 미용실 저렴한 곳 | 미용실 (0.11~0.19) | 미용실 (0.09~0.14) | 미용실 (0.13~0.19) |
| 동래구 목욕탕 | 목욕탕 3건 (0.91~0.99) | 온천 3건 발굴 (0.89~0.98) | 목욕탕+온천 4건 (0.89~0.99) |
| 아이스크림 가게 | 극저 (0.003) | 극저 (0.02) | 극저 (0.02) |
쿼리별 승자
| 질의 | 승자 | 이유 |
|---|---|---|
| 부산진구 한식 맛집 | multi_rerank | 4개 질의로 더 다양한 음식점 발굴, 최저 relevance도 0.81로 상승 |
| 해운대구에서 양식 할인 | multi_rerank | rerank은 미용실 혼입, multi는 식당 중심 정렬 (0.59~0.78) |
| 미용실 저렴한 곳 | 무승부 | 전 모드 relevance 0.2 미만 — 데이터에 “저렴” 표현 자체가 부재 |
| 동래구 목욕탕 | multi_rerank | 기존 목욕탕 3건 + 온천 1건 추가 발굴로 커버리지 확대 |
| 아이스크림 가게 | 없음 | 6개 모드 전부 실패. 데이터에 아이스크림 업체가 없음 |
”동래구 목욕탕” 상세 분석
가장 흥미로운 점은 Rerank은 “목욕탕” 키워드에 정확히 매칭되는 3건을 찾았는데, HyDE와 Multi-Query는 거기서 한 발 더 나갔습니다.
HyDE가 생성한 가상 문서에 “온천”이라는 단어가 포함되면서, Dense 검색이 “동래 온천” 관련 업체를 새롭게 발굴했는데, Multi-Query 역시 대안 질의에 “온천”, “사우나” 같은 표현이 들어가면서 목욕탕 3건 + 온천 1건으로 커버리지가 넓어졌습니다.
“목욕탕”이라고 검색했지만 “온천”도 찾아주는 것 — 이게 Query Rewriting의 핵심 가치입니다 👀
레이턴시 비교
모드별 평균 응답 시간 (초)
| 모드 | 순차 검색 | 병렬 검색 | 단축 |
|---|---|---|---|
| rerank | ~2-4초 | — | 기준선 |
| hyde_rerank | ~3-5초 | — | LLM 1회 + 임베딩 1회 추가 |
| multi_rerank | ~7-9초 | ~3.5-6초 | 평균 -45% |
Multi-Query가 당연하게도 가장 느립니다. 4개 질의를 순차적으로 검색하기 때문인데, 그래도 또 병렬로 해보면 더 효율적으로 할 수 있기 때문에 곧바로 asyncio.gather()로 병렬화를 적용해봤습니다.
결과가 꽤 인상적인데요! 평균 약 45% 단축 (8초대 → 3-6초대). GIL과 Qdrant Cloud 네트워크 레이턴시 때문에 4배가 아닌 2-3배 개선에 그쳤지만, 검색 품질(relevance score)은 완전히 동일하게 유지됐습니다 👍
모드별 총평
| 모드 | 특징 | 적합한 경우 |
|---|---|---|
| naive | 빠름, 의미 파악 | 단순 의미 검색 |
| bm25 | 키워드 정확 | 정확한 고유명사 검색 |
| hybrid | 두 방식 결합 | 일반적인 용도 |
| rerank | 정밀 재정렬 | 명확한 질의 |
| hyde_rerank | 가상 답변으로 Dense 강화 | 도메인 특화 질의, 연관 개념 발굴 |
| multi_rerank | 어휘 다양화로 커버리지 확대 | 전반적으로 가장 다양한 결과 |
인사이트
- Multi-Query가 전반적으로 가장 균형 잡힌 결과: 4개 질의로 검색 범위가 넓어지면서 다양한 관련 업체를 발굴합니다. “부산진구 한식 맛집”에서 최저 relevance가 0.74 → 0.81로 올라간 것도 인상적입니다 👍
- HyDE는 연관 개념 발굴에 강하다: “동래구 목욕탕” → “온천” 연결처럼, 가상 문서가 도메인 지식을 담아서 직접적으로 매칭되지 않는 관련 결과를 찾아냅니다
- 재작성 질의는 검색에만: 리랭킹과 답변 생성을 원본 질의로 고정했더니 답변의 초점이 유지됐습니다. 가상 문서나 대안 질의 기준으로 답변을 생성하면 원본 의도에서 벗어날 위험이 있습니다 ⚠️
- 레이턴시는 트레이드오프: Multi-Query는 ~8-9초로 가장 느리지만 결과 품질이 가장 좋습니다. 프로덕션에서는 병렬 검색, 캐싱, 또는 질의 복잡도에 따른 모드 자동 선택이 필요할 것 같습니다
- “아이스크림 가게”는 여전히 전멸: Query Rewriting도 없는 데이터를 만들어내지는 못합니다. 다만 relevance score가 0.003~0.02 수준이라 “관련 데이터 없음” 판별에는 활용할 수 있습니다
다음 퀘스트
Quest 1~6을 거치면서 Naive → Hybrid → Re-rank → Query Rewriting까지 쭉 달려왔는데, 사실 한 가지 빠진 게 있습니다 — “진짜로 더 나아졌나?”를 숫자로 측정하지 않았다는 거예요.
모드 비교를 “눈으로” 보면서 “이게 더 좋은 것 같다”는 느낌으로 판단했는데, 이게 사실 굉장히 주관적입니다 😅 다음 퀘스트에서는 RAG Evaluation을 도입해서 각 모드의 개선 효과를
이제야 진짜 RAG 연구 흉내를 내볼 수 있을 것 같네요
공공데이터로 RAG 뚝딱거려보기 시리즈
- 프롤로그
- Quest 1: 프로젝트 환경 세팅
- Quest 2: 공공데이터 API 연동
- Quest 3: Naive RAG 파이프라인 구축
- Quest 4: Hybrid Search
- Quest 5: Re-ranking
- Quest 6: Query Rewriting ← 현재