ProductionAgents

Document Intelligence Pipeline

Vision-LLM · 4-Phase Async Architecture

10–30×document throughput — hours of turnaround down to minutes

Converts complex multi-page insurance-style PDFs to semantic HTML using a 4-phase pipeline: Architect (layout analysis) → Specialist (adaptive extraction) → Builder (HTML assembly) → Verifier (quality checks). Achieves 10–30× speedup through three levels of async parallelism.

Engagement

Status
Production
Primary KPI
10-30x document throughput
Starting point
Sequential extraction with manual QA bottlenecks
Scope
Document-heavy back-office processing
My role
Lead AI engineer (architecture + async pipeline design)

01 Overview

From sequential extraction with manual QA bottlenecks to 10-30x document throughput.

Before

Sequential extraction with manual QA bottlenecks

After

10-30x document throughput

How it works

The pipeline converts multi-page insurance PDFs into structured semantic HTML through four sequential phases, each powered by a vision-capable LLM. An Architect agent analyses the full document layout and assigns a regional extraction strategy per page. Specialist agents then apply those strategies concurrently across regions. A Builder agent assembles validated HTML from all extracted fragments, and a Verifier agent runs quality checks before committing output. Three levels of asyncio parallelism — batch, page, and region — are layered on top to maximise throughput without hitting provider rate limits.

What I built

  • 4-phase Map-Reduce pipeline with pluggable AI provider abstraction
  • Async parallelism at batch, page, and region levels via asyncio.gather()
  • Token-bucket rate limiter with sliding window — prevents 429s under load
  • Real-time rich progress UI with live cost tracking across workers

02 The system

Architecture at a glance.

FIG. 01 — SYSTEM ARCHITECTUREPDF batchmulti-pageArchitectlayout analysisSpecialistregion · asyncSpecialistregion · asyncSpecialistregion · asyncBuildersemantic HTMLVerifierquality gate
Map-reduce over agents: one Architect plans, a Specialist pool extracts in parallel, a Builder assembles, a Verifier gates quality.
  1. 01

    Architect Agent

    Analyses full document structure and assigns per-page extraction strategies based on detected layout complexity.

  2. 02

    Specialist Agent Pool

    Applies layout-specific extraction prompts to individual page regions concurrently via asyncio.gather().

  3. 03

    Builder Agent

    Assembles validated HTML from extracted fragments, resolving cross-page references and table continuations.

  4. 04

    Verifier Agent

    Runs structural quality checks on assembled HTML and flags low-confidence regions for review.

  5. 05

    Token-Bucket Rate Limiter

    Sliding-window limiter shared across all async workers prevents provider 429s under concurrent load.

  6. 06

    Multi-Provider Abstraction

    Pluggable provider interface allows Gemini 3.0 Flash and GPT-4.1 to be swapped or load-balanced without pipeline changes.

Stack

Models
Gemini 3.0 FlashGPT-4.1
Backend
Python asyncioPydanticDocling
Data
ChromaDB

03 Engineering decisions

The 2 decisions that made it production-grade.

Each decision opens with the business problem, contrasts the common approach with what I shipped, then walks through the real code — the highlighted lines follow the step you are reading.

  1. 01Reliability Under Burst Traffic
  2. 02Throughput Architecture

Decision 01 · Async Systems · Rate Limiting

Reliability Under Burst Traffic

In plain termsThis component controls how many document-processing requests run simultaneously — preventing system overload during peak periods and ensuring consistent throughput without manual throttling.

The problem

Async services don’t fail at peak — they fail during burst recovery.

When concurrent coroutines share a rate-limited API, they all stall, then resume simultaneously. A global counter with no lock lets every waiting coroutine pass the check at once, blowing past the limit in a single tick.

Before · Common approach

Global counter + asyncio.sleep() wrapper. Simple, works sequentially.

✗ Under burst: all coroutines wake together, all pass the counter check — multiple calls in one tick.

After · This implementation

Sliding-window deque with asyncio.Lock guarding every acquire. Instance-owned state.

✓ TOCTOU-safe: check-and-append is atomic. Burst of N proceeds one slot at a time.

How it worksToken Bucket — Sliding Window
  1. 01Request arrives → acquire asyncio.LockLock prevents concurrent state mutation
  2. 02Expire stale entries from dequePop timestamps older than the window boundary
  3. 03Check capacity: len(deque) < max_calls?Read and decide atomically — no race window
  4. 04Append timestamp and release LockOr sleep until earliest entry expires + 100ms buffer

✓ TOCTOU-safe · burst-proof · O(1) per call

Code walkthrough — 4 steps · scroll or click

document-intelligence/rate_limiter.py
"""
AsyncRateLimiter — Token-bucket rate limiter with sliding window.

WHAT IT DEMONSTRATES:
  Async-safe rate limiting across concurrent worker pools processing PDFs.
  Multiple asyncio workers share one limiter instance; the sliding window
  algorithm prevents bursts from accumulating hidden debt across workers.

WHY IT'S INTERESTING:
  Rather than a fixed token bucket that refills on a timer, this uses a
  deque to track actual request timestamps. The window is always exactly
  60 seconds wide, so the effective rate converges to the RPM limit even
  under bursty load. The asyncio.Lock ensures the critical section is
  atomic — no two coroutines can both observe "under limit" simultaneously.

NOVELTY:
  Most async LLM wrappers use fixed sleep intervals between retries. This
  implementation uses a sliding-window deque (not a token bucket with timer
  refill) so the effective rate converges to exactly RPM over any 60-second
  window — even under bursty parallel workloads across multiple worker pools.
  The asyncio.Lock ensures the critical read-modify-write on the deque is
  atomic across all coroutines sharing one limiter instance. The alternative
  (a simple asyncio.Semaphore) would bound concurrency but not rate — a
  semaphore with N=10 allows 10 simultaneous requests with no per-minute cap,
  which is the wrong primitive for API rate-limit enforcement.
"""

import asyncio
import time
from collections import deque
from typing import Optional
import logging

logger = logging.getLogger(__name__)


class AsyncRateLimiter:
    """Token-bucket rate limiter with sliding window for async contexts.

    Limits requests-per-minute (RPM) with optional burst support.
    Thread-safe for asyncio via asyncio.Lock.

    Args:
        rpm:        Maximum requests per minute.
        burst_size: Max concurrent in-flight requests (default: rpm // 2).
    """

    def __init__(self, rpm: int, burst_size: Optional[int] = None):
        if rpm <= 0:
            raise ValueError(f"RPM must be positive, got {rpm}")

        self.rpm = rpm
        self.burst_size = burst_size if burst_size is not None else max(1, rpm // 2)
        self.semaphore = asyncio.Semaphore(self.burst_size)
        self.request_times: deque[float] = deque()
        self.lock = asyncio.Lock()

    async def __aenter__(self):
        await self.acquire()
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        self.semaphore.release()
        return False

    async def acquire(self):
        """Block until a rate-limit slot is available, then claim it.

        Algorithm:
          1. Purge timestamps older than 60 s (sliding window eviction).
          2. If at RPM capacity, sleep until the oldest request ages out.
          3. Append current timestamp and release the lock.
        """
        async with self.lock:
            now = time.time()

            # Evict stale entries from the front of the sliding window
            while self.request_times and self.request_times[0] < now - 60:
                self.request_times.popleft()

            if len(self.request_times) >= self.rpm:
                # Sleep exactly long enough for the oldest entry to expire
                sleep_time = 60 - (now - self.request_times[0]) + 0.1  # 100 ms buffer
                if sleep_time > 0:
                    logger.debug(
                        "Rate limit reached (%d/%d RPM), sleeping %.2fs",
                        len(self.request_times), self.rpm, sleep_time,
                    )
                    await asyncio.sleep(sleep_time)
                    now = time.time()
                    while self.request_times and self.request_times[0] < now - 60:
                        self.request_times.popleft()

            self.request_times.append(now)

    def get_capacity(self) -> int:
        """Remaining requests available in the current 60-second window."""
        now = time.time()
        while self.request_times and self.request_times[0] < now - 60:
            self.request_times.popleft()
        return max(0, self.rpm - len(self.request_times))


# Usage — every AI call in the pipeline is wrapped like this:
#
#   async with self.rate_limiter:
#       result = await ai_client.extract_region_async(image, prompt)
import asyncio, time

_calls = 0                   # module-level
_window_start = time.time()  # shared state

async def rate_limited_call(fn, *args):
    global _calls, _window_start
    elapsed = time.time() - _window_start
    if _calls >= 10 and elapsed < 1.0:
        # BUG: no lock — concurrent reads here
        await asyncio.sleep(1.0 - elapsed)
        _calls = 0
        _window_start = time.time()
    _calls += 1  # unguarded write
    return await fn(*args)

The Impact

0race conditionsAcross 12 instances under burst load
<1mslock overheadPer acquire at full throughput
12concurrent instancesZero shared state between them

Moving from module-level state to an instance-owned sliding-window deque with asyncio.Lock eliminated a TOCTOU race that only surfaces under burst load — the exact scenario that matters in production.

Decision 02 · Async Systems · Pipeline Architecture

Throughput Architecture

In plain termsInstead of processing documents one at a time, this design parallelizes work so multiple documents flow through the pipeline simultaneously — multiplying throughput without proportional cost increases.

The problem

Adding async doesn’t add concurrency — you have to fan out explicitly at every level.

Nested for loops with await process one PDF, then one page, then one region sequentially. Each await yields control but starts no new work. A 30-page document with 4 regions still runs 120 calls end-to-end.

Before · Common approach

Nested for loops each awaiting one result before starting the next.

✗ Total latency = sum of all tasks. One slow document stalls the entire batch.

After · This implementation

asyncio.Queue + workers at batch level, asyncio.gather() at page and region levels, Semaphore for back-pressure.

✓ Latency ≈ slowest document, not their sum. Three independent fan-outs, each bounded.

How it worksFan-Out Pipeline — 3-Level Concurrency
  1. 01Enqueue all PDF paths → worker poolasyncio.Queue + create_task() per worker
  2. 02Per PDF: asyncio.gather() all pagesAll page coroutines fire simultaneously
  3. 03Per page: acquire Semaphore → regionsmax_pages cap prevents rate-limit exhaustion
  4. 04Collect results, filter exceptionsreturn_exceptions=True — failures don't abort siblings

✓ 120 sequential calls → full concurrency at every level

Code walkthrough — 3 steps · scroll or click

document-intelligence/async_pipeline.py
"""
Async pipeline core — three-level parallelism for document processing.

WHAT IT DEMONSTRATES:
  How the Document Intelligence Pipeline achieves 10-30x speedup by running
  concurrent asyncio tasks at every level: batches → pages → regions.
  Each level is independently bounded to avoid resource exhaustion.

WHY IT'S INTERESTING:
  The pattern uses asyncio.Queue + worker coroutines for the batch level
  (fan-out to N workers), then asyncio.gather() for page/region parallelism
  within each worker. return_exceptions=True at each gather ensures one
  failed region never crashes the entire document.

NOVELTY:
  Combining two different async parallelism primitives at different pipeline
  levels is the key design decision here. asyncio.Queue + workers (fan-out
  pattern) is chosen at the batch level because documents arrive at variable
  rates and work should be load-balanced dynamically across a fixed worker
  pool. asyncio.gather() is chosen for page/region parallelism because the
  set of subtasks is known upfront and bounded. Using gather() at the outer
  level too would create an unbounded task explosion under large batch sizes.
  The dual-pattern approach bounds memory and concurrency without sacrificing
  throughput.
"""

import asyncio
from dataclasses import dataclass, field
from typing import Any


@dataclass
class PageResult:
    page_number: int
    html: str
    token_usage: Any


@dataclass
class DocumentResult:
    source_path: str
    pages: list[PageResult] = field(default_factory=list)
    total_tokens: Any = None


class AsyncDocumentPipeline:
    """
    Orchestrates concurrent document processing.

    Levels of parallelism:
      1. Batch level  — asyncio.Queue + N worker coroutines
      2. Page level   — asyncio.gather() of page tasks per document
      3. Region level — asyncio.gather() of region tasks per page
    """

    def __init__(self, ai_client, rate_limiter, max_workers: int = 3, max_pages: int = 5):
        self.ai_client = ai_client
        self.rate_limiter = rate_limiter
        self.max_workers = max_workers
        self._page_semaphore = asyncio.Semaphore(max_pages)

    # ── Batch level ────────────────────────────────────────────────────────

    async def convert_batch_async(self, pdf_paths: list[str]) -> list[DocumentResult]:
        """Process a list of PDFs concurrently using a worker pool."""
        queue: asyncio.Queue[str] = asyncio.Queue()
        results: list[DocumentResult] = []

        for path in pdf_paths:
            await queue.put(path)

        workers = [
            asyncio.create_task(self._worker(queue, results))
            for _ in range(self.max_workers)
        ]

        await queue.join()          # block until all items are processed
        for w in workers:
            w.cancel()
        await asyncio.gather(*workers, return_exceptions=True)
        return results

    async def _worker(self, queue: asyncio.Queue, results: list):
        """Continuously pull PDFs from the queue until cancelled."""
        while True:
            path = await queue.get()
            try:
                result = await self.convert_async(path)
                results.append(result)
            except Exception as exc:
                results.append(DocumentResult(source_path=path))  # empty on failure
            finally:
                queue.task_done()

    # ── Page level ─────────────────────────────────────────────────────────

    async def convert_async(self, pdf_path: str) -> DocumentResult:
        """Process all pages of a single PDF concurrently."""
        pages = self._load_pdf_pages(pdf_path)   # returns list of page images

        # Semaphore caps concurrent pages; gather collects results in order
        tasks = [self._process_page_async(page_img, i) for i, page_img in enumerate(pages)]
        page_results = await asyncio.gather(*tasks, return_exceptions=True)

        valid_pages = [r for r in page_results if isinstance(r, PageResult)]
        return DocumentResult(source_path=pdf_path, pages=valid_pages)

    # ── Region level ───────────────────────────────────────────────────────

    async def _process_page_async(self, page_image, page_number: int) -> PageResult:
        """Detect layout regions then extract each region concurrently."""
        async with self._page_semaphore:
            # Phase 1: layout analysis (rate-limited AI call)
            async with self.rate_limiter:
                regions = await self.ai_client.analyze_layout_async(page_image)

            # Phase 2: extract each region in parallel
            extract_tasks = [
                self._extract_region_async(page_image, region)
                for region in regions
            ]
            region_html_parts = await asyncio.gather(*extract_tasks, return_exceptions=True)

            html = "\n".join(
                r for r in region_html_parts if isinstance(r, str)
            )
            return PageResult(page_number=page_number, html=html, token_usage=None)

    async def _extract_region_async(self, page_image, region) -> str:
        """Rate-limited extraction call for a single document region."""
        async with self.rate_limiter:
            return await self.ai_client.extract_content_async(page_image, region)

    def _load_pdf_pages(self, pdf_path: str) -> list:
        """Load PDF pages as PIL images (sync — called before gather)."""
        raise NotImplementedError  # implemented in full pipeline
import asyncio

async def convert_batch(pdf_paths):
    results = []
    for path in pdf_paths:    # one PDF at a time
        result = await convert(path)
        results.append(result)
    return results

async def convert(pdf_path):
    pages = load_pdf_pages(pdf_path)
    page_results = []
    for page in pages:        # one page at a time
        result = await process_page(page)
        page_results.append(result)

The Impact

10×throughput gainOver sequential single-threaded extraction
3parallelism levelsBatch, page, and region — independently bounded
0cascade failuresreturn_exceptions=True isolates failures per level

Layering asyncio.Queue workers at the batch level and asyncio.gather() at page and region levels produces compounding concurrency. The semaphore on the page level prevents task explosion under large batch sizes.

04 Production hardening

What breaks, why, and what keeps it fixed.

The failure modes this system had to survive — each with its root cause, the fix that shipped, and the guardrail that stops it coming back.

  1. 01
    Failure modeFailure mode · Reliability Under Burst Traffic

    Under burst: all coroutines wake together, all pass the counter check — multiple calls in one tick.

    Root cause

    Async services don’t fail at peak — they fail during burst recovery.

    Fix shipped

    Sliding-window deque with asyncio.Lock guarding every acquire. Instance-owned state.

    Guardrail

    TOCTOU-safe · burst-proof · O(1) per call

  2. 02
    Failure modeFailure mode · Throughput Architecture

    Total latency = sum of all tasks. One slow document stalls the entire batch.

    Root cause

    Adding async doesn’t add concurrency — you have to fan out explicitly at every level.

    Fix shipped

    asyncio.Queue + workers at batch level, asyncio.gather() at page and region levels, Semaphore for back-pressure.

    Guardrail

    120 sequential calls → full concurrency at every level

Interactive · runs in your browser

Burst-traffic lab

The same burst of LLM calls through both rate limiters from this case file. The provider rejects anything over the limit in any rolling 1-second window (HTTP 429); rejected calls retry after a 1 s back-off.

ClassicModule-global counter, no lock
429 errors
—
Peak calls / 1 s
—
All done at
—
Sliding window + asyncio.LockWhat shipped
429 errors
—
Peak calls / 1 s
—
All done at
—
acceptedrejected (429) — then retriedTime is scaled from the production 60-second window to 1 second. Both limiters are simulated exactly as written in rate_limiter.py.

05 Outcomes

What changed for the business.

  1. 01

    10-30x document throughput

  2. 02

    Reduced turnaround from hours to minutes

  3. 03

    Applied to complex multi-page document workflows across pilot operations.

Key benefits

  • 10–30× throughput increase over sequential single-threaded extraction
  • Insurance-specific extraction strategies reduce hallucination on complex form layouts
  • Zero-touch processing — no human review required for standard document types
  • Real-time cost tracking across workers enables per-document billing accountability
  • Production-ready rate limiting sustains throughput under continuous batch loads

Metrics are from project-specific observation windows. Codebases are private; excerpts are curated for illustration.

Want the full story behind Document Intelligence Pipeline?

I’m happy to walk through the architecture, trade-offs and numbers in an interview.