[카테고리:] Tech Trends

기술 동향과 시리즈에 속하지 않는 단편 글을 모았습니다.

  • AI 개발자가 알아야 할 비동기 프로그래밍 2부

    AI 개발자가 알아야 할 비동기 프로그래밍 2부

    동기와 비동기의 진짜 차이 — async는 동시에 실행한다는 뜻일까?

    1부에서는 AI 서비스를 개발할 때 왜 비동기 프로그래밍에 대한 이해가 중요한지 살펴봤다.

    특히 FastAPI API Server가 하는 일을 보면 CPU로 직접 계산하는 시간보다 다른 시스템의 응답을 기다리는 시간이 상당히 많다는 이야기를 했다.

    예를 들어 RAG 서비스 하나만 보더라도 다음과 같은 작업이 반복된다.

    FastAPI API Server
            │
            ├──→ Embedding API ─── 기다림
            ├──→ Vector DB ─────── 기다림
            ├──→ Reranker API ──── 기다림
            ├──→ LLM API ───────── 기다림
            ├──→ RDB ───────────── 기다림
            └──→ Redis ─────────── 기다림

    그리고 비동기의 핵심 아이디어를 아주 간단하게 정리했다.

    기다리는 동안 다른 일을 하자.

    그런데 Python 비동기 프로그래밍을 공부하기 시작하면 여기서부터 조금 혼란스러워진다.

    갑자기 비슷해 보이는 용어들이 쏟아져 나오기 때문이다.

    Synchronous
    Asynchronous
    
    Blocking
    Non-blocking
    
    Concurrency
    Parallelism
    
    async
    await
    
    Coroutine
    Task
    Event Loop

    특히 이런 설명을 자주 접한다.

    “async를 사용하면 여러 작업을 동시에 실행할 수 있습니다.”

    완전히 틀린 말은 아니다.

    하지만 이 표현만 기억하면 비동기를 잘못 이해하기 쉽다.

    여러 작업을 동시에 다루는 것과 여러 작업을 실제로 동시에 실행하는 것은 같은 이야기가 아니기 때문이다.

    이번 편에서는 이 차이를 하나씩 풀어보자.


    1. 가장 먼저 동기(Synchronous)부터 이해하자

    동기 방식은 우리가 일반적으로 프로그램을 작성할 때 가장 자연스럽게 생각하는 실행 흐름이다.

    예를 들어 다음 코드가 있다고 하자.

    result = call_llm()
    save_to_db(result)
    return result

    코드를 읽는 순서와 실행되는 순서가 거의 같다.

    call_llm()
        │
        ▼
    결과를 받음
        │
        ▼
    save_to_db()
        │
        ▼
    저장 완료
        │
        ▼
    return

    call_llm()이 끝나야 save_to_db()를 실행할 수 있고,

    save_to_db()가 끝나야 결과를 반환한다.

    매우 직관적이다.

    그래서 동기 프로그램은 이해하기 쉽고 디버깅하기도 상대적으로 쉽다.

    문제는 call_llm()이 오래 걸릴 때다.


    2. LLM API가 5초 걸린다면?

    FastAPI API Server가 외부 LLM API를 호출한다고 생각해보자.

    FastAPI API Server
            │
            │ HTTP Request
            ▼
         LLM API
            │
            │
            │ 5초 동안 추론
            │
            ▼
         Response
            │
            ▼
    FastAPI API Server

    LLM이 답변을 만드는 데 5초가 걸린다.

    중요한 것은 이 5초 동안 누가 계산하고 있는가다.

    외부 LLM 서비스를 사용한다면 실제 AI 추론은 LLM Provider의 시스템에서 수행된다.

    우리 FastAPI API Server의 CPU가 5초 동안 LLM 추론을 하는 것이 아니다.

    FastAPI 입장에서는 상당 부분이

    요청 보냄
       ↓
    기다림
       ↓
    응답 도착

    이다.

    바로 이 기다림을 어떻게 처리할 것인가에서 동기와 비동기의 차이가 드러나기 시작한다.


    3. 다시 식당으로 돌아가 보자

    1부에서 사용했던 식당을 다시 생각해보자.

    직원 한 명이 있고 손님 A가 주문한다.

    손님 A
       │
       ▼
    직원
       │
       │ 주문 전달
       ▼
    주방

    주방에서 음식을 만드는 데 10분이 걸린다.

    첫 번째 방식은 직원이 주방 앞에서 기다리는 것이다.

    손님 A
       ↓
    직원
       ↓
    주방
    
    직원 : "음식 나올 때까지 여기 있어야지."
    
            10분...
    
    음식 완성
       ↓
    직원
       ↓
    손님 A

    그동안 손님 B가 들어왔다.

    하지만 직원은 주방 앞에서 기다리고 있다.

    손님 C도 들어왔다.

    역시 기다린다.

    손님 B ─┐
    손님 C ─┤
    손님 D ─┤──→ 직원은 주방 앞에서 기다리는 중
    손님 E ─┘

    이것이 우리가 이해하기 쉬운 Blocking 형태의 기다림이다.

    현재 작업이 끝날 때까지 실행 흐름을 붙잡고 있다.


    4. 그렇다면 비동기 방식은 어떻게 다를까?

    이번에는 직원의 행동을 바꿔보자.

    손님 A의 주문을 주방에 전달한다.

    손님 A
       ↓
    직원
       ↓
    주방

    그런데 직원이 주방 앞에서 기다리지 않는다.

    직원은 이렇게 생각한다.

    “음식이 완성되면 알려주세요.”

    그리고 손님 B에게 간다.

    손님 A 주문 → 주방
    
            음식 만드는 중...
                  │
                  │
    직원 ─────────┼──→ 손님 B 주문 받기
                  │
                  ├──→ 손님 C 주문 받기
                  │
                  └──→ 손님 D 주문 받기

    잠시 뒤 주방에서 알린다.

    "A 주문 나왔습니다!"

    그러면 직원은 다시 A의 작업으로 돌아간다.

    주방
     ↓
    A 음식 완성
     ↓
    직원
     ↓
    손님 A

    여기서 직원이 여러 명이 된 것은 아니다.

    직원은 여전히 한 명이다.

    달라진 것은 기다리는 방법이다.

    이것이 비동기를 이해하는 핵심이다.


    5. 비동기(Asynchronous)는 “동시에 실행”이라는 뜻이 아니다

    여기서 가장 중요한 오해 하나를 정리하자.

    비동기라고 해서 반드시 여러 코드가 물리적으로 같은 순간에 실행되는 것은 아니다.

    앞의 식당을 보면 직원은 한 명이다.

    시간 ─────────────────────────────→
    
    직원
    
    A 주문 처리
        ↓
    A 주방 대기
    
            B 주문 처리
                ↓
            B 주방 대기
    
                    C 주문 처리
                        ↓
                    C 주방 대기
    
            A 음식 완성
                 ↓
            A 응답 처리

    직원 한 명이 매우 빠르게 여러 업무 사이를 이동하고 있을 뿐이다.

    이 상태를 이해하려면 Concurrency, 즉 동시성이라는 개념이 필요하다.


    6. Concurrency — 여러 일을 동시에 “다루는” 능력

    Concurrency는 보통 여러 작업이 같은 시간 구간에 진행될 수 있도록 관리하는 것이라고 이해하면 좋다.

    예를 들어 직원 한 명이 세 테이블을 담당한다.

    직원 1명
    
    Table A ──────── 기다림 ────── 처리
            \
    Table B ── 처리 ─── 기다림 ───── 처리
                 \
    Table C ───── 처리 ─── 기다림 ─── 처리

    어느 한 순간을 아주 잘게 잘라서 보면 직원은 한 가지 일을 하고 있을 수 있다.

    하지만 조금 긴 시간 범위에서 보면 A, B, C 세 작업이 모두 진행되고 있다.

    이것이 Concurrency다.

    중요한 표현은 이것이다.

    Concurrency는 여러 작업을 동시에 완료하는 것이 아니라 여러 작업이 함께 진행될 수 있도록 다루는 것이다.


    7. Parallelism — 진짜 동시에 실행한다

    이번에는 직원이 세 명이라고 생각해보자.

    직원 A → Table A
    
    직원 B → Table B
    
    직원 C → Table C

    세 직원이 같은 순간에 각각 다른 일을 할 수 있다.

    컴퓨터로 생각하면 여러 CPU Core에서 실제 계산이 동시에 실행되는 상황과 비슷하다.

    CPU Core 1 ─→ Task A
    
    CPU Core 2 ─→ Task B
    
    CPU Core 3 ─→ Task C
    
    CPU Core 4 ─→ Task D

    이것이 Parallelism, 병렬성이다.

    그래서 둘을 간단히 비교하면 다음과 같다.

    개념의미Concurrency여러 작업을 함께 진행되도록 관리Parallelism여러 작업을 실제 같은 순간에 실행

    비동기 프로그래밍에서 우리가 주로 활용하는 것은 Concurrency다.


    8. Concurrency와 Parallelism을 그림으로 비교해보자

    Concurrency는 이런 모습에 가깝다.

    CPU / Thread 1개
    
    시간 ─────────────────────────────→
    
    Task A ███───────███────────██
    
    Task B ───███────────███──────
    
    Task C ──────██──────────████

    하나의 실행 자원이 여러 Task 사이를 오간다.

    반면 Parallelism은 다음과 같다.

    시간 ─────────────────────────────→
    
    CPU Core 1 : Task A █████████████
    
    CPU Core 2 : Task B █████████████
    
    CPU Core 3 : Task C █████████████

    실제로 여러 작업이 동시에 실행된다.

    따라서

    Async
       ≠
    Parallel

    이다.

    비동기 코드를 작성했다고 CPU Core가 늘어나는 것은 아니다.


    9. 그렇다면 Blocking과 Non-blocking은 무엇일까?

    여기서 또 하나 헷갈리는 용어가 등장한다.

    Synchronous / Asynchronous
    
    Blocking / Non-blocking

    이 둘을 같은 의미로 사용하는 경우가 많은데 정확히는 서로 다른 관점의 개념이다.

    간단하게 접근하면 Blocking은

    어떤 작업을 기다리는 동안 현재 실행 흐름을 붙잡고 있는가?

    를 보는 것이다.

    예를 들어 다음 코드가 있다고 하자.

    import requests
    
    response = requests.get(url)

    HTTP 요청을 보낸 뒤 응답이 돌아올 때까지 현재 실행 흐름은 이 호출에서 기다린다.

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

    현재 실행 흐름
          │
          ▼
    HTTP Request
          │
          ▼
    ████████████████
     응답 기다리는 중
    ████████████████
          │
          ▼
    Response
          │
          ▼
    다음 코드

    이것이 Blocking I/O의 전형적인 모습이다.


    10. Non-blocking은 기다림을 붙잡고 있지 않는다

    비동기 HTTP Client를 사용하는 경우를 생각해보자.

    response = await client.get(url)

    개념적으로는 이렇게 생각할 수 있다.

    Task A
       │
       ▼
    HTTP Request
       │
       ▼
    응답 대기
       │
       └──────── 실행 기회 양보
                      │
                      ▼
                   Task B
                      │
                      ▼
                   Task C
    
            HTTP Response 도착
                      │
                      ▼
                   Task A 재개

    Task A는 여전히 결과를 기다리고 있다.

    중요한 것은 Task A가 기다리는 동안 Event Loop 전체가 같이 기다리지 않는다는 것이다.


    11. 여기서 Event Loop가 다시 등장한다

    1부에서 Event Loop를 잠깐 설명했다.

    다시 한 문장으로 정리하면 다음과 같다.

    Event Loop는 지금 실행할 수 있는 작업을 실행하고, 기다려야 하는 작업은 잠시 내려놓았다가 준비되면 다시 이어서 실행하도록 관리하는 반복 실행 구조다.

    예를 들어 세 개의 요청이 있다고 해보자.

    Request A → LLM API
    Request B → Vector DB
    Request C → RDB

    Event Loop 관점에서는 대략 이런 일이 반복된다.

    Event Loop
        │
        ▼
    Request A 실행
        │
        ▼
    LLM 응답 기다려야 함
        │
        ▼
    실행권 양보
        │
        ▼
    Request B 실행
        │
        ▼
    Vector DB 응답 기다려야 함
        │
        ▼
    실행권 양보
        │
        ▼
    Request C 실행
        │
        ▼
    ...

    그러던 중 LLM 응답이 준비된다.

    LLM 응답 도착
          │
          ▼
    Event Loop
          │
          ▼
    Request A 다시 실행

    이 구조 덕분에 하나의 Event Loop가 많은 I/O-bound 작업을 효율적으로 다룰 수 있다.


    12. 그렇다면 await는 정확히 무엇을 하는가?

    이제 다음 코드가 조금 다르게 보일 것이다.

    async def ask_llm():
        result = await call_llm()
        return result

    처음 보면 await라는 단어 때문에 이런 느낌이 든다.

    “여기서 기다리라는 뜻인가?”

    맞다.

    현재 Coroutine은 결과를 기다린다.

    하지만 중요한 것이 빠져 있다.

    어떻게 기다리는가?

    개념적으로는 이렇게 이해하는 것이 좋다.

    await
      │
      ▼
    "나는 이 결과가 있어야
     다음 단계로 갈 수 있습니다."
      │
      ▼
    "하지만 지금 당장 할 일이 없으니
     실행 기회를 다른 Task에 양보합니다."

    즉,

    await는 단순히 “멈춰서 기다려”가 아니라 “이 작업의 완료를 기다려야 하니, 그동안 다른 실행 가능한 작업이 있다면 실행 기회를 넘겨”에 가깝다.

    물론 await 가능한 객체와 Coroutine의 실제 동작은 조금 더 복잡하다.

    지금 단계에서는 이 정도 개념이면 충분하다.


    13. await를 만나면 무엇이 멈추는가?

    이 질문은 굉장히 중요하다.

    다음 코드가 있다고 하자.

    @app.get("/question")
    async def question():
        result = await call_llm()
        return result

    call_llm()의 결과가 필요하므로 현재 question() Coroutine의 진행은 잠시 멈춘다.

    하지만 제대로 구현된 비동기 I/O라면 Event Loop 자체가 멈추는 것은 아니다.

    question() Coroutine
            │
            ▼
    await call_llm()
            │
            ├──────────────→ LLM API
            │
            │
            │  현재 Coroutine 대기
            │
            ▼
       Event Loop는
       다른 Task 처리
            │
            ├─ Request B
            ├─ Request C
            └─ Request D

    이 차이를 반드시 기억해야 한다.

    await
    
    현재 Coroutine     → 기다릴 수 있음
    
    Event Loop 전체    → 반드시 멈추는 것이 아님

    바로 이 구조 때문에 비동기 I/O가 높은 동시성을 처리할 수 있다.


    14. 그런데 async 함수 안에서 Blocking 코드를 실행하면?

    이제 위험한 코드 하나를 보자.

    import requests
    
    @app.get("/question")
    async def question():
        response = requests.get(
            "https://example.com/api"
        )
    
        return response.json()

    Endpoint가 async def로 선언되어 있다.

    그런데 내부에서는 동기 HTTP Client인 requests를 사용했다.

    실행 흐름은 이렇게 될 수 있다.

    Event Loop
        │
        ▼
    Request A
        │
        ▼
    requests.get()
        │
        ▼
    ██████████████████████
     HTTP Response 기다림
     Event Loop 진행 방해
    ██████████████████████
        │
        ▼
    Response

    이 동안 같은 Event Loop가 담당하는 다른 Task들이 영향을 받을 수 있다.

    Request A ── requests.get() ─────────→
    
    Request B ───────────── 기다림 ──────→
    
    Request C ───────────── 기다림 ──────→
    
    Request D ───────────── 기다림 ──────→

    그래서 중요한 것은 함수 앞에 async를 붙였느냐가 아니다.

    그 함수 안에서 Event Loop를 Blocking하는 코드가 실행되고 있는가를 봐야 한다.


    15. CPU-bound 작업도 같은 문제가 생긴다

    다음 Endpoint를 생각해보자.

    @app.post("/parse")
    async def parse_pdf():
        result = parse_large_pdf()
        return result

    parse_large_pdf()가 CPU를 10초 동안 계속 사용하는 작업이라고 하자.

    Event Loop
        │
        ▼
    parse_large_pdf()
        │
        ▼
    CPU 계산
    ████████████████████████
    ████████████████████████
    ████████████████████████
              10초
    ████████████████████████
    ████████████████████████
        │
        ▼
    완료

    여기에는 기다리면서 실행권을 양보할 지점이 없다.

    CPU가 계속 Python 코드를 실행하고 있다.

    그 결과 Event Loop가 다른 Task를 처리할 기회를 얻지 못할 수 있다.

    Request A ─→ CPU-bound 작업 ─────────────────→ 완료
                        │
                        │ Event Loop 점유
                        │
    Request B ──────────┼──────────── 기다림
    Request C ──────────┼──────────── 기다림
    Request D ──────────┴──────────── 기다림

    여기서 중요한 차이가 있다.

    앞에서 살펴본 LLM API 호출은 기다리는 작업이었다.

    LLM API 호출
         ↓
    외부 시스템이 처리
         ↓
    우리 프로그램은 기다림

    하지만 PDF Parsing과 같은 CPU-bound 작업은 다르다.

    PDF Parsing
         ↓
    우리 CPU가 직접 계산
         ↓
    계속 실행 중

    따라서 단순히 async를 붙인다고 해결되지 않는다.

    async def parse_pdf():
        result = heavy_pdf_parsing()
        return result

    이 코드는 비동기 함수처럼 보이지만 heavy_pdf_parsing()이 CPU를 오래 점유한다면 Event Loop에게 실행권을 돌려주지 않는다.

    그래서 CPU-bound 작업에는 다른 접근이 필요하다.

    예를 들어

    CPU-bound 작업
          │
          ├── Thread
          ├── Process
          └── Background Worker

    등으로 분리하는 방법을 고려할 수 있다.

    어떤 방식을 선택해야 하는지는 작업의 특성과 Python의 실행 모델까지 함께 봐야 한다.

    이 부분은 뒤의 실전 편에서 자세히 다루겠다.


    16. async가 효과적인 곳은 어디일까?

    이제 어느 정도 기준이 생겼다.

    AI 서비스를 예로 들어보자.

    다음 작업들은 대표적으로 I/O 대기가 발생한다.

    FastAPI API Server
           │
           ├──→ LLM API
           ├──→ Embedding API
           ├──→ Vector DB
           ├──→ RDB
           ├──→ Redis
           ├──→ S3
           └──→ 외부 REST API

    이런 작업들은 대부분 다른 시스템에 요청을 보내고 결과를 기다린다.

    따라서 사용하는 Client나 Driver가 비동기를 지원한다면 async를 활용할 좋은 후보가 된다.

    예를 들어 다음과 같다.

    LLM API 호출             → I/O-bound
    Embedding API 호출       → I/O-bound
    Vector DB Query          → I/O-bound
    RDB Query                → I/O-bound
    Redis                    → I/O-bound
    S3 Download / Upload     → I/O-bound
    외부 HTTP API            → I/O-bound

    반대로 다음과 같은 작업은 성격이 다르다.

    이미지 변환
    PDF Parsing
    대규모 압축
    복잡한 데이터 변환
    로컬 ML Inference
    대규모 수치 계산

    이런 작업은 CPU-bound일 가능성이 있다.

    물론 실제 작업은 이렇게 깔끔하게 둘 중 하나로만 나뉘지는 않는다.

    예를 들어 PDF Parsing도 파일을 읽는 과정에는 I/O가 있고 실제 문서를 분석하는 과정에는 CPU 연산이 있을 수 있다.

    그래서 중요한 것은 이름만 보고 판단하는 것이 아니다.

    그 작업이 실제로 대부분의 시간을 어디에서 보내는지를 보는 것이다.


    17. Background Worker에서도 똑같이 생각해야 한다

    비동기는 FastAPI API Server에서만 필요한 개념이 아니다.

    Background Worker를 생각해보자.

    예를 들어 문서 처리 Worker가 다음 작업을 수행한다고 하자.

    Queue
      │
      ▼
    Background Worker
      │
      ├── S3에서 PDF 다운로드
      │
      ├── PDF Parsing
      │
      ├── Chunking
      │
      ├── Embedding API 호출
      │
      ├── Vector DB 저장
      │
      └── RDB 상태 업데이트

    각 단계의 성격을 구분해보면 다음과 같다.

    S3 Download        → I/O-bound
    
    PDF Parsing        → CPU-bound 가능성
    
    Chunking           → CPU-bound
    
    Embedding API      → I/O-bound
    
    Vector DB          → I/O-bound
    
    RDB                → I/O-bound

    하나의 Worker 안에서도 CPU 작업과 I/O 작업이 섞여 있다.

    그래서

    “Worker니까 동기로 만들어도 된다.”

    또는

    “AI 서비스니까 전부 async로 만들자.”

    둘 다 좋은 기준이 아니다.

    먼저 각각의 작업이 어떤 성격인지 분석해야 한다.


    18. 그렇다면 async를 쓰면 요청을 많이 받을수록 좋은가?

    여기서 또 다른 함정이 등장한다.

    비동기를 이해하기 시작하면 이런 생각을 하기 쉽다.

    “기다리는 동안 다른 작업을 할 수 있다면 최대한 많이 동시에 실행하면 되겠네?”

    예를 들어 Embedding API를 호출해야 할 Chunk가 10,000개 있다고 하자.

    Python에서는 다음과 같은 코드를 작성할 수 있다.

    await asyncio.gather(
        *[
            create_embedding(chunk)
            for chunk in chunks
        ]
    )

    Chunk가 10,000개라면 10,000개의 비동기 작업이 만들어질 수도 있다.

    처음에는 굉장히 효율적으로 보인다.

    Embedding 1      ─→
    Embedding 2      ─→
    Embedding 3      ─→
    Embedding 4      ─→
    ...
    Embedding 10000  ─→

    하지만 실제 Production 시스템에는 무한한 자원이 존재하지 않는다.

    10,000 Tasks
          │
          ▼
    HTTP Connection 증가
          │
          ├── Connection Pool 고갈
          ├── Socket 증가
          ├── Memory 증가
          ├── 외부 API Rate Limit
          └── Timeout 증가
                  │
                  ▼
                 Retry
                  │
                  ▼
              요청량 증가
                  │
                  ▼
                 장애

    즉 비동기는 동시성을 가능하게 하는 기술이지,

    무한한 동시성을 가능하게 하는 기술이 아니다.

    이 차이가 매우 중요하다.


    19. Concurrency에는 반드시 한계가 필요하다

    Production에서는 질문이 한 단계 더 발전해야 한다.

    처음에는

    “동기로 할까, 비동기로 할까?”

    를 고민한다.

    하지만 실제 운영에서는 이런 질문이 더 중요하다.

    “동시에 몇 개까지 실행할 것인가?”

    예를 들어 Embedding API를 최대 20개까지만 동시에 호출하도록 제한할 수 있다.

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

    10,000 Tasks
         │
         ▼
    ┌───────────────────┐
    │ Concurrency = 20  │
    └───────────────────┘
         │
         ├── Task 1
         ├── Task 2
         ├── Task 3
         │
         ...
         └── Task 20
    
    나머지 Task
         │
         ▼
        대기

    Python의 asyncio에서는 Semaphore 등을 이용해 이런 동시성 제한을 구현할 수 있다.

    semaphore = asyncio.Semaphore(20)
    
    async def create_embedding(chunk):
        async with semaphore:
            return await embedding_api(chunk)

    이제 최대 20개의 작업만 동시에 외부 Embedding API를 호출한다.

    하나가 완료되면 기다리던 다음 작업이 실행된다.

    동시에 실행
    
    1  2  3  4  ... 20
    │  │  │  │       │
    ▼  ▼  ▼  ▼       ▼
    
    하나 완료
        │
        ▼
    
    21번 Task 실행

    비동기 프로그래밍의 목적은 가능한 한 많이 동시에 실행하는 것이 아니다.

    시스템이 감당할 수 있는 범위에서 기다리는 시간을 효율적으로 활용하는 것이다.


    20. 결국 Connection Pool과도 연결된다

    Concurrency를 이야기하면 자연스럽게 Connection Pool 문제가 따라온다.

    예를 들어 RDB Connection Pool 크기가 20이라고 하자.

    FastAPI API Server
            │
            ▼
    RDB Connection Pool
    
    ┌─────────────────────────┐
    │ 1  2  3  ...       20   │
    └─────────────────────────┘
            │
            ▼
           RDB

    그런데 동시에 500개의 요청이 DB를 사용하려 한다.

    500 Requests
          │
          ▼
    Connection Pool = 20
          │
          ├── 20개 사용
          │
          └── 나머지 대기

    async라고 해서 500개의 Connection이 갑자기 생기는 것이 아니다.

    외부 시스템에는 각각 처리 가능한 용량이 있다.

    FastAPI
       │
       ├── HTTP Connection Pool
       │
       ├── RDB Connection Pool
       │
       ├── Redis Connection Pool
       │
       ├── Vector DB 처리량
       │
       └── LLM API Rate Limit

    그래서 비동기 프로그래밍을 Production 수준에서 이해하려면 결국

    Concurrency
    Connection Pool
    Timeout
    Rate Limit
    Semaphore
    Queue
    Backpressure

    같은 개념까지 연결해서 생각해야 한다.


    21. 동시성과 병렬성을 다시 AI 서비스에 적용해보자

    지금까지 내용을 실제 AI 서비스에 대입해보자.

    FastAPI API Server가 다음 세 요청을 처리하고 있다고 하자.

    Request A → LLM API
    Request B → Vector DB
    Request C → RDB

    각 작업이 비동기 I/O를 지원한다면 하나의 Event Loop에서도 다음과 같이 진행될 수 있다.

    시간 ───────────────────────────────────→
    
    Request A
    ████ LLM 요청 ─────── 대기 ─────── ███ 응답처리
    
    Request B
         ███ Vector DB ── 대기 ── ██ 응답처리
    
    Request C
              ███ RDB ── 대기 ── ██ 응답처리

    세 작업이 같은 시간 구간 안에서 함께 진행되고 있다.

    이것이 Concurrency다.

    하지만 CPU가 실제로 세 개의 Python 코드를 같은 순간에 실행하고 있다는 뜻은 아니다.

    반대로 CPU-bound 작업을 여러 Process에 분산한다고 생각해보자.

    Process 1 → CPU Core 1 → PDF Parsing A
    
    Process 2 → CPU Core 2 → PDF Parsing B
    
    Process 3 → CPU Core 3 → PDF Parsing C

    이 경우에는 실제 여러 CPU Core에서 계산이 동시에 이루어질 수 있다.

    이것이 Parallelism이다.

    따라서 AI 서비스를 설계할 때는 두 가지 문제를 구분해야 한다.

    I/O 대기가 많은가?
           │
           ▼
    Concurrency / Async
    
    CPU 계산이 많은가?
           │
           ▼
    Parallelism / Process / Worker

    이 구분만 제대로 해도 많은 설계 실수를 피할 수 있다.


    22. 한 장으로 정리해보자

    지금까지 나온 개념을 한 번에 정리하면 다음과 같다.

    ┌─────────────────────────────────────┐
    │            Synchronous              │
    │                                     │
    │ 현재 작업의 결과를 따라             │
    │ 순차적인 실행 흐름으로 진행          │
    └─────────────────────────────────────┘
    
    
    ┌─────────────────────────────────────┐
    │            Asynchronous             │
    │                                     │
    │ 작업 완료를 기다리는 동안           │
    │ 다른 실행 가능한 작업을 진행         │
    └─────────────────────────────────────┘
    
    
    ┌─────────────────────────────────────┐
    │              Blocking               │
    │                                     │
    │ 기다리는 작업이 현재 실행 흐름의     │
    │ 진행을 붙잡는다                      │
    └─────────────────────────────────────┘
    
    
    ┌─────────────────────────────────────┐
    │            Non-blocking             │
    │                                     │
    │ 기다리는 동안 다른 작업이           │
    │ 진행될 기회를 가질 수 있다           │
    └─────────────────────────────────────┘
    
    
    ┌─────────────────────────────────────┐
    │            Concurrency              │
    │                                     │
    │ 여러 작업을 같은 시간 구간 안에서    │
    │ 함께 진행되도록 관리한다             │
    └─────────────────────────────────────┘
    
    
    ┌─────────────────────────────────────┐
    │            Parallelism              │
    │                                     │
    │ 여러 작업을 실제로 동시에 실행한다   │
    └─────────────────────────────────────┘

    그리고 Python의 asyncio는 주로 다음 영역에 있다.

     I/O-bound
                   │
                   ▼
             Asynchronous
                   │
                   ▼
              Event Loop
                   │
                   ▼
              Concurrency 

    이 관계를 머릿속에 잡아두면 이후의 asyncio가 훨씬 이해하기 쉬워진다.


    23. async/await를 문법으로 먼저 배우면 어려운 이유

    많은 Python 비동기 강좌가 다음 코드부터 시작한다.

    import asyncio
    
    async def main():
        await something()
    
    asyncio.run(main())

    그러고 나서 Coroutine, Task, Future 같은 용어가 등장한다.

    문법부터 보면 당연히 어렵다.

    하지만 지금까지 살펴본 관점에서 보면 이야기가 달라진다.

    우리가 해결하고 싶은 문제는 처음부터 하나였다.

    외부 시스템에 요청했다.
            │
            ▼
    결과가 아직 없다.
            │
            ▼
    그동안 무엇을 할 것인가?

    동기 Blocking 방식이라면

    기다린다.

    비동기 방식이라면

    현재 작업은 잠시 멈추고
            │
            ▼
    실행 기회를 양보하고
            │
            ▼
    다른 작업을 처리하고
            │
            ▼
    결과가 준비되면
            │
            ▼
    다시 돌아온다.

    이 동작을 가능하게 만들기 위해 등장하는 것이

    Coroutine
    Task
    Event Loop
    await

    다.

    즉 이 용어들은 각각 따로 외워야 하는 이상한 Python 문법이 아니다.

    기다리는 시간을 다른 작업에 활용하기 위한 실행 모델을 구성하는 요소들이다.


    24. 이번 편에서 이것만은 기억하자

    이번 편의 내용을 모두 기억할 필요는 없다.

    다음 몇 가지만 정확히 기억하면 된다.

    첫째,

    비동기는 무조건 빠르게 만드는 기술이 아니다.

    LLM API가 5초 걸린다면 async로 호출해도 LLM 자체는 여전히 5초 걸릴 수 있다.


    둘째,

    비동기는 병렬처리와 다르다.

    Async ≠ Parallel

    비동기는 주로 I/O 대기시간을 이용해 여러 작업을 함께 진행시키는 Concurrency와 관련이 있다.


    셋째,

    await에서 현재 Coroutine이 기다리는 것과 Event Loop 전체가 멈추는 것은 다르다.

    await
    
    현재 Coroutine
          ↓
    대기
    
    Event Loop
          ↓
    다른 Task 처리 가능

    넷째,

    async 함수 안에 Blocking 코드를 넣으면 비동기의 장점을 잃을 수 있다.

    async def foo():
        requests.get(url)   # 주의

    async def라고 적었다고 내부 코드가 자동으로 비동기가 되는 것은 아니다.


    다섯째,

    CPU-bound 작업은 단순히 async로 해결되지 않는다.

    I/O-bound
        ↓
    Async / Event Loop / Concurrency
    
    
    CPU-bound
        ↓
    Thread / Process / Worker / Parallelism 검토

    그리고 마지막으로 가장 중요한 것.

    비동기는 무한한 동시성을 의미하지 않는다.

    Production에서는 항상

    "동시에 실행할 수 있는가?"

    다음에

    "동시에 몇 개까지 실행할 것인가?"

    를 물어야 한다.

    이 질문이 다음 단계의 시작이다.


    마치며

    비동기 프로그래밍을 처음 접하면 async와 await가 가장 눈에 띈다.

    하지만 실제로 중요한 것은 문법 뒤에 있는 실행 구조를 이해하는 것이다.

    우리는 이번 편에서 다음 흐름을 살펴봤다.

    I/O 요청
       ↓
    결과를 기다림
       ↓
    현재 작업 실행권 양보
       ↓
    Event Loop
       ↓
    다른 Task 실행
       ↓
    I/O 완료
       ↓
    기존 Task 재개

    이 흐름을 이해하면 이제 다음 질문이 생긴다.

    Event Loop는 실제로 이 작업들을 어떻게 관리하는 것일까?

    async def로 만든 함수는 일반 함수와 무엇이 다른가?

    Coroutine은 무엇인가?

    Coroutine과 Task는 무엇이 다른가?

    await를 만나면 Python 내부에서는 어떤 일이 벌어지는가?

    그리고 다음 코드는

    async def foo():
        print("A")
        await asyncio.sleep(1)
        print("B")

    실제로 어떤 순서로 실행되는가?

    이제 비동기의 필요성은 충분히 이해했다.

    다음 편부터는 Python 내부로 한 단계 들어가 보자.


    다음 글

    AI 개발자가 알아야 할 비동기 프로그래밍 3부

    Python asyncio는 어떻게 움직이는가 — Event Loop, Coroutine, Task 그리고 await

    다음 편에서는 다음 코드 하나를 출발점으로 삼는다.

    import asyncio
    
    async def worker(name):
        print(f"{name} start")
        await asyncio.sleep(2)
        print(f"{name} end")
    
    async def main():
        await asyncio.gather(
            worker("A"),
            worker("B"),
                    worker("C"),
        )
    
    asyncio.run(main())

    이 코드를 실행하면 세 개의 worker()는 어떤 순서로 움직일까?

    처음 비동기 코드를 접하면 다음과 같이 예상할 수도 있다.

    A start
    2초 대기
    A end
    
    B start
    2초 대기
    B end
    
    C start
    2초 대기
    C end

    그렇다면 전체 실행시간은 약 6초가 되어야 한다.

    하지만 실제 실행 흐름은 다르다.

    A start
    B start
    C start
    
          약 2초
    
    A end
    B end
    C end

    전체 실행시간은 약 2초에 가깝다.

    그렇다면 여기서 질문이 생긴다.

    worker A ─┐
    worker B ─┼── 정말 동시에 실행된 것일까?
    worker C ─┘

    CPU가 세 개의 worker()를 동시에 실행한 것일까?

    아니다.

    이 코드에서 핵심은 바로 다음 부분이다.

    await asyncio.sleep(2)

    worker(“A”)가 이 지점에 도달하면 A의 실행은 잠시 멈춘다.

    하지만 Event Loop까지 2초 동안 멈추는 것은 아니다.

    worker A
       │
       ▼
    await asyncio.sleep(2)
       │
       └──── 실행권 양보
                  │
                  ▼
              Event Loop
                  │
                  ▼
              worker B

    B도 같은 지점에 도달한다.

    worker B
       │
       ▼
    await asyncio.sleep(2)
       │
       └──── 실행권 양보
                  │
                  ▼
              Event Loop
                  │
                  ▼
              worker C

    C 역시 실행권을 양보한다.

    이제 세 Coroutine은 모두 기다리고 있다.

    Event Loop
    
    A ── sleep 2초 ── 대기
    B ── sleep 2초 ── 대기
    C ── sleep 2초 ── 대기

    약 2초가 지나면 다시 실행할 수 있는 상태가 된다.

    Event Loop는 준비된 작업들을 다시 실행한다.

    약 2초 후
    
    A ──→ 실행 가능
    B ──→ 실행 가능
    C ──→ 실행 가능
              │
              ▼
          Event Loop
              │
              ├── A 계속 실행
              ├── B 계속 실행
              └── C 계속 실행

    바로 이 과정에서 Python 비동기 프로그래밍의 핵심 개념들이 모두 등장한다.

    async def
       │
       ▼
    Coroutine
       │
       ▼
    Task
       │
       ▼
    Event Loop
       │
       ▼
    await
       │
       ▼
    실행권 양보
       │
       ▼
    다른 Task 실행

    3부에서는 이 구조를 코드 한 줄씩 따라가며 살펴볼 것이다.

    특히 다음 질문에 답해보려고 한다.

    • async def를 호출하면 왜 함수가 바로 실행되지 않을까?
    • Coroutine 객체는 일반 함수의 반환값과 무엇이 다를까?
    • Coroutine과 Task는 무엇이 다를까?
    • await를 만나면 현재 Coroutine에는 어떤 일이 생길까?
    • Event Loop는 기다리고 있는 작업이 완료됐다는 것을 어떻게 알까?
    • asyncio.create_task()는 무엇을 하는가?
    • asyncio.gather()는 실제로 무엇을 동시에 처리하는가?
    • asyncio.sleep()과 time.sleep()은 왜 완전히 다른 결과를 만들까?

    그리고 마지막에는 FastAPI로 돌아와 다음 구조가 실제로 어떻게 동작하는지도 연결해보자.

    @app.get("/question")
    async def question():
    
        embedding = await embedding_api()
    
        documents = await vector_db.search(embedding)
    
        answer = await llm.generate(documents)
    
        return answer

    겉으로 보면 몇 줄 되지 않는 코드다.

    하지만 내부에서는

    HTTP Request
          ↓
    FastAPI
          ↓
    Coroutine 생성
          ↓
    Event Loop
          ↓
    Embedding API 요청
          ↓
    await
          ↓
    다른 Request 처리
          ↓
    Embedding 응답 도착
          ↓
    Coroutine 재개
          ↓
    Vector DB 요청
          ↓
    await
          ↓
    다른 Request 처리
          ↓
    ...

    와 같은 일이 계속 반복되고 있다.

    이 구조가 머릿속에 그려지기 시작하면 async와 await는 더 이상 외워야 하는 Python 문법이 아니다.

    프로그램이 언제 실행되고, 언제 기다리고, 언제 다른 작업에 실행권을 넘기는지를 표현하는 코드로 보이기 시작한다.

    다음 편에서는 드디어 Python의 Event Loop 안으로 들어가 보자.

  • AI 서비스는 왜 개발할 때는 잘 되는데 운영에 가면 터질까?

    AI 서비스는 왜 개발할 때는 잘 되는데 운영에 가면 터질까?

    AI 개발자가 알아야 할 비동기 프로그래밍 1부

    AI 서비스를 개발하는 일은 예전보다 훨씬 쉬워졌다.

    Python으로 FastAPI API 서버를 만들고, LLM API를 호출하고, Vector DB를 연결하면 생각보다 빠르게 서비스를 만들 수 있다.

    예를 들어 간단한 RAG 서비스를 만든다고 해보자.

    글 내용

    개발 환경에서 테스트해 보면 꽤 잘 동작한다.

    질문을 입력한다.

    몇 초 기다린다.

    답변이 나온다.

    “잘 되는데?”

    그런데 이 서비스를 실제 Production 환경에 올리면 이야기가 달라진다.

    사용자가 10명, 100명으로 증가하면서 API 응답이 느려지기 시작한다.

    어떤 요청은 timeout이 발생한다.

    RDB Connection Pool이 부족해질 수도 있고, 외부 LLM API 호출이 밀릴 수도 있다. 메모리가 계속 증가하거나 API 프로세스가 더 이상 새로운 요청을 원활하게 처리하지 못하

    는 상황도 생긴다.

    경우에 따라서는 Background Worker의 작업이 밀리면서 Queue가 계속 쌓이는 문제도 발생한다.

    코드는 똑같은데 왜 개발할 때는 잘 되던 서비스가 운영에 가면 문제가 생길까?

    이 질문을 이해하려면 async와 await 문법을 배우기 전에 먼저 알아야 하는 것이 있다.

    우리 시스템에서 누가 무엇을 하고 있고, 누가 무엇을 기다리고 있는가?


    1. 먼저 ‘서버’라는 말을 구분하자

    개발하면서 우리는 흔히 이런 말을 한다.

    서버에서 처리하면 됩니다.

    하지만 실제 AI 서비스에는 서로 전혀 다른 역할을 하는 여러 프로그램과 시스템이 존재한다.

    예를 들어 다음과 같은 구조를 생각해볼 수 있다.

    글 내용

    여기에는 적어도 몇 가지 서로 다른 역할이 있다.

    FastAPI API Server

    사용자의 HTTP 요청을 받아 처리하고 HTTP 응답을 반환한다.

    예를 들면 다음과 같다.

    POST /question
    POST /documents
    GET  /documents
    GET  /status

    사용자가 질문을 보내면 API Server가 Vector DB와 LLM 등을 호출하여 결과를 사용자에게 반환할 수 있다.


    Background Worker

    사용자의 HTTP 요청과 직접 연결되지 않은 작업을 처리한다.

    예를 들어 문서 업로드 이후 다음 작업들이 있을 수 있다.

    PDF Parsing
        ↓
    Chunking
        ↓
    Embedding
        ↓
    Vector DB 저장

    이런 작업을 API 요청 안에서 끝날 때까지 수행하기보다는 Queue에 넣고 Worker가 처리하도록 만들 수 있다.

    예를 들면 Celery Worker, RQ Worker 또는 직접 만든 Python Worker 등이 이런 역할을 한다.


    외부 AI 서비스

    OpenAI, AWS Bedrock 같은 LLM/Embedding 서비스를 사용할 수도 있다.

    이 경우 실제 AI 추론은 우리의 FastAPI 프로세스에서 실행되는 것이 아니다.

    FastAPI API Server나 Worker는 외부 AI 서비스에 HTTP 요청을 보내고 결과를 기다린다.


    데이터 저장 시스템

    RDB, Redis, Vector DB, Object Storage 등도 별도의 시스템이다.

    예를 들어

    PostgreSQL
    MySQL
    Redis
    Qdrant
    S3

    등이 있다.

    따라서 앞으로 이 글에서는 가능한 한 막연하게 ‘서버’라고 표현하지 않고,

    FastAPI API Server
    Background Worker
    LLM API
    Embedding API
    Vector DB
    RDB
    Redis
    Object Storage

    처럼 어떤 역할을 수행하는 시스템인지 구분해서 이야기하겠다.

    이 구분이 비동기를 이해하는 데 상당히 중요하다.


    2. FastAPI API Server는 생각보다 많은 시간을 기다린다

    사용자가 RAG 질문을 보냈다고 생각해보자.

    사용자
       │
       │ POST /question
       ▼
    FastAPI API Server
       │
       ├─→ Embedding API
       │       ↓
       │     결과 대기
       │
       ├─→ Vector DB
       │       ↓
       │     결과 대기
       │
       ├─→ Reranker
       │       ↓
       │     결과 대기
       │
       ├─→ LLM API
       │       ↓
       │     결과 대기
       │
       └─→ RDB
               ↓
             저장 완료 대기
       │
       ▼
    사용자에게 응답

    각 단계가 다음 정도 걸린다고 가정해보자.

    Embedding API       150ms
    Vector DB            80ms
    Reranker             300ms
    LLM API            4,000ms
    RDB 저장              30ms

    전체 API 요청에는 약 4~5초가 걸린다.

    그렇다면 이 4~5초 동안 FastAPI가 실행되는 CPU가 계속 계산하고 있을까?

    대부분의 경우 그렇지 않다.

    예를 들어 FastAPI API Server가 외부 LLM API에 요청을 보냈다고 하자.

    FastAPI API Server
            │
            │ HTTP Request
            ▼
         LLM API
            │
            │ AI 추론 수행
            │
            │
            ▼
         HTTP Response
            │
            ▼
    FastAPI API Server

    LLM이 답변을 만드는 동안 실제 AI 추론을 수행하는 것은 LLM Provider 쪽 시스템이다.

    우리 FastAPI API Server는 결과가 돌아오기를 기다리고 있다.

    즉 API 요청 처리시간이 5초라고 해서 우리 CPU가 5초 동안 계산하고 있다는 의미는 아니다.

    이 차이가 중요하다.


    3. CPU가 일하는 것과 I/O를 기다리는 것은 다르다

    프로그램의 작업을 이해하기 위해 크게 두 종류로 나눠보자.

    글 내용

    첫 번째는 CPU가 실제로 계산해야 하는 작업이다.

    FastAPI / Worker Process
            │
            ▼
          CPU
            │
            ├─ 이미지 변환
            ├─ 대규모 데이터 계산
            ├─ 압축
            ├─ 암호화
            └─ 로컬 ML 모델 추론

    이런 작업을 일반적으로 CPU-bound 작업이라고 한다.

    CPU가 실제로 바쁘다.

    반면 다음과 같은 작업도 있다.

    FastAPI API Server / Worker
            │
            ├─ HTTP ─────→ LLM API
            │
            ├─ TCP ──────→ PostgreSQL
            │
            ├─ HTTP ─────→ Vector DB
            │
            ├─ TCP ──────→ Redis
            │
            └─ HTTP ─────→ S3

    요청을 보낸 뒤 상대 시스템이 결과를 반환하기까지 기다려야 한다.

    이런 작업은 대표적인 I/O-bound 작업이다.

    AI 서비스에는 이런 I/O 작업이 굉장히 많다.


    4. 문제는 기다리는 것 자체가 아니다

    LLM API가 답변을 생성하는 데 5초가 걸리는 것 자체는 반드시 문제가 아니다.

    LLM Provider가 답변을 만드는 데 5초가 필요하다면 우리 FastAPI API Server가 그 시간을 0.1초로 만들 수는 없다.

    중요한 질문은 이것이다.

    FastAPI API Server가 그 5초 동안 다른 사용자의 요청을 처리할 수 있는가?

    식당을 하나 생각해보자.

    FastAPI API Server가 식당 직원이라고 생각하면 된다.

    손님 A가 주문한다.

    직원은 주문을 주방에 전달한다.

    여기서 주방은 LLM API라고 생각해보자.

    손님 A
      │
      ▼
    직원(FastAPI)
      │
      │ 주문
      ▼
    주방(LLM API)

    주방에서 음식을 만드는 데 10분이 걸린다.

    그런데 직원이 음식을 받을 때까지 주방 앞에 서 있다고 생각해보자.

    손님 A
       │
       ▼
    직원
       │
       ▼
    주방
       │
       │
       │ 10분...
       │
       │
       ▼
    음식 완성

    그동안 손님 B가 들어온다.

    손님 C도 들어온다.

    하지만 직원은 여전히 주방 앞에서 기다리고 있다.

    이 식당은 손님 한 명일 때는 아무 문제가 없다.

    하지만 점심시간이 되면 상황이 완전히 달라진다.

    글 내용

    5. 개발 환경에서는 왜 문제가 잘 보이지 않을까?

    개발할 때는 대부분 한 사람이 테스트한다.

    개발자
      │
      │ POST /question
      ▼
    FastAPI API Server
      │
      ├─→ Vector DB
      ├─→ LLM API
      └─→ RDB
      │
      ▼
    응답

    잘 된다.

    다시 질문한다.

    또 잘 된다.

    그래서 생각한다.

    API 잘 만들어졌네.

    하지만 Production 환경은 다르다.

    User 1 ─┐
    User 2 ─┤
    User 3 ─┤
    User 4 ─┤
            │
            ▼
    ┌────────────────────┐
    │ FastAPI API Server │
    └────────────────────┘
            ▲
            │
    User 97 ─┤
    User 98 ─┤
    User 99 ─┤
    User100 ─┘

    사용자들은 순서대로 한 명씩 들어오지 않는다.

    여러 요청이 겹쳐서 들어온다.

    바로 여기에서 동시성(Concurrency) 문제가 등장한다.

    글 내용

    6. 사용자 한 명에게 5초 걸리는 API

    다음과 같은 FastAPI Endpoint가 있다고 생각해보자.

    POST /question

    이 Endpoint가 LLM API를 호출하고 답변을 반환하는 데 평균 5초가 걸린다.

    사용자가 한 명이라면 별 문제가 없다.

    User A
      │
      ▼
    FastAPI
      │
      ├──────── LLM API ────────┐
      │                         │
      │        5초 대기          │
      │                         │
      ◀─────────────────────────┘
      │
      ▼
    User A

    그런데 동시에 많은 사용자가 요청하면 상황이 달라진다.

    만약 어떤 실행 흐름이 LLM 응답을 기다리는 동안 다른 작업을 진행하지 못한다면 그 실행 흐름은 몇 초 동안 묶여 있게 된다.

    요청 수가 증가하면 이런 대기들이 계속 겹친다.

    결국 API Server가 처리할 수 있는 동시 요청 수와 Worker Process 수, Thread 수, Connection Pool 크기 등의 한계가 드러나기 시작한다.

    이때 흔히 나타나는 현상이 있다.

    동시 요청 증가
          ↓
    처리 대기 증가
          ↓
    응답시간 증가
          ↓
    Timeout 증가
          ↓
    Retry 증가
          ↓
    더 많은 요청
          ↓
    시스템 부하 증가

    개발 환경에서는 거의 볼 수 없었던 현상이다.


    7. 여기서 비동기가 등장한다

    이제 다시 LLM 호출을 생각해보자.

    FastAPI API Server가 LLM API에 요청을 보냈다.

    FastAPI
       │
       ├─────────────→ LLM API
       │
       │
       │   응답을 기다려야 함
       │
       │
       ◀────────────── LLM 응답

    비동기 방식의 핵심 아이디어는 간단하다.

    기다리는 동안 다른 일을 하자.

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

    Request A
       │
       ├──→ LLM API
       │
       │    응답 대기
       │
       ├──────────────┐
                      │
    Request B         │
       │              │
       ├──→ Vector DB │
       │              │
       │              │
    Request C         │
       │              │
       ├──→ RDB       │
                      │
                      │
    LLM 응답 도착 ◀───┘
       │
       ▼
    Request A 계속 처리

    LLM이 빨라진 것이 아니다.

    CPU가 빨라진 것도 아니다.

    I/O를 기다리는 동안 같은 실행 환경이 다른 처리 기회를 활용할 수 있게 된 것이다.

    이것이 비동기를 이해하는 가장 중요한 출발점이다.

    글 내용

    8. 그래서 async와 await가 등장한다

    Python에서는 이런 코드를 보게 된다.

    async def ask_llm():
        result = await llm.generate(...)
        return result

    처음 비동기 프로그래밍을 접하면 다음과 같은 용어가 한꺼번에 등장한다.

    async
    await
    coroutine
    event loop
    task
    future

    그래서 비동기 프로그래밍을 Python의 어려운 문법이라고 생각하기 쉽다.

    하지만 그 전에 기억해야 할 것은 단 하나다.

    I/O를 기다리는 동안 다른 일을 할 수 있도록 실행 흐름을 양보한다.

    async와 await는 이 아이디어를 프로그램으로 표현하기 위한 도구다.

    구체적으로 어떻게 동작하는지는 다음 편에서 자세히 살펴보자.


    9. 비동기는 LLM을 빠르게 만드는 기술이 아니다

    여기서 매우 중요한 오해 하나를 없애야 한다.

    비동기를 사용한다고 LLM API 자체가 빨라지는 것은 아니다.

    LLM 응답에 5초가 걸린다면

    동기 호출     약 5초
    비동기 호출   약 5초

    한 사용자가 느끼는 응답시간은 크게 달라지지 않을 수도 있다.

    차이가 나타나는 것은 여러 요청이 겹쳐 들어올 때다.

    여기서 두 가지 개념을 구분해야 한다.

    Latency : 한 요청이 완료되는 데 걸리는 시간.
    Throughput : 일정 시간 동안 시스템이 처리할 수 있는 작업량.

    비동기는 많은 경우 한 작업의 계산 자체를 빠르게 만드는 기술이라기보다,

    I/O 대기가 많은 프로그램이 대기시간을 효율적으로 활용하여 더 많은 작업을 처리할 수 있도록 만드는 방법이다.


    10. 비동기는 FastAPI API Server에서만 필요한가?

    아니다.

    Background Worker에서도 똑같은 문제가 생길 수 있다.

    예를 들어 사용자가 PDF 문서 100개를 업로드했다고 생각해보자.

    API Server는 작업을 Queue에 넣고 바로 응답한다.

    사용자
      │
      ▼
    FastAPI API Server
      │
      ▼
    Queue
      │
      ▼
    Background Worker

    Worker가 다음 작업을 수행한다.

    S3에서 PDF 다운로드
            ↓
    Parsing
            ↓
    Chunking
            ↓
    Embedding API 호출
            ↓
    Vector DB 저장
            ↓
    RDB 상태 업데이트

    여기에서도 작업의 성격은 서로 다르다.

    S3 Download       → I/O 대기
    Parsing           → CPU 작업 가능성
    Chunking          → CPU 작업
    Embedding API     → I/O 대기
    Vector DB         → I/O 대기
    RDB               → I/O 대기

    즉,

    API Server냐 Worker냐가 비동기 여부를 결정하는 것이 아니다.

    중요한 것은 그 프로세스가 수행하는 작업의 성격이다.

    지금 이 프로그램은 CPU로 계산하고 있는가?

    아니면,

    다른 시스템의 응답을 기다리고 있는가?

    이 질문이 먼저다.


    11. 그렇다면 모든 것을 async로 만들면 될까?

    그렇지 않다.

    예를 들어 Worker가 대용량 이미지 변환 작업을 수행한다고 생각해보자.

    Background Worker
          │
          ▼
    대용량 이미지 처리
          │
          ▼
    CPU 100%

    이 경우 문제는 기다림이 아니다.

    CPU가 실제 계산을 하고 있다.

    이런 CPU-bound 작업에 단순히 `async를 붙인다고 CPU가 하나 더 생기는 것은 아니다.

    예를 들어 다음과 같은 작업들은 대표적으로 CPU 사용량을 많이 요구할 수 있다.

    이미지 변환
    영상 인코딩
    압축 / 압축 해제
    대규모 데이터 변환
    OCR
    로컬 ML 모델 추론
    복잡한 수치 계산

    이런 작업은 비동기 I/O와는 다른 접근이 필요하다.

    경우에 따라 Thread나 Process를 사용하거나, CPU 작업 전용 Worker를 별도로 구성해야 할 수도 있다.

    즉,

    I/O 대기가 많다
         ↓
    비동기를 검토한다
    
    CPU 계산이 많다
         ↓
    Thread / Process / Worker 구조를 검토한다

    정도로 우선 구분해서 생각하면 좋다.

    물론 실제 시스템에서는 I/O-bound와 CPU-bound 작업이 섞여 있다.

    앞서 살펴본 문서 처리 Worker가 대표적인 예다.

    Background Worker
          │
          ├─ S3 Download ─────── I/O
          │
          ├─ PDF Parsing ─────── CPU + I/O
          │
          ├─ Chunking ────────── CPU
          │
          ├─ Embedding API ───── I/O
          │
          ├─ Vector DB ───────── I/O
          │
          └─ RDB Update ──────── I/O

    그래서 실제 Production 시스템에서는 단순히

    “우리 서비스는 async로 만들었습니다.”

    라는 말만으로는 시스템이 잘 설계되었는지 판단할 수 없다.

    어떤 작업을 어떤 방식으로 실행하고 있는지를 봐야 한다.


    12. async def 라고 쓰면 비동기 코드일까?

    여기서 또 하나의 중요한 함정이 있다.

    다음 코드를 보자.

    import requests
    
    async def call_external_api():
        response = requests.get("https://example.com")
        return response.json()

    함수 앞에 분명히 async가 붙어 있다.

    그렇다면 이 함수는 다른 작업을 방해하지 않고 비동기로 동작할까?

    그렇게 단순하지 않다.

    requests.get()은 일반적인 동기 HTTP 호출이다.

    HTTP 요청을 보낸 뒤 응답이 돌아올 때까지 현재 실행 흐름을 붙잡는다.

    즉 겉모습은

    async def

    이지만 함수 내부에서 blocking I/O를 수행하고 있다.

    FastAPI의 비동기 Endpoint에서도 이런 실수는 발생할 수 있다.

    @app.get("/question")
    async def question():
        result = requests.get("https://llm-api.example.com")
        return result.json()

    코드만 보면 비동기 API처럼 보인다.

    하지만 내부의 blocking 호출 때문에 해당 Event Loop의 진행을 막을 수 있다.

    반면 비동기 HTTP Client를 사용한다면 구조가 달라진다.

    import httpx
    
    async def call_external_api():
        async with httpx.AsyncClient() as client:
            response = await client.get("https://example.com")
            return response.json()

    여기서는 HTTP 응답을 기다리는 동안 await를 통해 실행 기회를 다른 작업에 넘길 수 있다.

    따라서 중요한 것은

    async def를 사용했는가?

    가 아니라

    그 함수 내부의 작업이 실제로
    비동기 방식으로 기다릴 수 있는가?

    이다.

    이 차이를 모르면 코드 전체에 async와 await가 가득한데도 실제 서비스에서는 동시 요청이 제대로 처리되지 않는 일이 생길 수 있다.


    13. FastAPI도 자동으로 모든 것을 해결해 주지는 않는다

    FastAPI가 비동기를 지원한다고 해서 FastAPI로 만든 API가 자동으로 효율적인 비동기 서비스가 되는 것도 아니다.

    예를 들어 구조가 다음과 같다고 해보자.

                  FastAPI API Server
    │
    ▼
    async Endpoint
    │
    ┌─────────┼─────────┐
    ▼ ▼ ▼
    LLM API RDB Vector DB
    │ │ │
    ▼ ▼ ▼
    async sync async

    LLM 호출과 Vector DB Client는 비동기로 구현되어 있는데 RDB 접근에 사용하는 라이브러리는 동기 방식일 수도 있다.

    또는 다음과 같은 경우도 있다.

    async Endpoint
         │
         ▼
    PDF Parsing
         │
         ▼
    CPU 100%

    이 경우 PDF Parsing 작업이 오래 걸리면 Event Loop가 다른 요청을 처리할 기회를 얻지 못할 수 있다.

    따라서

    FastAPI를 사용했다.

    와

    비동기 처리가 올바르게 설계되어 있다.

    는 전혀 다른 이야기다.

    FastAPI는 비동기 프로그램을 만들 수 있는 도구를 제공한다.

    그 도구를 어떻게 사용하는지는 개발자의 몫이다.


    14. 그렇다고 동기 방식이 나쁜 것도 아니다

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

    그러면 앞으로 모든 코드를 async로 작성해야 하나?

    그럴 필요는 없다.

    동기 방식은 훨씬 단순하고 이해하기 쉽다.

    예를 들어 하루에 몇 번 실행되는 관리용 Script가 있다고 해보자.

    Python Script
        │
        ├─ DB 조회
        │
        ├─ 데이터 가공
        │
        └─ 결과 저장

    실행시간이 3초에서 4초가 되는 것이 아무 문제가 없다면 굳이 복잡한 비동기 구조를 만들 이유가 없다.

    사용자 요청을 실시간으로 처리하지 않는 간단한 Batch 프로그램도 마찬가지다.

    매일 새벽 2시
    
    Batch 시작
        ↓
     DB 조회
        ↓
       처리
        ↓
     파일 생성
        ↓
       종료

    10분 안에 끝나면 되는 작업이 동기 방식으로 3분 만에 끝난다면 충분하다.

    비동기를 도입하면 얻는 것만 있는 것도 아니다.

    코드의 실행 흐름을 이해하기 어려워질 수 있고,

    동시 실행에 따른 새로운 문제도 생긴다.

    따라서 중요한 것은

    비동기를 사용하는 것 자체가 아니라 비동기가 필요한 상황을 판단하는 능력이다.


    15. 비동기는 무한 동시 실행을 의미하지 않는다

    비동기를 처음 배우고 나면 반대 방향의 실수를 하기도 한다.

    “기다리는 동안 다른 작업을 할 수 있다면 가능한 한 많이 동시에 실행하면 빠르지 않을까?”

    예를 들어 Embedding API를 호출해야 하는 데이터가 10,000개 있다고 하자.

    이론적으로는 다음과 같이 생각할 수 있다.

    Embedding 1   ─→
    Embedding 2   ─→
    Embedding 3   ─→
    ...
    Embedding 10000 ─→

    Python에서는 asyncio.gather() 등을 이용해 많은 작업을 동시에 실행할 수도 있다.

    하지만 10,000개의 요청을 한꺼번에 실행한다고 해서 좋은 시스템이 되는 것은 아니다.

    오히려 다음과 같은 일이 발생할 수 있다.

    10,000 Tasks
          │
          ▼
    HTTP Connection 급증
          │
          ├─ Connection Pool 고갈
          ├─ Memory 증가
          ├─ 외부 API Rate Limit
          ├─ Socket 증가
          └─ Timeout 증가
                  │
                  ▼
                 Retry
                  │
                  ▼
              요청량 더 증가
                  │
                  ▼
                 장애

    이것은 매우 중요한 사실이다.

    비동기는 동시성을 가능하게 하는 기술이지, 무한한 동시성을 가능하게 하는 기술이 아니다.

    글 내용

    16. 결국 Production에서는 ‘얼마나 동시에’가 중요해진다

    이제 질문이 조금 달라진다.

    처음에는 이렇게 질문했다.

    비동기로 만들 것인가?

    하지만 실제 서비스를 운영할 때는 더 중요한 질문이 생긴다.

    동시에 몇 개까지 실행할 것인가?

    예를 들어 FastAPI API Server가 외부 LLM API를 호출한다고 해보자.

    FastAPI API Server
    
    Request 1 ──────→ LLM
    Request 2 ──────→ LLM
    Request 3 ──────→ LLM
    ...
    Request 100 ────→ LLM

    비동기 구조라면 많은 요청이 동시에 LLM 응답을 기다릴 수 있다.

    그런데 시스템에는 여러 한계가 존재한다.

    FastAPI 동시 요청
            │
            ├─ HTTP Connection Pool
            │
            ├─ DB Connection Pool
            │
            ├─ Vector DB 처리량
            │
            ├─ LLM API Rate Limit
            │
            ├─ Memory
            │
            └─ Network

    어느 하나라도 한계를 넘으면 병목이 발생한다.

    예를 들어 RDB Connection Pool이 20개인데 동시에 500개의 요청이 DB Connection을 요구한다면 500개가 모두 즉시 DB를 사용할 수는 없다.

    결국 일부 요청은 기다려야 한다.

    그래서 Production에서는 단순히 async를 사용하는 것을 넘어

    Concurrency Limit
    Semaphore
    Connection Pool
    Timeout
    Rate Limit
    Queue
    Backpressure
    Worker

    같은 개념이 중요해진다.


    17. API Server와 Background Worker는 문제의 모습도 다르다

    FastAPI API Server와 Background Worker를 구분해서 보는 이유가 여기에도 있다.

    API Server의 가장 중요한 특징은 사용자가 응답을 기다리고 있다는 것이다.

    사용자
      │
      │ Request
      ▼
    FastAPI API Server
      │
      │ 처리
      ▼
    Response
      │
      ▼
    사용자

    처리가 밀리면 사용자가 직접 느낀다.

    2초
     ↓
    5초
     ↓
    15초
     ↓
    30초
     ↓
    Timeout

    반면 Background Worker는 조금 다르다.

    FastAPI
       │
       ▼
    Queue
       │
       ├─ Task 1
       ├─ Task 2
       ├─ Task 3
       ├─ Task 4
       └─ ...
            │
            ▼
    Background Worker

    Worker의 처리속도보다 작업이 들어오는 속도가 빠르면 Queue가 쌓인다.

    작업 유입
    
    100 task/sec
         │
         ▼
       Queue
         │
         ▼
    Worker 처리
    
    50 task/sec

    매초 100개의 작업이 들어오는데 Worker가 50개밖에 처리하지 못한다면 매초 50개의 작업이 밀린다.

    1분이면 3,000개다.

    10분이면 30,000개다.

    API Server가 바로 죽지 않더라도 서비스는 이미 정상적인 상태가 아니다.

    따라서 Worker에서는

    Queue Length
    Processing Rate
    Worker Concurrency
    Task Duration
    Retry
    Memory
    CPU

    같은 요소를 함께 봐야 한다.


    18. 그래서 ‘서버가 터졌다’는 말도 정확하지 않다

    현장에서 흔히 듣는 말이 있다.

    서버가 터졌어요.

    그런데 실제로 무엇이 문제인지 보면 전혀 다른 원인들이 있을 수 있다.

    "서버가 느려졌다"
    
                   │
            ├─ FastAPI Event Loop가 Blocking됨
            │
            ├─ API Worker가 모두 점유됨
            │
            ├─ RDB Connection Pool 고갈
            │
            ├─ HTTP Connection Pool 고갈
            │
            ├─ Background Queue 적체
            │
            ├─ Worker CPU 100%
            │
            ├─ Memory 부족
            │
            ├─ 외부 LLM API 지연
            │
            └─ 외부 API Rate Limit

    겉으로 사용자에게 보이는 현상은 비슷할 수 있다.

    “응답이 안 온다.”

    하지만 원인은 완전히 다르다.

    그래서 Production 문제를 해결하려면

    ‘서버가 느리다’가 아니라 ‘어떤 프로세스가 어떤 자원을 기다리고 있는가’를 봐야 한다.

    이 관점은 비동기를 공부하면서 반드시 익혀야 한다.


    19. AI 서비스는 ‘계산 서버’라기보다 ‘오케스트레이터’에 가까워지고 있다

    특히 최근의 AI 서비스를 보면 이런 특징이 더욱 강해진다.

    RAG 서비스를 생각해보자.

                        ┌──────────────┐
    사용자 ───────────→ │ FastAPI API │
    └──────┬───────┘
    │
    ┌───────────────┼────────────────┐
    │ │ │
    ▼ ▼ ▼
    Embedding API Vector DB RDB
    │
    ▼
    Reranker
    │
    ▼
    LLM API

    Agent 서비스는 더 심하다.

    FastAPI API Server
            │
            ▼
          Agent
            │
            ├─→ LLM API
            │
            ├─→ Search API
            │
            ├─→ Database
            │
            ├─→ MCP / Tool
            │
            ├─→ 다른 사내 API
            │
            └─→ 다시 LLM API

    우리 프로그램이 모든 계산을 직접 수행하는 것이 아니다.

    여러 시스템에 일을 요청하고,

    기다리고,

    결과를 받고,

    다른 시스템에 다시 요청하고,

    마지막 결과를 조합한다.

    따라서 현대적인 AI 애플리케이션의 API Server나 Worker는 많은 경우

    여러 시스템 사이의 작업을 조율하는 오케스트레이터(Orchestrator) 역할을 한다.

    이런 구조에서는 I/O 대기를 어떻게 다루느냐가 시스템 전체 처리량에 큰 영향을 미친다.

    그래서 AI 서비스 개발자에게 비동기는 선택적인 Python 고급 문법 정도로만 보기 어렵다.


    20. 우리가 정말 공부해야 하는 것

    이번 시리즈에서 공부하려는 것은 단순히 다음 문법을 외우는 것이 아니다.

    async def
    await
    asyncio.create_task()
    asyncio.gather()

    궁극적으로 다음 질문에 답할 수 있어야 한다.

    사용자 요청이 들어왔다.
            │
            ▼
    어느 프로세스가 받는가?
            │
            ▼
    FastAPI API Server인가?
    Background Worker인가?
            │
            ▼
    그 프로세스는 지금
    무엇을 하고 있는가?
            │
            ├─ CPU 계산?
            │
            └─ I/O 대기?
                    │
                    ▼
    그 대기가 다른 작업을 막는가?
                    │
                    ▼
    동시에 몇 개까지 실행되는가?
                    │
                    ▼
    그 숫자를 제한하고 있는 것은 무엇인가?

    여기까지 생각할 수 있다면 코드도 다르게 보이기 시작한다.

    예를 들어 이런 질문을 하게 된다.

    LLM API 호출에 5초가 걸릴 때 FastAPI API Server로 동시에 500개의 요청이 들어오면 어떻게 되는가?

    이 Endpoint 안에서 사용하는 DB Driver는 동기인가 비동기인가?

    HTTP Client의 Connection Pool은 몇 개인가?

    Vector DB가 10초 동안 느려지면 API 요청들은 어디에서 기다리게 되는가?

    Background Worker가 처리할 수 있는 속도보다 Queue에 작업이 들어오는 속도가 빠르면 어떻게 되는가?

    CPU-bound 작업을 FastAPI Event Loop 안에서 직접 실행하고 있지는 않은가?

    외부 LLM API가 응답하지 않을 때 우리는 몇 초까지 기다릴 것인가?

    이 질문들은 AI 모델에 대한 질문이 아니다.

    하지만 AI 서비스를 Production에서 운영하려면 반드시 답해야 하는 질문들이다.


    마치며

    AI 서비스를 만드는 진입장벽은 빠르게 낮아지고 있다.

    Python과 FastAPI를 사용하고 LLM API, Vector DB, RDB를 연결하면 비교적 짧은 시간에도 동작하는 서비스를 만들 수 있다.

    하지만

    동작하는 서비스를 만드는 것과 많은 사용자가 동시에 사용하는 서비스를 만드는 것은 다른 문제다.

    개발 환경에서는 보통 이런 구조다.

    개발자 한 명
         ↓
    FastAPI API Server
         ↓
    외부 서비스
         ↓
       응답

    Production에서는 이렇게 된다.

                    수많은 사용자
    │
    ▼
    FastAPI API Server
    │
    ┌───────────┼───────────┐
    ▼ ▼ ▼
    LLM API Vector DB RDB
    │
    ▼
    Queue
    │
    ▼
    Background Worker
    │
    ┌───────────┼───────────┐
    ▼ ▼ ▼
    S3 Embedding API RDB

    이제 단순히

    “API 호출이 성공하는가?”

    만 확인해서는부족하다.

    Production에서는 질문이 달라져야 한다.

    FastAPI API Server에 동시에 100개의 요청이 들어오면 어떻게 되는가?

    그 요청들은 어디에서 기다리고 있는가?

    LLM API를 기다리는 동안 FastAPI의 Event Loop는 다른 요청을 처리할 수 있는가?

    Background Worker 하나가 긴 작업을 수행하고 있을 때 다른 작업들은 어디에서 기다리는가?

    Worker가 처리하는 속도보다 Queue에 작업이 들어오는 속도가 빠르면 어떻게 되는가?

    RDB Connection Pool이 20개인데 동시에 100개의 작업이 Connection을 요구하면 어떻게 되는가?

    외부 API가 30초 동안 응답하지 않으면 FastAPI API Server나 Worker는 그동안 무엇을 하고 있는가?

    CPU-bound 작업을 Event Loop 안에서 실행하면 다른 요청에는 어떤 영향을 미치는가?

    이 질문에 답하기 시작하면 비동기는 더 이상 단순한 Python 문법이 아니다.

    시스템의 구조를 이해하는 방법이 된다.

    그리고 비동기 프로그래밍을 공부하면서 가장 먼저 기억해야 할 것은 의외로 간단하다.

    누가 무엇을 기다리고 있는가?

    FastAPI API Server가 LLM API를 기다리고 있는가?

    Background Worker가 S3 다운로드를 기다리고 있는가?

    RDB Connection을 얻지 못해 요청이 기다리고 있는가?

    아니면 프로그램은 기다리는 것이 아니라 실제로 CPU를 사용해 계산하고 있는가?

    이것부터 구분해야 한다.

    그다음 질문은 이것이다.

    기다리는 동안 다른 일을 할 수 있는가?

    바로 여기에서 비동기 프로그래밍이 시작된다.

    하지만 아직 중요한 질문 하나가 남아 있다.

    비동기 = 동시에 실행?
    
    동시성 = 병렬 처리?
    
    async = non-blocking?
    
    await = 기다리는 것?

    이 용어들은 비슷하게 느껴지지만 같은 의미가 아니다.

    특히 async와 await를 사용하면서도 이 차이를 정확히 이해하지 못하면 코드가 어떻게 실행되는지 예상하기 어려워진다.

    다음 글에서는 코드를 본격적으로 작성하기 전에 이 개념부터 정리해보자.


    다음 글

    AI 개발자가 알아야 할 비동기 프로그래밍 2부

    동기와 비동기의 진짜 차이 — async는 동시에 실행한다는 뜻일까?

    2부에서는 다음 네 가지를 구분하는 것부터 시작한다.

    Synchronous   vs   Asynchronous
    
    Blocking      vs   Non-blocking
    
    Concurrency   vs   Parallelism

    그리고 이것들이 FastAPI에서 실제로 어떤 의미를 갖는지 살펴본다.

    예를 들어 다음과 같은 상황이다.

    @app.get("/question")
    async def question():
        result = await call_llm()
        return result

    await를 만났는데 왜 프로그램은 멈추지 않는 것일까?

    아니, 정확히 말하면 무엇은 멈추고 무엇은 멈추지 않는 것일까?

    하나의 Python 프로세스가 여러 사용자의 요청을 처리한다는 것은 실제로 무엇을 의미할까?

    Event Loop는 무엇을 하고 있는가?

    그리고 흔히 말하는

    “비동기는 동시에 여러 작업을 실행한다.”

    라는 설명은 정확한 표현일까?

    2부에서는 식당의 직원 한 명으로 다시 돌아가 이 문제를 하나씩 풀어본다.

    직원 한 명이 여러 테이블을 담당할 수 있다고 해서 직원이 여러 명이 된 것은 아니다.

    이 차이를 이해하면 Concurrency와 Parallelism의 차이, 그리고 Python 비동기 프로그래밍의 핵심인 Event Loop가 왜 필요한지도 자연스럽게 이해할 수 있다.

    여기서 잠깐, Event Loop란 무엇일까

    앞에서부터 Event Loop라는 용어가 조금씩 등장하기 시작했다.

    비동기 프로그래밍을 처음 접한다면 이름부터 조금 어렵게 느껴질 수 있다.

    하지만 지금 단계에서는 복잡하게 생각할 필요가 없다.

    Event Loop는 아주 단순하게 말하면,

    “지금 실행할 수 있는 작업을 찾아 실행하고, 기다려야 하는 작업은 잠시 내려놓았다가 준비되면 다시 이어서 실행하도록 관리하는 반복 실행 구조” 라고 이해하면 된다.

    예를 들어 FastAPI API Server가 다음 세 개의 요청을 처리하고 있다고 생각해보자.

    Request A
       ↓
    LLM API 호출
       ↓
    응답 기다림 ─────────────┐
                          │
    Request B             │
       ↓                  │
    Vector DB 호출         │
       ↓                  │
    응답 기다림 ────────┐    │
                     │    │
    Request C        │    │
       ↓             │    │
    RDB 호출          │    │
       ↓             │    │
    응답 기다림         │    │
                     │    │
                     ▼    ▼
                   응답이 도착하면
                   해당 작업 계속

    Request A가 LLM API의 응답을 기다린다고 해서 Event Loop까지 함께 가만히 기다릴 필요는 없다.

    A가 기다리는 동안 실행할 수 있는 Request B의 작업을 처리하고,

    B 역시 I/O를 기다리게 되면 Request C의 작업을 처리할 수 있다.

    개념적으로 보면 Event Loop는 계속 이런 질문을 하는 셈이다.

    ┌──────────────────────────┐
    │ 지금 실행할 수 있는 작업은?    │
    └────────────┬─────────────┘
                 │
                 ▼
              실행한다
                 │
                 ▼
           I/O를 기다려야 하나?
              /          \
            YES           NO
             │             │
             ▼             ▼
       잠시 실행을 양보     계속 실행
             │
             ▼
      다른 작업을 확인
             │
             ▼
      I/O 완료 알림 도착
             │
             ▼
      이전 작업을 다시 진행
             │
             └──────────→ 반복

    그래서 이름도 Event Loop다.

    I/O 완료 같은 이벤트를 확인하면서 실행 가능한 작업들을 계속 처리하는 흐름이 반복된다.

    Python의 asyncio에서는 이 Event Loop가 비동기 작업을 실행하는 핵심 역할을 한다.

    그리고 await는 이 구조와 밀접한 관계가 있다.

    result = await call_llm()

    이 코드를 아주 개념적으로 표현하면,

    “LLM 응답이 필요하니 나는 지금 여기서 진행할 수 없다. 기다리는 동안 다른 실행 가능한 작업이 있다면 그쪽에 실행 기회를 넘기고, 결과가 준비되면 나를 다시 진행시켜 달라.”

    정도로 생각하면 된다.

    여기서 중요한 점이 하나 있다.

    Event Loop가 여러 작업을 관리한다고 해서 CPU 하나가 동시에 여러 Python 코드를 실행하고 있다는 뜻은 아니다.

    Event Loop는 실행 가능한 작업을 빠르게 전환하면서 I/O 대기시간을 활용한다.

    그래서 비동기 프로그래밍에서 중요한 것은 Event Loop를 오래 붙잡지 않는 것이다.

    예를 들어 다음처럼 오래 걸리는 CPU 작업을 Event Loop에서 직접 실행한다고 생각해보자.

    Event Loop
        │
        ▼
    PDF Parsing 시작
        │
        │
        │ CPU 작업 중...
        │
        │
        ▼
    PDF Parsing 완료
        │
        ▼
    다음 작업

    PDF Parsing 코드가 CPU를 계속 사용하면서 실행권을 놓지 않는다면 Event Loop는 그동안 다른 비동기 작업으로 넘어갈 기회를 얻지 못할 수 있다.

    그래서 다른 요청들은 실행 가능한 상태여도 기다리게 된다.

    Request A ─→ PDF Parsing ───────────────→ 완료
                        │
                        │ Event Loop 점유
                        │
    Request B ──────────┼──────────── 기다림
    Request C ──────────┼──────────── 기다림
    Request D ──────────┴──────────── 기다림

    이것이 흔히 말하는 “Event Loop를 막았다(Block the Event Loop)” 라는 상황이다.

    지금은 여기까지만 이해하면 충분하다.

    정리하면,

    Event Loop는 기다리는 작업과 실행할 수 있는 작업 사이를 조율하면서 비동기 프로그램이 효율적으로 움직일 수 있도록 관리하는 핵심 실행 구조다.

    그리고 앞으로 async, await, Coroutine, Task를 하나씩 살펴보면 Event Loop가 실제로 이 작업들을 어떻게 관리하는지도 자연스럽게 이해할 수 있게 된다.

    Event Loop의 내부 동작은 다음 편에서 조금 더 자세히 살펴보자.

  • 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의 시스템 구조를 뜯어보자.