Models & Algorithms

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

Query Planning 실패를 진단했다면, 이제 해결할 차례입니다. 세 가지 패턴이 각각 어떤 상황에서 빛나는지 비교합니다.

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

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

Query Planning 실패를 진단했다면, 이제 해결할 차례입니다. 세 가지 패턴이 각각 어떤 상황에서 빛나는지 비교합니다.

왜 Retrieval Planning인가?

이전 글에서 Query Planning의 세 가지 실패 지점을 살펴봤습니다:

  • Decomposition: 질문을 잘못 쪼갬
  • Sequencing: 실행 순서를 잘못 잡음
  • Grounding: 쿼리가 문서와 매칭 안 됨

이 문제들을 해결하는 접근법은 크게 세 가지입니다:

패턴핵심 아이디어한 줄 요약
**ReAct**생각 → 행동 → 관찰 반복"한 발 가보고, 결과 보고, 다시 생각"
**Self-Ask**스스로 후속 질문 생성"이걸 답하려면 먼저 뭘 알아야 하지?"
**Plan-and-Solve**먼저 전체 계획, 그 다음 실행"지도 그리고 출발"

패턴 1: ReAct (Reasoning + Acting)

핵심 구조

text
Thought → Action → Observation → Thought → Action → ... → Answer

ReAct는 매 단계마다 추론과 행동을 번갈아 수행합니다. 검색 결과를 보고 다음 행동을 결정하므로, 예상치 못한 상황에 유연하게 대응합니다.

동작 방식

python
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"

실행 예시

text
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에 대한 지지를 표명했다...

장단점

장점단점
동적 적응: 중간 결과에 따라 경로 변경토큰 소비 많음: 매 단계 전체 컨텍스트 전달
디버깅 용이: Thought가 추론 과정 노출무한 루프 위험: 종료 조건 필요
예외 처리 강함: 검색 실패 시 대안 탐색일관성 낮음: 같은 질문에 다른 경로

언제 쓰나?

  • 질문이 예측 불가능할 때 (다양한 도메인, 열린 질문)
  • 검색 결과에 따라 전략을 바꿔야 할 때
  • 디버깅이 중요할 때 (추론 과정 추적 필요)

패턴 2: Self-Ask

핵심 구조

text
Question → Follow-up Question → Intermediate Answer → ... → Final Answer

Self-Ask는 "이 질문에 답하려면 먼저 뭘 알아야 하지?"를 반복합니다. 명시적으로 서브 질문을 생성하고, 각각에 답한 뒤 최종 답을 조합합니다.

동작 방식

python
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: "

실행 예시

text
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였다.

장단점

장점단점
구조화된 분해: 서브 질문이 명시적깊이 제한: 너무 많은 hop은 성능 저하
중간 답변 캐싱 가능분기 처리 약함: 선형 체인에 최적화
검증 용이: 각 중간 답변 확인 가능병렬화 어려움: 순차 의존성

언제 쓰나?

  • 체인 형태의 Multi-hop 질문 (A → B → C)
  • 중간 결과를 캐싱하거나 검증해야 할 때
  • 질문의 분해 구조가 명확할 때

패턴 3: Plan-and-Solve

핵심 구조

text
Question → Plan (전체 단계) → Execute Step 1 → Execute Step 2 → ... → Answer

Plan-and-Solve는 먼저 전체 계획을 세우고, 그 다음 순차 실행합니다. 계획 단계에서 의존성과 병렬화를 미리 파악합니다.

동작 방식

python
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)

실행 예시

text
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% 상승했고,
경쟁사들도 가격 인하로 대응했다.

장단점

장점단점
병렬 실행 가능: 의존성 명시로 최적화계획 수정 어려움: 중간에 경로 변경 힘듦
예측 가능: 실행 전 계획 검토 가능계획 실패 시 전체 실패
효율적: 불필요한 검색 최소화복잡한 질문에서 계획 품질 저하

언제 쓰나?

  • 질문 구조가 미리 파악 가능할 때
  • 병렬 처리로 속도를 높여야 할 때
  • 실행 전 계획을 검토/승인받아야 할 때

패턴 비교

구조 비교

text
ReAct:        Think → Act → Observe → Think → Act → ... (반복)
Self-Ask:     Question → Follow-up → Answer → Follow-up → ... (체이닝)
Plan-Solve:   Plan all steps → Execute s1 → Execute s2 → ... (순차/병렬)

상세 비교표

기준ReActSelf-AskPlan-and-Solve
**적응성**높음 (매 단계 재평가)중간낮음 (계획 고정)
**효율성**낮음 (토큰 많이 사용)중간높음 (병렬화 가능)
**예측 가능성**낮음중간높음
**디버깅**쉬움 (Thought 추적)쉬움 (Follow-up 추적)중간
**복잡한 질문**강함중간약함 (계획 품질 의존)
**구현 난이도**중간쉬움어려움

의사결정 플로우

text
질문 유형 판단
    │
    ├─ 예측 불가능, 열린 질문 ──────────→ ReAct
    │
    ├─ 명확한 체인 구조 (A→B→C) ────────→ Self-Ask
    │
    └─ 병렬 가능, 구조 명확 ────────────→ Plan-and-Solve

하이브리드 접근: 실전에서는 섞어 쓴다

실제 프로덕션에서는 순수한 단일 패턴보다 하이브리드가 효과적입니다.

Plan-then-ReAct

python
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

python
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. 종료 조건 명확히

python
# 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 False

2. 검색 실패 처리

python
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. 컨텍스트 압축

python
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)

결론

세 패턴은 경쟁 관계가 아니라 상호 보완 관계입니다.
text
ReAct:         유연성 최고, 예측 불가능한 질문에 강함
Self-Ask:      구조화된 분해, 체인 형태 질문에 최적
Plan-Solve:    효율성 최고, 병렬화와 사전 검토 가능

실전 추천:

  1. 기본값으로 Plan-and-Solve (효율적)
  2. 계획 실패 시 ReAct로 폴백 (유연성)
  3. 명확한 체인 질문은 Self-Ask (구조화)

Multi-hop RAG의 성능은 결국 상황에 맞는 패턴 선택에 달려 있습니다.

관련 글