RAG Python Qdrant HyDE Query Rewriting LLM

[RAG Playground] Quest 6: Query Rewriting으로 검색 질의 똑똑하게 바꾸기

지난 Quest 5에서 Re-ranker를 붙이고 나서 검색 결과가 눈에 띄게 좋아지긴 하더라구요 “동래구 목욕탕” 같은 명확한 질의에는 relevance 0.99가 찍히는 걸 보니 확실히 이게 맞다 라고 생각했는데 문제는 사용자가 항상 명확하게 검색하지 않는다는 거죠

예를 들어 “이번 주말에 아이들이랑 갈 만한 데” 같은 질의는 벡터 검색이 뭔가를 찾기는 하겠지만, 정확히 무엇을 원하는지 파악하기 어렵습니다. Re-ranker가 아무리 좋아도, 검색 자체에 좋은 후보가 안 들어오면 소용이 없다는 거죠 🤔

이번 퀘스트에서는 Query RewritingQuery Rewriting검색 성능을 높이기 위해 사용자의 원본 질의를 LLM으로 변환하거나 확장하는 기법입니다. HyDE(가상 문서 생성)와 Multi-Query(대안 질의 생성) 등이 대표적입니다.을 도입해서 조금 더 RAG 성능을 개선해 보겠습니다. LLM으로 원본 질의를 재작성해서 검색 품질을 높이는 두 가지 기법 — HyDEMulti-Query — 를 테스트 해보겠습니다

Query Rewriting이 무엇인가???

질의 재작성은 말 그대로 사용자의 원본 질의를 검색에 더 유리한 형태로 바꾸는 기법입니다. 방향은 크게 두 가지예요.

HyDE (HyDEHyDEHypothetical Document Embeddings의 약자입니다. 사용자 질의에 대한 가상의 답변 문서를 LLM으로 생성한 뒤, 그 문서의 임베딩으로 검색하는 기법입니다. 실제 답변과 유사한 문서를 찾는 데 효과적입니다.) — 질의에 대한 가상의 답변 문서를 LLM이 직접 생성합니다. 그리고 그 가상 문서의 임베딩으로 벡터 검색을 수행합니다. “질문의 임베딩” 대신 “답변처럼 생긴 텍스트의 임베딩”으로 검색하는 겁니다.

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_rerankmulti_rerank는 LLM API를 한 번 더 호출하기 때문에 당연하게도 응답 시간이 늘어납니다 실제 서비스면 캐싱이나 비동기 처리가 필요할듯

Rerank vs HyDE vs Multi-Query 비교

질의별 최고 relevance score를 시각화하면 각 모드의 강약점이 한눈에 보입니다.

질의별 최고 Relevance Score 비교

질의RerankHyDE+RerankMulti-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_rerank4개 질의로 더 다양한 음식점 발굴, 최저 relevance도 0.81로 상승
해운대구에서 양식 할인multi_rerankrerank은 미용실 혼입, 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어휘 다양화로 커버리지 확대전반적으로 가장 다양한 결과

인사이트

  1. Multi-Query가 전반적으로 가장 균형 잡힌 결과: 4개 질의로 검색 범위가 넓어지면서 다양한 관련 업체를 발굴합니다. “부산진구 한식 맛집”에서 최저 relevance가 0.74 → 0.81로 올라간 것도 인상적입니다 👍
  2. HyDE는 연관 개념 발굴에 강하다: “동래구 목욕탕” → “온천” 연결처럼, 가상 문서가 도메인 지식을 담아서 직접적으로 매칭되지 않는 관련 결과를 찾아냅니다
  3. 재작성 질의는 검색에만: 리랭킹과 답변 생성을 원본 질의로 고정했더니 답변의 초점이 유지됐습니다. 가상 문서나 대안 질의 기준으로 답변을 생성하면 원본 의도에서 벗어날 위험이 있습니다 ⚠️
  4. 레이턴시는 트레이드오프: Multi-Query는 ~8-9초로 가장 느리지만 결과 품질이 가장 좋습니다. 프로덕션에서는 병렬 검색, 캐싱, 또는 질의 복잡도에 따른 모드 자동 선택이 필요할 것 같습니다
  5. “아이스크림 가게”는 여전히 전멸: Query Rewriting도 없는 데이터를 만들어내지는 못합니다. 다만 relevance score가 0.003~0.02 수준이라 “관련 데이터 없음” 판별에는 활용할 수 있습니다

다음 퀘스트

Quest 1~6을 거치면서 Naive → Hybrid → Re-rank → Query Rewriting까지 쭉 달려왔는데, 사실 한 가지 빠진 게 있습니다 — “진짜로 더 나아졌나?”를 숫자로 측정하지 않았다는 거예요.

모드 비교를 “눈으로” 보면서 “이게 더 좋은 것 같다”는 느낌으로 판단했는데, 이게 사실 굉장히 주관적입니다 😅 다음 퀘스트에서는 RAG Evaluation을 도입해서 각 모드의 개선 효과를 NDCGNDCGNormalized Discounted Cumulative Gain의 약자입니다. 검색 결과의 순위와 관련도를 함께 반영하는 랭킹 평가 지표로, 관련 문서가 상위에 올수록 점수가 높습니다., MRRMRRMean Reciprocal Rank의 약자입니다. 첫 번째 정답 문서가 검색 결과에서 얼마나 앞에 나타나는지를 측정하는 평가 지표로, 정답이 더 높은 순위에 있을수록 점수가 높습니다. 같은 지표로 정량화해볼 예정입니다.

이제야 진짜 RAG 연구 흉내를 내볼 수 있을 것 같네요


공공데이터로 RAG 뚝딱거려보기 시리즈

  1. 프롤로그
  2. Quest 1: 프로젝트 환경 세팅
  3. Quest 2: 공공데이터 API 연동
  4. Quest 3: Naive RAG 파이프라인 구축
  5. Quest 4: Hybrid Search
  6. Quest 5: Re-ranking
  7. Quest 6: Query Rewriting ← 현재