RAG 심화 #4 쿼리 변환과 리랭킹

5 분 소요

3편까지로 조각과 검색기를 다듬었습니다. 이번에는 검색 파이프라인의 양 끝을 봅니다. 앞단에서는 사용자의 질문을 검색에 좋은 형태로 바꾸고(쿼리 변환), 뒷단에서는 넓게 가져온 후보를 정밀하게 추립니다(리랭킹). 둘 다 “검색기 자체는 그대로 두고” 입력과 출력을 손보는 기법이라, 기존 파이프라인에 끼워 넣기 쉽습니다.

사용자의 질문은 검색어가 아닙니다 #

검색 실패의 한 갈래는 질문 쪽에 있습니다. 실제 사용자의 질문은 이렇게 생겼습니다.

  • “그럼 그건 언제까지 돼?” — 대화 맥락 없이는 무엇을 묻는지조차 알 수 없습니다.
  • “환불하려는데 카드로 했고 부분 취소면 어떻게 돼?” — 여러 질문이 한 문장에 겹쳐 있습니다.

이런 질문을 그대로 임베딩하면 검색이 흔들립니다. 그래서 검색 전에 질문을 변환하는 단계를 둡니다.

쿼리 리라이팅 — 맥락을 채워 독립된 질문으로 #

챗봇형 RAG에서 가장 효과가 큰 변환은 대화 맥락을 반영해 질문을 다시 쓰는 것입니다. 빠르고 싼 모델이면 충분해서, 본 응답과 다른 모델을 써도 됩니다.

query_rewrite.py
def rewrite_query(history: list, question: str) -> str:
    response = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=200,
        system=(
            "대화 맥락을 반영해 마지막 질문을 검색용 독립 질문 한 문장으로 다시 써라. "
            "대명사를 실제 대상으로 바꾸고, 답하지 말고 질문만 출력하라."
        ),
        messages=history + [{"role": "user", "content": question}],
    )
    return next(b.text for b in response.content if b.type == "text")

# "그럼 그건 언제까지 돼?" → "주문 취소는 결제 후 며칠 이내까지 가능한가?"

리라이팅된 질문은 검색에만 쓰고, 답 생성에는 원래 질문과 대화를 그대로 씁니다. 검색을 위한 보조 단계이지 대화를 바꾸는 단계가 아닙니다.

멀티 쿼리 — 한 질문을 여러 각도로 #

문서와 질문의 어휘가 다른 경우(1편 진단에서 서술형 질문의 실패가 많던 경우)에는, 같은 질문을 다른 표현으로 여러 개 만들어 모두 검색하는 멀티 쿼리가 효과적입니다.

multi_query.py
def expand_queries(question: str, n: int = 3) -> list:
    response = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=300,
        system=f"질문을 문서 검색용으로 서로 다른 표현 {n}개로 바꿔라. 한 줄에 하나씩.",
        messages=[{"role": "user", "content": question}],
    )
    text = next(b.text for b in response.content if b.type == "text")
    return [question] + [q.strip() for q in text.split("\n") if q.strip()]

def multi_query_search(question: str, top_k: int = 5) -> list:
    rankings = [vector_search_ids(q, top_k=20) for q in expand_queries(question)]
    return [chunks[i] for i in rrf(rankings, top_k=top_k)]   # 3편의 RRF 재사용

각 쿼리의 결과를 합치는 데에 3편의 RRF 를 그대로 다시 씁니다. 호출이 늘어나는 만큼 지연과 비용이 붙는 기법이므로, 모든 질문에 켜기보다 리라이팅만으로 부족했던 질문군에 적용하는 것이 균형 잡힌 선택입니다.

리랭킹 — 넓게 가져와서 정밀하게 추리기 #

이제 뒷단입니다. 임베딩 검색에는 구조적인 한계가 하나 있습니다. 질문과 문서를 각자 따로 벡터로 만든 뒤 거리를 재기 때문에, 둘을 맞대 놓고 비교하는 정밀함이 없습니다. 리랭킹(reranking, 검색된 후보를 더 정밀한 모델로 다시 정렬하는 일)은 이 한계를 보완합니다. 질문과 조각을 한 쌍으로 같이 읽고 관련도를 매기는 크로스 인코더(cross-encoder) 모델을 써서, 넓게 가져온 후보를 다시 줄 세웁니다.

rerank.py
from sentence_transformers import CrossEncoder

# 다국어 리랭커. 한국어 질문에도 동작한다.
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")

def search_with_rerank(question: str, top_k: int = 5) -> list:
    candidates = hybrid_search(question, top_k=30)        # 1단계: 넓게 30개
    pairs = [(question, c["text"]) for c in candidates]
    scores = reranker.predict(pairs)                       # 2단계: 쌍으로 정밀 채점
    ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
    return [c for c, _ in ranked[:top_k]]                  # 상위 5개만 생성에 사용

구조가 핵심입니다. 1단계 검색은 빠르고 넓게, 2단계 리랭킹은 느리지만 정밀하게라는 역할 분담입니다. 1단계가 재현율을, 2단계가 정확도를 맡습니다. 크로스 인코더는 쌍마다 모델을 돌려서 느리므로 전체 조각에는 못 쓰지만, 후보 30개 정도에는 충분히 쓸 수 있습니다.

리랭커 대신 Claude 같은 LLM에 “이 질문에 이 조각이 관련 있는가"를 묻는 LLM 리랭킹도 가능합니다. 더 유연하지만 더 느리고 비쌉니다. 전용 리랭커로 시작해서, 그걸로 부족한 영역이 확인될 때 검토하는 순서를 권합니다.

어디까지 쌓을 것인가 #

2편부터 지금까지의 기법을 다 켜면 파이프라인이 이렇게 됩니다. 리라이팅 → (멀티 쿼리) → 하이브리드 검색 → 리랭킹 → 생성. 단계마다 지연과 비용이 붙으므로, 전부 켜는 것이 목표가 아닙니다. 1편의 골든셋 측정에서 숫자가 오르는 단계만 남기는 것이 목표입니다. 경험적으로 효과 대비 비용이 좋은 출발점은 리라이팅(챗봇이라면)과 리랭킹 두 가지입니다.

흔히 걸려 넘어지는 곳 #

  • 리라이팅한 질문으로 답까지 생성한다 — 변환된 질문은 검색용입니다. 생성에까지 쓰면 사용자가 묻지 않은 질문에 답하게 됩니다.
  • 좁게 가져와서 리랭킹한다 — 후보 5개를 리랭킹하면 순서만 바뀔 뿐 새 정답이 들어올 수 없습니다. 리랭킹의 전제는 넓은 1단계(20〜50개)입니다.
  • 변환 모델에 큰 모델을 쓴다 — 리라이팅과 쿼리 확장은 단순 작업이라 작은 모델로 충분합니다. 본 응답보다 보조 단계가 비싸지면 배보다 배꼽입니다.

마무리 #

이번 글에서는 검색의 앞단과 뒷단을 보강했습니다.

  • 대화형 질문은 리라이팅으로 독립 질문으로 만들고, 어휘 차이가 큰 질문은 멀티 쿼리로 여러 각도에서 검색합니다.
  • 리랭킹은 넓게 가져온 후보를 크로스 인코더로 정밀하게 추립니다. 1단계는 재현율, 2단계는 정확도라는 분담입니다.
  • 기법을 전부 켜는 것이 아니라, 골든셋 숫자가 오르는 단계만 남깁니다.

여기까지가 검색 품질, 즉 “정답 조각을 가져오는 일"이었습니다. 다음은 생성 쪽입니다. 정답 조각을 줬는데도 답이 틀리거나, 조각에 없는 내용을 지어내는 문제를 다룹니다. 다음 글은 “RAG 심화 #5 인용으로 환각 줄이기"입니다.

X