[카테고리:] AI

  • AI Agent & MCP 4부 — Redis Streams + MCP로 실제 Agent Runtime 구현하기

    AI Agent & MCP 4부 — Redis Streams + MCP로 실제 Agent Runtime 구현하기

    파일 업로드부터 Human Review, Pause/Resume까지 — AI Agent 시리즈 최종편

    앞의 세 편에서 우리는 단순한 Worker에서 시작해 Agent Orchestrator, MCP Tool, 그리고 운영 가능한 Agent Runtime의 구조까지 살펴봤다.

    이번 최종편의 목표는 지금까지 설명한 구성 요소를 실제로 연결하는 것이다.

    Invoice Upload
          ↓
    FastAPI
          ↓
    Redis Streams
          ↓
    Agent Worker
          ↓
    Agent Runtime
          ↓
    MCP Tools
          ↓
    Invoice Analysis
          ↓
    Confidence = 0.58
          ↓
    Human Review
          ↓
    WAITING_HUMAN
          ↓
    Worker 종료
    
          ... 몇 시간 후 ...
    
    Human APPROVE
          ↓
    Redis Resume Event
          ↓
    Agent Worker
          ↓
    Load Checkpoint
          ↓
    ERP Registration
          ↓
    COMPLETED
    

    완전한 상용 시스템 전체를 구현하기보다 실제 서비스로 확장할 수 있는 최소 구조를 만드는 데 집중한다.

    1. 최종 아키텍처

                         User
                          │
                       Upload
                          ↓
                       FastAPI
                          │
                ┌─────────┴─────────┐
                ↓                   ↓
          Object Storage       Redis Streams
                                    │
                                    ↓
                               Agent Worker
                                    │
                                    ↓
                             Agent Runtime
                             ┌──────┴──────┐
                             ↓             ↓
                         State Store   Orchestrator
                                           │
                                           ↓
                                      LLM Decision
                                           │
                                           ↓
                                       MCP Client
                                           │
                                           ↓
                                       MCP Server
                                           │
                        ┌──────────────────┼──────────────────┐
                        ↓                  ↓                  ↓
                  Document Tool      Analysis Tool      Business Tool
                        │                  │                  │
                        └──────────────────┼──────────────────┘
                                           ↓
                                      Service Layer
                                           │
                             ┌─────────────┼─────────────┐
                             ↓             ↓             ↓
                          Storage          DB        External API
    

    FastAPI는 파일 업로드와 Review API, Redis Streams는 Start/Resume Event, Worker는 Runtime 실행, Runtime은 State와 종료 조건, Orchestrator는 다음 행동, MCP는 Tool 연결, Service Layer는 실제 업무를 담당한다.

    2. 프로젝트 구조

    agent-backend/
    │
    ├── api/
    │   ├── upload.py
    │   └── review.py
    ├── agent/
    │   ├── runtime.py
    │   ├── orchestrator.py
    │   ├── state.py
    │   └── worker.py
    ├── mcp_server/
    │   └── server.py
    ├── services/
    │   ├── document_service.py
    │   ├── analysis_service.py
    │   ├── review_service.py
    │   └── erp_service.py
    ├── repositories/
    │   ├── agent_repository.py
    │   └── document_repository.py
    ├── infrastructure/
    │   ├── redis.py
    │   └── database.py
    └── main.py
    

    처음부터 Microservice로 나눌 필요는 없다. 하나의 Python 프로젝트 안에서도 책임을 분리해두면 나중에 필요한 부분만 독립시킬 수 있다.

    3. Agent State 정의

    from dataclasses import dataclass, field
    from typing import Any
    
    @dataclass
    class AgentState:
        run_id: str
        document_id: str
        status: str = "CREATED"
        step: int = 0
        last_tool: str | None = None
        retry_count: int = 0
        document_type: str | None = None
        confidence: float | None = None
        human_result: dict | None = None
        context: dict[str, Any] = field(default_factory=dict)
    

    State는 단순한 LLM 대화 기록이 아니라 Agent Runtime이 중단되고 다시 실행되어도 복구할 수 있는 시스템 상태다.

    4. State를 DB에 저장한다

    CREATE TABLE agent_runs (
        run_id VARCHAR(64) PRIMARY KEY,
        document_id VARCHAR(64) NOT NULL,
        status VARCHAR(32) NOT NULL,
        current_step INT NOT NULL DEFAULT 0,
        last_tool VARCHAR(100),
        retry_count INT NOT NULL DEFAULT 0,
        state_json JSON NOT NULL,
        created_at DATETIME NOT NULL,
        updated_at DATETIME NOT NULL
    );
    

    Tool 실행 이력도 별도로 저장한다.

    CREATE TABLE agent_steps (
        id BIGINT AUTO_INCREMENT PRIMARY KEY,
        run_id VARCHAR(64) NOT NULL,
        step_no INT NOT NULL,
        tool_name VARCHAR(100) NOT NULL,
        input_json JSON,
        output_json JSON,
        status VARCHAR(32) NOT NULL,
        error_message TEXT,
        started_at DATETIME NOT NULL,
        finished_at DATETIME,
        UNIQUE KEY uq_agent_step (run_id, step_no)
    );
    

    agent_runs는 현재 상태, agent_steps는 실행 이력을 담당한다.

    5. 파일 업로드 API

    from fastapi import APIRouter, UploadFile
    
    router = APIRouter()
    
    @router.post("/documents")
    async def upload_document(file: UploadFile):
        document = await document_service.save(file)
    
        run = await agent_service.create_run(
            document_id=document.id
        )
    
        await event_bus.publish_start(
            run_id=run.run_id,
            document_id=document.id,
        )
    
        return {
            "document_id": document.id,
            "run_id": run.run_id,
            "status": "QUEUED",
        }
    

    FastAPI가 직접 Agent를 실행하지 않는 것이 중요하다.

    HTTP Request
         ↓
    File Save
         ↓
    Agent Run 생성
         ↓
    Event Publish
         ↓
    HTTP Response
    

    6. Redis Streams에 START Event를 넣는다

    import redis.asyncio as redis
    
    redis_client = redis.Redis(
        host="localhost",
        port=6379,
        decode_responses=True,
    )
    
    async def publish_agent_start(run_id: str, document_id: str):
        await redis_client.xadd(
            "agent:events",
            {
                "event": "START",
                "run_id": run_id,
                "document_id": document_id,
            },
        )
    

    7. Consumer Group

    Stream: agent:events
            │
            ↓
    Consumer Group: agent-workers
            │
        ┌───┼───┐
        ↓   ↓   ↓
       W1  W2  W3
    
    async def ensure_consumer_group():
        try:
            await redis_client.xgroup_create(
                name="agent:events",
                groupname="agent-workers",
                id="0",
                mkstream=True,
            )
        except Exception as e:
            if "BUSYGROUP" not in str(e):
                raise
    

    실제 운영에서는 구체적인 Redis 예외 타입을 처리하는 편이 좋다.

    8. Agent Worker

    async def run_worker(worker_name: str):
        while True:
            messages = await redis_client.xreadgroup(
                groupname="agent-workers",
                consumername=worker_name,
                streams={"agent:events": ">"},
                count=10,
                block=5000,
            )
    
            for stream, entries in messages:
                for message_id, data in entries:
                    try:
                        await handle_event(data)
    
                        await redis_client.xack(
                            "agent:events",
                            "agent-workers",
                            message_id,
                        )
                    except Exception:
                        logger.exception("Agent event failed")
    

    Worker는 업무 규칙을 알 필요가 없다. Event를 받아 Runtime을 실행할 뿐이다.

    9. START와 RESUME Event

    async def handle_event(event):
        event_type = event["event"]
    
        if event_type == "START":
            state = await agent_repository.load(
                event["run_id"]
            )
            await agent_runtime.run(state)
            return
    
        if event_type == "RESUME":
            await resume_agent(event)
            return
    
        raise ValueError(f"Unknown event: {event_type}")
    

    10. MCP Tool은 기존 Service를 감싼다

    from mcp.server.fastmcp import FastMCP
    
    mcp = FastMCP("document-agent-tools")
    
    @mcp.tool()
    def get_document(document_id: str) -> dict:
        return document_service.get_document(document_id)
    
    @mcp.tool()
    def extract_document_content(document_id: str) -> dict:
        return document_service.extract_content(document_id)
    
    @mcp.tool()
    def analyze_invoice(document_id: str) -> dict:
        return analysis_service.analyze_invoice(document_id)
    
    @mcp.tool()
    def request_human_review(
        run_id: str,
        document_id: str,
        reason: str,
    ) -> dict:
        return review_service.create_review(
            run_id=run_id,
            document_id=document_id,
            reason=reason,
        )
    
    @mcp.tool()
    def create_erp_record(
        document_id: str,
        operation_id: str,
    ) -> dict:
        return erp_service.create_invoice(
            document_id=document_id,
            operation_id=operation_id,
        )
    

    MCP Server에는 Agent Runtime의 State 관리 코드를 넣지 않는다. Tool은 실제 업무 기능 제공에 집중한다.

    11. Orchestrator의 역할

    Orchestrator는 현재 State와 사용 가능한 MCP Tool을 보고 다음 행동을 결정한다.

    Current State
          +
    Available MCP Tools
          +
    System Instruction
          ↓
    LLM
          ↓
    Next Action
    

    예를 들어 System Instruction에는 다음과 같은 규칙을 넣을 수 있다.

    You process uploaded business documents.
    
    Use the available tools to analyze the document.
    
    Do not call write tools unless the result has been validated.
    
    If confidence is too low to safely continue,
    request human review.
    
    Never retry the same failed action indefinitely.
    
    Finish when the document has been successfully processed.
    

    하지만 Prompt만으로 모든 안전을 보장해서는 안 된다.

    Max Steps, Permission, Idempotency, Timeout 같은 제한은 Runtime 코드에서도 적용해야 한다.


    12. Decision 구조를 명시적으로 만든다

    LLM의 응답을 자유로운 문자열로 받기보다 구조화된 Decision으로 다루는 편이 좋다.

    from dataclasses import dataclass
    from typing import Any
    
    
    @dataclass
    class AgentDecision:
        action: str
    
        tool_name: str | None = None
        arguments: dict[str, Any] | None = None
    
        reason: str | None = None
    

    action은 다음 정도로 제한할 수 있다.

    TOOL
    FINISH
    HUMAN_REVIEW
    FAIL
    

    그러면 Runtime은 LLM의 자연어를 직접 해석하는 대신 명확한 상태를 기준으로 동작할 수 있다.


    13. Agent Runtime의 기본 Loop

    이제 핵심인 Runtime을 만들어보자.

    MAX_STEPS = 10
    
    
    class AgentRuntime:
    
        async def run(self, state: AgentState):
    
            state.status = "RUNNING"
    
            await agent_repository.save(state)
    
            while state.step < MAX_STEPS:
    
                decision = await orchestrator.decide(
                    state
                )
    
                if decision.action == "FINISH":
    
                    state.status = "COMPLETED"
    
                    await agent_repository.save(
                        state
                    )
    
                    return
    
                if decision.action == "FAIL":
    
                    state.status = "FAILED"
    
                    await agent_repository.save(
                        state
                    )
    
                    return
    
                if decision.action == "HUMAN_REVIEW":
    
                    await self.pause_for_human(
                        state,
                        decision,
                    )
    
                    return
    
                await self.execute_tool(
                    state,
                    decision,
                )
    
            state.status = "MAX_STEPS_EXCEEDED"
    
            await agent_repository.save(state)
    

    Runtime의 책임이 조금 더 명확해졌다.

    Orchestrator
        ↓
    무엇을 할지 결정
    
    Runtime
        ↓
    그 결정을 실행해도 되는지 통제
    
    MCP
        ↓
    실제 Tool 연결
    

    14. Tool 실행 전 Policy를 검사한다

    Agent가 Tool을 선택했다고 무조건 실행해서는 안 된다.

    예를 들어 다음과 같이 Tool을 분류할 수 있다.

    READ_TOOLS = {
        "get_document",
        "extract_document_content",
        "analyze_invoice",
    }
    
    WRITE_TOOLS = {
        "create_erp_record",
    }
    

    Write Tool에는 추가 검증을 적용한다.

    async def validate_tool_call(
        state,
        decision,
    ):
    
        if decision.tool_name == "create_erp_record":
    
            if state.human_result is None:
                raise PermissionError(
                    "Human approval is required"
                )
    
            if state.human_result["action"] != "APPROVE":
                raise PermissionError(
                    "Invoice was not approved"
                )
    

    LLM은 create_erp_record를 선택할 수 있다.

    하지만 실제 실행 권한은 Runtime이 결정한다.

    Tool Selection과 Tool Authorization은 서로 다른 문제다.


    15. MCP Tool을 실행한다

    개념적인 MCP Client 호출은 다음과 같다.

    async def call_mcp_tool(
        tool_name: str,
        arguments: dict,
    ):
    
        result = await mcp_client.call_tool(
            tool_name,
            arguments=arguments,
        )
    
        return result
    

    Runtime에서는 Tool 실행 전후로 Step을 기록한다.

    async def execute_tool(
        self,
        state,
        decision,
    ):
    
        await validate_tool_call(
            state,
            decision,
        )
    
        state.step += 1
    
        step = await agent_repository.start_step(
            run_id=state.run_id,
            step_no=state.step,
            tool_name=decision.tool_name,
            arguments=decision.arguments,
        )
    
        try:
    
            result = await call_mcp_tool(
                decision.tool_name,
                decision.arguments,
            )
    
            await agent_repository.complete_step(
                step.id,
                result=result,
            )
    
            state.last_tool = decision.tool_name
    
            self.apply_result(
                state,
                decision.tool_name,
                result,
            )
    
            await agent_repository.save(state)
    
        except Exception as e:
    
            await agent_repository.fail_step(
                step.id,
                error=str(e),
            )
    
            raise
    

    여기서 agent_repository.save(state)가 Checkpoint 역할을 한다.


    16. Tool Result를 State에 반영한다

    Tool마다 반환 결과가 다르다.

    예를 들어 analyze_invoice 결과가 다음과 같다고 하자.

    {
      "document_type": "invoice",
      "vendor": "ABC Corp",
      "amount": 12500000,
      "confidence": 0.58
    }
    

    State에 필요한 정보를 반영한다.

    def apply_result(
        self,
        state,
        tool_name,
        result,
    ):
    
        state.context[
            tool_name
        ] = result
    
        if tool_name == "analyze_invoice":
    
            state.document_type = (
                result["document_type"]
            )
    
            state.confidence = (
                result["confidence"]
            )
    

    전체 Tool Result를 LLM Context에 계속 쌓을 필요는 없다.

    DB에는 전체 이력을 보관하고, Orchestrator에는 현재 판단에 필요한 State만 전달하는 방식이 더 효율적이다.


    17. Invoice 분석 결과가 0.58이라면

    이제 실제 시나리오를 따라가 보자.

    Agent는 다음 순서로 Tool을 호출했다.

    Step 1
    get_document
          ↓
    SUCCESS
    
    Step 2
    extract_document_content
          ↓
    SUCCESS
    
    Step 3
    analyze_invoice
          ↓
    confidence = 0.58
    

    Orchestrator가 다음 Decision을 반환한다.

    {
      "action": "HUMAN_REVIEW",
      "reason": "Invoice analysis confidence is too low"
    }
    

    Runtime은 더 이상 Tool을 계속 호출하지 않는다.

    Human Review 상태로 전환한다.


    18. Human Review Task를 만든다

    async def pause_for_human(
        self,
        state,
        decision,
    ):
    
        review = await review_service.create(
            run_id=state.run_id,
            document_id=state.document_id,
            reason=decision.reason,
            data=state.context.get(
                "analyze_invoice"
            ),
        )
    
        state.status = "WAITING_HUMAN"
    
        state.context["review_id"] = (
            review.id
        )
    
        await agent_repository.save(state)
    

    여기서 중요한 부분은 마지막이다.

    return
    

    Runtime은 종료된다.

    Worker도 이 Event 처리를 끝낸다.

    Agent Runtime
          ↓
    WAITING_HUMAN
          ↓
    Checkpoint Save
          ↓
    return
          ↓
    Worker는 다음 Event 처리
    

    Agent가 몇 시간 동안 살아 있는 것이 아니다.

    Agent Run의 상태만 살아 있다.


    19. Human Review API

    담당자가 Review 화면에서 결과를 확인한다고 해보자.

    API는 다음과 같이 만들 수 있다.

    from pydantic import BaseModel
    
    
    class ReviewRequest(BaseModel):
        action: str
        edited_data: dict | None = None
    
    
    @router.post(
        "/reviews/{review_id}"
    )
    async def submit_review(
        review_id: str,
        request: ReviewRequest,
    ):
    
        review = await review_service.complete(
            review_id=review_id,
            action=request.action,
            edited_data=request.edited_data,
        )
    
        await event_bus.publish_resume(
            run_id=review.run_id,
            review_id=review.id,
        )
    
        return {
            "status": "ACCEPTED"
        }
    

    가능한 Action은 예를 들어 다음과 같다.

    APPROVE
    REJECT
    EDIT
    

    20. Resume Event를 Redis Streams에 넣는다

    async def publish_resume(
        run_id: str,
        review_id: str,
    ):
    
        await redis_client.xadd(
            "agent:events",
            {
                "event": "RESUME",
                "run_id": run_id,
                "review_id": review_id,
            },
        )
    

    Redis에는 다음과 같은 Event가 들어간다.

    XADD agent:events *
         event RESUME
         run_id run_123
         review_id review_123
    

    Worker 입장에서는 START와 RESUME 모두 그냥 Event다.


    21. Resume 처리

    async def resume_agent(event):
    
        state = await agent_repository.load(
            event["run_id"]
        )
    
        if state.status != "WAITING_HUMAN":
            return
    
        review = await review_service.get(
            event["review_id"]
        )
    
        state.human_result = {
            "action": review.action,
            "edited_data": review.edited_data,
        }
    
        state.status = "RUNNING"
    
        await agent_repository.save(state)
    
        await agent_runtime.run(state)
    

    이제 Agent는 5시간 전의 Python 프로세스가 아니다.

    새로운 Worker가 DB에서 State를 읽고 논리적인 Agent Run을 이어간다.


    22. REJECT라면 어떻게 할까?

    Human Review 결과가 REJECT라면 굳이 LLM에게 다시 물어볼 필요가 없을 수도 있다.

    if review.action == "REJECT":
    
        state.status = "FAILED"
    
        state.context[
            "failure_reason"
        ] = "Rejected by human"
    
        await agent_repository.save(state)
    
        return
    

    이것은 의미적 판단이 아니라 명확한 업무 규칙이다.

    코드가 처리하는 것이 더 좋다.


    23. EDIT라면 어떻게 할까?

    담당자가 Invoice 데이터를 수정했다고 해보자.

    {
      "action": "EDIT",
      "edited_data": {
        "vendor": "ABC Corporation",
        "amount": 12450000
      }
    }
    

    이 경우 State에 수정 결과를 넣고 Agent에게 다시 판단을 맡길 수 있다.

    if review.action == "EDIT":
    
        state.context[
            "human_edited_invoice"
        ] = review.edited_data
    

    Agent는 다음 단계에서 다시 Validation Tool을 호출하거나 ERP 등록을 선택할 수 있다.


    24. APPROVE라면 ERP 등록

    승인이 완료되면 다음 Tool을 호출할 수 있다.

    Human APPROVE
          ↓
    Agent Resume
          ↓
    create_erp_record
    

    하지만 ERP 등록은 Write Tool이다.

    반복 실행에 안전해야 한다.


    25. ERP Tool에 Idempotency를 적용한다

    operation_id를 Agent Run과 업무 작업을 조합해서 만든다.

    operation_id = (
        f"{state.run_id}:create_erp_record"
    )
    

    MCP Tool 호출은 다음과 같다.

    {
      "document_id": "doc_123",
      "operation_id": "run_123:create_erp_record"
    }
    

    ERP Service에서는 이미 처리한 Operation인지 확인한다.

    def create_invoice(
        document_id: str,
        operation_id: str,
    ):
    
        existing = operation_repository.find(
            operation_id
        )
    
        if existing:
            return existing.result
    
        result = erp_client.create_invoice(
            document_id
        )
    
        operation_repository.save(
            operation_id=operation_id,
            result=result,
        )
    
        return result
    

    Worker가 ERP 등록 직후 죽더라도 같은 operation_id로 다시 실행하면 중복 등록을 막을 수 있다.


    26. Redis Streams에서도 장애 복구를 생각해야 한다

    Consumer가 메시지를 읽은 뒤 XACK하기 전에 죽을 수 있다.

    XREADGROUP
         ↓
    Agent 실행
         ↓
    Worker Crash
         ↓
    XACK 못함
    

    이 메시지는 Pending 상태로 남는다.

    따라서 운영 환경에서는 Pending Entry를 감시하고 일정 시간 이상 처리되지 않은 메시지를 다른 Worker가 Claim하는 전략이 필요하다.

    개념적으로는 다음과 같다.

    Pending Message
          ↓
    Idle Time > Threshold
          ↓
    Claim
          ↓
    Another Worker
          ↓
    Resume Processing
    

    Redis Streams의 전달 보장과 Agent Tool의 Idempotency는 함께 생각해야 한다.

    Queue가 메시지를 다시 전달할 수 있기 때문이다.


    27. Runtime Retry와 Tool Retry를 구분한다

    OCR API가 일시적으로 실패하는 것은 Tool 내부에서 Retry할 수 있다.

    Technical Failure
          ↓
    Tool Retry
    

    반면 Invoice 분석 신뢰도가 낮아 다른 방법을 선택하는 것은 Agent의 판단이다.

    Semantic Failure
          ↓
    Agent Decision
    

    Runtime에서는 Tool의 결과를 보고 다음과 같이 처리할 수 있다.

    if result.get("retryable"):
    
        state.retry_count += 1
    
        if state.retry_count > 3:
            state.status = "FAILED"
    

    다만 네트워크 재시도와 의미적 재시도를 같은 Counter로 관리하지 않는 편이 더 명확하다.


    28. Loop Detection

    Max Steps만으로도 무한 실행은 막을 수 있지만 반복 패턴을 감지하면 더 빨리 중단할 수 있다.

    extract_document_content
    run_ocr
    extract_document_content
    run_ocr
    

    최근 Tool History를 확인한다.

    def has_repeating_pattern(
        history: list[str],
    ) -> bool:
    
        if len(history) < 4:
            return False
    
        last_four = history[-4:]
    
        return (
            last_four[0] == last_four[2]
            and
            last_four[1] == last_four[3]
        )
    

    Loop가 감지되면 Human Review로 보내거나 실패 처리할 수 있다.


    29. Agent Run을 어떻게 관찰할까?

    관리 화면에서는 최소한 다음 정도를 볼 수 있으면 좋다.

    Run ID
    Document
    Status
    Current Step
    Last Tool
    Started At
    Updated At
    LLM Calls
    Tool Calls
    Token Usage
    Estimated Cost
    Human Review
    Error
    

    하나의 Run을 클릭하면 Timeline을 보여줄 수 있다.

    10:00:01 START
    
    10:00:02 get_document
             SUCCESS
    
    10:00:03 extract_document_content
             SUCCESS
    
    10:00:05 analyze_invoice
             confidence = 0.58
    
    10:00:06 HUMAN_REVIEW
             WAITING
    
    15:14:22 HUMAN_APPROVE
    
    15:14:23 RESUME
    
    15:14:25 create_erp_record
             SUCCESS
    
    15:14:26 COMPLETED
    

    Agent 시스템에서는 이런 Timeline이 디버깅에 특히 유용하다.


    30. 비용도 Run 단위로 기록한다

    LLM을 여러 번 호출하는 Agent는 일반 API보다 비용 구조가 복잡하다.

    agent_usage
    
    run_id
    step_no
    model
    input_tokens
    output_tokens
    cost
    latency_ms
    

    그러면 다음과 같은 지표를 계산할 수 있다.

    Average Cost per Run
    
    Average LLM Calls per Run
    
    Average Tool Calls per Run
    
    Human Review Rate
    
    Completion Rate
    
    Average Steps
    
    P95 Run Duration
    

    Agent가 유연해졌지만 고정 Workflow보다 비용이 지나치게 증가하고 있지는 않은지 확인할 수 있다.

    31. 전체 실행 흐름을 다시 따라가 보자

    이제 처음부터 끝까지 한 번에 연결해보자.

    1단계 — Upload

    User
      ↓
    POST /documents
      ↓
    Object Storage
      ↓
    Document Record
      ↓
    Agent Run 생성
    

    State:

    {
      "run_id": "run_123",
      "document_id": "doc_123",
      "status": "CREATED",
      "step": 0
    }
    

    2단계 — START Event

    XADD agent:events *
         event START
         run_id run_123
    

    Worker가 Event를 읽는다.

    Redis Streams
          ↓
    Agent Worker
          ↓
    Load State
          ↓
    Agent Runtime
    

    3단계 — Document 처리

    get_document
          ↓
    extract_document_content
          ↓
    analyze_invoice
    

    결과:

    {
      "vendor": "ABC Corp",
      "amount": 12500000,
      "confidence": 0.58
    }
    

    4단계 — Human Review

    confidence = 0.58
          ↓
    HUMAN_REVIEW
          ↓
    Review Task 생성
          ↓
    Checkpoint
          ↓
    WAITING_HUMAN
          ↓
    Runtime 종료
    

    이 시점에 실행 중인 Agent 프로세스는 없다.

    DB에 논리적인 Agent Run만 남아 있다.

    5단계 — 5시간 뒤 승인

    Human
      ↓
    APPROVE
      ↓
    POST /reviews/review_123
    

    Resume Event가 발생한다.

    XADD agent:events *
         event RESUME
         run_id run_123
    

    6단계 — Resume

    Agent Worker
          ↓
    Load State
          ↓
    Load Human Result
          ↓
    RUNNING
          ↓
    Agent Runtime
    

    7단계 — ERP 등록

    create_erp_record
          ↓
    operation_id 확인
          ↓
    ERP API
          ↓
    SUCCESS
    

    8단계 — 완료

    {
      "run_id": "run_123",
      "document_id": "doc_123",
      "status": "COMPLETED",
      "last_tool": "create_erp_record"
    }
    

    최종 Timeline은 다음과 같다.

    Invoice Upload
          ↓
    Agent Start
          ↓
    get_document
          ↓
    extract_document_content
          ↓
    analyze_invoice
          ↓
    confidence = 0.58
          ↓
    Human Review
          ↓
    Checkpoint
          ↓
    WAITING_HUMAN
    
          ... 5시간 후 ...
    
    Human APPROVE
          ↓
    Resume Event
          ↓
    Load Checkpoint
          ↓
    Agent Resume
          ↓
    create_erp_record
          ↓
    COMPLETED
    

    32. 이 구조에서 Agent가 실제로 판단하는 것은 생각보다 많지 않다

    전체 시스템을 보면 Agent가 모든 것을 결정하는 것처럼 보일 수 있다.

    하지만 실제로 Agent가 판단할 영역을 다시 분리해보면 그렇지 않다.

    코드가 담당하는 영역:

    File Size Limit
    Authentication
    Authorization
    State Transition
    Max Steps
    Timeout
    Retry Limit
    Idempotency
    Consumer Recovery
    Unique Constraint
    Human Approval Policy
    

    Agent가 담당할 수 있는 영역:

    Document Classification
    Tool Selection
    OCR 필요 여부
    Alternative Analysis Strategy
    Semantic Validation
    Additional Analysis 필요 여부
    Human Review 필요 여부
    

    이 구분이 중요하다.

    LLM이 잘하는 것은 모호한 상황에서 의미를 해석하고 다음 행동을 선택하는 것이다.

    반면 시스템의 안전성과 일관성을 보장하는 것은 코드가 더 잘한다.


    33. “Agent가 알아서 한다”는 표현을 조심해야 한다

    Agent 시스템을 설명할 때 흔히 다음과 같이 표현한다.

    “Agent가 알아서 Tool을 선택하고 업무를 처리한다.”

    개념적으로는 맞다.

    하지만 운영 시스템에서는 조금 더 정확하게 표현하는 편이 좋다.

    Agent
        ↓
    허용된 범위 안에서
        ↓
    사용 가능한 Tool 중
        ↓
    현재 Context에 적합한 행동을 제안한다
        ↓
    Runtime Policy 검증
        ↓
    실행
    

    즉 Agent의 자유는 Runtime이 허용한 경계 안에서의 자유다.

    이 경계가 없다면 Agent는 자동화 시스템이 아니라 예측하기 어려운 프로그램이 될 수 있다.


    34. MCP를 사용하는 이유도 다시 명확해진다

    이번 시스템에서 MCP가 Agent Runtime을 만들어준 것은 아니다.

    Redis Streams를 대신한 것도 아니다.

    State Store를 제공한 것도 아니다.

    Human Review를 구현해준 것도 아니다.

    MCP가 담당한 것은 명확하다.

    Agent
      ↓
    "What can I do?"
      ↓
    MCP
      ↓
    Available Capabilities
    

    예를 들어 기존 백엔드에 다음 기능이 있었다.

    Document Service
    OCR Service
    Invoice Analyzer
    ERP Service
    Notification Service
    

    MCP를 통해 이것을 Agent가 사용할 수 있는 Capability로 제공했다.

    get_document
    run_ocr
    analyze_invoice
    create_erp_record
    send_notification
    

    MCP의 가치는 기존 백엔드의 능력을 Agent가 사용할 수 있는 공통 인터페이스로 연결하는 데 있다.


    35. 처음부터 모든 것을 MCP로 만들 필요도 없다

    기존 시스템에 REST API가 있다면 MCP Tool 내부에서 REST API를 호출해도 된다.

    Agent
      ↓
    MCP Tool
      ↓
    REST API
      ↓
    Existing Service
    

    같은 프로세스 안의 Service Layer를 직접 호출할 수도 있다.

    Agent
      ↓
    MCP Tool
      ↓
    Service Layer
      ↓
    Repository
    

    중요한 것은 기술을 많이 사용하는 것이 아니다.

    기존 시스템의 경계를 유지하면서 Agent에게 필요한 Capability를 제공하는 것이다.


    36. 실제 서비스로 확장한다면

    이번 예제를 Production 수준으로 확장한다면 다음 항목을 추가로 고려할 수 있다.

    Authentication / Authorization
    
    Tenant Isolation
    
    Tool Permission
    
    Secret Management
    
    Distributed Lock
    
    Pending Message Recovery
    
    Dead Letter Queue
    
    Rate Limit
    
    Circuit Breaker
    
    Structured Logging
    
    Tracing
    
    Metrics
    
    LLM Cost Tracking
    
    Prompt / Tool Versioning
    
    Agent Evaluation
    
    Human Review SLA
    
    Audit Log
    

    특히 Multi-Tenant SaaS라면 모든 Tool 호출에 Tenant Context를 명확하게 전달하고 서버 측에서 다시 검증해야 한다.

    Agent가 전달한 tenant_id를 그대로 신뢰해서는 안 된다.


    37. Agent Version도 기록하는 것이 좋다

    Agent Prompt와 Tool Description이 변경되면 같은 문서에서도 실행 결과가 달라질 수 있다.

    따라서 Run에 다음 정보를 기록하는 것이 좋다.

    {
      "agent_version": "1.3.0",
      "prompt_version": "invoice-v4",
      "toolset_version": "2026-09-01",
      "model": "example-model"
    }
    

    나중에 품질이 갑자기 달라졌을 때 어떤 변경 때문인지 추적하기 쉬워진다.

    Agent도 결국 운영되는 Software다.

    Versioning이 필요하다.


    38. 테스트는 어떻게 할까?

    Agent 시스템이라고 테스트를 LLM에게 맡길 수는 없다.

    계층별로 나누는 것이 좋다.

    Tool Test

    Input
     ↓
    Tool
     ↓
    Expected Output
    

    Tool 자체는 가능한 한 Deterministic하게 테스트한다.

    Runtime Test

    LLM Decision을 Mock으로 고정할 수 있다.

    Decision 1 = get_document
    Decision 2 = analyze_invoice
    Decision 3 = HUMAN_REVIEW
    

    그 상태에서 Runtime이 올바르게 State를 전환하는지 확인한다.

    Resume Test

    WAITING_HUMAN
          ↓
    APPROVE Event
          ↓
    RUNNING
          ↓
    COMPLETED
    

    Failure Test

    Tool Timeout
    Worker Crash
    Duplicate Event
    Duplicate Write
    Redis Redelivery
    Human Review Expired
    

    Happy Path보다 이런 테스트가 운영 환경에서는 더 중요할 수 있다.


    39. Agent Evaluation도 필요하다

    Runtime이 안정적이라고 Agent의 판단이 좋은 것은 아니다.

    다음 질문은 별도의 Evaluation 영역이다.

    올바른 Tool을 선택했는가?
    
    불필요한 Tool을 호출하지 않았는가?
    
    Human Review를 너무 자주 요청하지 않는가?
    
    잘못된 문서를 Invoice로 분류하지 않는가?
    
    비용 대비 충분히 좋은 결과를 내는가?
    

    따라서 두 가지 품질을 구분할 필요가 있다.

    Runtime Reliability
            +
    Agent Decision Quality
    

    둘 다 좋아야 실제 업무에서 사용할 수 있다.


    40. 최종 아키텍처

    이번 시리즈에서 만든 구조를 마지막으로 한 번 정리해보자.

                             ┌──────────────────┐
                             │       User       │
                             └────────┬─────────┘
                                      │
                                   Upload
                                      │
                                      ↓
                             ┌──────────────────┐
                             │     FastAPI      │
                             └────────┬─────────┘
                                      │
                         ┌────────────┴────────────┐
                         ↓                         ↓
                  Object Storage             Redis Streams
                                                   │
                                                   ↓
                                             Agent Worker
                                                   │
                                                   ↓
                                            Agent Runtime
                                      ┌────────────┼────────────┐
                                      ↓            ↓            ↓
                                   State      Orchestrator   Checkpoint
                                                   │
                                                   ↓
                                              LLM Decision
                                                   │
                                                   ↓
                                               MCP Client
                                                   │
                                                   ↓
                                               MCP Server
                                                   │
                                 ┌─────────────────┼─────────────────┐
                                 ↓                 ↓                 ↓
                             Document          Analysis          Business
                               Tools             Tools             Tools
                                 │                 │                 │
                                 └─────────────────┼─────────────────┘
                                                   ↓
                                              Service Layer
                                                   │
                                  ┌────────────────┼────────────────┐
                                  ↓                ↓                ↓
                               Storage          Database       External API
                                                                     │
                                                                     ↓
                                                                   ERP
    
    Human Review
         │
         └──────────→ Resume Event ──────────→ Redis Streams
    

    이 구조에서 Agent는 시스템 전체가 아니다.

    Agent는 기존 백엔드 위에 추가된 의사결정 계층이다.


    41. 이 시리즈에서 말하고 싶었던 것

    처음의 질문은 단순했다.

    파일이 업로드되면 Agent가 깨어나 알아서 분석하고 저장하도록 만들 수 없을까?

    가능하다.

    하지만 실제 시스템을 만들어보면 중요한 것은 “Agent가 얼마나 똑똑한가”만이 아니다.

    Event를 어떻게 전달할 것인가?
    
    State를 어디에 저장할 것인가?
    
    실패하면 어떻게 복구할 것인가?
    
    중복 실행을 어떻게 막을 것인가?
    
    사람이 필요하면 어떻게 멈출 것인가?
    
    몇 시간 뒤 어떻게 다시 시작할 것인가?
    
    Agent에게 어디까지 권한을 줄 것인가?
    
    무엇을 했는지 어떻게 추적할 것인가?
    

    결국 우리가 다시 만나게 되는 것은 익숙한 백엔드 엔지니어링 문제들이다.

    Queue, State, Retry, Idempotency, Timeout, Authorization, Observability, Recovery.

    Agent가 등장했다고 이 문제들이 사라지는 것은 아니다.

    오히려 Agent의 실행 경로가 동적으로 변하기 때문에 더 중요해진다.


    42. 가장 중요한 원칙

    이 시리즈를 한 문장으로 정리한다면 다음과 같다.

    Agent에게 판단을 맡기되, 실행의 통제권까지 모두 넘기지는 않는다.

    코드가 잘하는 것은 코드에게 맡긴다.

    Permission
    Timeout
    Retry Limit
    Idempotency
    State Transition
    Validation
    

    Agent가 잘하는 것은 Agent에게 맡긴다.

    Semantic Understanding
    Tool Selection
    Contextual Decision
    Alternative Strategy
    Human Review 판단
    

    그리고 MCP가 그 둘 사이에서 기존 백엔드의 Capability를 Agent에게 연결한다.

    Backend Capability
            ↓
           MCP
            ↓
          Agent
    

    이렇게 보면 Agent 시스템은 기존 백엔드와 완전히 다른 새로운 세계가 아니다.

    우리가 이미 알고 있는 Backend Architecture 위에 LLM 기반 의사결정 계층을 추가하는 것에 가깝다.


    시리즈를 마치며

    Part 1에서는 Worker에서 Agent Orchestrator로 넘어가는 이유를 살펴봤다.

    Part 2에서는 Python으로 MCP Tool을 만들었다.

    Part 3에서는 Agent Loop, State, Retry, Checkpoint, Human-in-the-Loop을 통해 운영 가능한 Agent Runtime을 설계했다.

    그리고 이번 Part 4에서는 그것들을 하나로 연결했다.

    Part 1
    Why Agent?
    
            ↓
    
    Part 2
    How to build MCP Tools?
    
            ↓
    
    Part 3
    How to run Agents safely?
    
            ↓
    
    Part 4
    How to connect everything?
    

    처음에는 단순한 Worker였다.

    File
     ↓
    Worker
     ↓
    Result
    

    마지막에는 다음 구조가 되었다.

    Event
     ↓
    Agent Runtime
     ↓
    Decision
     ↓
    MCP Tool
     ↓
    Existing Backend
     ↓
    State / Human / Recovery
    

    Agent를 실제 업무에 연결하는 순간부터 중요한 것은 멋진 Demo가 아니다.

    실패해도 복구되고, 중복 실행에도 안전하고, 사람이 개입할 수 있으며, 어떤 판단과 실행이 있었는지 추적할 수 있는 시스템을 만드는 것이다.

    그 위에서 Agent의 판단 능력이 비로소 실제 비즈니스의 자동화 능력이 된다.

  • AI Agent & MCP 3부 — MCP Tool을 Agent에게 맡기면 정말 더 좋은가?

    AI Agent & MCP 3부 — MCP Tool을 Agent에게 맡기면 정말 더 좋은가?

    Agent Loop, 상태 관리, Retry 그리고 Human-in-the-Loop

    1부에서는 Worker에서 Agent Orchestrator로 확장하는 이유를 살펴봤고, 2부에서는 Python 백엔드 기능을 MCP Tool로 제공했다.

    이제 Agent에게 get_document, extract_document_content, run_ocr, analyze_document, save_analysis, request_human_review 같은 능력이 생겼다.

    그렇다면 Agent에게 “이 문서를 알아서 처리해”라고 하면 끝일까?

    개발 데모에서는 가능하다. 하지만 운영 환경에서는 여기서부터 진짜 문제가 시작된다.

    같은 Tool을 반복 호출하면?
    잘못된 Tool을 선택하면?
    Tool이 실패하면?
    Worker가 죽으면?
    이미 저장한 데이터를 다시 저장하면?
    사람의 승인이 필요하면?
    5시간 뒤 승인되면 어디서부터 다시 시작할까?
    

    MCP Tool을 만드는 것과 운영 가능한 Agent 시스템을 만드는 것은 다른 문제다.


    1. Agent의 본질은 Loop다

    가장 단순한 Agent 실행 구조는 다음과 같다.

            ┌──────────────────┐
            │                  │
            ↓                  │
       LLM Decision            │
            ↓                  │
         Tool Call             │
            ↓                  │
        Tool Result ───────────┘
    

    현재 State와 Tool 목록을 LLM에게 전달하고, LLM이 다음 Tool을 선택하고, 실행 결과를 다시 State에 넣는다.

    async def run_agent(state):
        while True:
            decision = await llm.decide(
                state=state,
                tools=available_tools,
            )
    
            if decision.type == "finish":
                return state
    
            result = await call_tool(
                name=decision.tool_name,
                arguments=decision.arguments,
            )
    
            state.add_tool_result(
                tool_name=decision.tool_name,
                result=result,
            )
    

    문제는 while True다. 실행 경로가 코드에 고정되어 있지 않기 때문에 Runtime이 Agent의 행동을 제한해야 한다.

    2. 반드시 종료 조건이 있어야 한다

    COMPLETED
    FAILED
    WAITING_HUMAN
    MAX_STEPS_EXCEEDED
    TIMEOUT
    

    예를 들어 최대 Step을 제한한다.

    MAX_STEPS = 10
    
    async def run_agent(state):
        while state.step < MAX_STEPS:
            decision = await decide_next_action(state)
    
            if decision.type == "finish":
                state.status = "COMPLETED"
                return state
    
            result = await execute_tool(decision)
            state.add_result(result)
            state.step += 1
    
        state.status = "MAX_STEPS_EXCEEDED"
        return state
    

    Agent에게 판단의 자유를 주더라도 실행 범위까지 무제한으로 주어서는 안 된다.

    3. Max Steps만으로 충분하지 않다

    다음처럼 같은 패턴을 반복할 수도 있다.

    extract_document_content
            ↓
    run_ocr
            ↓
    extract_document_content
            ↓
    run_ocr
            ↓
    ...
    

    따라서 Loop Detection도 필요하다.

    중요한 것은 Agent가 계속 행동하고 있다는 사실과 문제를 해결하고 있다는 사실은 다르다는 점이다.

    4. Tool 실패와 Agent 실패는 다르다

    Tool Failure
         ↓
    Recoverable?
       ┌────┴────┐
       ↓         ↓
      YES        NO
       ↓         ↓
    Retry /     Agent
    Alternative FAILED
    

    예를 들어 일시적인 OCR 오류는 복구 가능할 수 있다.

    {
      "success": false,
      "reason": "OCR_TEMPORARY_ERROR",
      "retryable": true
    }
    

    지원하지 않는 파일 형식은 재시도해도 의미가 없을 수 있다.

    {
      "success": false,
      "reason": "UNSUPPORTED_FILE_FORMAT",
      "retryable": false
    }
    

    5. Retry는 Agent가 할까, Tool이 할까?

    네트워크 Timeout이나 일시적인 5xx 오류까지 LLM에게 판단시킬 필요는 없다.

    async def run_ocr(document_id):
        for attempt in range(3):
            try:
                return await ocr_client.analyze(document_id)
            except TemporaryError:
                if attempt == 2:
                    raise
                await asyncio.sleep(2 ** attempt)
    

    반면 분석 결과의 신뢰도가 낮아 다른 Analyzer나 Human Review를 선택하는 것은 의미적 판단이다.

    Technical Retry
          ↓
    Tool / Service Layer
    
    Semantic Retry
          ↓
    Agent / Orchestrator
    

    6. Timeout도 계층별로 나눈다

    HTTP Timeout
    LLM Timeout
    Tool Timeout
    Agent Step Timeout
    Agent Run Timeout
    Human Review Timeout
    

    Human Review는 일반 Timeout과 다르다. 몇 시간 동안 프로세스를 붙잡아 두는 대신 Workflow를 Pause하는 편이 좋다.

    7. Agent에게 State가 필요한 이유

    {
      "run_id": "run_123",
      "document_id": "doc_123",
      "status": "RUNNING",
      "step": 3,
      "last_tool": "analyze_document",
      "document_type": "invoice",
      "confidence": 0.58,
      "retry_count": 0
    }
    

    Agent Runtime은 이 State를 기준으로 다음 행동을 결정한다.

    8. State와 LLM Context는 같은 것이 아니다

    LLM Context
    - System Prompt
    - Tool Definitions
    - Previous Tool Results
    - Current Task
    
    Runtime State
    - run_id
    - status
    - step
    - retry_count
    - last_tool
    - checkpoint
    - document_id
    

    LLM Context는 모델의 판단을 위한 정보이고, Runtime State는 시스템이 Agent 실행을 관리하고 복구하기 위한 정보다.

    9. State는 메모리에만 두면 안 된다

    Worker가 죽어도 복구하려면 중요한 State를 외부 저장소에 기록해야 한다.

    agent_runs
    - run_id
    - document_id
    - status
    - current_step
    - last_tool
    - retry_count
    - created_at
    - updated_at
    
    agent_steps
    - id
    - run_id
    - step_no
    - tool_name
    - input
    - output
    - status
    - started_at
    - finished_at
    - error
    

    10. Checkpoint를 만든다

    Tool Execute
         ↓
    Tool Result
         ↓
    Save Checkpoint
         ↓
    Next Decision
    

    Worker가 죽으면 새 Worker가 마지막 Checkpoint를 불러와 Resume할 수 있다.

    Worker Crash
         ↓
    New Worker
         ↓
    Load run_123
         ↓
    Last Checkpoint
         ↓
    Resume
    

    11. Resume은 생각보다 어렵다

    다음 상황을 보자.

    Step 5
    save_analysis
          ↓
    Database INSERT 성공
          ↓
    Worker Crash
          ↓
    Checkpoint 저장 실패
    

    DB에는 데이터가 있지만 State에는 성공 기록이 없다. 새 Worker는 save_analysis를 다시 호출할 수 있다.

    그래서 Write Tool의 Idempotency가 중요하다.

    12. Agent 시스템의 Idempotency

    예를 들어 operation_id를 사용한다.

    {
      "operation_id": "run_123_step_5",
      "document_id": "doc_123"
    }
    

    DB에서 operation_id를 Unique하게 관리하면 재호출 시 이전 결과를 반환할 수 있다.

    First Call
    run_123_step_5
          ↓
    INSERT
          ↓
    SUCCESS
    
    Second Call
    run_123_step_5
          ↓
    Already Processed
          ↓
    Return Previous Result
    

    13. Human-in-the-Loop가 들어오면 구조가 달라진다

    Invoice 분석 결과가 다음과 같다고 해보자.

    {
      "document_type": "invoice",
      "confidence": 0.58,
      "amount": 12500000
    }
    

    Agent는 Human Review를 선택한다.

    analyze_invoice
          ↓
    confidence = 0.58
          ↓
    request_human_review
    

    사람이 10초 뒤 응답할지, 5시간 뒤 응답할지 알 수 없다. Worker를 계속 실행한 채 기다리는 것은 비효율적이다.

    14. Human Review에서는 Agent를 Pause한다

    Agent
      ↓
    request_human_review
      ↓
    Create Review Task
      ↓
    Save Checkpoint
      ↓
    status = WAITING_HUMAN
      ↓
    Worker 종료
    

    State는 다음처럼 저장할 수 있다.

    {
      "run_id": "run_123",
      "document_id": "doc_123",
      "status": "WAITING_HUMAN",
      "current_step": 5,
      "last_tool": "request_human_review",
      "confidence": 0.58
    }
    

    이 상태에서는 Agent 프로세스가 존재할 필요가 없다.

    15. 사람이 승인하면 Event가 Agent를 다시 깨운다

    5시간 뒤 담당자가 승인했다고 해보자.

    Human
      ↓
    Approve
      ↓
    Review API
      ↓
    Event
    

    Redis Streams를 사용한다면 다음과 같은 Resume Event를 만들 수 있다.

    XADD agent:resume *
         run_id run_123
         action approved
    
    Redis Stream
          ↓
    Agent Worker
          ↓
    Load run_123
          ↓
    Resume Agent
    

    파일 업로드 Event가 Agent를 처음 깨웠듯이 Human Approval Event가 Agent를 다시 깨운다.

    16. Pause와 Resume을 코드로 생각해보자

    async def run_agent(state):
        while True:
            decision = await decide_next_action(state)
    
            if decision.type == "human_review":
                await create_review_task(state)
    
                state.status = "WAITING_HUMAN"
                await save_checkpoint(state)
    
                return
    
            result = await execute_tool(decision)
    
            state.add_result(result)
            await save_checkpoint(state)
    

    사람의 응답이 들어오면 별도 Handler가 실행된다.

    async def resume_agent(run_id, human_result):
        state = await load_state(run_id)
    
        state.human_result = human_result
        state.status = "RUNNING"
    
        await save_checkpoint(state)
        await run_agent(state)
    

    핵심은 await human_response()로 몇 시간 동안 기다리지 않는다는 것이다.

    State를 저장하고 프로세스를 종료한 뒤 Event가 들어오면 다시 실행한다.

    17. Human Review에도 Timeout이 필요하다

    사람이 영원히 응답하지 않을 수도 있다.

    {
      "review_id": "review_123",
      "run_id": "run_123",
      "status": "WAITING",
      "expires_at": "2026-09-14T12:00:00"
    }
    

    만료되면 EXPIRED로 바꾸거나 담당자에게 재알림을 보낼 수 있다.

    WAITING_HUMAN
          ↓
    TTL / Deadline
          ↓
    Expired?
     ┌────┴────┐
     ↓         ↓
    NO        YES
     ↓         ↓
    Wait    Re-notify / EXPIRED
    

    18. Human Review 결과도 구조화해야 한다

    단순히 “승인”만 있는 것은 아니다.

    APPROVE
    REJECT
    EDIT
    

    EDIT라면 사람이 수정한 데이터도 함께 반환할 수 있다.

    {
      "action": "EDIT",
      "data": {
        "vendor": "OpenAI Korea",
        "amount": 12500000
      }
    }
    

    Agent는 Human Result를 새로운 State로 받아 다음 작업을 이어간다.

    19. Agent가 Resume될 때 LLM을 다시 호출해야 할까?

    항상 그럴 필요는 없다.

    사람의 승인 이후 다음 행동이 명확하다면 코드가 바로 실행할 수도 있다.

    Human APPROVE
          ↓
    Policy
          ↓
    create_erp_record
    

    반대로 사람이 수정한 내용에 따라 추가 검증이 필요하다면 Agent에게 다시 판단을 맡길 수 있다.

    Human EDIT
          ↓
    Agent
          ↓
    Validate Again?
          ↓
    Next Tool
    

    여기에서도 같은 원칙이 적용된다.

    결정적인 규칙은 코드가 처리하고, 의미적 판단이 필요한 지점에서 Agent를 사용한다.

    20. Agent의 상태 머신을 명시적으로 만들어보자

    Agent를 자유로운 Loop로만 생각하지 않고 상태 머신으로 보는 것도 유용하다.

    CREATED
       ↓
    RUNNING
       ├──→ COMPLETED
       ├──→ FAILED
       ├──→ WAITING_HUMAN
       │         ↓
       │       RESUME
       │         ↓
       └────── RUNNING
    

    예를 들어 다음 상태를 정의할 수 있다.

    CREATED
    RUNNING
    WAITING_HUMAN
    COMPLETED
    FAILED
    EXPIRED
    CANCELLED
    

    상태 전이를 명확하게 정의하면 운영과 장애 복구가 쉬워진다.

    21. Tool 실행과 Agent Decision을 모두 기록한다

    Tool 실행 기록뿐 아니라 Agent가 어떤 결정을 내렸는지도 추적할 필요가 있다.

    run_123
    
    Step 1
    Decision: get_document
    Result: SUCCESS
    
    Step 2
    Decision: extract_document_content
    Result: SUCCESS
    
    Step 3
    Decision: analyze_invoice
    Result: confidence 0.58
    
    Step 4
    Decision: request_human_review
    Result: WAITING
    
    5 hours later
    
    Step 5
    Human: APPROVE
    
    Step 6
    Decision: create_erp_record
    Result: SUCCESS
    

    이런 실행 이력은 디버깅, 감사, 비용 분석, Agent 품질 평가에 모두 도움이 된다.

    22. Observability에서 봐야 할 것

    일반 API의 latency와 error rate만으로는 충분하지 않다.

    Agent Run Count
    Completion Rate
    Failure Rate
    Average Steps
    Tool Calls per Run
    LLM Calls per Run
    Average Run Duration
    Human Review Rate
    Retry Rate
    Loop Detection Count
    Tool Error Rate
    Token Usage
    Cost per Run
    

    예를 들어 Human Review 비율이 지나치게 높다면 Agent Prompt나 Analyzer 품질에 문제가 있을 수 있다.

    평균 Step이 갑자기 증가한다면 Agent가 불필요한 Tool을 호출하기 시작했을 수도 있다.

    23. Agent의 비용도 Runtime 문제다

    Agent Loop에서는 LLM 호출이 여러 번 발생할 수 있다.

    Decision 1 → LLM
    Tool
    Decision 2 → LLM
    Tool
    Decision 3 → LLM
    Tool
    Decision 4 → LLM
    

    따라서 Run 단위로 다음을 기록하는 것이 좋다.

    input_tokens
    output_tokens
    llm_calls
    tool_calls
    model
    total_cost
    

    Agent가 더 유연하다는 이유만으로 고정 Workflow보다 훨씬 많은 비용을 사용하고 있지는 않은지 확인해야 한다.

    24. 모든 Tool Result를 LLM Context에 계속 넣어야 할까?

    Agent가 오래 실행되면 Context가 계속 커질 수 있다.

    Step 1 Result
    Step 2 Result
    Step 3 Result
    ...
    Step 30 Result
    

    Runtime State와 LLM Context를 분리하면 이 문제를 다루기 쉬워진다.

    DB에는 전체 이력을 보관하되 LLM에는 현재 판단에 필요한 요약된 State만 전달할 수 있다.

    Full History
        ↓
    Database
    
    Relevant State
        ↓
    LLM Context
    

    이것은 비용과 Context Window 관리에도 중요하다.

    25. MCP Server는 Agent Runtime이 아니다

    역할을 다시 구분해보자.

    Agent Runtime
    - State
    - Loop
    - Retry
    - Pause / Resume
    - Checkpoint
    
    Orchestrator
    - Next Action 결정
    - Tool 선택
    
    MCP
    - Tool 연결
    - Tool Schema
    - Tool 호출
    
    Tool
    - 실제 업무 수행
    

    MCP가 Agent의 상태를 저장하거나 Workflow를 복구해주는 것은 아니다.

    MCP는 Capability Interface이고, Agent Runtime은 실행 엔진이다.

    이 둘을 구분해야 아키텍처가 명확해진다.

    26. 전체 Runtime 구조

                        Event
                          │
                          ↓
                     Agent Worker
                          │
                          ↓
                    Load / Create State
                          │
                          ↓
                     Agent Runtime
                          │
                  ┌───────┴───────┐
                  ↓               ↓
             Orchestrator       State Store
                  │
                  ↓
              LLM Decision
                  │
                  ↓
               MCP Client
                  │
                  ↓
               MCP Server
                  │
                  ↓
                 Tool
                  │
                  ↓
              Service Layer
                  │
                  ↓
            External Systems
    

    Tool Result가 돌아오면 Checkpoint를 저장하고 다음 Decision을 수행한다.

    27. Invoice 예제로 전체 흐름을 따라가 보자

    사용자가 Invoice PDF를 업로드한다.

    Invoice Upload
          ↓
    FastAPI
          ↓
    Object Storage
          ↓
    Redis Stream
          ↓
    Agent Worker
    

    Agent Run을 생성한다.

    {
      "run_id": "run_123",
      "document_id": "doc_123",
      "status": "RUNNING",
      "step": 0
    }
    

    Agent가 문서를 조회한다.

    Step 1
    get_document
          ↓
    SUCCESS
    

    내용을 추출한다.

    Step 2
    extract_document_content
          ↓
    SUCCESS
    

    Invoice를 분석한다.

    Step 3
    analyze_invoice
          ↓
    confidence = 0.58
    

    신뢰도가 낮다.

    Step 4
    request_human_review
          ↓
    WAITING_HUMAN
    

    Checkpoint를 저장하고 Worker는 종료된다.

    28. 5시간 뒤

    담당자가 검토 화면에서 내용을 확인한다.

    금액과 거래처가 맞다.

    Human
      ↓
    APPROVE
    

    Resume Event가 발생한다.

    XADD agent:resume *
         run_id run_123
         action APPROVE
    

    새 Worker가 State를 읽는다.

    Load run_123
          ↓
    status = WAITING_HUMAN
          ↓
    human_result = APPROVE
          ↓
    status = RUNNING
    

    이제 다음 작업을 실행한다.

    create_erp_record
          ↓
    SUCCESS
    

    최종 State를 저장한다.

    {
      "run_id": "run_123",
      "document_id": "doc_123",
      "status": "COMPLETED",
      "last_tool": "create_erp_record"
    }
    

    전체 흐름은 다음과 같다.

    Invoice Upload
          ↓
    Agent Start
          ↓
    Extract
          ↓
    Analyze
          ↓
    Confidence = 0.58
          ↓
    Human Review
          ↓
    Agent Pause
    
          ... 5시간 후 ...
    
    Human Approve
          ↓
    Resume Event
          ↓
    Agent Resume
          ↓
    ERP Registration
          ↓
    Completed
    

    이제 Agent는 단순히 LLM이 Tool 몇 개를 호출하는 프로그램이 아니다.

    29. 실제 운영에서는 Agent를 “오래 실행되는 함수”로 만들지 않는다

    다음과 같은 구현은 피하는 편이 좋다.

    async def process_invoice():
        result = await analyze()
    
        human_result = await wait_for_human()
    
        await register_erp(human_result)
    

    wait_for_human()이 5시간 동안 Worker를 붙잡고 있을 수 있기 때문이다.

    대신 실행을 작은 구간으로 나눈다.

    Execution Segment 1
    Upload → Analyze → Human Review
                     ↓
                   PAUSE
    
    Execution Segment 2
    Human Event → Resume → ERP
    

    Agent Run이라는 논리적 실행은 계속되지만 실제 프로세스는 필요할 때만 살아난다.

    30. Queue와 Agent는 경쟁 관계가 아니다

    Agent를 도입했다고 Redis Streams나 SQS가 필요 없어지는 것은 아니다.

    오히려 Event Driven Architecture와 잘 결합된다.

    Upload Event
         ↓
    Agent Start
    
    Human Event
         ↓
    Agent Resume
    
    Retry Event
         ↓
    Agent Retry
    
    Timeout Event
         ↓
    Agent Expire
    

    Queue는 언제 실행할 것인가를 전달하고 Agent는 실행된 뒤 무엇을 할 것인가를 판단한다.

    31. 어디까지 Agent에게 맡겨야 할까?

    다음은 코드가 처리하는 편이 좋다.

    Permission Check
    File Size Limit
    Retry Count Limit
    Unique Constraint
    Timeout
    State Transition Validation
    Idempotency
    

    다음은 Agent가 개입할 가치가 있다.

    Document Classification
    Tool Selection
    Alternative Analysis Strategy
    Semantic Validation
    Additional Information 필요 여부
    Human Review 필요 여부
    

    Agent를 많이 사용하는 것이 좋은 아키텍처는 아니다.

    불확실성이 있는 곳에만 Agent를 넣는 것이 더 중요하다.

    32. Agent Runtime에서 최소한 필요한 안전장치

    운영 시스템이라면 최소한 다음을 고려해야 한다.

    Max Steps
    Loop Detection
    Tool Timeout
    Agent Run Timeout
    Retry Policy
    Idempotency
    State Persistence
    Checkpoint
    Tool Permission
    Human Approval
    Audit Log
    Observability
    Cost Tracking
    

    이것들이 없으면 Agent는 똑똑한 자동화 도구가 아니라 예측하기 어려운 백엔드 프로세스가 될 수 있다.

    33. 결국 Agent는 새로운 형태의 Workflow Runtime이다

    전통적인 Workflow는 실행 경로가 미리 정해져 있다.

    A → B → C → D
    

    Agent Workflow는 일부 경로가 런타임에 결정된다.

                 ┌→ B → D
                 │
    A → Agent ───┼→ C → E
                 │
                 └→ Human → F
    

    하지만 운영 시스템의 관점에서는 여전히 State, Retry, Timeout, Checkpoint, Resume, Audit이 필요하다.

    즉 Agent가 기존 Workflow Engine의 문제를 없애는 것이 아니다.

    오히려 Workflow Runtime 위에 LLM 기반 의사결정을 추가한다고 보는 편이 정확하다.

    34. 1부부터 다시 연결해보자

    1부의 시작은 아주 단순했다.

    File Upload
        ↓
    Worker
        ↓
    Analyze
        ↓
    Save
    

    업무가 복잡해지면서 판단을 분리했다.

    File Upload
        ↓
    Agent Orchestrator
        ↓
    Tools
    

    2부에서는 Tool을 MCP로 제공했다.

    Agent
      ↓
    MCP Client
      ↓
    MCP Server
      ↓
    Tools
    

    3부에서는 Agent가 실제 운영 환경에서 살아남기 위한 Runtime을 추가했다.

                      Agent Runtime
                           │
            ┌──────────────┼──────────────┐
            ↓              ↓              ↓
          State        Orchestrator    Checkpoint
                           │
                           ↓
                          MCP
                           │
                           ↓
                         Tools
                           │
            ┌──────────────┼──────────────┐
            ↓              ↓              ↓
          Retry          Human          Audit
                         Review
    

    이제 처음의 단순한 Worker가 꽤 다른 형태의 시스템으로 발전했다.

    마치며

    MCP Tool을 Agent에게 연결하는 것 자체는 어렵지 않다.

    어려운 것은 Agent에게 자유를 준 이후다.

    언제 멈출 것인가?
    실패하면 어떻게 복구할 것인가?
    같은 Tool을 다시 호출해도 안전한가?
    Worker가 죽으면 어디서 다시 시작할 것인가?
    사람을 기다려야 하면 어떻게 Pause할 것인가?
    몇 시간 뒤 어떻게 Resume할 것인가?
    Agent가 무엇을 했는지 어떻게 추적할 것인가?
    비용이 얼마나 들었는지 어떻게 알 것인가?
    

    이 문제들을 해결하면서 Agent는 단순한 Tool Calling 프로그램에서 상태를 가진 Workflow Runtime으로 바뀐다.

    그리고 여기서 중요한 원칙은 하나다.

    Agent에게 판단을 맡기되, 실행의 통제권까지 모두 넘기지는 않는다.

    LLM은 의미적 판단과 Tool 선택에 강점이 있다.

    반면 Timeout, Retry Limit, Idempotency, Permission, State Transition 같은 규칙은 코드가 더 잘한다.

    둘을 적절하게 결합하는 것이 운영 가능한 Agent 시스템의 핵심이다.

    다음 글

    4부 — Agent Runtime을 실제 Python 코드로 만들어보기

    Redis Streams + State Store + MCP로 Pause/Resume 가능한 Agent 구현

    다음 편에서는 지금까지의 개념을 실제 코드 구조로 내려볼 수 있다.

    FastAPI
       ↓
    Redis Streams
       ↓
    Agent Worker
       ↓
    Agent Runtime
       ├── State Store
       ├── Orchestrator
       └── MCP Client
              ↓
           MCP Server
              ↓
            Tools
    

    그리고 다음 시나리오를 실제 구현 대상으로 삼을 수 있다.

    Invoice Upload
          ↓
    Agent 실행
          ↓
    분석
          ↓
    Human Review 요청
          ↓
    WAITING_HUMAN
          ↓
    Worker 종료
    
          ... 승인 Event ...
    
    Agent Resume
          ↓
    ERP 등록
          ↓
    COMPLETED
    

    여기까지 구현하면 지금까지 설명한 Agent Architecture가 실제 동작하는 백엔드 프로그램의 형태를 갖추게 된다.

  • AI Agent & MCP 2부 — Python으로 파일 분석용 MCP Tool 직접 만들어보기

    AI Agent & MCP 2부 — Python으로 파일 분석용 MCP Tool 직접 만들어보기

    Agent가 사용할 수 있는 백엔드 기능을 MCP Server로 제공하기

    1부에서는 파일 업로드 이후 모든 처리를 하나의 Worker가 담당하는 구조에서 출발했다.

    File Upload
        ↓
    Worker
        ↓
    Parse
        ↓
    Analyze
        ↓
    Save
    

    파일 종류와 업무 규칙이 다양해지면 Worker 내부의 판단 로직도 늘어난다. 그래서 이번에는 판단과 실행을 분리한다.

    File Upload
         ↓
    Event
         ↓
    Agent Worker
         ↓
    Orchestrator
         ↓
    MCP
         ↓
    Tools
    

    Agent는 현재 상황을 보고 무엇을 해야 할지 판단하고, 실제 작업은 Tool이 수행한다.


    1. MCP는 비즈니스 로직이 아니다

    MCP Server 안에 S3 접속, DB 조회, PDF Parsing, LLM 호출, 저장 로직을 모두 넣어버리면 MCP Tool이 새로운 비즈니스 로직 계층이 된다.

    기존 백엔드가 다음 구조라면 그대로 활용하는 편이 낫다.

    API
     ↓
    Service
     ↓
    Repository
     ↓
    Database
    

    MCP는 새로운 접근 경로를 추가한다.

                 FastAPI
                    │
                    ↓
                 Service
                    ↑
                    │
                 MCP Tool
                    ↑
                    │
                  Agent
    

    즉 FastAPI와 MCP Tool이 동일한 Service Layer를 사용할 수 있다.

    2. 예제 프로젝트 구조

    document-agent/
    │
    ├── mcp_server.py
    ├── services/
    │   ├── document_service.py
    │   ├── analysis_service.py
    │   └── storage_service.py
    ├── repositories/
    │   └── document_repository.py
    └── models/
        └── document.py
    

    mcp_server.py는 Tool 정의, services/는 업무 로직, repositories/는 Database 접근을 담당한다.

    3. 가장 작은 MCP Server

    from mcp.server.fastmcp import FastMCP
    
    mcp = FastMCP("document-tools")
    
    @mcp.tool()
    def hello(name: str) -> str:
        return f"Hello {name}"
    

    Agent에게 중요한 것은 함수 내부 구현보다 이 Tool이 무엇을 하는가다.

    4. 첫 번째 Tool: get_document

    class DocumentService:
        def get_document(self, document_id: str):
            return {
                "document_id": document_id,
                "filename": "invoice_2026.pdf",
                "content_type": "application/pdf",
                "storage_path": f"documents/{document_id}/invoice_2026.pdf",
                "status": "UPLOADED",
            }
    
    from mcp.server.fastmcp import FastMCP
    from services.document_service import DocumentService
    
    mcp = FastMCP("document-tools")
    document_service = DocumentService()
    
    @mcp.tool()
    def get_document(document_id: str) -> dict:
        """
        Retrieve metadata for an uploaded document.
    
        Use this tool when you need information about a document
        before deciding how the document should be processed.
        """
        return document_service.get_document(document_id)
    

    Agent Tool에서는 Docstring이 단순 개발 문서가 아니다. Tool 설명은 Agent의 Tool 선택에 영향을 준다.

    5. Tool 이름과 Description은 중요하다

    process 같은 이름보다 extract_invoice처럼 행동을 명확하게 드러내는 이름이 좋다.

    extract_invoice
    
    Extract structured invoice fields including
    invoice number, vendor, date, amount and currency
    from an uploaded invoice document.
    

    Agent가 이름과 설명을 보고 언제 이 Tool을 사용할지 판단할 수 있어야 한다.

    6. 두 번째 Tool: extract_document_content

    class DocumentService:
        def extract_content(self, document_id: str):
            document = self.get_document(document_id)
            content_type = document["content_type"]
    
            if content_type == "application/pdf":
                return self._extract_pdf(document)
    
            if content_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
                return self._extract_excel(document)
    
            raise ValueError(f"Unsupported content type: {content_type}")
    
    @mcp.tool()
    def extract_document_content(document_id: str) -> dict:
        """
        Extract machine-readable content from an uploaded document.
        """
        return document_service.extract_content(document_id)
    
    Agent
      ↓
    extract_document_content
      ↓
    DocumentService
      ↓
    PDF Parser
    

    7. Tool을 너무 작게 나누지 않는다

    download_file, open_pdf, read_page, normalize_text 같은 내부 함수를 전부 Tool로 만들면 Agent의 의사결정 공간만 커진다.

    MCP Tool의 단위는 내부 구현 단계가 아니라 Agent가 선택할 가치가 있는 행동의 단위여야 한다.

    8. 세 번째 Tool: analyze_document

    class AnalysisService:
        def analyze(self, document_id: str, text: str):
            return {
                "document_id": document_id,
                "document_type": "invoice",
                "summary": "Software service invoice",
                "confidence": 0.94,
            }
    
    analysis_service = AnalysisService()
    
    @mcp.tool()
    def analyze_document(document_id: str, text: str) -> dict:
        """
        Analyze extracted document content and return
        structured analysis information including confidence.
        """
        return analysis_service.analyze(
            document_id=document_id,
            text=text,
        )
    

    9. 네 번째 Tool: save_analysis

    @mcp.tool()
    def save_analysis(document_id: str, analysis: dict) -> dict:
        """
        Persist a validated document analysis result.
    
        Use this tool only when the analysis result
        is considered valid and ready to be stored.
        """
        return analysis_service.save_analysis(
            document_id=document_id,
            analysis=analysis,
        )
    

    Description에는 무엇을 하는지만 아니라 언제 사용해야 하는지도 적어주는 것이 좋다.

    10. Agent에게 네 가지 능력이 생겼다

    get_document
    extract_document_content
    analyze_document
    save_analysis
    

    Agent 코드에는 PDF Parser나 Database 코드가 없다. Agent는 Tool을 선택할 뿐이다.

    11. MCP Server 전체 형태

    from mcp.server.fastmcp import FastMCP
    from services.document_service import DocumentService
    from services.analysis_service import AnalysisService
    
    mcp = FastMCP("document-tools")
    document_service = DocumentService()
    analysis_service = AnalysisService()
    
    @mcp.tool()
    def get_document(document_id: str) -> dict:
        """Retrieve metadata for an uploaded document."""
        return document_service.get_document(document_id)
    
    @mcp.tool()
    def extract_document_content(document_id: str) -> dict:
        """Extract machine-readable content from an uploaded document."""
        return document_service.extract_content(document_id)
    
    @mcp.tool()
    def analyze_document(document_id: str, text: str) -> dict:
        """Analyze document content and return structured information."""
        return analysis_service.analyze(document_id=document_id, text=text)
    
    @mcp.tool()
    def save_analysis(document_id: str, analysis: dict) -> dict:
        """Persist a validated document analysis result."""
        return analysis_service.save_analysis(
            document_id=document_id,
            analysis=analysis,
        )
    
    if __name__ == "__main__":
        mcp.run()
    

    MCP Server 자체가 비즈니스 로직의 중심일 필요는 없다. 기존 기능을 Agent가 사용할 수 있도록 인터페이스를 제공하면 충분하다.

    12. Agent는 Tool을 어떻게 발견할까?

    Tools
    
    1. get_document
       input: document_id
    
    2. extract_document_content
       input: document_id
    
    3. analyze_document
       input: document_id, text
    
    4. save_analysis
       input: document_id, analysis
    

    Agent는 현재 상태와 Tool 목록을 보고 다음 행동을 선택한다.

    get_document
          ↓
    extract_document_content
          ↓
    analyze_document
          ↓
    save_analysis
    

    13. 일반적인 함수 호출과 무엇이 다른가

    일반 Python 프로그램에서는 개발자가 호출 순서를 작성한다.

    document = get_document(document_id)
    content = extract_document_content(document_id)
    analysis = analyze_document(document_id, content["text"])
    save_analysis(document_id, analysis)
    

    Agent 구조에서는 다음과 같다.

    Current State
         +
    Available Tools
         +
    LLM
         ↓
    Next Tool
    

    Tool은 같지만 누가 실행 순서를 결정하는지가 달라진다.

    14. Tool 입력은 명확해야 한다

    @mcp.tool()
    def analyze_document(
        document_id: str,
        text: str,
    ) -> dict:
        ...
    

    필요하면 Pydantic 모델로 구조를 명확하게 만들 수 있다.

    from pydantic import BaseModel
    
    class DocumentAnalysisInput(BaseModel):
        document_id: str
        text: str
    
    class DocumentAnalysisResult(BaseModel):
        document_id: str
        document_type: str
        summary: str
        confidence: float
    

    15. 반환값도 Agent가 이해하기 쉬워야 한다

    {
      "success": true,
      "document_id": "doc_123",
      "status": "SAVED"
    }
    
    {
      "type": "invoice",
      "confidence": 0.94,
      "requires_review": false
    }
    

    Tool의 Output은 다음 Agent Decision의 입력이기도 하다.

    16. 오류도 Tool의 결과다

    PDF에서 텍스트를 찾지 못한 것은 스캔 PDF라는 업무 상태일 수 있다.

    {
      "success": false,
      "reason": "NO_TEXT_FOUND",
      "suggested_action": "OCR"
    }
    
    extract_document_content
            ↓
    NO_TEXT_FOUND
            ↓
          Agent
            ↓
         run_ocr
    

    업무적으로 복구 가능한 상태와 시스템 오류를 구분하는 것이 중요하다.

    17. OCR Tool을 추가해보자

    @mcp.tool()
    def run_ocr(document_id: str) -> dict:
        """
        Extract text from a scanned document using OCR.
    
        Use this tool when normal document text extraction fails.
        """
        return document_service.run_ocr(document_id)
    
    get_document
          ↓
    extract_document_content
          ↓
    NO_TEXT_FOUND
          ↓
    run_ocr
          ↓
    analyze_document
          ↓
    save_analysis
    

    18. Human Review Tool

    @mcp.tool()
    def request_human_review(document_id: str, reason: str) -> dict:
        """
        Request human review when analysis cannot be completed
        with sufficient confidence.
        """
        return {
            "success": True,
            "document_id": document_id,
            "status": "WAITING_HUMAN_REVIEW",
            "reason": reason,
        }
    

    AI가 처리할 수 있는 것은 자동화하고 판단하기 어려운 것은 사람에게 넘길 수 있다.

    19. 저장 Tool에는 특별한 문제가 있다

    get_document는 두 번 호출해도 큰 문제가 없지만 save_analysis를 두 번 호출하면 중복 데이터가 생길 수 있다. ERP 등록, 이메일 전송, 결제 승인 같은 Tool은 더 위험하다.

    20. Idempotency를 생각해야 한다

    같은 요청을 여러 번 실행해도 결과가 한 번 실행한 것과 같도록 만들어야 한다.

    def save_analysis(document_id: str, analysis: dict, analysis_version: str):
        existing = repository.find_analysis(
            document_id=document_id,
            analysis_version=analysis_version,
        )
    
        if existing:
            return {
                "success": True,
                "already_exists": True,
                "analysis_id": existing.id,
            }
    
        result = repository.create_analysis(
            document_id=document_id,
            analysis=analysis,
            analysis_version=analysis_version,
        )
    
        return {
            "success": True,
            "already_exists": False,
            "analysis_id": result.id,
        }
    

    21. 읽는 Tool과 변경하는 Tool을 구분한다

    Read Tool
    - get_document
    - search_documents
    - get_analysis
    
    Write Tool
    - save_analysis
    - update_document
    - request_human_review
    - send_email
    - create_erp_record
    

    Write Tool에는 Validation, Idempotency, Authorization, Audit Log 같은 추가 통제가 필요하다.

    22. Agent가 Tool을 선택한다고 권한까지 갖는 것은 아니다

    Agent
      ↓
    Tool Request
      ↓
    Policy / Authorization
      ↓
    Tool Execution
    

    Agent가 Tool을 선택할 권한과 실제 시스템을 변경할 권한은 분리해서 생각해야 한다.

    23. Tool 실행 기록을 남긴다

    run_abc123
    
    10:00:01 get_document               SUCCESS
    10:00:02 extract_document_content   FAILED
    10:00:03 run_ocr                    SUCCESS
    10:00:08 analyze_document           SUCCESS
    10:00:11 save_analysis              SUCCESS
    

    run_id, document_id, tool_name, input, output, status, error, duration_ms 등을 기록하면 Agent 실행을 추적할 수 있다.

    24. Tool이 많아지면 MCP Server를 어떻게 나눌까?

                    Agent
                      │
               ┌──────┼──────┐
               ↓      ↓      ↓
           File MCP  AI MCP  Business MCP
    

    처음부터 지나치게 나누면 Deployment, Authentication, Network, Monitoring, Versioning 같은 운영 부담이 늘어난다. 처음에는 단순하게 시작하는 편이 현실적이다.

    25. 우리가 만든 구조를 다시 보자

                         Agent
                           │
                           ↓
                     Orchestrator
                           │
                           ↓
                      MCP Client
                           │
                           ↓
                      MCP Server
                           │
              ┌────────────┼────────────┐
              ↓            ↓            ↓
        Document Tool  Analysis Tool  Business Tool
              │            │            │
              └────────────┼────────────┘
                           ↓
                     Service Layer
                           │
              ┌────────────┼────────────┐
              ↓            ↓            ↓
           Storage        LLM        Database
    

    파일 업로드 이벤트까지 포함하면 다음과 같다.

    User
     │
     │ Upload
     ↓
    FastAPI
     │
     ├──────────────→ Object Storage
     │
     └── Event
           ↓
     Redis Streams / Queue
           ↓
      Agent Worker
           ↓
      Orchestrator
           ↓
       MCP Client
           ↓
       MCP Server
           │
           ├── get_document
           ├── extract_document_content
           ├── run_ocr
           ├── analyze_document
           ├── save_analysis
           └── request_human_review
                   ↓
              Service Layer
                   │
          ┌────────┼────────┐
          ↓        ↓        ↓
       Storage    LLM    Database
    

    기존 FastAPI, Queue, Service Layer, Database, Object Storage는 그대로 사용할 수 있다. 새롭게 추가되는 핵심은 Orchestrator와 MCP Interface다.

    26. 기존 Worker 방식과 비교해보자

    기존 Worker에서는 실행 순서가 코드에 들어 있다.

    def process_document(document_id):
        document = get_document(document_id)
        content = extract_document_content(document_id)
    
        if not content["text"]:
            content = run_ocr(document_id)
    
        analysis = analyze_document(
            document_id,
            content["text"],
        )
    
        if analysis["confidence"] < 0.8:
            request_human_review(document_id)
            return
    
        save_analysis(document_id, analysis)
    

    업무 규칙이 명확하고 실행 순서가 항상 동일하다면 이런 방식이 더 좋다.

    Agent 구조에서는 개발자가 실행 순서를 모두 작성하는 대신 Agent가 사용할 수 있는 Tool을 정의한다.

    Developer
        ↓
    Tool Definition
        ↓
    Agent
        ↓
    Runtime Decision
    

    같은 Tool을 가지고도 상황에 따라 실행 경로가 달라질 수 있다.

    일반 PDF
    
    get_document
          ↓
    extract_document_content
          ↓
    analyze_document
          ↓
    save_analysis
    
    스캔 PDF
    
    get_document
          ↓
    extract_document_content
          ↓
    NO_TEXT_FOUND
          ↓
    run_ocr
          ↓
    analyze_document
          ↓
    save_analysis
    
    낮은 신뢰도
    
    get_document
          ↓
    extract_document_content
          ↓
    analyze_document
          ↓
    LOW_CONFIDENCE
          ↓
    request_human_review
    

    27. 그렇다고 모든 판단을 LLM에게 맡기지는 않는다

    다음 조건은 Agent에게 물어볼 필요가 없다.

    if file_size > MAX_FILE_SIZE:
        reject_file()
    
    if retry_count >= 3:
        fail_job()
    
    if user_id != document.owner_id:
        raise PermissionError()
    

    시스템에는 크게 두 종류의 판단이 있다.

    Deterministic Decision
            ↓
           Code
    
    Semantic / Contextual Decision
            ↓
           Agent
    

    파일 크기 제한, 권한, Retry 횟수는 코드가 판단한다. 반면 문서 유형, OCR 필요 여부, 적절한 분석 방법, 추가 분석 필요 여부 같은 문제에는 Agent가 개입할 수 있다.

    코드가 잘하는 판단과 LLM이 잘하는 판단을 구분해야 한다.

    28. Agent와 Workflow를 섞는 것이 현실적이다

                     File Upload
                          ↓
                   Fixed Workflow
                          ↓
                 Security Validation
                          ↓
                     Store File
                          ↓
                    Create Record
                          ↓
                     Agent Start
                          ↓
                    Orchestrator
                          │
                ┌─────────┼─────────┐
                ↓         ↓         ↓
              Parser     OCR      Vision
                │         │         │
                └─────────┼─────────┘
                          ↓
                       Analyze
                          ↓
                      Validation
                          ↓
                ┌─────────┴─────────┐
                ↓                   ↓
              Save             Human Review
    

    항상 실행되어야 하는 앞부분은 Deterministic Workflow로 두고, 처리 방법을 선택해야 하는 영역에서 Agent를 사용한다. 중요한 비즈니스 규칙은 다시 코드나 Policy가 통제할 수 있다.

    29. MCP가 여기서 해결하는 문제는 무엇인가?

    Agent와 Tool이 하나의 Python 프로그램 안에 있고 앞으로도 분리할 필요가 없다면 일반적인 Function Calling으로 충분할 수도 있다.

    MCP를 사용한다고 Agent가 갑자기 더 똑똑해지는 것은 아니다. MCP의 중요한 장점은 Agent와 Tool 구현을 분리하는 것이다.

    Agent A ──────┐
                  │
    Agent B ──────┼──→ MCP Server
                  │
    Agent C ──────┘
    

    Tool을 제공하는 쪽은 Agent 프레임워크를 알 필요가 줄어들고, Agent는 Tool 내부 구현을 알 필요가 없다.

    MCP는 Agent와 Capability 사이의 인터페이스를 분리한다.

    30. REST API와는 무엇이 다른가?

    실제 업무 기능은 REST API로도 충분히 제공할 수 있다.

    GET  /documents/{id}
    POST /documents/{id}/extract
    POST /documents/{id}/analyze
    POST /documents/{id}/save
    

    일반적인 API에서는 개발자가 문서를 읽고 Client Code를 작성한다. Agent 환경에서는 Agent가 사용할 수 있는 Tool을 확인하고, 설명과 Schema를 이해하고, 현재 상황에 맞는 Tool을 선택해야 한다.

    Agent
      ↓
    Discover Tools
      ↓
    Understand Description
      ↓
    Understand Schema
      ↓
    Select Tool
      ↓
    Call Tool
    

    따라서 MCP를 REST를 대체하는 기술이라기보다 Agent가 외부 Capability를 발견하고 사용할 수 있도록 연결하는 Protocol로 이해하는 편이 자연스럽다.

    MCP Tool 내부에서 기존 REST API를 호출하는 구조도 가능하다.

    Agent
      ↓
    MCP
      ↓
    Tool
      ↓
    REST API
      ↓
    Existing Backend
    

    31. 기존 시스템에 MCP를 추가한다면

    FastAPI
       ↓
    Service Layer
       ↓
    Repository
       ↓
    MySQL
    
       +
    Object Storage
    
       +
    Redis Streams
    

    전체 시스템을 다시 만들 필요는 없다.

                     Existing Backend
    
    FastAPI ──────→ Service Layer ──────→ Database
                        ↑
                        │
                     MCP Tool
                        ↑
                        │
                    MCP Server
                        ↑
                        │
                      Agent
    

    기존 Redis Streams Worker 중 일부가 Agent 실행을 담당하게 할 수도 있다.

    FastAPI
       ↓
    Redis XADD
       ↓
    Agent Worker
       ↓
    Orchestrator
       ↓
    MCP
       ↓
    Existing Service
    

    기존 Event Driven Architecture를 유지하면서 필요한 부분에만 Agent를 도입할 수 있다.

    32. 이번 글에서 만든 MCP Tool 정리

    • get_document: 문서 기본 정보 조회
    • extract_document_content: 분석 가능한 내용 추출
    • run_ocr: 스캔 문서 OCR
    • analyze_document: 문서 분석
    • save_analysis: 검증된 결과 저장
    • request_human_review: 사람에게 검토 요청
                      get_document
                           ↓
                extract_document_content
                           ↓
                    ┌──────┴──────┐
                    ↓             ↓
                  TEXT       NO_TEXT_FOUND
                    │             ↓
                    │          run_ocr
                    │             │
                    └──────┬──────┘
                           ↓
                    analyze_document
                           ↓
                   ┌───────┴───────┐
                   ↓               ↓
            High Confidence   Low Confidence
                   ↓               ↓
            save_analysis   request_human_review
    

    33. 하지만 아직 해결하지 않은 것이 있다

    Tool을 만드는 것보다 어려운 문제는 Agent Runtime과 Orchestration이다.

    Agent가 같은 Tool을 반복 호출하면?
    잘못된 Tool을 선택하면?
    무한 Loop에 빠지면?
    Tool 실행 중 오류가 발생하면?
    LLM 호출 자체가 실패하면?
    Agent 실행 도중 서버가 재시작되면?
    Human Review가 하루 뒤 완료되면?
    Agent는 어디서부터 다시 시작해야 할까?
    

    34. Tool을 만드는 것보다 Tool을 통제하는 것이 더 어렵다

    Step 1
    extract_document_content
            ↓
    FAILED
    
    Step 2
    run_ocr
            ↓
    FAILED
    
    Step 3
    extract_document_content
            ↓
    FAILED
    

    실제 Agent Runtime에는 다음과 같은 제어 장치가 필요하다.

    Max Steps
    Retry Limit
    Timeout
    Loop Detection
    Tool Permission
    Execution State
    Checkpoint
    Audit Log
    

    Agent가 어디까지 행동할 수 있는지를 시스템이 통제해야 한다.

    35. Agent의 실행 상태는 어디에 저장할까?

    Human Review가 몇 시간 뒤에 끝날 수도 있다. Worker를 계속 실행한 채 기다리는 것은 좋은 방법이 아니다.

    Agent
      ↓
    request_human_review
      ↓
    Save State
      ↓
    WAITING_HUMAN
      ↓
    Worker 종료
    

    사람이 검토를 완료하면 다시 실행한다.

    Human Approve
          ↓
    Event
          ↓
    Agent Worker
          ↓
    Load State
          ↓
    Resume
    

    Agent는 자신의 현재 상태를 저장하고 종료한 뒤 나중에 복원할 수 있어야 한다.

    36. Agent의 상태에는 무엇이 들어갈까?

    {
      "run_id": "run_123",
      "document_id": "doc_123",
      "status": "WAITING_HUMAN",
      "step": 5,
      "last_tool": "request_human_review",
      "document_type": "invoice",
      "confidence": 0.58,
      "retry_count": 1
    }
    

    Tool 실행 이력도 함께 저장할 수 있다.

    Step 1  get_document               SUCCESS
    Step 2  extract_document_content   SUCCESS
    Step 3  analyze_document           SUCCESS
    Step 4  validate_invoice           FAILED
    Step 5  request_human_review       WAITING
    

    37. MCP Server는 상태를 가져야 할까?

    가능하면 Agent 실행 상태와 Tool 실행 상태를 구분하는 편이 좋다.

                  Agent Runtime
                        │
                  Execution State
                        │
                        ↓
                    MCP Client
                        │
                        ↓
                    MCP Server
                        │
                        ↓
                       Tool
                        │
                        ↓
                  Business Service
    

    MCP Server는 Tool 제공과 실행에 집중하고, Agent Runtime은 Step, Retry Count, Human Review Status, Execution History 같은 실행 상태를 관리한다.

    38. 결국 MCP는 Orchestrator가 아니다

    Agent / Orchestrator
            ↓
    무엇을 할지 결정
    
    MCP
            ↓
    Tool을 연결
    
    Tool
            ↓
    실제 작업 수행
    

    MCP가 Workflow를 결정하는 것은 아니다. 그 판단은 Agent 또는 Orchestrator의 책임이다.

    39. 이번 예제에서 가장 중요한 코드

    @mcp.tool()
    def extract_document_content(document_id: str) -> dict:
        """
        Extract machine-readable content from an uploaded document.
        """
        return document_service.extract_content(document_id)
    

    중요한 것은 코드의 복잡성이 아니다. 기존 document_service.extract_content() 기능을 Agent가 사용할 수 있는 Capability로 바꿨다는 점이다.

    40. MCP를 도입할 때 먼저 해야 할 일

    현재 시스템의 기능을 먼저 나열한다.

    파일 조회
    파일 다운로드
    PDF Parsing
    OCR
    Excel Parsing
    Document Classification
    Invoice Extraction
    Validation
    Database 저장
    Human Review 요청
    Notification
    ERP 등록
    

    그리고 각각에 대해 묻는다.

    이 기능을 Agent가 상황에 따라 선택할 필요가 있는가?

    이 과정을 거치면 자연스럽게 MCP Tool 후보가 나온다.

    Existing Functions
            ↓
    Capability Analysis
            ↓
    Agent Decision Boundary
            ↓
    MCP Tools
    

    MCP 설계는 코드 작성보다 Capability 설계가 먼저다.

    41. Tool의 수는 많다고 좋은 것이 아니다

    Tool이 너무 많으면 Agent의 선택 공간이 커지고 Description도 서로 비슷해질 수 있다.

    따라서

    How many functions can I expose?
    
    What decisions should the Agent make?
    

    를 먼저 생각하는 것이 좋다.

    42. 2부에서 만든 전체 구조

                        User
                         │
                      Upload
                         ↓
                      FastAPI
                         │
                    Redis Stream
                         ↓
                    Agent Worker
                         ↓
                    Agent Runtime
                         ↓
                    Orchestrator
                         │
                  ┌──────┴──────┐
                  │             │
              LLM Decision      State
                  │             │
                  └──────┬──────┘
                         ↓
                     MCP Client
                         ↓
                     MCP Server
                         ↓
              ┌──────────┼──────────┐
              ↓          ↓          ↓
           Parser       OCR      Analyzer
              │          │          │
              └──────────┼──────────┘
                         ↓
                    Service Layer
                         ↓
           ┌─────────────┼─────────────┐
           ↓             ↓             ↓
        Storage         LLM         Database
    

    Tool Result는 다시 Agent Runtime으로 돌아가 다음 Decision의 입력이 된다. 종료 조건을 만족하면 COMPLETED, 실패하면 FAILED, 사람의 판단이 필요하면 WAITING_HUMAN 상태가 된다.


    마치며

    이번 글에서는 Python으로 간단한 MCP Server를 만들고 기존 백엔드 기능을 MCP Tool로 제공하는 방법을 살펴봤다.

    중요한 것은 MCP 코드 자체보다 다음 설계 문제다.

    어떤 기능을 Tool로 만들 것인가?
    Tool의 크기는 어느 정도가 적절한가?
    Input / Output을 어떻게 정의할 것인가?
    오류를 Agent에게 어떻게 전달할 것인가?
    Write Tool의 중복 실행을 어떻게 막을 것인가?
    Agent가 Tool을 선택하는 것과 실행 권한을 어떻게 분리할 것인가?
    Tool 실행을 어떻게 추적할 것인가?
    

    기존 구조가 다음과 같다면

    FastAPI
       ↓
    Service
       ↓
    Repository
       ↓
    Database
    

    새로운 접근 경로를 하나 추가할 수 있다.

    FastAPI ──────────┐
                      ↓
                   Service
                      ↑
                      │
                   MCP Tool
                      ↑
                      │
                    Agent
    

    기존 비즈니스 로직은 그대로 두고 Agent가 사용할 수 있는 Capability를 제공하는 것이다.

    MCP Tool을 만드는 것은 생각보다 간단하다. 실제 Agent 시스템의 난이도는 Tool을 만드는 데 있는 것이 아니라 Tool을 사용하는 Agent를 어떻게 통제하고 복구할 것인가에서 시작된다.


    다음 글

    3부 — MCP Tool을 Agent에게 맡기면 정말 더 좋은가?

    Agent Loop, 상태 관리, Retry 그리고 Human-in-the-Loop

    다음 편에서는 MCP Server가 아니라 Orchestrator 내부로 들어간다.

            ┌──────────────────┐
            │                  │
            ↓                  │
       LLM Decision            │
            ↓                  │
         Tool Call             │
            ↓                  │
        Tool Result ───────────┘
    

    다음 문제들을 다룰 예정이다.

    State
    Max Steps
    Retry
    Timeout
    Loop Detection
    Checkpoint
    Idempotency
    Human-in-the-Loop
    Pause / Resume
    Observability
    

    특히 다음 상황을 하나의 예제로 살펴본다.

    Invoice Upload
          ↓
    Agent Start
          ↓
    Extract
          ↓
    Analyze
          ↓
    Confidence = 0.58
          ↓
    Human Review
          ↓
    Agent Pause
    
            ... 5시간 후 ...
    
    Human Approve
          ↓
    Agent Resume
          ↓
    ERP Registration
          ↓
    Completed
    

    여기까지 구현할 수 있다면 Agent는 단순히 LLM이 Tool 몇 개를 호출하는 프로그램이 아니다.

    오랫동안 실행되고, 중간에 멈출 수 있고, 실패에서 복구하며, 사람과 협업할 수 있는 하나의 백엔드 Workflow Runtime이 된다.

  • AI Agent & MCP 1부 — 파일이 업로드되면 Agent가 깨어난다

    AI Agent & MCP 1부 — 파일이 업로드되면 Agent가 깨어난다

    Worker에서 Agent Orchestrator로

    백엔드에서 흔한 요구사항이 있다.

    사용자가 파일을 업로드하면 자동으로 분석하고 결과를 저장한다.

    처음에는 단순하다.

    File Upload
        ↓
    Worker
        ↓
    Parse
        ↓
    Analyze
        ↓
    Save
    def process_document(document_id):
        file = download_file(document_id)
        text = parse_document(file)
        result = analyze_document(text)
        save_result(document_id, result)

    처리 과정이 항상 같다면 이 구조가 가장 단순하다. 문제는 시스템이 발전하면서 파일 종류와 업무 규칙이 늘어날 때 시작된다.

    1. 처음에는 Worker 하나면 충분했다

    처음에는 PDF만 처리했지만 Excel, 이미지, 스캔 PDF가 추가될 수 있다.

    if file_type == "pdf":
        text = parse_pdf(file)
    elif file_type == "xlsx":
        data = parse_excel(file)
    elif file_type in ["jpg", "png"]:
        data = analyze_image(file)
    
    if not text:
        text = run_ocr(file)

    여기까지는 관리할 만하다. 하지만 요구사항은 계속 늘어난다.

    2. 어려운 것은 “다음에 무엇을 해야 하는가”다

    Invoice라면 텍스트 추출뿐 아니라 금액, 거래처, 날짜, 통화, 세금 등을 추출하고 검증해야 한다.

    File Upload
         ↓
    Parse
         ↓
    Invoice Detection
         ↓
    Invoice Extraction
         ↓
    Validation

    신뢰도가 높으면 저장한다. 낮으면 재분석, 다른 모델, OCR 또는 Human Review가 필요할 수 있다.

                        ┌── Save
                        │
    Analysis → Decision ├── Retry
                        ├── Another Model
                        └── Human Review

    문제는 이제 “파일을 어떻게 처리할까?”가 아니라 “현재 상태에서 다음에 무엇을 실행할까?”가 된다.

    3. Workflow와 Agent의 차이

    실행 순서가 명확한 RAG 적재 파이프라인을 생각해보자.

    Upload → Parse → Chunk → Embedding → Vector DB

    이런 작업에는 Agent가 필요하지 않다. 코드나 Redis Streams, RabbitMQ, SQS 같은 Queue 기반 Workflow가 더 단순하고 예측 가능하다.

    반대로 처리 경로를 미리 결정하기 어렵다면 이야기가 달라진다.

    4. 처리 경로를 미리 결정하기 어려운 경우

                         ┌── PDF Parser
                         ├── Excel Analyzer
    File Upload → 판단 ──┼── OCR
                         ├── Vision Model
                         └── Invoice Extractor

    분석 이후에도 판단이 이어진다.

    Analyze
       ↓
    Validate
       ↓
    Decision
       ├── Save
       ├── Retry
       ├── Additional Analysis
       └── Human Review

    물론 if/elif로도 만들 수 있다. 하지만 조건이 계속 늘어나면 Worker는 단순 실행 프로그램이 아니라 거대한 의사결정 프로그램이 된다.

    5. Worker에게서 “판단”을 분리한다

                     Orchestrator
                          │
                  "무엇을 해야 하지?"
                          │
              ┌───────────┼───────────┐
              ↓           ↓           ↓
           Parser      Analyzer     Database

    Orchestrator는 직접 PDF를 분석하거나 DB에 저장하지 않는다. 현재 상황을 보고 어떤 작업을 실행할지 결정한다.

    Orchestrator
        ├── get_document
        ├── extract_text
        ├── analyze_document
        ├── extract_invoice
        ├── validate_invoice
        ├── save_analysis
        └── request_human_review

    이 Orchestrator가 Agent 역할을 한다.

    6. Agent를 “생각하는 Worker”라고 생각해보자

    기존 Worker는 개발자가 정한 순서를 실행한다.

    A → B → C → D

    Agent는 현재 상태를 보고 사용할 작업을 선택한다.

              ┌── B
    A → Agent ├── C
              └── D

    Invoice 분석 결과의 신뢰도가 높다면 저장하고 낮다면 Human Review로 보낼 수 있다. 같은 시스템에서도 런타임 상태에 따라 실행 경로가 달라진다.

    7. Agent는 실제 기능을 어떻게 호출할까?

    Python 함수를 Agent 코드에서 직접 호출할 수도 있다. 하지만 기능이 많아질수록 Agent와 실제 업무 기능이 강하게 결합된다.

    Agent Application
    │
    ├── Agent Logic
    ├── PDF Parser
    ├── OCR
    ├── Invoice Analyzer
    ├── Database
    └── Human Review

    그래서 질문이 생긴다.

    Agent가 사용할 기능을 독립적인 Tool로 제공할 수 없을까?

    이 지점에서 MCP가 등장한다.

    8. MCP를 이용해 Agent와 Tool을 분리한다

                  ┌─────────────────────┐
                  │     Orchestrator    │
                  │        Agent        │
                  └──────────┬──────────┘
                             │
                            MCP
                             │
            ┌────────────────┼────────────────┐
            ↓                ↓                ↓
        File Tools        AI Tools        Data Tools
        get_document      classify        save_result
        extract_text      summarize       update_status
        run_ocr           extract_info    get_analysis

    Agent는 Tool 내부 구현을 알 필요가 없다. 어떤 Tool이 존재하고, 무엇을 하며, 어떤 입력과 출력을 갖는지가 중요하다.

    9. MCP는 Agent의 “능력 목록”을 제공한다

    기존 REST API가 다른 프로그램이 사용할 기능을 제공하는 인터페이스라면, MCP Tool은 Agent가 발견하고 선택해서 사용할 수 있는 능력을 제공하는 인터페이스라고 볼 수 있다.

    Tool: extract_invoice
    
    Description:
    Extract structured invoice information from a document.
    
    Input:
    {
      "document_id": "string"
    }
    
    Output:
    {
      "vendor": "string",
      "amount": "number",
      "currency": "string",
      "confidence": "number"
    }

    Agent는 Tool 이름과 Description, Schema를 보고 사용할 기능을 판단한다.

    10. 모든 함수를 MCP Tool로 만들 필요는 없다

    다음 같은 내부 함수까지 Agent에게 제공할 필요는 적다.

    normalize_string
    calculate_hash
    convert_datetime
    remove_whitespace

    반면 다음은 Agent가 선택할 가치가 있다.

    analyze_document
    extract_invoice
    search_documents
    request_human_review
    save_analysis

    함수(Function)와 Agent Tool은 같은 것이 아니다. Tool은 Agent가 선택할 가치가 있는 행동 단위여야 한다.

    11. 파일 업로드가 Agent를 깨운다

    Agent가 계속 파일 업로드를 감시할 필요는 없다. 기존 Event Driven Architecture를 활용하면 된다.

    User
      │ Upload
      ↓
    FastAPI
      ├── Object Storage
      └── Event
            ↓
       Redis Streams
            ↓
       Agent Worker

    예를 들어 다음 이벤트가 Agent 실행의 시작점이 된다.

    XADD document:uploaded * document_id 12345
    Redis Stream
          ↓
    Agent Worker
          ↓
    Orchestrator
          ↓
    MCP Client
          ↓
    MCP Server
          ↓
    Tools

    즉 Agent가 파일을 감시하는 것이 아니라 Event가 Agent를 깨우는 것이다.

    12. 전체 아키텍처

                        User
                          │
                       Upload
                          ↓
                      FastAPI
                          │
                 ┌────────┴────────┐
                 ↓                 ↓
           Object Storage       Redis Stream
                                   │
                                   ↓
                             Agent Worker
                                   │
                                   ↓
                             Orchestrator
                                   │
                                MCP Client
                                   │
                                   ↓
                               MCP Server
                                   │
              ┌────────────────────┼────────────────────┐
              ↓                    ↓                    ↓
          File Tools           AI Tools            Data Tools

    FastAPI는 요청과 파일 저장, Queue는 이벤트 전달, Agent Worker는 Agent 실행, Orchestrator는 다음 행동 결정, MCP Server는 Tool 제공, Tool은 실제 업무 수행을 담당한다.

    13. 기존 Workflow는 없어지는가?

    그렇지 않다. 중요한 것은 어디까지 Workflow로 만들고 어디부터 Agent에게 맡길 것인가다.

    Upload
      ↓
    Virus Scan
      ↓
    Store File
      ↓
    Create Document
      ↓
    Publish Event
      ↓
    ────────────────────────
           Agent 영역
      ↓
    Agent Orchestrator
      ├── PDF Parser
      ├── OCR
      ├── Excel Analyzer
      ├── Vision Analyzer
      └── Invoice Extractor
              ↓
           Validate
              ↓
      ┌───────┼────────┐
      ↓       ↓        ↓
     Save    Retry   Human Review

    항상 실행되는 작업은 Workflow가 더 적합하다.

    14. Agent가 모든 것을 직접 처리할 필요는 없다

    Agent의 핵심 역할은 실행보다 판단과 조정(Orchestration) 이다.

    실제 작업은 Tool이 담당한다.

                     Agent
                       │
                 Decision Making
                       │
            ┌──────────┼──────────┐
            ↓          ↓          ↓
          Tool       Tool       Tool
       parse_pdf    run_ocr   save_result

    15. 기존 서비스를 버리고 MCP를 다시 만드는 것은 아니다

    기존 Service Layer를 그대로 활용할 수 있다.

    Agent
      ↓
    MCP Tool
      ↓
    Existing Service
      ↓
    Database / Storage / AI

    MCP는 기존 백엔드를 대체하기보다 Agent가 기존 백엔드 기능을 사용할 수 있도록 새로운 인터페이스를 추가하는 것에 가깝다.

    16. MCP Tool의 경계가 중요하다

    get_file_path, calculate_hash, normalize_filename 같은 내부 함수를 모두 Tool로 노출하기보다 다음처럼 의미 있는 업무 단위로 묶는 편이 좋다.

    prepare_document
    extract_document_content
    analyze_invoice
    validate_analysis
    save_analysis
    request_human_review

    Tool은 함수의 단위가 아니라 Agent가 선택할 수 있는 행동의 단위다.

    17. MCP를 사용한다고 시스템이 자동으로 좋아지는 것은 아니다

    모든 문서가 항상 Parse → Chunk → Embedding → Save 순서로 처리된다면 Agent에게 다음 행동을 물어볼 이유가 없다.

    Agent를 넣으면 LLM 호출 비용, 지연시간, 장애 지점과 예측 불가능성이 늘어날 수 있다.

    따라서 먼저 물어봐야 한다.

    이 시스템에는 정말 ‘판단’이 필요한가?

    18. Agent + MCP가 유리한 상황

    다음 특징이 있다면 검토할 가치가 있다.

    • 입력 종류가 PDF, Excel, Image, Invoice, Contract처럼 다양하다.
    • 분석 결과에 따라 Save, Retry, Additional Analysis, Human Review 등 다음 행동이 달라진다.
    • OCR, Vision, ERP, Email, Slack 등 사용할 기능이 계속 증가한다.
    • 업무 규칙이 복잡해져 단순한 고정 Workflow로 표현하기 어려워진다.

    이런 지점에서 Agent Orchestrator의 가치가 생긴다.

    19. 결국 중요한 것은 “판단”과 “실행”의 분리다

    기존 Worker
     ├── Decision
     └── Execution

    Agent 기반 구조에서는 이를 분리한다.

                 Orchestrator
                      │
                  Decision
                      │
                      ↓
                     MCP
                      │
            ┌─────────┼─────────┐
            ↓         ↓         ↓
          Tool      Tool      Tool
                     Execution

    실제 비즈니스 로직은 기존 Service가 담당할 수도 있다.

    Agent
      ↓
    Orchestrator
      ↓
    MCP Tool
      ↓
    Service
      ↓
    Infrastructure

    20. Agent는 Workflow를 없애는 기술이 아니다

    Queue도, Worker도, Redis Streams나 RabbitMQ, SQS도 여전히 유용하다. Database와 Service Layer도 그대로 존재한다.

    달라지는 것은 일부 영역에서 Agent가 현재 상황을 보고 실행할 Tool을 선택한다는 점이다.

    Developer
        ↓
    Available Tools
        ↓
    Agent
        ↓
    Runtime Decision

    Agent는 Workflow를 없애는 기술이 아니라, Workflow를 미리 결정하기 어려운 지점에 들어가는 기술이다.

    그리고 MCP는 그 Agent가 사용할 수 있는 능력을 Tool 형태로 제공하는 방법 중 하나다.

    마치며

    처음에는 단순했다.

    File Upload
        ↓
    Worker
        ↓
    Analyze
        ↓
    Save

    하지만 업무가 복잡해지면서 처리 과정에 판단이 들어오기 시작했다.

                         ┌── OCR
                         ├── PDF Parser
    File Upload → Agent ─┼── Vision
                         ├── Invoice Analyzer
                         └── Human Review

    판단과 실제 실행을 분리하면 다음 구조를 생각할 수 있다.

    File Upload
         ↓
    Event
         ↓
    Agent Worker
         ↓
    Orchestrator
         ↓
    MCP
         ↓
    Tools

    이제 다음 질문이 자연스럽게 생긴다.

    실제 Python 백엔드 기능을 어떻게 MCP Tool로 만들 수 있을까?

    다음 글에서는 Python으로 MCP Server를 만들고, 기존 Service를 MCP Tool로 노출하고, Tool의 Input/Output Schema를 정의하는 방법을 살펴본다.

    다음 글

    2부 — Python으로 파일 분석용 MCP Tool 직접 만들어보기

    Agent
      ↓
    MCP Client
      ↓
    MCP Server
      ├── get_document
      ├── extract_document_content
      ├── analyze_document
      └── save_analysis

    그리고 “그냥 Python 함수를 호출하는 것과 MCP Tool을 사용하는 것은 무엇이 다른가?”라는 문제까지 이어서 살펴본다.

  • Pixel RAG 3부(Part2) — ColPali·ColQwen과 Qdrant로 구현하는 Visual Document Retrieval

    Pixel RAG 3부(Part2) — ColPali·ColQwen과 Qdrant로 구현하는 Visual Document Retrieval

    18. Pixel RAG에서 GPU는 필요한가?

    여기서 현실적인 문제가 나온다.

    Text Embedding Model은 비교적 가볍다.

    하지만 Visual Document Retriever는 Vision-Language Model을 기반으로 하기 때문에 훨씬 무겁다.

    특히 문서 적재 시 다음 과정이 필요하다.

    100 Page PDF
    
    → 100 Page Rendering
    → 100 Visual Encoding
    → Multi-Vector 생성

    문서가 1,000개이고 평균 100페이지라면 처리해야 할 페이지는 100,000장이다.

    CPU만으로도 기술적으로 처리할 수 있는 경우가 있지만, 실제 서비스에서는 처리 시간이 문제가 될 가능성이 높다.

    그래서 대규모 Pixel RAG 시스템에서는 GPU Worker를 별도로 두는 구조를 고려할 수 있다.

                       Document Upload
                             │
                             ▼
                        Job Queue
                             │
                  ┌──────────┴──────────┐
                  │                     │
                  ▼                     ▼
            Text Worker            Visual Worker
               CPU                     GPU
                  │                     │
                  ▼                     ▼
          Text Embedding          Visual Embedding
                  │                     │
                  ▼                     ▼
            Vector DB              Vector DB

    이렇게 하면 일반적인 Text Pipeline과 무거운 Visual Pipeline을 분리할 수 있다.

    특히 Visual Embedding은 반드시 실시간으로 처리할 필요도 없다.

    예를 들어 다음과 같이 상태를 관리할 수 있다.

    UPLOADED
        ↓
    PARSING
        ↓
    CHUNKING
        ↓
    TEXT_EMBEDDING
        ↓
    TEXT_READY
        ↓
    VISUAL_PROCESSING
        ↓
    VISUAL_READY
        ↓
    COMPLETED

    Text RAG는 먼저 사용할 수 있도록 만들고 Pixel RAG 처리는 백그라운드에서 계속 진행하는 방식이다.

    사용자 입장에서도 수백 페이지의 PDF가 Visual Processing까지 끝날 때까지 기다릴 필요가 없다.


    19. Visual Embedding 비용보다 더 조심해야 할 것이 있다

    Pixel RAG에서는 세 종류의 비용을 따로 생각하는 것이 좋다.

    ① Ingestion Cost

    문서를 처음 등록할 때 발생한다.

    PDF Rendering
          +
    Visual Encoding
          +
    Vector Storage
          +
    Image Storage

    Visual Embedding은 문서를 등록할 때 한 번 만들어두면 이후 질문마다 다시 만들 필요는 없다.

    따라서 대부분 초기 적재 비용이다.


    ② Retrieval Cost

    질문이 들어올 때마다 발생한다.

    Query Encoding
          ↓
    Vector Search
          ↓
    Late Interaction

    일반적인 Single-Vector 검색보다 계산량과 저장량이 증가할 수 있다.

    특히 페이지마다 많은 Vector를 보관한다면 페이지 수가 많아질수록 부담이 커진다.


    ③ Generation Cost

    여기가 상당히 중요하다.

    Relevant Page Images
            │
            ▼
           VLM

    VLM에게 여러 장의 고해상도 페이지 이미지를 계속 전달하면 비용이 빠르게 증가할 수 있다.

    그래서

    Top 20 Pages → VLM

    같은 방식은 피하는 것이 좋다.

    대신

    Retrieval Top 20
           ↓
    Reranking
           ↓
    Top 3
           ↓
    VLM

    처럼 최종 이미지 수를 줄이는 것이 중요하다.

    Pixel RAG에서는 Retrieval 정확도가 비용 최적화와 직접 연결된다.


    20. 페이지 해상도도 비용과 성능에 영향을 준다

    PDF 페이지를 무조건 최고 해상도로 저장한다고 좋은 것은 아니다.

    예를 들어 A4 페이지를 지나치게 고해상도로 변환하면 이미지 파일 크기도 커지고 Visual Model 처리 비용도 증가한다.

    반대로 너무 낮은 해상도로 변환하면 작은 글자와 표 안의 숫자를 읽기 어려워진다.

    따라서 적절한 균형을 찾아야 한다.

                  Resolution
    
    낮음  ◀────────────────────▶ 높음
    
    저비용                         고비용
    빠름                           느림
    
            그러나
    
    문자 인식 ↓               문자 인식 ↑
    표 인식 ↓                 표 인식 ↑

    특히 기업 문서에서는 작은 글씨의 표와 각주가 많기 때문에 실제 문서 샘플을 이용한 테스트가 필요하다.


    21. 모든 페이지를 Pixel Embedding 해야 할까?

    반드시 그럴 필요는 없다.

    문서를 분석해서 Visual Information이 중요한 페이지만 선택할 수도 있다.

    예를 들어 다음과 같은 페이지는 Visual Processing 우선순위를 높인다.

    표 존재
    그래프 존재
    다이어그램 존재
    이미지 존재
    다단 레이아웃
    복잡한 문서 구조
    OCR 필요

    반대로 다음과 같은 페이지는 Text RAG만으로 충분할 가능성이 높다.

    일반 문단
    텍스트 위주 페이지
    단순 목록
    텍스트 기반 규정

    그러면 파이프라인은 다음처럼 발전할 수 있다.

    Page
     │
     ▼
    Page Analyzer
     │
     ├───────────────┐
     │               │
     ▼               ▼
    TEXT_ONLY      VISUAL
     │               │
     ▼               ▼
    Text RAG      Pixel RAG

    이렇게 하면 전체 문서의 일부 페이지만 Visual Embedding할 수 있다.


    22. 그런데 여기에는 함정이 있다

    “표가 있는 페이지만 Pixel RAG를 사용하면 되지 않을까?”

    언뜻 보면 좋은 최적화다.

    하지만 Document Analyzer가 잘못 판단하면 검색 대상 자체에서 페이지가 사라진다.

    예를 들어 페이지에 작은 그림과 설명이 있고 그것이 질문의 핵심 정보라고 하자.

    Analyzer가 해당 페이지를 TEXT_ONLY로 분류했다면 Visual Search에서는 그 페이지를 찾을 방법이 없다.

    따라서 초기 버전에서는 지나친 최적화를 하지 않는 편이 안전하다.

    먼저 모든 PDF 페이지를 Visual Indexing하고 실제 사용 데이터를 수집한 뒤 최적화하는 방법도 충분히 합리적이다.


    23. Text RAG와 Pixel RAG는 경쟁 관계가 아니다

    여기까지 보면 이런 생각이 들 수 있다.

    “Pixel RAG가 발전하면 Text RAG가 필요 없어지는 것 아닌가?”

    현재로서는 그렇게 보기 어렵다.

    Text RAG는 여전히 굉장히 강력하다.

    예를 들어 다음 질문이 있다고 하자.

    개인정보 처리방침에서 개인정보 보관 기간과 파기 절차를 설명해줘.

    텍스트로 정확하게 Parsing되어 있다면 Text Retrieval이 빠르고 저렴하며 정확하다.

    반대로 이런 질문은 다르다.

    37페이지 표에서 각 제품의 전년 대비 성장률을 비교해줘.

    이 경우 Visual Information이 중요하다.

    그래서 두 방식은 서로 다른 강점을 가진다.

                    Document Understanding
    
            ┌──────────────┴──────────────┐
            │                             │
            ▼                             ▼
    
         Text RAG                     Pixel RAG
    
    텍스트 의미 검색                 시각적 문서 검색
    
    문장                             표
    문단                             차트
    규정                             레이아웃
    정책                             다이어그램
    FAQ                              스캔 문서
    
            │                             │
            └──────────────┬──────────────┘
                           ▼
    
                      Hybrid RAG

    24. 실제 서비스라면 나는 이렇게 시작할 것이다

    기존 Text RAG가 이미 운영되고 있다고 가정해보자.

    처음부터 ColPali, Multi-Vector DB, Query Router, Reranker까지 한꺼번에 넣지는 않을 것이다.

    첫 번째 단계에서는 문서를 적재할 때 페이지 번호를 보존하는 것부터 시작한다.

    Document
       ↓
    Parser
       ↓
    Chunk
    
    chunk_id
    document_id
    page_no

    그리고 원본 PDF에서 페이지 이미지를 생성한다.

    Document
    
    ├── original.pdf
    │
    └── pages
        ├── 001.webp
        ├── 002.webp
        ├── 003.webp
        └── ...

    검색은 기존 Text RAG를 그대로 사용한다.

    Question
       ↓
    Text Retrieval
       ↓
    Chunk Top-K
       ↓
    page_no 확인
       ↓
    Page Image
       ↓
    VLM

    이것만으로도 꽤 재미있는 실험을 할 수 있다.


    25. 그 다음 Visual Retrieval을 추가한다

    Text Retrieval + VLM의 효과가 확인되면 그때 Visual Retriever를 추가한다.

                        Question
                           │
               ┌───────────┴───────────┐
               │                       │
               ▼                       ▼
         Text Retrieval          Visual Retrieval
               │                       │
               ▼                       ▼
            Chunks                    Pages
               │                       │
               └───────────┬───────────┘
                           ▼
                        Fusion
                           │
                           ▼
                        Rerank
                           │
                           ▼
                   Relevant Context
                           │
                           ▼
                        LLM/VLM

    여기까지 오면 진짜 Hybrid Multimodal RAG 시스템이라고 부를 만한 구조가 된다.


    26. 여기서 Reranker가 상당히 중요해진다

    Text Retrieval과 Visual Retrieval을 동시에 사용하면 Candidate가 많아진다.

    예를 들어

    Text Search
    
    10 Chunks
    
    
    Visual Search
    
    10 Pages

    총 20개의 후보가 생긴다.

    이것을 모두 LLM/VLM에게 보내는 것은 비효율적이다.

    그래서 Retrieval과 Generation 사이에 후보를 다시 평가하는 단계가 필요하다.

    Retriever
    
    Top 20
       ↓
    Reranker
    
    Top 5
       ↓
    LLM / VLM

    이 구조에서 Retriever의 역할은

    “놓치지 않고 후보를 많이 찾는 것”

    이고,

    Reranker의 역할은

    “그중 정말 중요한 것을 골라내는 것”

    이다.

    이 두 역할을 분리하면 Retrieval Recall과 Generation Cost 사이의 균형을 잡기 쉬워진다.


    27. Pixel RAG에서 Citation은 오히려 좋아질 수 있다

    Pixel RAG의 흥미로운 장점 중 하나가 출처 표시다.

    Text RAG에서는 보통

    Document
      ↓
    Chunk
      ↓
    Citation

    관계를 관리한다.

    Pixel RAG에서는 Page 자체가 Retrieval 단위이기 때문에 다음처럼 표현할 수 있다.

    답변 근거
    2026_사업보고서.pdf — Page 27

    사용자가 Citation을 클릭하면 실제 원본 페이지를 보여줄 수도 있다.

    Answer
    
    "제품 C의 매출 증가율이 가장 높습니다."
    
              [근거 1]
    
                  ↓ 클릭
    
    ┌────────────────────────────────┐
    │ 2026_사업보고서.pdf             │
    │                                │
    │            Page 27             │
    │                                │
    │      [원본 페이지 이미지]       │
    │                                │
    └────────────────────────────────┘

    기업용 RAG에서는 이 기능이 상당히 중요하다.

    AI의 답변 자체보다

    “왜 이렇게 답했는가?”

    를 확인할 수 있어야 하기 때문이다.


    28. 더 발전시키면 영역 단위 Citation도 가능하다

    페이지 전체를 보여주는 것에서 한 단계 더 발전시킬 수 있다.

    관련된 표나 그래프 영역을 Highlight해서 보여주는 것이다.

    예를 들어 VLM 또는 별도의 Layout 분석 결과를 이용해서

    Page 27
    
    ┌─────────────────────────────┐
    │                             │
    │     ┌─────────────────┐     │
    │     │                 │     │
    │     │  관련 매출 표    │ ← Highlight
    │     │                 │     │
    │     └─────────────────┘     │
    │                             │
    └─────────────────────────────┘

    처럼 사용자에게 근거를 보여줄 수 있다.

    그러면 Citation이 단순히

    문서 27페이지

    에서 끝나는 것이 아니라

    문서 27페이지의 이 표

    까지 내려갈 수 있다.

    이것은 향후 Enterprise RAG에서 상당히 중요한 UX가 될 가능성이 있다.


    29. 전체 시스템을 한 장으로 정리해보자

    지금까지 만든 구조를 합치면 다음과 같다.

                           DOCUMENT
                               │
                 ┌─────────────┴─────────────┐
                 │                           │
                 ▼                           ▼
            Text Pipeline               Visual Pipeline
                 │                           │
                 ▼                           ▼
               Parser                   Page Renderer
                 │                           │
                 ▼                           ▼
              Chunking                  Page Images
                 │                           │
                 ▼                           ▼
          Text Embedding              ColPali/ColQwen
                 │                           │
                 ▼                           ▼
          Text Vector DB              Multi-Vector DB
                 │                           │
                 │                           │
                 └─────────────┬─────────────┘
                               │
                               │
                        USER QUESTION
                               │
                               ▼
                          Query Router
                               │
                  ┌────────────┴────────────┐
                  │                         │
                  ▼                         ▼
            Text Retrieval           Visual Retrieval
                  │                         │
                  ▼                         ▼
                Chunks                     Pages
                  │                         │
                  └────────────┬────────────┘
                               ▼
                             Fusion
                               │
                               ▼
                            Reranker
                               │
                               ▼
                      Relevant Context
                               │
                     ┌─────────┴─────────┐
                     │                   │
                     ▼                   ▼
                   Text               Images
                     │                   │
                     └─────────┬─────────┘
                               ▼
                            LLM/VLM
                               │
                               ▼
                             ANSWER
                               │
                               ▼
                            Citation
                               │
                               ▼
                        Original Page

    이 그림 하나가 이번 3부의 핵심이다.


    30. Pixel RAG에서 정말 어려운 것은 모델 선택이 아니다

    Pixel RAG를 처음 접하면 보통 관심이 모델로 향한다.

    ColPali를 쓸까?

    ColQwen을 쓸까?

    어떤 VLM을 쓸까?

    물론 중요한 문제다.

    하지만 실제 서비스를 만들기 시작하면 더 어려운 문제들이 나타난다.

    페이지와 Chunk의 관계를 어떻게 관리할 것인가?
    
    Visual Vector를 어떻게 저장할 것인가?
    
    수십만 페이지를 어떻게 Indexing할 것인가?
    
    문서가 변경되면 Visual Index를 어떻게 갱신할 것인가?
    
    Text와 Visual 검색 결과를 어떻게 합칠 것인가?
    
    VLM에게 몇 페이지를 전달할 것인가?
    
    멀티테넌트 검색 Filter는 어디에서 적용할 것인가?
    
    Citation을 어떻게 원본 페이지와 연결할 것인가?
    
    GPU Worker를 어떻게 운영할 것인가?
    
    Visual Processing 실패를 어떻게 재처리할 것인가?

    결국 Pixel RAG 역시 모델 하나를 붙이는 문제가 아니다.

    Document Ingestion → Retrieval → Generation → Citation 전체 파이프라인을 설계하는 문제다.


    31. Pixel RAG가 보여주는 RAG의 다음 방향

    초기의 RAG는 거의 Text Retrieval을 의미했다.

    Document
       ↓
    Text
       ↓
    Chunk
       ↓
    Embedding
       ↓
    Vector Search

    하지만 실제 기업의 지식은 텍스트로만 존재하지 않는다.

    PDF
    PPT
    Excel
    표
    차트
    다이어그램
    스캔 문서
    이미지
    설계도

    가 모두 지식이다.

    그래서 RAG도 자연스럽게 다음 방향으로 확장되고 있다.

    Text RAG
       ↓
    Hybrid Search
       ↓
    Multimodal RAG
       ↓
    Pixel / Visual Retrieval
       ↓
    Document Understanding

    Pixel RAG의 의미는 단순히 PDF를 이미지로 검색한다는 데 있지 않다.

    더 중요한 변화는

    AI가 사람이 문서를 보는 방식에 조금 더 가까워지고 있다는 것

    이다.


    마치며

    Pixel RAG를 처음 보면 기존 RAG와 완전히 다른 기술처럼 보인다.

    하지만 구조를 하나씩 뜯어보면 기존 RAG에서 크게 벗어나지 않는다.

    기존에는

    Text
       ↓
    Embedding
       ↓
    Retrieval
       ↓
    LLM

    이었다면,

    Pixel RAG에서는

    Page Image
         ↓
    Visual Embedding
         ↓
    Visual Retrieval
         ↓
    VLM

    이라는 새로운 검색 경로가 하나 추가된 것이다.

    그리고 실제 서비스에서는 결국 두 경로가 합쳐진다.

    Text Retrieval
           +
    Visual Retrieval
           ↓
    Hybrid Retrieval
           ↓
    LLM / VLM

    그래서 Pixel RAG를 도입하려는 개발자라면 처음부터 거대한 Multimodal RAG 시스템을 만들려고 하기보다는 다음 순서로 접근하는 것이 현실적이다.

    ① Chunk에 Page 정보 보존
    
            ↓
    
    ② Page Image 저장
    
            ↓
    
    ③ Text Retrieval + Page Image + VLM
    
            ↓
    
    ④ Visual Embedding 추가
    
            ↓
    
    ⑤ Visual Retrieval 추가
    
            ↓
    
    ⑥ Text + Visual Fusion
    
            ↓
    
    ⑦ Reranker
    
            ↓
    
    ⑧ Query Router / Adaptive Retrieval

    특히 ③ Text Retrieval + Page Image + VLM까지는 기존 RAG 시스템을 크게 변경하지 않고도 실험할 수 있다.

    그리고 효과가 확인됐을 때 Visual Retrieval을 추가해도 늦지 않다.

    Pixel RAG의 핵심은 기존 RAG를 버리는 것이 아니다.

    기존 RAG가 읽지 못했던 문서의 시각적 의미까지 검색 대상으로 확장하는 것.

    그것이 Pixel RAG를 이해하는 가장 중요한 관점이라고 생각한다.

    지금까지 RAG에서 문서는 사실상 ‘텍스트의 저장소’였다.

    하지만 Pixel RAG에서는 문서 그 자체가 지식이 된다.

    기존 RAG
    
    Document
       ↓
    Text
       ↓
    Knowledge
    
    
    Pixel RAG
    
    Document
       │
       ├── Text
       ├── Layout
       ├── Table
       ├── Chart
       ├── Image
       └── Spatial Relationship
              │
              ▼
           Knowledge

    이 차이는 생각보다 크다.

    앞으로의 RAG는 단순히 **“관련 문장을 얼마나 잘 찾는가”**를 넘어,

    “문서가 가지고 있는 의미를 얼마나 온전히 이해하고 검색할 수 있는가”

    의 문제로 발전할 가능성이 높다.

    그리고 Pixel RAG와 Visual Document Retrieval은 그 변화의 중요한 출발점 중 하나다.


    3부 핵심 정리

    이번 글의 내용을 한 문장으로 압축하면 다음과 같다.

    ColPali·ColQwen 계열 Visual Retriever는 문서 페이지를 Multi-Vector로 표현하고 Late Interaction을 이용해 질문과 관련된 페이지를 찾으며, 찾아낸 페이지를 VLM에게 전달함으로써 기존 Text RAG가 놓치기 쉬운 표·차트·레이아웃까지 활용할 수 있게 한다.

    전체 구조를 다시 아주 간단하게 표현하면 다음과 같다.

                  ┌── Text Embedding ── Text Search ──┐
                  │                                   │
    Document ─────┤                                   ├── Rerank ── LLM/VLM ── Answer
                  │                                   │
                  └─ Visual Embedding ─ Visual Search ┘

    그리고 실제 기존 RAG 시스템에 도입한다면 처음부터 이 모든 것을 구현할 필요는 없다.

    가장 현실적인 출발점은 다음과 같다.

    기존 RAG
       │
       ▼
    Chunk ↔ Page Mapping
       │
       ▼
    Page Image 저장
       │
       ▼
    Text Retrieval
       │
       ▼
    관련 Page Image
       │
       ▼
    VLM

    여기서 효과가 확인되면 그 다음에

    Visual Embedding
          ↓
    Visual Retrieval
          ↓
    Hybrid Retrieval
          ↓
    Reranking
          ↓
    Adaptive Routing

    순으로 확장하면 된다.

    이렇게 접근하면 Pixel RAG는 생각보다 기존 RAG 시스템에서 멀리 떨어져 있는 기술이 아니다.

    오히려 기존 RAG 위에 하나씩 쌓아갈 수 있는 자연스러운 확장에 가깝다.


    다음 글

    Pixel RAG 4부 — 직접 만들어보자: Python + Qdrant로 구현하는 Pixel RAG

    지금까지는 아키텍처를 이해하기 위해 의도적으로 코드보다 개념에 집중했다.

    4부에서는 실제 구현으로 들어간다.

    하나의 PDF 파일을 업로드하고 질문을 던졌을 때 답변이 만들어지는 과정을 처음부터 끝까지 구현해본다.

    PDF
     │
     ▼
    Page Rendering
     │
     ├───────────────┐
     │               │
     ▼               ▼
    Text Parsing   Page Image
     │               │
     ▼               ▼
    Chunking      Visual Embedding
     │               │
     ▼               ▼
    Text Embedding  Multi-Vector
     │               │
     └───────┬───────┘
             ▼
           Qdrant
             │
             ▼
          Question
             │
             ▼
       Hybrid Search
             │
             ▼
          Reranking
             │
             ▼
       Relevant Pages
             │
             ▼
            VLM
             │
             ▼
           Answer
             │
             ▼
          Citation

    4부에서는 특히 다음 내용을 실제 코드 수준에서 다룬다.

    • Python으로 PDF를 페이지 이미지로 변환하기
    • ColPali/ColQwen 계열 모델 로딩
    • Page Visual Embedding 생성
    • Multi-Vector 구조 이해
    • Qdrant Collection 설계
    • Visual Vector 저장
    • Query Embedding 생성
    • Late Interaction 기반 검색
    • Text Retrieval과 Visual Retrieval 결합
    • 검색된 원본 페이지 가져오기
    • VLM에 질문과 페이지 이미지 전달
    • Citation 생성
    • 전체 Pipeline을 하나의 Python 코드로 연결하기

    그리고 단순한 데모 코드에서 끝내지 않고,

    API Server
         │
         ▼
    Job Queue
         │
     ┌───┴────┐
     ▼        ▼
    CPU      GPU
    Worker   Worker
     │        │
     └───┬────┘
         ▼
       Qdrant
         │
         ▼
    Object Storage

    처럼 실제 서비스로 확장한다면 어떤 구조가 필요한지도 함께 살펴볼 예정이다.

    여기까지 이해했다면 이제 Pixel RAG의 개념은 충분하다.

    다음부터는 직접 만들어볼 차례다.

  • Pixel RAG 3부(Part1) — ColPali·ColQwen과 Qdrant로 구현하는 Visual Document Retrieval

    Pixel RAG 3부(Part1) — ColPali·ColQwen과 Qdrant로 구현하는 Visual Document Retrieval

    1부에서는 Pixel RAG가 무엇인지 알아봤고, 2부에서는 기존 Text RAG 시스템에 Pixel RAG를 추가하면 아키텍처가 어떻게 달라지는지 살펴봤다.

    이번에는 한 단계 더 들어가 보자.

    실제로 Pixel RAG의 Visual Retrieval은 어떻게 구현할까?

    여기서 중요한 기술들이 등장한다.

    • ColPali
    • ColQwen 계열 Visual Retriever
    • Multi-Vector Embedding
    • Late Interaction
    • Qdrant
    • Vision-Language Model(VLM)

    처음 보면 꽤 복잡해 보인다.

    하지만 전체 흐름부터 보면 의외로 단순하다.

    PDF
     │
     ▼
    Page Rendering
     │
     ▼
    Page Image
     │
     ▼
    Visual Document Retriever
     │
     ▼
    Visual Embedding
     │
     ▼
    Vector DB
     │
     ▼
    User Question
     │
     ▼
    Query Embedding
     │
     ▼
    Visual Retrieval
     │
     ▼
    Relevant Page
     │
     ▼
    VLM
     │
     ▼
    Answer

    문제는 중간에 있는 Visual Embedding이 일반적인 Text Embedding과 조금 다르다는 것이다.

    이 차이를 이해하면 Pixel RAG의 핵심 구조가 보이기 시작한다.


    1. 일반적인 이미지 검색과 Pixel RAG는 조금 다르다

    먼저 가장 단순한 이미지 검색을 생각해보자.

    이미지 한 장을 Embedding Model에 넣는다.

    Image
      │
      ▼
    Image Encoder
      │
      ▼
    [0.12, -0.31, 0.87, ...]

    이미지 한 장이 하나의 Vector가 된다.

    질문도 Vector로 만든다.

    "매출이 가장 크게 증가한 제품은?"
    
                │
                ▼
    
    [0.32, -0.11, 0.72, ...]

    그리고 Vector Similarity를 계산한다.

    이 방식은 이미지 검색에는 꽤 유용하다.

    하지만 문서 검색에서는 문제가 생긴다.


    2. 문서 한 페이지에는 너무 많은 정보가 있다

    다음과 같은 보고서 한 페이지를 생각해보자.

    ┌─────────────────────────────────────┐
    │        2026년 사업 실적 보고서       │
    │                                     │
    │ 제품별 매출                          │
    │                                     │
    │          2025      2026              │
    │ Product A  100       180             │
    │ Product B  200       150             │
    │ Product C   80       160             │
    │                                     │
    │       ┌───────────────────┐          │
    │       │     매출 그래프    │          │
    │       │    ▂ ▅ █ ▆        │          │
    │       └───────────────────┘          │
    │                                     │
    │ ※ 해외 매출 제외                     │
    └─────────────────────────────────────┘

    이 페이지에는 동시에 여러 정보가 존재한다.

    • 제목
    • 제품명
    • 숫자
    • 표의 행/열 관계
    • 그래프
    • 범례
    • 각주
    • 페이지 레이아웃

    이 모든 정보를 하나의 Vector로 압축하면 정보 손실이 발생할 수 있다.

    그래서 ColPali 계열의 Visual Document Retrieval은 조금 다른 접근을 사용한다.


    3. 페이지 하나에서 Vector 하나만 만들지 않는다

    핵심 개념은 Multi-Vector Representation이다.

    페이지 이미지를 여러 영역 또는 Visual Token으로 표현한다.

    개념적으로 보면 다음과 같다.

                    Page Image
    
    ┌─────────────────────────────────────┐
    │  [A] Title                          │
    │                                     │
    │  [B] Table                          │
    │                                     │
    │                     [C] Chart       │
    │                                     │
    │  [D] Footnote                       │
    └─────────────────────────────────────┘
    
                      │
                      ▼
    
                Visual Encoder
    
                      │
                      ▼
    
            ┌─────────┼─────────┐
            ▼         ▼         ▼
    
          Vector    Vector     Vector  ...
            #1        #2         #3

    실제로는 단순히 제목·표·그래프처럼 사람이 영역을 직접 나누는 것이 아니다.

    모델 내부의 Visual Token들이 페이지의 여러 특징을 표현한다.

    즉,

    Page
    
    → Vector 1
    → Vector 2
    → Vector 3
    → ...
    → Vector N

    이 된다.

    이것이 일반적인 Single-Vector Image Retrieval과 중요한 차이다.


    4. 그런데 질문도 하나의 Vector가 아니다

    사용자가 다음과 같이 질문했다고 하자.

    2026년에 매출이 가장 많이 증가한 제품은?

    질문도 Token 단위로 표현할 수 있다.

    2026년
    매출
    가장 많이
    증가한
    제품

    개념적으로 각각에 대응하는 Vector가 만들어진다.

    Query
    
     ├── q1
     ├── q2
     ├── q3
     ├── q4
     └── q5

    문서 페이지에도 여러 Vector가 있다.

    Document Page
    
     ├── d1
     ├── d2
     ├── d3
     ├── ...
     └── dn

    그렇다면 질문과 문서의 유사도를 어떻게 계산해야 할까?

    여기서 Late Interaction이 등장한다.


    5. Late Interaction이란?

    이 개념은 처음 보면 어려워 보이지만 아이디어는 꽤 직관적이다.

    Query의 각 Vector에 대해 Document의 Vector 중 가장 비슷한 것을 찾는다.

    Query Vector q1
          │
          ├── d1
          ├── d2
          ├── d3   ← 가장 유사
          └── d4
    
    
    Query Vector q2
          │
          ├── d1   ← 가장 유사
          ├── d2
          ├── d3
          └── d4

    각 Query Token마다 최고의 Document Token을 찾는다.

    수식으로 단순화하면 다음과 같은 개념이다.

    Score(Q, D)
    
    = Σ max Similarity(qᵢ, dⱼ)

    즉,

    질문의 각각의 의미 요소가 페이지 어디와 가장 잘 대응하는가?

    를 계산한다.

    이 방식의 장점은 페이지 전체를 하나의 Vector로 압축하지 않아도 된다는 것이다.


    6. 왜 이름이 Late Interaction일까?

    일반적인 Embedding 검색에서는 문서 전체를 먼저 하나의 Vector로 만든다.

    Document
        │
        ▼
    Embedding
        │
        ▼
    Single Vector

    검색 시점에는 이 Vector끼리 비교한다.

    하지만 Late Interaction 방식에서는 Query와 Document가 각각 여러 Vector를 유지한다.

    Query Tokens
         │
         │
         │    검색 시점
         │       ↓
         ├────────────── Document Tokens
         │
         ▼
    Interaction

    즉 Query와 Document의 세부 표현을 검색 단계까지 유지한 후 나중에 상호작용시킨다.

    그래서 Late Interaction이라고 부른다.


    7. ColPali는 무엇인가?

    ColPali는 이런 방식의 Visual Document Retrieval을 가능하게 만든 대표적인 모델 계열이다.

    핵심 아이디어를 단순화하면 다음과 같다.

    Document Page Image
            │
            ▼
    Vision-Language Model
            │
            ▼
    Visual Token Representation
            │
            ▼
    Projection
            │
            ▼
    Multi-Vector Embedding

    질문 역시 같은 검색 공간에서 표현된다.

    Question
        │
        ▼
    Language Representation
        │
        ▼
    Multi-Vector Embedding

    그리고 Late Interaction을 사용해 관련 페이지를 찾는다.

    중요한 것은 PDF를 먼저 OCR해서 완벽한 텍스트로 복원할 필요가 없다는 것이다.

    페이지 자체를 검색 대상으로 사용할 수 있다.


    8. ColQwen 계열은?

    ColQwen 계열 역시 같은 Visual Document Retrieval 방향에 있다.

    기본 철학은 비슷하다.

    Page Image
       │
       ▼
    Vision-Language Model
       │
       ▼
    Visual Tokens
       │
       ▼
    Multi-Vector

    차이는 기반 Vision-Language Model과 모델 규모, 지원 기능, 성능 등에 있다.

    Pixel RAG 시스템을 설계할 때 중요한 것은 특정 모델 이름 자체보다는 다음 구조를 이해하는 것이다.

    페이지 → Visual Multi-Vector → Late Interaction → Page Retrieval

    모델은 앞으로 계속 바뀔 수 있다.

    하지만 이 구조를 이해하면 새로운 모델이 나와도 시스템 전체를 이해하기 쉽다.


    9. 여기서 굉장히 중요한 사실

    ColPali가 답변까지 만들어주는 것은 아니다.

    역할을 분리해서 생각해야 한다.

    Retriever

    ColPali / ColQwen
    
    "어떤 페이지가 질문과 관련 있는가?"

    Generator

    GPT 계열 Vision Model
    Gemini
    Claude
    Qwen-VL
    기타 VLM
    
    "이 페이지를 보고 질문에 답해라."

    전체 구조는 다음과 같다.

    User Question
          │
          ▼
    Visual Retriever
          │
          ▼
    Relevant Pages
          │
          ▼
    Vision-Language Model
          │
          ▼
    Answer

    이 역할 분리는 상당히 중요하다.

    Retriever에게 답변까지 맡기는 것이 아니다.


    10. Qdrant에는 어떻게 저장할까?

    여기서 기존 Text RAG와 차이가 커진다.

    일반 Text Embedding은 보통 다음처럼 저장한다.

    Chunk
    
    vector:
    [0.13, 0.72, -0.11, ...]

    즉 Point 하나에 하나의 Dense Vector를 저장한다.

    하지만 Visual Document Retriever에서는 페이지 하나가 여러 Vector를 가진다.

    개념적으로는 다음과 같다.

    Page 27
    
    [
       [0.13, 0.72, ...],
       [0.42, 0.11, ...],
       [0.83, 0.22, ...],
       ...
    ]

    즉,

    Page
     ↓
    Multi-Vector

    구조가 필요하다.

    따라서 Vector DB를 선택할 때도 단순한 Dense Vector 검색만 생각하면 안 된다.

    Multi-Vector와 Late Interaction 검색을 어떻게 지원할 것인가가 중요하다.


    11. Metadata는 반드시 함께 저장해야 한다

    Vector만 저장하면 실제 서비스에서는 거의 쓸 수 없다.

    최소한 다음 정보가 필요하다.

    {
      "tenant_id": 100,
      "cabinet_id": 25,
      "document_id": 918,
      "page_no": 27,
      "file_name": "2026_sales_report.pdf",
      "image_path": "documents/918/pages/027.webp"
    }

    특히 멀티테넌트 SaaS라면

    tenant_id
    cabinet_id
    document_id

    같은 접근 범위가 반드시 검색 Filter에 포함되어야 한다.

    예를 들어 사용자가 A 문서함에서 질문했다면 Visual Search 역시 A 문서함에 속한 Page만 검색해야 한다.


    12. 페이지 이미지는 Vector DB에 넣지 않는다

    페이지 이미지 자체까지 Qdrant에 저장할 필요는 없다.

    보통 다음처럼 역할을 나누는 것이 좋다.

    Object Storage
    ────────────────────────
    
    original.pdf
    
    pages/
     ├── 001.webp
     ├── 002.webp
     ├── 003.webp
     └── ...
    
    
    Vector DB
    ────────────────────────
    
    page_id
    document_id
    page_no
    visual_vectors
    image_path
    metadata

    실제 이미지는

    S3
    MinIO
    Object Storage

    등에 저장한다.

    Vector DB에는 이미지 위치만 저장한다.


    13. 실제 적재 파이프라인

    이제 전체 Ingestion Pipeline을 만들어보자.

                         PDF Upload
                             │
                             ▼
                      Original Storage
                             │
                             ▼
                       PDF Renderer
                             │
                 ┌───────────┴───────────┐
                 │                       │
                 ▼                       ▼
            Text Parser              Page Images
                 │                       │
                 ▼                       ▼
              Chunking            Visual Retriever
                 │                       │
                 ▼                       ▼
          Text Embedding           Multi-Vectors
                 │                       │
                 ▼                       ▼
           Text Vector DB          Visual Vector DB

    결국 문서 하나에서 두 종류의 Representation이 생성된다.

    Document
    
     ├── Semantic Text Representation
     │
     └── Visual Document Representation

    이것이 Hybrid Pixel RAG의 기본 구조다.


    14. Python 코드 구조는 어떻게 될까?

    실제 라이브러리와 모델별 API는 달라질 수 있지만 전체 코드는 대략 이런 형태가 된다.

    from pathlib import Path
    
    def ingest_document(pdf_path):
    
        document = create_document(pdf_path)
    
        pages = render_pdf(pdf_path)
    
        for page_no, image in enumerate(pages, start=1):
    
            image_path = save_page_image(
                document.id,
                page_no,
                image
            )
    
            visual_vectors = visual_encoder.encode(image)
    
            save_visual_vectors(
                document_id=document.id,
                page_no=page_no,
                image_path=image_path,
                vectors=visual_vectors
            )

    기존 Text Pipeline도 그대로 유지한다.

    text = parse_document(pdf_path)
    
    chunks = chunk_document(text)
    
    for chunk in chunks:
    
        vector = text_embedding_model.embed(chunk.text)
    
        save_text_vector(
            document_id=document.id,
            page_no=chunk.page_no,
            chunk=chunk,
            vector=vector
        )

    결국 하나의 문서 적재 작업에서 두 Pipeline이 실행된다.


    15. 검색 코드는 어떻게 될까?

    사용자 질문이 들어온다.

    query = "2026년에 매출 증가율이 가장 높은 제품은?"

    먼저 Text Search를 한다.

    text_results = text_search(
        query=query,
        top_k=10
    )

    Visual Search도 수행한다.

    visual_results = visual_search(
        query=query,
        top_k=10
    )

    결과는 대략 다음과 같다.

    TEXT
    
    page 27     score ...
    page 31     score ...
    page 12     score ...
    
    
    VISUAL
    
    page 27     score ...
    page 18     score ...
    page 31     score ...

    이 둘을 합친다.

    candidates = fuse_results(
        text_results,
        visual_results
    )

    필요하면 Reranker를 적용한다.

    results = rerank(
        query,
        candidates
    )

    그리고 최종 Page를 가져온다.

    pages = load_page_images(
        results[:3]
    )

    마지막으로 VLM에게 전달한다.

    answer = vlm.generate(
        question=query,
        images=pages
    )

    전체 흐름은 이것이 전부다.


    16. 그런데 모든 질문에 VLM을 호출하면 비싸지 않을까?

    맞다.

    Pixel RAG에서 실제 서비스 운영 시 가장 먼저 부딪히는 문제가 비용이다.

    다음 질문을 생각해보자.

    회사의 휴가 규정은?

    관련 Chunk가 정확하게 검색됐다면 굳이 페이지 이미지까지 Vision Model에게 보낼 이유가 없다.

    반면

    다음 그래프에서 2025년 대비 가장 크게 성장한 사업부는?

    이미지를 봐야 한다.

    그래서 Query Router를 둘 수 있다.

                       Question
                          │
                          ▼
                    Query Router
                          │
             ┌────────────┼────────────┐
             │            │            │
             ▼            ▼            ▼
           TEXT          VISUAL       HYBRID
             │            │            │
             ▼            ▼            ▼
          Text RAG     Pixel RAG    Both RAG

    Router의 출력은 간단하게 만들 수 있다.

    {
      "mode": "HYBRID",
      "reason": "question requires interpretation of table data"
    }

    17. 하지만 처음부터 Router를 만들 필요는 없다

    개발 초기에는 시스템을 너무 복잡하게 만들 필요가 없다.

    오히려 다음과 같은 순서가 현실적이다.

    Version 1
    
    Text Retrieval
         ↓
    Relevant Page
         ↓
    VLM
    
    
    Version 2
    
    Text Retrieval
           +
    Visual Retrieval
           ↓
    Fusion
           ↓
    VLM
    
    
    Version 3
    
    Query Router
           ↓
    TEXT / VISUAL / HYBRID
    
    
    Version 4
    
    Adaptive Retrieval

    실제 사용자 질문 데이터가 쌓인 뒤 Router를 만드는 편이 훨씬 낫다.

  • Pixel RAG 2부 — 기존 RAG 시스템에 Pixel RAG를 붙이면 무엇이 달라질까?

    Pixel RAG 2부 — 기존 RAG 시스템에 Pixel RAG를 붙이면 무엇이 달라질까?

    1부에서 Pixel RAG의 기본 개념을 살펴봤다면, 이번에는 조금 더 실전적인 이야기를 해보자.

    이미 운영 중인 RAG 시스템에 Pixel RAG를 추가하려면 무엇을 바꿔야 할까?

    결론부터 말하면 기존 RAG를 버리고 Pixel RAG로 다시 만드는 것은 좋은 접근이 아니다.

    오히려 현실적인 구조는 다음과 같다.

    Text RAG + Pixel RAG = Hybrid Document RAG

    텍스트 검색이 잘하는 것은 그대로 텍스트 RAG에게 맡기고, 표·차트·이미지·레이아웃처럼 시각적인 정보가 중요한 부분만 Pixel RAG가 담당하도록 만드는 것이다.


    1. 기존 RAG 파이프라인부터 다시 보자

    일반적인 RAG 시스템의 문서 적재 과정은 다음과 같다.

    질문이 들어오면 반대 방향의 검색 과정이 시작된다.

    이 구조는 일반적인 텍스트 문서에서는 상당히 잘 작동한다.

    문제는 문서가 항상 텍스트만으로 이루어져 있지 않다는 것이다.


    2. 문서를 텍스트로 바꾸는 순간 정보가 사라진다

    다음과 같은 보고서가 있다고 생각해보자.

    2026년 제품별 매출
    ────────────────────────
    
            2025    2026
    제품 A   100     180   ▲80%
    제품 B   200     150   ▼25%
    제품 C    80     160   ▲100%
    
            [ 매출 추이 그래프 ]
    
    ※ 해외 매출 제외

    사람은 이것을 보는 순간 구조를 이해한다.

    • 행은 제품이다.
    • 열은 연도다.
    • 화살표는 증가·감소를 의미한다.
    • 아래 그래프는 표와 관련되어 있다.
    • 각주는 전체 숫자에 대한 조건이다.

    그런데 PDF Parser가 이것을 텍스트로 변환하면 다음처럼 될 수도 있다.

    2026년 제품별 매출
    2025 2026
    제품 A 100 180
    제품 B 200 150
    제품 C 80 160
    80% 25% 100%
    해외 매출 제외

    글자는 대부분 살아 있다.

    하지만 글자 사이의 관계가 사라졌다.

    이것이 상당히 중요한 차이다.

    Pixel RAG가 해결하려는 핵심 문제도 여기에 있다.


    3. Pixel RAG에서는 페이지 자체도 데이터다

    Pixel RAG를 추가하면 문서 처리 과정에서 새로운 경로가 하나 생긴다.

    같은 PDF에서 두 종류의 검색 데이터를 만드는 것이다.

    Text Representation

    chunk #101
    "제품 A의 2026년 매출은..."

    그리고

    Visual Representation

    page #7
    [페이지 전체 이미지]

    둘은 서로 경쟁하는 데이터가 아니다.

    같은 원본 문서를 서로 다른 관점에서 표현한 데이터다.


    4. Chunk 중심에서 Page 중심으로 하나의 축이 추가된다

    기존 RAG에서 검색의 기본 단위는 대부분 Chunk다.

    Document
     ├─ Chunk 1
     ├─ Chunk 2
     ├─ Chunk 3
     └─ Chunk 4

    Pixel RAG에서는 Page라는 검색 단위가 추가된다.

    Document
     ├─ Page 1
     │   ├─ Chunk 1
     │   ├─ Chunk 2
     │   └─ Visual Representation
     │
     ├─ Page 2
     │   ├─ Chunk 3
     │   ├─ Chunk 4
     │   └─ Visual Representation
     │
     └─ Page 3
         ├─ Chunk 5
         └─ Visual Representation

    여기서 굉장히 중요한 설계 포인트가 생긴다.

    Chunk와 Page 사이의 관계를 반드시 알고 있어야 한다.

    예를 들어 Chunk 검색 결과가 다음과 같다고 하자.

    chunk_id = 392
    document_id = 15
    page_no = 27

    그러면 시스템은 필요할 경우 즉시

    document 15의 page 27 이미지

    를 가져올 수 있어야 한다.

    이 관계가 Hybrid RAG의 핵심이다.


    5. 저장 구조는 어떻게 달라질까?

    기존 시스템이 다음과 같은 구조를 가지고 있다고 해보자.

    documents
        │
        ├── chunks
        │      │
        │      └── embeddings
        │
        └── original_file

    Pixel RAG를 추가하면 대략 다음과 같은 구조가 된다.

    documents
        │
        ├── original_file
        │
        ├── pages
        │      ├── page_001.png
        │      ├── page_002.png
        │      └── page_003.png
        │
        ├── chunks
        │      └── text_embeddings
        │
        └── visual_embeddings

    관계형 DB로 표현한다면 예를 들어 다음과 같이 설계할 수 있다.

    documents
    ---------
    id
    file_name
    storage_path
    
    
    document_pages
    --------------
    id
    document_id
    page_no
    image_path
    width
    height
    
    
    chunks
    ------
    id
    document_id
    page_id
    text
    chunk_index
    
    
    text_embeddings
    ---------------
    chunk_id
    vector_id
    
    
    visual_embeddings
    -----------------
    page_id
    vector_id
    model

    특히

    chunks.page_id

    관계가 중요하다.

    텍스트 검색에서 찾은 Chunk가 원본 문서의 어느 페이지에 존재하는지 알아야 하기 때문이다.


    6. Qdrant에는 어떻게 저장할까?

    여러 방법이 있지만 가장 이해하기 쉬운 방법은 Collection을 분리하는 것이다.

    예를 들어 기존 Text RAG가

    documents_text

    Collection을 사용한다면 Pixel RAG용으로

    documents_visual

    을 추가할 수 있다.

    Text Collection

    {
      "document_id": 15,
      "chunk_id": 392,
      "page_no": 27,
      "text": "제품 A의 매출은...",
      "vector": [...]
    }

    Visual Collection

    {
      "document_id": 15,
      "page_id": 128,
      "page_no": 27,
      "image_path": "documents/15/pages/027.png",
      "vector": [...]
    }

    그러면

    documents_text
            +
    documents_visual

    두 개의 Retrieval 경로를 사용할 수 있다.


    7. 그렇다면 질문할 때 둘 다 검색해야 할까?

    여기서 재미있는 문제가 하나 등장한다.

    사용자가 이렇게 질문했다고 해보자.

    회사의 개인정보 보관 기간은 몇 년인가?

    이 질문은 거의 완전히 텍스트 기반이다.

    굳이 페이지 이미지를 VLM에 전달할 필요가 없다.

    반면 다음 질문은 다르다.

    2025년과 2026년 제품별 매출 증가율을 비교해줘.

    관련 정보가 표에 있다면 Pixel Retrieval이 유리할 가능성이 높다.

    또 이런 질문도 있다.

    이 보고서의 17페이지 그래프에서 가장 크게 증가한 항목은 뭐야?

    이건 사실상 Visual Question이다.

    따라서 질문을 먼저 분류할 수 있다.

                        User Query
                             │
                             ▼
                      Query Analyzer
                             │
                 ┌───────────┼───────────┐
                 │           │           │
                 ▼           ▼           ▼
               TEXT        VISUAL       HYBRID
                 │           │           │
                 ▼           ▼           ▼
             Text RAG     Pixel RAG    Both Search

    예를 들어 Query Analyzer가 다음처럼 판단할 수 있다.

    {
      "retrieval_mode": "visual",
      "reason": "question refers to chart comparison"
    }

    또는

    {
      "retrieval_mode": "hybrid",
      "reason": "requires both textual explanation and table data"
    }

    8. 하지만 처음부터 Query Router를 만드는 것이 최선일까?

    꼭 그렇지는 않다.

    초기 시스템에서는 오히려 항상 두 검색을 실행하는 방식도 충분히 현실적이다.

    User Query
         │
         ├──────────────┐
         ▼              ▼
    Text Search     Visual Search
         │              │
     Top-K            Top-K
         │              │
         └──────┬───────┘
                ▼
           Result Merge
                │
                ▼
             Reranker
                │
                ▼
               LLM

    장점은 구현이 단순하다는 것이다.

    단점은 당연히 비용과 검색량이 증가한다는 것이다.

    서비스가 성장하면 이후에 Query Router를 넣어 최적화할 수 있다.

    1단계
    Always Hybrid
    
           ↓
    
    2단계
    Rule Based Router
    
           ↓
    
    3단계
    LLM Query Router
    
           ↓
    
    4단계
    Retrieval 결과까지 이용하는
    Adaptive Router

    개인적으로는 이런 순서가 훨씬 현실적이라고 생각한다.

    처음부터 똑똑한 Router를 만드는 것보다 먼저 실제 질문 데이터를 모으는 편이 낫다.


    9. 검색 결과를 어떻게 합칠 것인가?

    Text Search 결과가 다음과 같다고 해보자.

    Chunk 392     score 0.91
    Chunk 102     score 0.87
    Chunk 812     score 0.82

    Visual Search에서는

    Page 27       score 0.94
    Page 12       score 0.89
    Page 43       score 0.83

    가 나왔다.

    단순히 score를 비교해서 합치면 문제가 생길 수 있다.

    두 Embedding Model의 score 의미가 동일하지 않기 때문이다.

    따라서 보통은 Ranking 기반 결합을 생각할 수 있다.

    대표적인 방법이 **RRF(Reciprocal Rank Fusion)**다.

    개념은 간단하다.

    Text Ranking
    
    1. Page 27
    2. Page 12
    3. Page 5
    
    
    Visual Ranking
    
    1. Page 12
    2. Page 27
    3. Page 33

    두 결과를 합치면

    Final Ranking
    
    1. Page 27
    2. Page 12
    3. Page 5
    4. Page 33

    처럼 만들 수 있다.

    여기에 Reranker까지 적용하면 더욱 정교하게 만들 수 있다.


    10. VLM은 언제 등장할까?

    여기서 많이 헷갈리는 부분이 있다.

    Pixel RAG라고 해서 모든 페이지를 VLM에게 보내는 것은 아니다.

    그렇게 하면 비용이 너무 커진다.

    먼저 Retrieval을 수행한다.

    1000 Page PDF
          │
          ▼
    Visual Retrieval
          │
          ▼
    Top 5 Pages

    그리고 Top-K 페이지만 VLM에 전달한다.

    User Question
    
          +
    
    Page 17 Image
    Page 23 Image
    Page 81 Image
    
          │
          ▼
    
         VLM
    
          │
          ▼
    
       Answer

    즉 Pixel Retrieval의 중요한 목적 중 하나는

    VLM이 봐야 할 페이지를 먼저 찾아주는 것

    이라고 할 수 있다.


    11. 그래서 비용 구조도 달라진다

    기존 Text RAG에서는 주로 다음 비용이 발생한다.

    문서 적재
    
    Parsing
       ↓
    Embedding
       ↓
    Vector Storage

    질의에서는

    Query Embedding
       ↓
    Vector Search
       ↓
    LLM

    정도다.

    Pixel RAG를 추가하면 다음 비용이 추가된다.

    Page Rendering
          +
    Visual Embedding
          +
    Image Storage
          +
    Visual Retrieval
          +
    VLM Input

    특히 수천 페이지짜리 문서를 많이 저장하는 SaaS라면 이 비용을 무시하면 안 된다.

    그래서 모든 문서를 무조건 Pixel RAG로 처리할 필요도 없다.


    12. 문서별로 Pixel RAG를 선택적으로 적용할 수도 있다

    문서 적재 과정에서 Document Analyzer를 둘 수 있다.

                        Document
                            │
                            ▼
                    Document Analyzer
                            │
               ┌────────────┴────────────┐
               │                         │
               ▼                         ▼
          Text 중심                  Visual 중심
               │                         │
               ▼                         ▼
          Text RAG                 Hybrid RAG

    예를 들어

    TXT
    Markdown
    HTML 문서

    등은 Text RAG만 수행한다.

    반대로

    PDF 보고서
    PPT
    스캔 문서
    표가 많은 문서
    차트가 많은 문서
    복잡한 레이아웃 문서

    는 Pixel RAG까지 수행한다.

    이렇게 하면 비용을 상당히 줄일 수 있다.


    13. 또 하나의 방법 — 검색된 페이지에 대해서만 VLM 사용하기

    더 현실적인 절충안도 있다.

    Visual Embedding 자체를 모든 페이지에 만들지 않는 것이다.

    먼저 기존 Text RAG로 검색한다.

    User Query
         │
         ▼
    Text Retrieval
         │
         ▼
    Chunk Top-K
         │
         ▼
    Page Number 확인
         │
         ▼
    원본 Page Image 로딩
         │
         ▼
    VLM

    예를 들어 Text Search가

    chunk 382 → page 17
    chunk 912 → page 32
    chunk 128 → page 17

    을 찾았다면

    page 17
    page 32

    두 장만 이미지로 가져와 VLM에게 보여준다.

    이 방식에는 Visual Retrieval이 없다.

    따라서 엄밀히 말하면 완전한 Pixel Retrieval 구조와는 다르지만, 기존 RAG를 크게 변경하지 않으면서 문서의 시각 정보를 활용할 수 있다는 장점이 있다.

    실제 서비스를 개발한다면 꽤 매력적인 첫 단계다.


    14. 현실적인 도입 단계를 정리해보자

    기존 RAG 시스템이 이미 있다면 다음 순서가 안전하다.

    Phase 1 — Page Mapping

    기존 Chunk에

    document_id
    page_no

    정보를 정확하게 저장한다.

    Chunk
      │
      └── Page

    관계를 만든다.


    Phase 2 — Page Image 저장

    PDF를 적재할 때 페이지 이미지를 생성한다.

    original.pdf
    
    → page_001.webp
    → page_002.webp
    → page_003.webp

    Object Storage에 저장한다.

    아직 Visual Embedding은 만들지 않는다.


    Phase 3 — Text Retrieval + VLM

    기존 검색 결과에서 Page를 찾는다.

    Text Retrieval
          ↓
    Relevant Chunk
          ↓
    Page Image
          ↓
    VLM

    이 단계만 적용해도 표와 차트 질문의 품질이 크게 달라질 수 있다.


    Phase 4 — Visual Retrieval 추가

    그 다음에 필요하면 ColPali나 ColQwen 계열과 같은 Visual Document Retrieval을 도입한다.

    Page Image
        ↓
    Visual Embedding
        ↓
    Vector DB

    이제 텍스트 파서가 놓친 페이지도 직접 찾을 수 있게 된다.


    Phase 5 — Hybrid Retrieval

    최종적으로

                     User Query
                         │
              ┌──────────┴──────────┐
              ▼                     ▼
         Text Retrieval       Visual Retrieval
              │                     │
              └──────────┬──────────┘
                         ▼
                      Fusion
                         │
                         ▼
                      Rerank
                         │
                         ▼
              Text

    마무리

    15. 최종적으로 이런 아키텍처가 된다

    지금까지 설명한 내용을 하나의 그림으로 합치면 Hybrid Pixel RAG는 다음과 같은 구조가 된다.

    이 구조에서 중요한 것은 Pixel RAG가 기존 RAG를 대체하지 않는다는 것이다.

    오히려 기존 RAG가 가지고 있던 텍스트라는 하나의 정보 표현 방식에 ‘문서의 시각적 표현’이라는 또 하나의 검색 축을 추가하는 것에 가깝다.


    16. 모든 RAG가 Pixel RAG가 되어야 하는 것은 아니다

    Pixel RAG가 흥미로운 기술이라고 해서 모든 문서를 이미지 기반으로 처리해야 하는 것은 아니다.

    예를 들어 다음과 같은 문서는 기존 Text RAG가 훨씬 효율적이다.

    • Markdown
    • TXT
    • 소스코드
    • 구조가 단순한 HTML
    • 일반적인 텍스트 중심 문서

    반면 다음과 같은 문서에서는 Pixel RAG의 가치가 커진다.

    • 표가 많은 PDF
    • 재무 보고서
    • 통계 보고서
    • 논문
    • 프레젠테이션
    • 제품 카탈로그
    • 매뉴얼
    • 스캔 문서
    • 차트와 그래프가 많은 문서
    • 복잡한 레이아웃을 가진 문서

    결국 중요한 것은

    문서의 의미가 텍스트만으로 충분히 보존되는가?

    라는 질문이다.

    그렇다면 Text RAG면 충분하다.

    그렇지 않다면 Pixel RAG를 고려할 이유가 생긴다.


    17. Pixel RAG의 진짜 의미

    RAG 기술은 그동안 상당 부분 다음 문제에 집중해왔다.

    “텍스트를 어떻게 잘 나눌 것인가?”

    그래서 Chunk Size, Overlap, Semantic Chunking 같은 기술들이 중요했다.

    하지만 Pixel RAG는 질문 자체를 조금 바꾼다.

    “문서를 반드시 텍스트로 변환해야 하는가?”

    사람은 보고서를 읽을 때 글자만 읽지 않는다.

    표의 행과 열을 보고,

    그래프의 높이를 보고,

    제목과 본문의 위치 관계를 보고,

    이미지와 설명의 관계를 보고,

    페이지 전체의 구조를 함께 이해한다.

    그런 의미에서 Pixel RAG는 단순히 새로운 Embedding 기술 하나가 아니다.

    RAG가 다루는 정보의 단위를 Text에서 Document 자체로 확장하려는 흐름이라고 볼 수 있다.


    마치며

    기존 RAG 시스템에 Pixel RAG를 도입한다고 해서 처음부터 시스템 전체를 다시 만들 필요는 없다.

    가장 현실적인 접근은 단계적으로 시작하는 것이다.

    기존 Text RAG
          │
          ▼
    Chunk ↔ Page Mapping
          │
          ▼
    Page Image 저장
          │
          ▼
    Text Retrieval + VLM
          │
          ▼
    Visual Retrieval
          │
          ▼
    Hybrid Retrieval
          │
          ▼
    Adaptive Multimodal RAG

    특히 기존 RAG 시스템을 운영하고 있다면 Text Retrieval + Page Image + VLM 정도부터 실험해보는 것이 좋다.

    이 방법은 기존 검색 파이프라인을 거의 그대로 유지하면서도 표, 차트, 레이아웃 정보를 활용할 수 있기 때문이다.

    그리고 여기까지 구현했을 때 다음 질문이 생긴다.

    “그렇다면 실제 Pixel RAG는 어떤 모델을 사용하고, 페이지 이미지를 어떻게 Embedding하며, Qdrant에는 어떤 형태로 저장해야 할까?”

    여기서 ColPali, ColQwen 같은 Visual Document Retrieval 모델이 등장한다.

    다음 3부에서는 개념 설명을 넘어 실제 구현 관점에서 살펴본다.

    다음 글

    Pixel RAG 3부 — ColPali·ColQwen과 Qdrant로 구현하는 Visual Document Retrieval

    • ColPali는 무엇인가?
    • 일반 Image Embedding과 무엇이 다른가?
    • Multi-Vector Embedding이 필요한 이유
    • Late Interaction은 무엇인가?
    • 페이지 한 장에서 왜 여러 Vector가 나오는가?
    • Qdrant에는 어떻게 저장하는가?
    • Text RAG와 Visual Retrieval을 어떻게 결합하는가?
    • Python으로 만드는 간단한 구현 구조
    • 실제 서비스에 적용할 때 GPU와 비용은 어느 정도 필요한가?

    3부부터는 실제로 만들 수 있는 수준의 아키텍처와 코드를 중심으로 들어가 보자.

  • Pixel RAG 1부 — RAG가 문서를 ‘읽는’ 방식이 달라지고 있다.

    Pixel RAG 1부 — RAG가 문서를 ‘읽는’ 방식이 달라지고 있다.

    RAG(Retrieval-Augmented Generation)는 이제 생성형 AI 서비스를 만들 때 가장 많이 사용되는 아키텍처 중 하나가 되었다.

    기업 내부 문서를 검색하거나, 매뉴얼을 기반으로 질문에 답하거나, 수많은 보고서에서 필요한 정보를 찾아주는 AI 서비스를 생각해보자.

    일반적인 RAG 시스템은 대략 다음과 같이 동작한다.

    그런데 여기에는 한 가지 중요한 전제가 숨어 있다.

    문서의 의미를 텍스트로 변환할 수 있다.

    과연 항상 그럴까?

    PDF 보고서, 논문, 프레젠테이션, 재무제표처럼 표와 그래프가 많은 문서를 생각해보면 이야기가 달라진다.

    문서에는 글자만 있는 것이 아니다.

    표의 행과 열, 그래프의 높이, 이미지와 설명의 위치, 제목과 본문의 계층 관계까지 모두 정보다.

    그런데 기존 RAG는 이 문서를 먼저 텍스트로 바꾼 뒤 검색한다.

    바로 이 지점에서 Pixel RAG라는 새로운 접근이 등장한다.


    1. 기존 RAG는 실제 문서를 검색하지 않는다

    PDF 파일을 RAG에 넣는다고 생각해보자.

    사용자 입장에서는 PDF를 AI에게 전달했으니 AI가 PDF를 읽는다고 생각하기 쉽다.

    하지만 대부분의 RAG 시스템 내부에서는 그렇지 않다.

    즉 PDF 자체를 검색하는 것이 아니다.

    PDF에서 추출된 텍스트를 검색한다.

    이 차이는 매우 중요하다.


    2. 텍스트 중심 문서에서는 별 문제가 없다

    예를 들어 다음과 같은 사내 규정이 있다고 하자.

    ------------------------------------------------
    제17조 개인정보 보관
    
    회사는 계약 종료 후 개인정보를 3년간 보관한다.
    
    보관 기간이 종료된 개인정보는 복구할 수 없는 방법으로
    즉시 파기한다.
    -------------------------------------------------

    이 문서를 Parser가 처리하면 거의 동일한 텍스트를 얻을 수 있다.

    회사는 계약 종료 후 개인정보를 3년간 보관한다.
    보관 기간이 종료된 개인정보는 복구할 수 없는 방법으로
    즉시 파기한다.

    이것을 Chunk로 나누고 Embedding하면 된다.

    사용자가

    개인정보는 몇 년 동안 보관하나요?

    라고 질문하면 관련 Chunk를 쉽게 검색할 수 있다.

    이런 문서는 기존 Text RAG가 매우 잘 처리한다.


    3. 그런데 표를 만나면 이야기가 달라진다

    이번에는 다음과 같은 페이지를 생각해보자.

    2026년 제품별 매출

    제품2025년2026년 증가율
    A100180+80%
    B200150-25%
    C80160+100%

    ※ 해외 매출 제외

    사람은 이 표를 보는 순간 구조를 이해한다.

    제품 C가 80에서 160으로 증가했고 증가율이 100%라는 것도 쉽게 알 수 있다.

    그런데 PDF Parser가 다음처럼 텍스트를 추출했다고 해보자.

    2026년 제품별 매출
    
    제품
    2025년
    2026년
    증가율
    
    A
    B
    C
    
    100
    200
    80
    
    180
    150
    160
    
    80%
    -25%
    100%
    
    해외 매출 제외

    모든 글자는 살아 있다.

    그런데 중요한 것이 사라졌다.

    글자 사이의 관계다.

    어떤 숫자가 어떤 제품에 해당하는지, 어떤 숫자가 어느 연도인지, 증가율이 어느 행과 연결되는지 알기 어려워진다.


    4. 그래프에서는 문제가 더 커진다

    다음과 같은 페이지가 있다고 하자.
    

    사람은 그래프의 모양 자체에서 정보를 얻는다.

    하지만 Text Parser 입장에서는 추출할 텍스트가 거의 없다.

    결국 이런 결과가 나올 수도 있다.

    2022~2026 사업부별 성장률
    
    매출
    
    2022
    2023
    2024
    2025
    2026

    그래프가 보여주던 핵심 정보는 사라졌다.


    5. 문서는 텍스트 이상의 정보를 가지고 있다

    문서를 사람이 볼 때 실제로 사용하는 정보는 훨씬 다양하다.

    예를 들어

    • 제목이 어디에 있는가?
    • 표에서 어떤 값이 같은 행에 있는가?
    • 그래프에서 어떤 막대가 가장 높은가?
    • 이미지 옆의 설명은 무엇인가?
    • 어떤 각주가 어떤 표를 설명하는가?

    같은 정보는 텍스트만 추출해서는 완전히 보존하기 어렵다.

    그래서 이런 질문을 해볼 수 있다.

    문서를 굳이 텍스트로 바꾼 다음 검색해야 할까?


    6. Pixel RAG의 아이디어

    Pixel RAG의 아이디어는 놀랄 만큼 단순하다.

    문서를 사람이 보는 모습 그대로 검색해보자는 것이다.

    PDF 페이지를 이미지로 변환한다.

    그리고 페이지 이미지 자체를 검색할 수 있는 표현으로 변환한다.

    사용자가 질문하면 질문과 관련 있는 페이지를 찾는다.

    이것이 Pixel RAG를 이해하는 가장 기본적인 그림이다.


    7. OCR과 Pixel RAG는 무엇이 다를까?

    여기서 자연스럽게 이런 의문이 생긴다.

    “그냥 OCR을 잘하면 되는 것 아닌가?”

    OCR은 이미지 속 글자를 텍스트로 변환하는 기술이다.

    Image
      ↓
    OCR
      ↓
    Text

    예를 들어

    [문서 이미지]
    
    "2026년 매출 보고서"

    를

    2026년 매출 보고서

    로 바꾼다.

    좋은 OCR과 Layout Parser를 사용하면 표와 문서 구조를 상당 부분 복원할 수도 있다.

    하지만 결국 목표는 문서를 구조화된 텍스트 또는 데이터로 변환하는 것이다.

    Pixel RAG 계열의 Visual Retrieval은 접근 방향이 다르다.

    즉 문서를 반드시 텍스트로 완전히 복원한 다음 검색해야 한다는 전제에서 벗어난다.


    8. 그렇다고 OCR이 필요 없어지는 것은 아니다

    여기서 오해하면 안 되는 부분이 있다.

    Pixel RAG가 등장했다고 해서

    OCR은 이제 필요 없다.

    는 뜻은 아니다.

    실제 시스템에서는 오히려 둘을 함께 사용하는 것이 자연스럽다.

    텍스트 검색은 빠르고 저렴하다.

    Visual Retrieval은 표, 그래프, 이미지, 레이아웃을 이해하는 데 강점이 있다.

    두 방법의 장점을 같이 사용하는 것이다.


    9. Pixel RAG가 특히 유용한 문서는?

    Pixel RAG가 모든 문서에 필요한 것은 아니다.

    예를 들어 TXT 파일이나 Markdown 문서처럼 애초부터 구조가 명확한 텍스트라면 굳이 이미지로 바꿀 이유가 없다.

    반대로 다음과 같은 문서에서는 가치가 커진다.

    재무 보고서

    표와 그래프가 많고 숫자 사이의 관계가 중요하다.

    논문

    수식, 표, 그림, 그래프, 캡션이 함께 등장한다.

    프레젠테이션

    텍스트 자체보다 위치와 시각적 관계가 중요한 경우가 많다.

    제품 카탈로그

    제품 사진과 설명, 가격, 사양표의 관계가 중요하다.

    매뉴얼

    스크린샷, 다이어그램과 설명이 함께 등장한다.

    스캔 문서

    애초에 정상적인 Text Layer가 없을 수 있다.

    복잡한 PDF

    다단 레이아웃이나 표 때문에 Parser가 읽는 순서를 잘못 판단하기 쉽다.


    10. Text RAG와 Pixel RAG를 비교하면

    차이를 간단히 정리해보자.

    구분Text RAGPixel / Visual RAG
    검색 대상Text ChunkDocument Page
    입력추출된 텍스트페이지 이미지
    EmbeddingText EmbeddingVisual Document Representation
    표Parser 품질에 의존원본 구조 활용 가능
    차트제한적강점
    이미지대부분 손실활용 가능
    레이아웃손실 가능활용 가능
    검색 비용상대적으로 낮음상대적으로 높음
    저장량작음상대적으로 큼
    생성 모델LLM주로 VLM
    적합한 문서텍스트 중심복잡한 시각 문서

    따라서 둘 중 어느 것이 무조건 더 좋다고 말하기는 어렵다.

    문서의 특성이 다르기 때문이다.


    11. Pixel RAG는 ‘페이지를 VLM에 전부 보내는 것’이 아니다

    Pixel RAG를 처음 들으면 그림의 1과 같이 “1000 Page PDF -> VLM -> Answer” 이런 구조를 생각하기 쉽다.

    하지만 이렇게 하면 비용과 Context 문제가 매우 커진다.

    RAG인 이유는 먼저 검색하기 때문이다.

    1000 Pages -> Visual Retrieval -> Top-K Pages -> 3~5 Pages ->
    VLM -> Answer

    즉 Visual Retriever의 역할은

    수많은 페이지 중 VLM이 봐야 할 몇 장을 찾아주는 것

    이다.

    이 관점이 중요하다.


    12. 그렇다면 Visual Retrieval은 어떻게 가능할까?

    여기서부터 Pixel RAG의 기술적으로 재미있는 부분이 시작된다.

    페이지 한 장에는 너무 많은 정보가 들어 있다.

    이 페이지 전체를 하나의 Vector로 압축해버리면 세부적인 정보가 손실될 수 있다.

    그래서 Visual Document Retrieval에서는 페이지를 여러 Vector로 표현하는 Multi-Vector 방식을 사용할 수 있다.

    그리고 질문 역시 여러 표현을 가진다.

    질문의 각 요소와 페이지의 여러 요소를 비교해서 관련성을 계산한다.

    이 과정에서 Late Interaction이라는 개념이 등장한다.

    그리고 바로 이 분야에서 자주 등장하는 이름이

    ColPali, ColQwen 계열의 Visual Document Retrieval 모델이다.

    이 부분부터는 조금 기술적인 이야기가 된다.

    그래서 다음 편에서 자세히 다룬다.


    13. Pixel RAG라는 말을 볼 때 주의할 점

    ‘Pixel RAG’라는 표현을 너무 엄격한 하나의 표준 기술명으로 받아들일 필요는 없다.

    실제로 관련 분야에서는

    Visual Document Retrieval
    Visual RAG
    Multimodal RAG
    Vision-based Document Retrieval
    Document Image Retrieval

    등 여러 표현이 사용된다.

    또한 구현 방식도 하나가 아니다.

    어떤 시스템은 페이지 이미지를 직접 검색하고,

    어떤 시스템은 OCR과 Layout 정보를 함께 사용하고,

    어떤 시스템은 Text Retrieval로 먼저 후보 페이지를 찾은 뒤 VLM에게 원본 페이지를 보여준다.

    따라서 중요한 것은 이름보다 검색 과정에서 문서의 시각적 표현을 활용한다는 개념이다.


    14. RAG의 관점이 바뀌고 있다

    기존 RAG의 중요한 질문은 이것이었다.

    “문서를 어떻게 잘 Chunking할 것인가?”

    그래서 우리는 계속 다음을 고민했다.

    Chunk Size는 얼마가 좋은가?
    
    Overlap은 얼마나 줘야 하는가?
    
    Paragraph 단위로 나눌까?
    
    Section 단위로 나눌까?
    
    Semantic Chunking을 사용할까?

    그런데 Pixel RAG는 조금 다른 질문을 던진다.

    “애초에 모든 지식을 Text Chunk로 바꾸어야 하는가?”

    이 질문은 꽤 중요하다.

    사람은 문서를 볼 때 Chunk 단위로 읽지 않는다.

    페이지를 보고,

    표를 보고,

    그래프를 보고,

    제목과 본문의 관계를 보고,

    그림과 설명을 함께 본다.

    즉 사람이 이해하는 문서는 본래부터 Multimodal이다.


    15. 결국 RAG는 이런 방향으로 확장되고 있다

    초기의 RAG를 단순화하면 다음과 같았다.

    이후 Keyword Search와 Vector Search를 결합한 Hybrid Search가 등장하고, Reranker가 적극적으로 사용되기 시작했다.

    그리고 이제 검색 대상 자체가 확장되고 있다.

    RAG가 단순한 Text Retrieval 시스템에서 Document Understanding 시스템으로 확장되는 과정이라고 볼 수도 있다.


    마치며

    Pixel RAG를 처음 접하면 기존 RAG와 완전히 다른 기술처럼 느껴질 수 있다.

    하지만 핵심 아이디어는 의외로 간단하다.

    기존 RAG는 문서를 먼저 텍스트로 변환하고 그 텍스트를 검색했다.

    기존 Text RAG
    
    Document
       ↓
    Text Parsing
       ↓
    Chunk
       ↓
    Embedding
       ↓
    Retrieval
       ↓
    LLM

    Pixel RAG 또는 Visual Document Retrieval은 여기에 새로운 길을 추가한다.

    Pixel / Visual RAG
    
    Document
       ↓
    Page Image
       ↓
    Visual Representation
       ↓
    Visual Retrieval
       ↓
    VLM

    그리고 실제 시스템에서는 둘 중 하나만 선택해야 하는 것도 아니다.

    오히려 다음과 같은 구조가 자연스럽다.
    
    

    텍스트로 충분히 표현할 수 있는 정보는 Text RAG가 처리한다.

    표, 차트, 이미지, 복잡한 레이아웃처럼 텍스트 변환 과정에서 의미가 손실될 수 있는 정보는 Visual Retrieval이 보완한다.

    그래서 Pixel RAG의 핵심을 한 문장으로 정리하면 다음과 같다.

    문서를 텍스트로 변환한 결과만 검색하지 말고, 사람이 보는 문서의 모습 자체도 검색 대상으로 사용하자.


    Pixel RAG가 흥미로운 진짜 이유

    개인적으로 Pixel RAG에서 가장 흥미로운 부분은 단순히 검색 정확도가 좋아질 수 있다는 점만은 아니다.

    RAG 시스템에서 ’문서란 무엇인가?’라는 관점 자체가 달라진다는 것이다.

    기존에는 사실상 이렇게 생각했다.

    Document ≒ Text

    하지만 실제 문서는 그렇지 않다.

    Document
    
     ├── Text
     ├── Table
     ├── Chart
     ├── Image
     ├── Diagram
     ├── Layout
     ├── Position
     └── Visual Relationship

    예를 들어 표에서는 숫자 자체보다 어느 행과 어느 열에 있는가가 중요할 수 있다.

    그래프에서는 글자보다 막대의 높이나 선의 변화가 중요한 정보일 수 있다.

    제품 카탈로그에서는 제품 사진과 옆에 위치한 설명의 관계가 중요할 수 있다.

    사람은 이런 정보를 자연스럽게 함께 본다.

    하지만 기존 Text RAG는 문서를 텍스트로 변환하는 순간 그중 일부를 포기해야 했다.

    Pixel RAG는 그 손실을 줄이려는 접근이라고 볼 수 있다.


    그렇다면 기존 RAG를 Pixel RAG로 바꿔야 할까?

    그럴 필요는 없다.

    오히려 기존 RAG 시스템을 운영하고 있다면 Text RAG를 그대로 유지하는 것이 좋다.

    Text Retrieval은 여전히 빠르고 저렴하며 강력하다.

    대신 기존 시스템에 새로운 검색 경로를 하나 추가하는 것이다.

    기존 시스템
    
    Text RAG
       │
       ▼
    Answer
    
    
    확장된 시스템
    
            ┌── Text RAG ────┐
    Question│                ├── Answer
            └── Pixel RAG ───┘

    그리고 질문이나 문서의 특성에 따라 적절한 Retrieval을 선택할 수 있다.

    "휴가 규정은?"
          ↓
    Text RAG
    
    
    "이 표에서 매출이 가장 높은 제품은?"
          ↓
    Pixel RAG
    
    
    "보고서 내용을 분석하고
     차트까지 고려해서 설명해줘."
          ↓
    Hybrid RAG

    이렇게 보면 Pixel RAG는 기존 RAG의 경쟁자라기보다는 확장 기능에 가깝다.


    1부 핵심 정리

    이번 글의 내용을 다섯 가지로 정리해보자.

    첫째. 기존 RAG는 대부분 원본 문서를 직접 검색하지 않는다.

    문서에서 추출한 Text Chunk를 검색한다.

    둘째. 문서를 텍스트로 변환하는 과정에서 정보가 손실될 수 있다.

    특히 표, 차트, 이미지, 다이어그램, 레이아웃에서 문제가 발생한다.

    셋째. Pixel RAG는 페이지의 시각적 표현을 검색에 활용한다.

    문서를 사람이 보는 형태에 더 가까운 상태로 다룬다.

    넷째. Pixel RAG가 OCR이나 Text RAG를 없애는 것은 아니다.

    실제 시스템에서는 Text Retrieval과 Visual Retrieval을 함께 사용하는 Hybrid 구조가 유용하다.

    다섯째. Pixel RAG의 중요한 변화는 검색 기술 하나가 아니다.

    RAG의 검색 대상을 Text Chunk 에서 Document 자체로 확장하려는 시도라는 점이다.


    다음 글

    Pixel RAG 2부 — 기존 RAG 시스템에 Pixel RAG를 붙이면 무엇이 달라질까?

    개념을 이해했다면 다음 질문은 자연스럽다.

    “이미 만들어 놓은 RAG 시스템에 Pixel RAG를 어떻게 추가하지?”

    2부에서는 실제 시스템 아키텍처 관점에서 살펴본다.

    기존의

    Document
       ↓
    Parser
       ↓
    Chunking
       ↓
    Embedding
       ↓
    Vector DB

    파이프라인이 Pixel RAG를 추가하면서 어떻게

    구조로 확장되는지 살펴본다.

    또한 다음 문제들도 다룬다.

    • Chunk와 Page의 관계를 어떻게 저장할 것인가?
    • 페이지 이미지는 어디에 저장할 것인가?
    • Vector DB를 분리해야 하는가?
    • 모든 질문에 Visual Search를 해야 하는가?
    • Text와 Visual 검색 결과는 어떻게 합칠 것인가?
    • VLM에는 어떤 페이지를 전달해야 하는가?
    • Pixel RAG 때문에 증가하는 비용은 어떻게 줄일 것인가?
    • 기존 RAG에 가장 적은 변경으로 도입하려면 어디서부터 시작해야 하는가?

    2부부터는 본격적으로 Pixel RAG의 시스템 구조를 뜯어보자.