본문으로 건너뛰기
윤승규
블로그 목록

RAG 시리즈 3편 — 고정 파이프라인 vs Tool Calling, LLM에게 검색을 맡기는 패턴

1편의 6단계 고정 파이프라인은 '안녕하세요'에도 LLM을 4번 호출해요. Tool Calling은 LLM에게 검색 도구를 쥐여주고 자율적으로 선택하게 해서 비용을 질문 복잡도에 비례시키는 패턴이에요. 두 방식의 비교, 도구 3종 설계, description 작성법, 안전장치와 함정을 정리했어요.

2026년 6월 7일 약 5분 0 0 AI Backend LLM OpenAI RAG ToolCalling

한 줄 요약

1편에서 만든 6단계 고정 파이프라인은 "안녕하세요"에도 LLM을 4번 호출해요. Tool Calling은 LLM에게 검색 도구를 쥐여주고 자율적으로 선택하게 해서, 비용을 질문 복잡도에 비례시키는 패턴이에요. 이 글에서는 두 방식을 비교하고, Tool Calling 기반 RAG의 핵심 구조와 함정을 정리해요.


고정 파이프라인의 비용 비효율

1편에서 본 6단계 파이프라인을 다시 한번 그려볼게요.

질문 → 1.질문분석 → 2.검색(전부실행) → 3.관련성검증 → 4.컨텍스트빌딩 → 5.응답생성 → 6.답변검증

이 흐름은 모든 질문에 대해 동일하게 6단계를 순차 실행해요. 사용자가 "안녕하세요"라고 보내도 똑같이 흘러요.

  • 질문 분석 (LLM 1회)
  • 벡터 + 키워드 + 정확 매칭 검색 실행
  • 관련성 검증 (LLM 1회)
  • 응답 생성 (LLM 1회, 큰 모델)
  • 답변 검증 (LLM 1회)

= 인사말 한 마디에 LLM 4회 호출 + 검색 3개

대부분의 RAG 챗봇 사용 로그를 까보면 인사·잡담·재질문이 30~50%예요. 이 트래픽에 매번 4회씩 LLM을 쓰면 비용이 빠르게 누적돼요.


Tool Calling 패턴

다른 접근이 있어요. LLM에게 "이런 도구들이 있다"고 알려주고, LLM이 직접 어떤 도구를 쓸지 결정하게 하는 거예요.

질문 → LLM(도구 목록 제공) → 필요한 도구만 호출 → 결과로 답변

OpenAI의 tools 파라미터(예전 이름은 function calling)를 쓰면 돼요. 호출은 이렇게 생겼어요.

response = await openai.chat.completions.create(
    model="gpt-5.4-mini",
    messages=messages,
    tools=tool_definitions,    # 사용 가능한 도구 목록
    tool_choice="auto",        # LLM이 자동으로 선택
    stream=True,
)

응답의 finish_reason이 두 가지 중 하나로 와요.

finish_reason의미다음 동작
tool_callsLLM이 도구 실행을 요청실행 → 결과를 messages에 추가 → 다시 LLM 호출
stop최종 답변 완성사용자에게 전달

이걸 반복하면 자율 루프가 돼요.


두 방식 비교

항목고정 파이프라인Tool Calling
실행 흐름항상 6단계 순차LLM 자율 반복 루프
검색 방식매번 전부 실행필요한 도구만
LLM 호출 수고정 4회가변 1~7회
인사 ("안녕")LLM 4회 + 검색 3개LLM 1회, 도구 0회
단순 검색LLM 4회LLM 2회 + 도구 1회
복합 질문LLM 4회LLM 3~7회
별도 검증 컴포넌트필요 (QueryPlanner, Verifier 등)불필요 (LLM이 직접 판단)
확장성파이프라인 코드 수정도구 추가만으로 확장
비용 구조고정질문 복잡도 비례

핵심 차이는 "매번 4회"가 "보통 1~3회, 가끔 7회"로 바뀐다는 거예요. 평균 비용이 30~50% 떨어져요.


도구 3종 — RAG 챗봇 기본 세트

대부분의 RAG 챗봇은 이 3가지 도구로 시작하면 충분해요.

(1) 의미 기반 검색 — 벡터 검색

{
    "type": "function",
    "function": {
        "name": "search_documents_by_content",
        "description": """의미 기반 벡터 검색으로 관련 문서를 찾습니다.
사용자의 질문과 의미적으로 유사한 문서를 찾을 때 사용합니다.
개념적 질문, 설명 요청, 유사 사례 탐색에 적합합니다.""",
        "parameters": {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "검색 쿼리"},
                "top_k": {"type": "integer", "default": 5}
            },
            "required": ["query"]
        }
    }
}

내부 구현: 임베딩 생성 → 벡터DB 코사인 유사도 검색.

(2) 메타데이터 검색 — 정형 조건

{
    "type": "function",
    "function": {
        "name": "search_documents_by_metadata",
        "description": """메타데이터 조건으로 문서를 검색합니다.
특정 유형, 카테고리, 작성자, 날짜 범위 등 정확한 조건으로 검색할 때 사용합니다.
"2024년 작성된 보고서" 같은 조건부 검색에 적합합니다.""",
        "parameters": {
            "type": "object",
            "properties": {
                "document_type": {"type": "string"},
                "author": {"type": "string"},
                "date_from": {"type": "string"},
                "date_to": {"type": "string"},
                "keyword": {"type": "string"},
            }
        }
    }
}

내부 구현: PostgreSQL WHERE 쿼리.

(3) 문서 원문 조회

{
    "type": "function",
    "function": {
        "name": "get_document_detail",
        "description": """특정 문서의 전체 원문을 조회합니다.
검색 결과에서 발견한 문서의 상세 내용을 확인할 때 사용합니다.
document_id는 다른 검색 도구의 결과에서 얻을 수 있습니다.""",
        "parameters": {
            "type": "object",
            "properties": {
                "document_id": {"type": "string"}
            },
            "required": ["document_id"]
        }
    }
}

내부 구현: ID로 문서 + 청크 전체 조회.

도구는 3~5개가 최적이에요. 너무 많으면 LLM이 잘못된 도구를 고르는 빈도가 늘어요. 도메인이 다르면 여기에 1~2개만 추가하는 정도로 충분해요.


description이 결정적이다

Tool Calling을 처음 깔면 십중팔구 이 문제로 막혀요.

"LLM이 도구를 잘못 골라요."

원인은 거의 항상 description이에요. LLM은 이 설명만 읽고 어떤 도구를 호출할지 결정하거든요.

나쁜 예:

"description": "문서를 검색합니다"

다른 검색 도구와 구분이 안 돼요. LLM이 아무거나 고르거나, 매번 같은 도구만 골라요.

좋은 예:

"description": """의미 기반 벡터 검색으로 관련 문서를 찾습니다.
개념적 질문, 설명 요청, 유사 사례 탐색에 적합합니다.
특정 조건(작성자, 날짜 등)으로 찾으려면 search_by_metadata를 사용하세요."""

description을 쓸 때 챙길 4가지.

  1. 용도 — 이 도구가 무엇을 하는지
  2. 적합한 상황 — 어떤 질문에 이 도구를 써야 하는지
  3. 다른 도구와의 차이 — 비슷한 도구가 있으면 언제 이걸 쓰는지
  4. 파라미터 출처 — document_id 같은 값을 어디서 얻는지

description 한 줄만 다듬어도 잘못된 도구 선택률이 확 떨어져요.


반복 루프 + 안전장치

도구 호출이 끝나면 결과를 다시 LLM에 넘겨야 답이 나와요. 이 과정이 한 번에 끝날 수도 있고, "이 도구 결과를 보니까 다른 도구도 호출해야겠다"가 될 수도 있어요. 그래서 반복 루프가 필요해요.

iteration = 0
while iteration < MAX_ITERATIONS:
    response = await openai.chat.completions.create(
        model="gpt-5.4-mini",
        messages=messages,
        tools=tool_definitions,
        tool_choice="auto" if iteration < MAX_ITERATIONS - 1 else "none",
        stream=True,
    )

    if response.finish_reason == "stop":
        return  # 답변 완성

    # 도구 호출 실행
    for tc in response.tool_calls:
        result = await execute_tool(tc.function.name, json.loads(tc.function.arguments))
        messages.append({"role": "assistant", "tool_calls": [tc.to_dict()]})
        messages.append({"role": "tool", "tool_call_id": tc.id, "content": result.content})

    iteration += 1

안전장치가 4개 박혀 있어요.

장치역할
MAX_ITERATIONS (보통 6)무한 루프 방지
마지막 반복 tool_choice="none"도구 호출 막고 강제로 답변 생성
도구 실행 타임아웃 (30초)한 도구가 통신 막혀도 전체 안 죽음
에러 → 도구 결과로 LLM에 반환LLM이 에러를 보고 다른 도구 시도

마지막 두 개가 특히 중요해요. 도구 실행 실패를 예외로 throw하지 말고, "Error: ..." 메시지를 도구 결과로 돌려주세요. 그러면 LLM이 "아 이 도구가 실패했네, 다른 걸 써볼까" 하고 대처해요. 예외를 그냥 던지면 사용자한테 "오류가 발생했습니다"만 보여줘야 해요.

async def execute_tool(name, args):
    try:
        executor = TOOL_EXECUTOR_MAP.get(name)
        if not executor:
            return ToolResult(content=f"Error: Unknown tool '{name}'", ...)
        return await asyncio.wait_for(executor(args), timeout=30)
    except asyncio.TimeoutError:
        return ToolResult(content=f"Error: Tool '{name}' timed out", ...)
    except Exception as e:
        return ToolResult(content=f"Error: {str(e)}", ...)

SSE 이벤트로 도구 실행을 보여주기

Tool Calling RAG는 응답이 더 느릴 때도 있어요. 도구를 여러 번 호출하면 그만큼 LLM 왕복이 늘어나니까요. 사용자가 "왜 답이 안 와?"라고 느끼지 않게 도구 실행 과정을 실시간으로 보여줘야 해요.

SSE로 흘려보내는 이벤트 예시.

이벤트데이터발생 시점
tool_call_startedtool_name, arguments도구 실행 직전
tool_call_completedtool_name, result_count, sources도구 실행 완료
tool_call_errortool_name, error도구 실행 실패
sources_previewsources 배열모든 도구 완료 후
stream_start—최종 답변 생성 시작
tokencontent답변 토큰
stream_endmessage_id완료

프론트엔드에서는 이 이벤트를 받아서 "벡터 검색 중...", "문서 5건 찾음", "답변 작성 중..." 같은 UI를 보여줘요. 같은 5초라도 "뭔가 하고 있구나"가 보이는 5초와 빈 화면 5초는 체감이 완전히 달라요.

type StreamPhase = "idle" | "tool_calling" | "streaming"

// 도구 호출 있는 경우
// idle → tool_calling → streaming → idle

// 도구 없이 바로 답변
// idle → streaming → idle

질문 유형별 비용 비교

같은 챗봇에 다양한 질문이 들어왔을 때 둘의 비용 차이.

질문 유형고정 파이프라인Tool Calling
"안녕하세요"LLM 4회 + 검색 3개LLM 1회
"AI가 뭐야?"LLM 4회 + 검색 3개LLM 1회
"X에 대한 사례는?"LLM 4회 + 검색 3개LLM 2회 + 검색 1개
"2024년 보고서"LLM 4회 + 검색 3개LLM 2회 + 검색 1개
"A 문서 전문 보여줘"LLM 4회 + 검색 3개LLM 3회 + 검색 2개
복합 질문LLM 4회 + 검색 3개LLM 3~7회 + 검색 여러 개

복합 질문에서는 Tool Calling이 오히려 더 비쌀 수도 있어요. 그런데 인사·잡담·단순 검색이 전체 트래픽의 70% 이상이라면 평균은 확실히 내려가요.


Tool Calling 한계와 함정

장점만 있으면 모든 RAG가 다 이걸로 갈아탔겠죠. 함정도 있어요.

1. 디버깅이 어렵다. 고정 파이프라인은 각 단계 로그가 명확해요. Tool Calling은 "LLM이 왜 이 도구를 골랐지?", "왜 답변 검증을 안 했지?" 같은 의문에 답하기 어려워요. LLM의 판단을 신뢰해야 하는데, 신뢰가 깨지면 디버깅이 힘들어요.

2. 답변 검증을 LLM이 알아서 한다는 가정. 고정 파이프라인의 6단계 답변 검증을 Tool Calling은 안 해요. 시스템 프롬프트에 "근거가 약하면 솔직히 밝혀라"를 박아두고 LLM의 판단에 맡기는 거예요. 환각이 더 자주 발생할 수 있어요.

3. 도구 선택이 일관되지 않을 수 있다. 같은 질문에도 LLM이 다른 도구를 고를 수 있어요. 답변 품질이 매번 미묘하게 달라져요.

4. 컨텍스트 윈도우를 빨리 먹는다. 도구 호출이 누적될수록 messages 배열이 커져요. 긴 대화에서는 토큰 비용이 의외로 빨리 늘어요.

그래서 현실적인 선택은 하이브리드예요. 인사·잡담은 Tool Calling으로 가볍게 처리하고, 중요한 답변에는 답변 검증 단계를 따로 붙이는 식으로요. 또는 사내 챗봇처럼 트래픽 패턴이 명확하면 Tool Calling, 검증이 중요한 의료·법률 도메인은 고정 파이프라인을 유지하는 분리도 가능해요.


정리

고정 파이프라인은 모든 질문에 같은 단계를 보장해서 품질이 안정적이지만 비용이 고정이에요. Tool Calling은 LLM이 자율적으로 도구를 선택해서 비용은 줄어들지만 일관성과 디버깅이 어려워요.

선택 기준은 단순해요.

  • 트래픽의 큰 비중이 단순 질문·인사라면 → Tool Calling
  • 모든 답변에 출처 검증이 중요한 도메인이라면 → 고정 파이프라인
  • 둘 다 필요하다면 → 하이브리드 (또는 도메인별 분리)

다음 편(4편)에서는 시각을 바꿔서 운영 관점의 RAG를 다뤄요. 검색 품질만큼 중요한 것이 "임베딩을 어떻게 채우고, 실패하면 어떻게 복구하고, 비용을 어떻게 통제할지"예요. 실제로 운영하면 만나게 되는 임베딩 파이프라인 4가지 경로와 각자의 함정을 정리할게요.


시리즈 다른 편

관련 프로젝트

댓글 0

  • 아직 댓글이 없습니다. 첫 댓글을 남겨주세요.