Retrieval Planning: ReAct vs Self-Ask vs Plan-and-Solve
SOTAAZ·

Retrieval Planning: ReAct vs Self-Ask vs Plan-and-Solve
Query Planning 실패를 진단했다면, 이제 해결할 차례입니다. 세 가지 패턴이 각각 어떤 상황에서 빛나는지 비교합니다.
왜 Retrieval Planning인가?
이전 글에서 Query Planning의 세 가지 실패 지점을 살펴봤습니다:
- Decomposition: 질문을 잘못 쪼갬
- Sequencing: 실행 순서를 잘못 잡음
- Grounding: 쿼리가 문서와 매칭 안 됨
이 문제들을 해결하는 접근법은 크게 세 가지입니다:
패턴 1: ReAct (Reasoning + Acting)
핵심 구조
Thought → Action → Observation → Thought → Action → ... → AnswerReAct는 매 단계마다 추론과 행동을 번갈아 수행합니다. 검색 결과를 보고 다음 행동을 결정하므로, 예상치 못한 상황에 유연하게 대응합니다.
동작 방식
class ReActAgent:
def __init__(self, llm, retriever):
self.llm = llm
self.retriever = retriever
def run(self, query: str, max_steps: int = 5) -> str:
context = f"Question: {query}\n"
for step in range(max_steps):
# 1. Thought: 현재 상황에서 무엇을 해야 할지 추론
thought = self.llm.generate(
f"{context}\nThought {step+1}:"
)
context += f"Thought {step+1}: {thought}\n"
# 종료 조건 체크
if "Final Answer:" in thought:
return self.extract_answer(thought)
# 2. Action: 검색 쿼리 결정
action = self.llm.generate(
f"{context}\nAction {step+1}: Search["
)
search_query = action.split("]")[0]
context += f"Action {step+1}: Search[{search_query}]\n"
# 3. Observation: 검색 실행 및 결과 관찰
results = self.retriever.search(search_query)
observation = self.format_results(results)
context += f"Observation {step+1}: {observation}\n"
return "Could not find answer within max steps"실행 예시
Question: OpenAI CEO가 해고됐을 때 Microsoft CEO는 뭐라고 했어?
Thought 1: OpenAI CEO가 언제 해고됐는지 먼저 알아야 한다.
Action 1: Search[OpenAI CEO 해고 날짜]
Observation 1: 2023년 11월 17일 Sam Altman이 OpenAI 이사회에 의해 해고됨.
Thought 2: 이제 그 날짜에 Microsoft CEO가 뭐라고 했는지 찾아야 한다.
Action 2: Search[Satya Nadella 2023년 11월 17일 Sam Altman]
Observation 2: Satya Nadella는 Sam Altman에 대한 지지를 표명하고...
Thought 3: 충분한 정보를 얻었다. 답변할 수 있다.
Final Answer: Satya Nadella는 Sam Altman에 대한 지지를 표명했다...장단점
언제 쓰나?
- 질문이 예측 불가능할 때 (다양한 도메인, 열린 질문)
- 검색 결과에 따라 전략을 바꿔야 할 때
- 디버깅이 중요할 때 (추론 과정 추적 필요)
패턴 2: Self-Ask
핵심 구조
Question → Follow-up Question → Intermediate Answer → ... → Final AnswerSelf-Ask는 "이 질문에 답하려면 먼저 뭘 알아야 하지?"를 반복합니다. 명시적으로 서브 질문을 생성하고, 각각에 답한 뒤 최종 답을 조합합니다.
동작 방식
class SelfAskAgent:
def __init__(self, llm, retriever):
self.llm = llm
self.retriever = retriever
def run(self, query: str) -> str:
context = f"Question: {query}\n"
context += "Are follow-up questions needed here: "
while True:
# 후속 질문이 필요한지 판단
needs_followup = self.llm.generate(context)
if "No" in needs_followup or "Final Answer" in needs_followup:
# 최종 답변 생성
final = self.llm.generate(
f"{context}\nSo the final answer is:"
)
return final
# 후속 질문 생성
context += "Yes.\n"
followup = self.llm.generate(
f"{context}Follow-up question:"
)
context += f"Follow-up question: {followup}\n"
# 후속 질문에 대한 검색 및 답변
results = self.retriever.search(followup)
intermediate = self.generate_intermediate_answer(followup, results)
context += f"Intermediate answer: {intermediate}\n"
context += "Are follow-up questions needed here: "실행 예시
Question: Sam Altman이 복귀하기 전에 누가 CEO였어?
Are follow-up questions needed here: Yes.
Follow-up question: Sam Altman은 언제 OpenAI CEO로 복귀했나?
Intermediate answer: 2023년 11월 22일에 복귀했다.
Are follow-up questions needed here: Yes.
Follow-up question: 2023년 11월 22일 직전에 OpenAI CEO는 누구였나?
Intermediate answer: Emmett Shear가 2023년 11월 20일부터 임시 CEO였다.
Are follow-up questions needed here: No.
So the final answer is: Sam Altman 복귀 직전 CEO는 Emmett Shear였다.장단점
언제 쓰나?
- 체인 형태의 Multi-hop 질문 (A → B → C)
- 중간 결과를 캐싱하거나 검증해야 할 때
- 질문의 분해 구조가 명확할 때
패턴 3: Plan-and-Solve
핵심 구조
Question → Plan (전체 단계) → Execute Step 1 → Execute Step 2 → ... → AnswerPlan-and-Solve는 먼저 전체 계획을 세우고, 그 다음 순차 실행합니다. 계획 단계에서 의존성과 병렬화를 미리 파악합니다.
동작 방식
class PlanAndSolveAgent:
def __init__(self, llm, retriever):
self.llm = llm
self.retriever = retriever
def run(self, query: str) -> str:
# 1. Planning: 전체 계획 수립
plan = self.create_plan(query)
# 2. Execution: 계획대로 실행
results = {}
for step in plan.steps:
# 의존성 있는 단계의 결과 주입
resolved_query = self.resolve_dependencies(step, results)
# 검색 실행
search_results = self.retriever.search(resolved_query)
results[step.id] = self.extract_answer(step, search_results)
# 3. Synthesis: 결과 종합
return self.synthesize(query, results)
def create_plan(self, query: str) -> Plan:
prompt = f"""
Question: {query}
Create a step-by-step plan to answer this question.
For each step, specify:
- step_id: unique identifier
- query: what to search for
- depends_on: list of step_ids this depends on (empty if none)
Output as JSON.
"""
plan_json = self.llm.generate(prompt)
return Plan.from_json(plan_json)실행 예시
Question: Tesla가 가격을 인하한 후 주가와 경쟁사 반응은 어땠어?
=== PLANNING PHASE ===
{
"steps": [
{"id": "s1", "query": "Tesla 가격 인하 날짜", "depends_on": []},
{"id": "s2", "query": "Tesla 주가 반응 {s1.date}", "depends_on": ["s1"]},
{"id": "s3", "query": "경쟁사 반응 Tesla 가격 인하", "depends_on": ["s1"]},
{"id": "s4", "query": "종합 분석", "depends_on": ["s2", "s3"]}
]
}
=== EXECUTION PHASE ===
Step s1: Tesla는 2023년 1월 13일 가격을 인하했다.
Step s2 (parallel): Tesla 주가는 8% 상승했다.
Step s3 (parallel): 경쟁사들도 가격 인하로 대응했다.
Step s4: [종합]
=== FINAL ANSWER ===
Tesla의 2023년 1월 가격 인하 후 주가는 8% 상승했고,
경쟁사들도 가격 인하로 대응했다.장단점
언제 쓰나?
- 질문 구조가 미리 파악 가능할 때
- 병렬 처리로 속도를 높여야 할 때
- 실행 전 계획을 검토/승인받아야 할 때
패턴 비교
구조 비교
ReAct: Think → Act → Observe → Think → Act → ... (반복)
Self-Ask: Question → Follow-up → Answer → Follow-up → ... (체이닝)
Plan-Solve: Plan all steps → Execute s1 → Execute s2 → ... (순차/병렬)상세 비교표
의사결정 플로우
질문 유형 판단
│
├─ 예측 불가능, 열린 질문 ──────────→ ReAct
│
├─ 명확한 체인 구조 (A→B→C) ────────→ Self-Ask
│
└─ 병렬 가능, 구조 명확 ────────────→ Plan-and-Solve하이브리드 접근: 실전에서는 섞어 쓴다
실제 프로덕션에서는 순수한 단일 패턴보다 하이브리드가 효과적입니다.
Plan-then-ReAct
class HybridAgent:
"""Plan-and-Solve로 시작, 실패 시 ReAct로 전환"""
def run(self, query: str) -> str:
# 1. 먼저 계획 수립 시도
plan = self.create_plan(query)
# 2. 계획 실행
for step in plan.steps:
try:
result = self.execute_step(step)
if not self.is_valid(result):
raise InvalidResultError()
except Exception:
# 3. 실패 시 ReAct로 폴백
return self.react_fallback(query, step)
return self.synthesize(results)
def react_fallback(self, query: str, failed_step: Step) -> str:
"""ReAct 모드로 전환하여 유연하게 해결"""
context = f"Original question: {query}\n"
context += f"Failed at: {failed_step.query}\n"
context += "Switching to exploratory mode...\n"
return self.react_agent.run(context)Self-Ask with Parallel Execution
class ParallelSelfAsk:
"""Self-Ask로 질문 분해, 독립 질문은 병렬 실행"""
def run(self, query: str) -> str:
# 1. 모든 follow-up 질문 먼저 생성
followups = self.generate_all_followups(query)
# 2. 의존성 분석
deps = self.analyze_dependencies(followups)
# 3. 독립 질문은 병렬, 의존 질문은 순차
results = {}
for group in self.topological_groups(deps):
# 같은 그룹 내 질문은 병렬 실행
group_results = parallel_execute(
[self.answer_followup(q) for q in group]
)
results.update(group_results)
return self.synthesize(query, results)구현 팁
1. 종료 조건 명확히
# ReAct에서 무한 루프 방지
MAX_STEPS = 7
CONFIDENCE_THRESHOLD = 0.8
def should_stop(thought: str, step: int, confidence: float) -> bool:
if step >= MAX_STEPS:
return True
if "Final Answer" in thought:
return True
if confidence > CONFIDENCE_THRESHOLD:
return True
return False2. 검색 실패 처리
def search_with_fallback(query: str) -> List[Document]:
# 1차: 정확한 검색
results = retriever.search(query)
if results:
return results
# 2차: 쿼리 확장
expanded = expand_query(query)
results = retriever.search(expanded)
if results:
return results
# 3차: 키워드 추출 후 재검색
keywords = extract_keywords(query)
return retriever.search(" ".join(keywords))3. 컨텍스트 압축
def compress_context(context: str, max_tokens: int = 2000) -> str:
"""긴 컨텍스트를 압축하여 토큰 절약"""
if count_tokens(context) <= max_tokens:
return context
# 최근 N개 단계만 유지
steps = parse_steps(context)
recent = steps[-3:] # 최근 3단계
# 중간 단계는 요약
summary = summarize(steps[:-3])
return f"[Summary of earlier steps: {summary}]\n" + format_steps(recent)결론
세 패턴은 경쟁 관계가 아니라 상호 보완 관계입니다.
ReAct: 유연성 최고, 예측 불가능한 질문에 강함
Self-Ask: 구조화된 분해, 체인 형태 질문에 최적
Plan-Solve: 효율성 최고, 병렬화와 사전 검토 가능실전 추천:
- 기본값으로 Plan-and-Solve (효율적)
- 계획 실패 시 ReAct로 폴백 (유연성)
- 명확한 체인 질문은 Self-Ask (구조화)
Multi-hop RAG의 성능은 결국 상황에 맞는 패턴 선택에 달려 있습니다.