왜 원본 질의로는 검색이 실패하는가
RAG 파이프라인에서 검색 품질은 대부분 임베딩 모델이 아니라 질의 자체에서 무너진다. 사용자는 "그거 지난번에 안 됐던 거"처럼 대명사·생략·오타가 섞인 문장을 던지고, 멀티턴 대화에서는 "그럼 그건 얼마야?"처럼 앞 맥락 없이는 의미가 성립하지 않는 질의를 보낸다. 벡터 검색은 이런 문장을 그대로 임베딩하기 때문에, 정작 문서에 존재하는 핵심 키워드와의 코사인 유사도가 떨어져 관련 청크를 놓친다.
또 하나의 흔한 실패는 어휘 불일치(vocabulary mismatch)다. 사용자는 "환불 며칠 걸려요?"라고 묻지만 문서에는 "결제 취소 처리 기간은 영업일 기준 3~5일"이라고 적혀 있다. 재작성은 이 간극을 메우는 전처리 단계다.
재작성 전략의 세 가지 축
실무에서 쓰는 재작성은 크게 세 가지로 나뉜다. 상황에 따라 조합해서 쓴다.
| 전략 | 해결하는 문제 | 비용/지연 |
|---|---|---|
| 대화 맥락 병합(condense) | 대명사·생략, 멀티턴 의존 | 낮음(1회 LLM 호출) |
| 다중 질의 확장(multi-query) | 어휘 불일치, 표현 다양성 | 중간(N배 검색) |
| HyDE(가설 문서 생성) | 짧은 질의의 정보 부족 | 중간(생성 후 임베딩) |
대화 맥락을 반영한 질의 응축
멀티턴에서 가장 효과가 큰 것은 이전 대화를 합쳐 독립적인(self-contained) 질의로 바꾸는 것이다. 핵심은 "요약"이 아니라 "지시어 해소"라는 점이다. 프롬프트에서 새 정보를 추가하지 말라고 명시해야 환각을 막는다.
from anthropic import Anthropic
client = Anthropic()
def condense_query(history: list[dict], question: str) -> str:
convo = "\n".join(f"{m['role']}: {m['content']}" for m in history)
prompt = (
"다음 대화를 참고해 마지막 질문을 그 자체로 이해 가능한 "
"독립 질의로 바꿔라. 새로운 사실을 추가하지 말고, "
"대명사와 생략만 해소하라. 질의문만 출력하라.\n\n"
f"[대화]\n{convo}\n\n[마지막 질문] {question}"
)
resp = client.messages.create(
model="claude-haiku-4-5-20251001", # 저지연·저비용 전처리에 적합
max_tokens=200,
messages=[{"role": "user", "content": prompt}],
)
return resp.content[0].text.strip()
전처리는 지연에 민감하므로 가장 가벼운 모델(Haiku)을 쓰고, 실패 시 원본 질의로 폴백하는 방어 코드를 반드시 둔다.
다중 질의로 재현율 끌어올리기
하나의 질의를 표현이 다른 3~5개로 확장한 뒤 각각 검색하고, 결과를 합쳐 중복 제거한다. 어휘 불일치를 통계적으로 흡수하는 방식이다. 병합 시 RRF(Reciprocal Rank Fusion)를 쓰면 스코어 스케일이 다른 검색 결과를 안정적으로 합칠 수 있다.
from collections import defaultdict
def rrf_merge(result_lists: list[list[str]], k: int = 60) -> list[str]:
scores = defaultdict(float)
for docs in result_lists: # docs: 검색 순위대로 정렬된 문서 id
for rank, doc_id in enumerate(docs):
scores[doc_id] += 1.0 / (k + rank + 1)
return sorted(scores, key=scores.get, reverse=True)
# variants = expand_queries(query) # LLM으로 3~5개 생성
# lists = [vector_search(v, top_k=10) for v in variants]
# final = rrf_merge(lists)[:5]
확장 개수는 재현율과 비용의 트레이드오프
변형 질의가 늘수록 재현율은 오르지만 검색 호출과 지연도 비례해 증가한다. 실측상 3개 부근에서 이득이 급격히 둔화되는 경우가 많으니, 오프라인 평가로 손익분기를 직접 확인하고 고정하라.
주의점: 재작성이 오히려 정확도를 떨어뜨릴 때
재작성은 공짜가 아니다. 실무에서 자주 겪는 함정은 다음과 같다.
- 환각 주입: 모델이 없던 조건(날짜, 제품명)을 임의로 붙이면 엉뚱한 문서를 검색한다. 프롬프트에 "새 사실 추가 금지"를 명시하고 출력 길이를 제한한다.
- 단순 질의 과잉 처리: 이미 명확한 질의까지 재작성하면 지연만 늘고 의미가 뒤틀린다. 질의 길이·대화 턴 수로 게이팅해 필요할 때만 호출한다.
- 평가 없는 배포: 재작성 효과는 도메인마다 다르다. 반드시 정답 청크가 top-k에 들어오는지(Recall@k)로 A/B 비교한 뒤 켠다.
평가 없이는 켜지 마라
재작성 도입 여부는 감이 아니라 숫자로 결정한다. 대표 질의 세트와 정답 문서를 고정해두고, 원본 질의 대비 재작성 질의의 Recall@k, MRR을 비교하는 회귀 테스트를 CI에 포함시켜라.
def recall_at_k(retrieved: list[str], gold: set[str], k: int) -> float:
return len(set(retrieved[:k]) & gold) / len(gold) if gold else 0.0
def evaluate(dataset, retriever):
base = sum(recall_at_k(retriever(d["q"]), set(d["gold"]), 5)
for d in dataset) / len(dataset)
rewritten = sum(recall_at_k(retriever(condense_query(d["hist"], d["q"])),
set(d["gold"]), 5) for d in dataset) / len(dataset)
return {"base_recall@5": round(base, 3),
"rewrite_recall@5": round(rewritten, 3)}
이 지표가 유의미하게 개선될 때만 재작성을 프로덕션에 반영하고, 지연 증가분이 사용자 경험 기준선을 넘지 않는지 함께 확인한다. 재작성은 강력한 도구지만, 검증된 범위 안에서만 정확도를 올린다.