Multi-hop RAG에서 Query Planning이 실패하는 패턴과 해결책
SOTAAZ·

Multi-hop RAG에서 Query Planning이 실패하는 패턴과 해결책
Query Decomposition을 붙였는데 왜 여전히 틀릴까요? 분해는 시작일 뿐, 진짜 문제는 Sequencing과 Grounding에서 터집니다.
Query Planning이란?
Multi-hop RAG에서 복잡한 질문을 처리하려면 세 단계가 필요합니다:
Query Planning = Decomposition + Sequencing + Grounding대부분의 Multi-hop RAG 실패는 이 세 단계 중 하나에서 발생합니다.
실패 패턴 #1: Decomposition 실패
1-A. 과분해 (Over-decomposition)
query = "Tesla가 가격을 인하한 후 주가가 어떻게 됐어?"
# 과분해 결과
decomposed = [
"Tesla는 어떤 회사인가?", # 불필요
"Tesla의 제품은 무엇인가?", # 불필요
"Tesla가 가격을 인하했나?",
"Tesla가 언제 가격을 인하했나?",
"가격 인하 폭은 얼마인가?",
"Tesla 주가는 현재 얼마인가?", # 잘못된 시점
"주가 변동의 원인은 무엇인가?" # 너무 추상적
]문제점:
- 불필요한 서브쿼리가 노이즈 문서를 가져옴
- 토큰 낭비 + 컨텍스트 오염
- 정작 필요한 "가격 인하 직후 주가 반응"이 희석됨
진단 기준:
def is_over_decomposed(decomposed, original):
# 서브쿼리 수가 원본 질문의 엔티티 수 × 2를 초과하면 과분해
entities = extract_entities(original)
return len(decomposed) > len(entities) * 21-B. 저분해 (Under-decomposition)
query = "Sam Altman이 OpenAI에서 해고됐을 때 Microsoft CEO는 뭐라고 했고, 이후 Sam이 복귀하기까지 누가 임시 CEO를 맡았어?"
# 저분해 결과
decomposed = [
"Sam Altman 해고와 Microsoft CEO 반응",
"Sam Altman 복귀 전 임시 CEO"
]문제점:
- 첫 번째 서브쿼리가 여전히 Multi-hop
- "해고 시점" → "Microsoft CEO 발언" 순서가 내재됨
- 두 번째도 "복귀 시점" → "그 전 CEO" 순서 필요
진단 기준:
def is_under_decomposed(sub_query):
# 서브쿼리에 시간 관계 키워드가 남아있으면 저분해
temporal_markers = ["때", "후", "전", "이후", "직전", "when", "after", "before"]
return any(marker in sub_query for marker in temporal_markers)1-C. 암묵적 조건 누락
query = "작년에 가장 많이 팔린 전기차는?"
# 누락된 분해
decomposed = [
"가장 많이 팔린 전기차는?" # "작년"이 사라짐
]
# 올바른 분해
decomposed = [
"2024년 전기차 판매량 순위는?" # 시간 조건 명시
]문제점:
- 상대적 시간 표현("작년", "최근")이 절대 시간으로 변환되지 않음
- 검색 시 시간 필터링 불가
실패 패턴 #2: Sequencing 실패
2-A. 의존성 무시
query = "OpenAI CEO가 해고된 날 Microsoft 주가는 어땠어?"
# 의존성 무시한 병렬 실행
decomposed = [
"OpenAI CEO가 언제 해고됐나?", # → 2023-11-17
"Microsoft 주가는 어땠나?" # → 언제? (의존성 끊김)
]
# 실제 실행
results = parallel_search(decomposed) # 두 번째 쿼리 실패문제점:
- 서브쿼리 2는 서브쿼리 1의 결과(날짜)가 필요
- 병렬 실행 시 의존성이 끊어져 잘못된 결과
의존성 그래프:
[Q1: 해고 날짜] ──→ [Q2: 그 날짜의 주가]
↓
2023-11-17 (이 값이 Q2에 필요)2-B. 순환 의존성
query = "A 회사 CEO가 B 회사로 이직했을 때, B 회사 주가와 A 회사 주가는 각각 어땠어?"
# 순환 구조로 잘못 분해
decomposed = [
"A 회사 CEO가 B 회사로 이직한 건 언제인가?",
"그때 B 회사 주가는?", # Q1 의존
"그때 A 회사 주가는?", # Q1 의존
"두 주가의 상관관계는?" # Q2, Q3 의존
]
# Q2와 Q3는 Q1에만 의존 → 병렬 가능
# 하지만 LLM이 순환으로 잘못 판단하면 교착2-C. 병렬 가능한데 직렬 처리
query = "2023년 Tesla와 BYD의 판매량 비교"
# 직렬 처리 (비효율)
step1 = search("2023년 Tesla 판매량")
step2 = search("2023년 BYD 판매량") # step1 끝날 때까지 대기
answer = compare(step1, step2)
# 병렬 처리 (효율적)
results = parallel_search([
"2023년 Tesla 판매량",
"2023년 BYD 판매량"
])
answer = compare(*results)문제점:
- 독립적인 쿼리를 직렬로 처리하면 지연 시간 2배
- 대규모 질문에서 성능 병목
실패 패턴 #3: Grounding 실패
3-A. 쿼리-문서 불일치
query = "일론 머스크가 트위터를 인수한 후 첫 해고는 언제였어?"
# 분해는 잘 됨
decomposed = [
"일론 머스크가 트위터를 인수한 날짜는?",
"트위터 첫 대규모 해고 날짜는?"
]
# 검색 실패
search("트위터 첫 대규모 해고")
# → 0건 (문서에는 "X" 또는 "Twitter layoffs"로 저장됨)문제점:
- 사용자 질문의 표현 ≠ 문서의 표현
- "트위터" vs "X", "해고" vs "layoffs", "구조조정"
해결 패턴:
def expand_query(query):
synonyms = {
"트위터": ["Twitter", "X", "트위터(X)"],
"해고": ["layoffs", "구조조정", "인력 감축", "fired"]
}
return generate_variations(query, synonyms)3-B. Entity Resolution 실패
query = "Apple CEO가 WWDC에서 발표한 새 기능 중 가장 반응이 좋았던 건?"
# 분해
decomposed = [
"Apple CEO는 누구인가?", # → Tim Cook
"WWDC에서 발표된 새 기능은?", # → Vision Pro, iOS 18, ...
"가장 반응이 좋았던 기능은?" # → 무엇 기준? 어느 WWDC?
]
# Entity 연결 실패
# "Tim Cook" ↔ "Apple CEO" 연결은 됨
# "WWDC" → 어느 연도? 연결 안 됨
# "반응" → 주가? 트윗? 리뷰? 기준 모호문제점:
- 동일 엔티티의 다른 표현을 연결 못함
- 암묵적 컨텍스트(연도, 기준)가 전파되지 않음
3-C. 중간 결과 손실
# Step 1
q1 = "OpenAI CEO 해고 날짜"
a1 = "2023년 11월 17일 Sam Altman이 해고됨"
# Step 2 (a1 일부만 사용)
q2 = "2023년 11월 17일 Microsoft 반응" # "Sam Altman" 정보 손실
# Step 3 (더 심각한 손실)
q3 = "그 반응의 영향" # 날짜도, 인물도, 회사도 손실문제점:
- 이전 단계의 풍부한 컨텍스트가 다음 단계로 전달 안 됨
- 각 hop이 독립적으로 실행되면서 맥락이 희석
해결책: 패턴별 대응 전략
Decomposition 실패 대응
class SmartDecomposer:
def decompose(self, query):
# 1. 엔티티 추출
entities = self.extract_entities(query)
# 2. 관계 추출
relations = self.extract_relations(query)
# 3. 시간 표현 정규화
query = self.normalize_temporal(query) # "작년" → "2024년"
# 4. 서브쿼리 생성 (엔티티 × 관계)
sub_queries = []
for entity in entities:
for relation in relations:
if self.is_relevant(entity, relation):
sub_queries.append(
self.generate_sub_query(entity, relation)
)
# 5. 과분해 체크
if len(sub_queries) > len(entities) * 2:
sub_queries = self.merge_similar(sub_queries)
return sub_queriesSequencing 실패 대응
class DependencyAwareSequencer:
def sequence(self, sub_queries):
# 1. 의존성 그래프 생성
graph = self.build_dependency_graph(sub_queries)
# 2. 순환 의존성 체크
if self.has_cycle(graph):
graph = self.break_cycle(graph)
# 3. 위상 정렬로 실행 순서 결정
execution_order = self.topological_sort(graph)
# 4. 병렬 가능한 쿼리 그룹화
parallel_groups = self.group_independent(execution_order)
return parallel_groups
def build_dependency_graph(self, queries):
"""서브쿼리 간 의존성 파악"""
graph = {}
for i, q in enumerate(queries):
deps = []
for j, other in enumerate(queries):
if i != j and self.depends_on(q, other):
deps.append(j)
graph[i] = deps
return graphGrounding 실패 대응
class RobustGrounder:
def ground(self, sub_query, previous_results):
# 1. 컨텍스트 주입
enriched_query = self.inject_context(sub_query, previous_results)
# 2. 쿼리 확장 (동의어, 별칭)
expanded_queries = self.expand_synonyms(enriched_query)
# 3. 다중 검색 전략
results = []
for eq in expanded_queries:
results.extend(self.search(eq))
# 4. 결과 검증
verified = self.verify_relevance(results, sub_query)
# 5. Entity Resolution
resolved = self.resolve_entities(verified, previous_results)
return resolved
def inject_context(self, query, previous):
"""이전 결과의 컨텍스트를 현재 쿼리에 주입"""
context = self.extract_key_info(previous)
return f"{query} (맥락: {context})"실전 디버깅 체크리스트
1단계: Decomposition 검증
def debug_decomposition(original, decomposed):
checks = {
"과분해": len(decomposed) > 5,
"저분해": any(is_still_complex(q) for q in decomposed),
"시간누락": has_temporal(original) and not any(has_temporal(q) for q in decomposed),
"엔티티누락": missing_entities(original, decomposed)
}
return {k: v for k, v in checks.items() if v}2단계: Sequencing 검증
def debug_sequencing(decomposed, execution_order):
checks = {
"의존성무시": has_broken_dependencies(decomposed, execution_order),
"불필요직렬": has_unnecessary_serial(decomposed, execution_order),
"순환의존성": has_circular_dependency(decomposed)
}
return {k: v for k, v in checks.items() if v}3단계: Grounding 검증
def debug_grounding(sub_query, search_results, expected):
checks = {
"결과없음": len(search_results) == 0,
"관련성낮음": avg_relevance(search_results) < 0.5,
"엔티티불일치": entity_mismatch(sub_query, search_results),
"컨텍스트손실": context_lost(sub_query, expected)
}
return {k: v for k, v in checks.items() if v}통합 디버깅 체크리스트
1. Decomposition Check
- 서브쿼리 수 적정? (2-5개)
- 각 서브쿼리가 단일 검색으로 해결 가능?
- 시간/조건 표현이 명시적?
2. Sequencing Check
- 의존성 그래프가 DAG?
- 병렬 가능한 쿼리 그룹화됨?
- 실행 순서가 의존성 존중?
3. Grounding Check
- 각 서브쿼리가 검색 결과 있음?
- 이전 결과 컨텍스트가 전파됨?
- Entity가 일관되게 연결됨?
결론
Query Planning 실패는 단순히 "질문을 잘못 쪼갰다"가 아닙니다. Decomposition, Sequencing, Grounding 세 단계 어디서 깨졌는지를 진단해야 합니다.
✓ Decomposition: 적정 수의 서브쿼리, 조건 명시
✓ Sequencing: 의존성 존중, 병렬화 최적화
✓ Grounding: 쿼리 확장, 컨텍스트 전파, Entity ResolutionMulti-hop RAG의 성능은 검색 모델이나 LLM보다 Query Planning 파이프라인의 견고함에 달려 있습니다.