왜 단일 검색 파이프라인은 실패하는가
대부분의 RAG 시스템은 "임베딩 → 벡터 검색 top-k → LLM 답변"이라는 단일 경로를 모든 질문에 강제한다. 그러나 실제 사용자 질문은 성격이 다르다. "환불 정책 알려줘" 같은 사실 조회는 벡터 검색이 잘 맞지만, "지난달 대비 이번달 매출 증감은?"은 SQL 집계가 필요하고, "이 문서 요약해줘"는 애초에 검색이 아니라 특정 문서 로딩이 정답이다. 단일 파이프라인은 이 차이를 무시하기 때문에, 집계 질문에 문서 조각을 던져주고 LLM이 환각으로 숫자를 지어내는 사고가 발생한다.
핵심 문제는 검색 전략의 선택이 질문 유형에 종속된다는 점이다. 라우팅은 질문을 분류해 적절한 검색기(retriever)로 보내는 계층을 추가하는 작업이다.
질문 유형과 검색 전략 매핑
| 질문 유형 | 예시 | 검색 전략 |
|---|---|---|
| 사실 조회 | "반품 기한은?" | 벡터 + 키워드 하이브리드 |
| 집계/분석 | "카테고리별 판매량" | Text-to-SQL |
| 다중 문서 비교 | "A안과 B안 차이" | 다중 검색 후 재정렬 |
| 요약/전체 문맥 | "이 계약서 핵심은?" | 문서 직접 로딩(검색 생략) |
| 대화형/잡담 | "고마워" | 검색 없이 직접 응답 |
라우팅 구현: 분류기 방식
라우터 구현은 크게 두 갈래다. 규칙 기반(정규식·키워드)과 LLM 분류다. 저지연이 중요하면 규칙을 먼저 태우고, 미분류 건만 LLM으로 넘기는 폴백 구조가 실무에서 안정적이다. LLM 분류는 자유 생성이 아니라 열거형(enum) 출력을 강제해야 한다.
from anthropic import Anthropic
import json
client = Anthropic()
ROUTES = ["factual", "aggregation", "compare", "summarize", "chitchat"]
def route_query(question: str) -> str:
tool = {
"name": "classify",
"description": "질문을 검색 전략으로 분류",
"input_schema": {
"type": "object",
"properties": {"route": {"type": "string", "enum": ROUTES}},
"required": ["route"],
},
}
resp = client.messages.create(
model="claude-haiku-4-5-20251001", # 라우팅은 저비용·저지연 모델
max_tokens=100,
tools=[tool],
tool_choice={"type": "tool", "name": "classify"},
messages=[{"role": "user", "content": question}],
)
for block in resp.content:
if block.type == "tool_use":
return block.input["route"]
return "factual" # 폴백 기본값
여기서 tool_choice로 도구 호출을 강제하고 enum으로 값을 제한한 것이 중요하다. 이렇게 하면 "이건 집계 질문 같네요" 같은 산문 대신 항상 파싱 가능한 라벨이 돌아온다. 라우팅에는 Haiku급 소형 모델을 쓰는 것이 비용·지연 면에서 합리적이다.
전략별 실행 디스패치
분류 결과를 실제 검색기로 연결한다. 각 핸들러가 서로 다른 데이터 소스와 접근 방식을 가진다는 점이 라우팅의 실익이다.
def handle(question: str):
route = route_query(question)
if route == "aggregation":
sql = text_to_sql(question) # LLM이 SQL 생성
rows = db.execute(sql).fetchall() # 실제 집계는 DB가 담당
return summarize_rows(question, rows)
elif route == "summarize":
doc = load_referenced_doc(question) # 검색 없이 전체 로딩
return summarize(doc)
elif route == "chitchat":
return direct_answer(question) # 검색 생략
else: # factual, compare
docs = hybrid_search(question, k=8) # 벡터+BM25
return generate(question, docs)
집계 질문을 SQL로 넘기면 숫자 계산을 DB가 담당하므로 LLM 환각을 근본적으로 차단한다. 요약 질문에서 벡터 검색을 건너뛰는 것도 중요하다. 검색은 top-k 조각만 가져오므로 "문서 전체"를 요구하는 요약과 상충하기 때문이다.
운영 시 주의점
라우팅 도입 시 몇 가지 함정이 있다.
- 오분류의 비용은 비대칭이다. 집계 질문을 factual로 잘못 보내면 틀린 숫자가 나온다. 신뢰도가 낮을 때는 안전한 하이브리드 검색으로 폴백하도록 설계한다.
- 경계 질문 처리. "환불 건수와 정책"처럼 두 전략이 섞인 질문은 복수 라우트를 병렬 실행 후 병합하는 편이 단일 선택보다 정확하다.
- 라우터도 관측 대상이다. 라우트별 분포와 정답률을 로깅해야 한다. 특정 라우트로만 90%가 몰린다면 분류 기준이나 프롬프트를 재검토할 신호다.
- 지연 예산. 라우팅은 검색 앞단에 추가 LLM 호출을 넣는다. 규칙 기반 선처리로 명백한 케이스는 분류 호출 없이 통과시켜 지연을 줄인다.
점진적 도입 전략
처음부터 5개 라우트를 만들 필요는 없다. 로그에서 실패가 잦은 질문 유형을 하나 식별해(대개 집계) 그 라우트만 분리하는 것으로 시작한다. 단일 파이프라인을 기본으로 두고 명확히 구분되는 유형만 떼어내는 방식이 유지보수 부담과 오분류 위험을 동시에 낮춘다. 라우팅은 정확도를 올리는 도구가 아니라, 질문마다 다른 정답 경로를 명시적으로 인정하는 설계 원칙에 가깝다.