AWSAIRAGFastAPI

How the AI Document Processor Actually Works

An arrow-by-arrow tour of the AI Document Processor's architecture — CloudFront routing, presigned S3 uploads, the async Textract pipeline, RAG retrieval, and WebSocket status updates.

September 18, 2026·11 min read·Sanjay Patoliya·⭐ View on GitHub

Upload a document and, a few seconds later, you can chat with it — ask questions and get answers with page citations. Between those two moments, the request passes through a long chain of AWS infrastructure: a CDN, a load balancer, a container, a queue, two Lambda functions, an OCR service, a vector database, and a WebSocket connection, among others. This post walks through that path arrow by arrow, using the actual architecture diagram as the map.

Edge Layer: One Domain, Two Origins

Everything starts at a single CloudFront distribution in front of two origins:

  • /* → S3 (the React static build)
  • /api/v1/* → an ALB in front of ECS Fargate (FastAPI)

CloudFront evaluates its cache behaviors in precedence order: the /api/v1/* behavior is checked first, so any request to /api/v1/... gets routed to the API, and everything else falls through to the default behavior and serves the frontend from S3. The API behavior has caching disabled and allows all HTTP methods, since these are live API calls and SSE chat streams, not static assets.

The reason this matters: the frontend and the API are served from the same domain. The browser never sees this as a cross-origin request, so there's no CORS configuration anywhere in the stack — no Access-Control-Allow-Origin headers, no preflight OPTIONS handling, no origin allowlist to keep in sync across environments. A separate API domain (api.example.com next to example.com) would need all of that. One distribution, one domain, and the browser's same-origin policy just works in your favor instead of against it.

HTTPS termination happens at the CloudFront edge too, so ECS Fargate only ever talks HTTP internally behind the ALB — TLS certificates live in one place, not on every container.

Upload Path: The API Never Sees the File

The naive way to build an upload endpoint is POST /upload with the file in the request body, streamed through your API into S3. That works, but it means every byte of every document — PDFs can easily be tens of megabytes — passes through FastAPI first. More server load, more latency, and a bigger blast radius if the upload stalls.

Instead, the upload is a three-step handshake where the file itself skips the API entirely:

  1. POST /api/v1/upload/presigned-url — FastAPI generates a doc_id, builds an S3 key scoped to the user ({user_id}/{doc_id}/{file_name}), writes a PENDING document record to the database, and returns a presigned POST URL plus the form fields S3 requires.
  2. Browser uploads directly to S3 — the multipart POST goes straight from the browser to the S3 bucket using that presigned URL. FastAPI is out of the request path completely.
  3. POST /api/v1/upload/complete/{doc_id} — the browser calls this once the S3 upload succeeds, just to flip the document's status to PROCESSING so the UI reflects it. It doesn't kick off processing itself.

That last point matters: the async pipeline isn't triggered by an API call at all — it's triggered by the S3 ObjectCreated event, which fires the moment the object lands in the bucket. So even if the browser's /complete call never fires (tab closed, network drop), the document still gets processed. The presigned URL expires in 300 seconds, and creating the PENDING record before returning the URL means the S3 event handler always finds a matching document row when it fires.

Async Processing: Two Lambdas and a Fork

The S3 ObjectCreated event doesn't invoke a Lambda directly — it lands on an SQS queue, and the queue triggers the Lambda Orchestrator. The queue sits between S3 and the Lambda so a burst of uploads waits in line instead of hitting the function all at once. The Orchestrator's only job is to look at the file and decide how to get text out of it:

  • PDF or image → the content isn't reliably machine-readable (scanned contracts, photographed receipts), so the orchestrator kicks off an async Amazon Textract text-detection job (textract:StartDocumentTextDetection) and returns immediately. Textract runs OCR in the background and publishes to an SNS topic when it's done.
  • DOCX or plain text → Textract can't process those formats, and doesn't need to, since the text is already machine-readable. The orchestrator publishes a message to the same SNS topic itself, shaped like a Textract completion event but flagged ExtractionMethod: "direct". The Processor sees the flag and extracts the text from the file on its own, so there's no OCR latency and no per-page Textract cost.

This fork is why the pipeline is two separate Lambdas instead of one. Textract's async API doesn't return results synchronously — it works in the background and pushes a completion event to SNS, which is what triggers the Lambda Processor. Splitting the work this way means the Orchestrator can be short-lived (a 60-second timeout) while Textract does the actual OCR on its own time, instead of one Lambda blocking and burning execution time waiting on a job that can take anywhere from a few seconds to a couple of minutes depending on page count. And because the direct path publishes to the same topic, both routes converge on a single Processor entry point.

Once the Processor picks up the SNS message, it gets the text — paging through the Textract result for PDFs and images, or parsing the file itself for DOCX and TXT (which are treated as a single page) — and hands off to the AI layer, keeping page numbers along the way for citations later.

AI Layer: Classify Once, Embed Once, Store for Later

Two very different AI workloads happen inside the Processor Lambda, and they're deliberately kept separate:

Classification + summary — the extracted text (truncated to 8,000 characters, enough context without burning the whole window on a 40-page contract) goes to Claude with a prompt asking for a doc_type (invoice, contract, receipt, resume, and a handful of others) and a 2-3 sentence summary, returned as strict JSON. This is a single, cheap Claude call per document, and it's what populates the classification badge and summary shown in the UI immediately after processing.

Chunking + embedding for retrieval — this is a separate concern from classification, because it's not about understanding the document once, it's about making it searchable later. Each page's text is split into ~800-character chunks with 100 characters of overlap (so a sentence that spans a chunk boundary isn't lost from either side), then every chunk in the document is embedded in a single batched Voyage AI call — not one API call per chunk — which is both faster and cheaper at scale. The resulting vectors are stored in pgvector on Aurora Serverless v2, alongside the chunk's page number, so a chunk retrieved months later still knows which page it came from.

The reason both of these live in pgvector on the same Aurora cluster as the rest of the app's relational data — instead of a dedicated vector database — is that Aurora Serverless v2 scales down to 0.5 ACU when idle. For a document Q&A workload where usage is bursty (upload a batch, ask a few questions, go quiet for hours), that's a meaningfully different cost profile than running a standing vector database around the clock, without giving up cosine similarity search.

Real-Time Status: Push, Not Poll

Processing a document — Textract OCR, a Claude call, embedding every chunk, writing to pgvector — takes anywhere from a few seconds to a couple of minutes. The UI needs to show that progress, and there are really only two ways to do it: have the browser poll GET /api/v1/documents/{doc_id} on a timer, or have the backend push a status update the moment something changes.

Polling is the simpler option, but it's a bad trade here: at a 2-second poll interval, a document that takes 90 seconds to process generates ~45 requests just to answer "is it done yet?" — most of which return the same PROCESSING status. Multiply that across every document a user has ever uploaded, sitting on a dashboard, and it's a lot of load for information that changes maybe twice (processing → completed, or processing → failed).

Instead, the moment the Processor Lambda finishes — success or failure — it invokes a dedicated WebSocket Lambda directly (via lambda_client.invoke, not through another queue) with the doc_id, status, classification, summary, and page count as payload. That Lambda pushes the update down the browser's open connection through an API Gateway WebSocket API. While a document is still processing, the browser holds one connection for it, authenticated with the user's Cognito token, and just listens — no polling, one message the instant something changes. Once the document reaches completed or failed, the connection is closed.

The tradeoff is real. API Gateway hands each connection an ID but doesn't remember who it belongs to, so the stack needs its own connection-tracking state: a small DynamoDB table mapping connection_id to doc_id, with a secondary index on doc_id so the notify Lambda can find every connection watching a given document, and a 24-hour TTL so stale rows clean themselves up. The $connect route verifies the JWT before storing anything. On the frontend there's also a 25-second keep-alive ping and reconnect logic (retry after 3 seconds) for when the network blips. For a handful of status transitions per document, that's a worthwhile trade against dozens of polling requests that mostly return "still working."

Chat: Retrieval First, Then a Grounded Answer

By the time a user asks a question, the document is already chunked and embedded in pgvector from the processing stage — so the chat request is pure RAG, no processing pipeline involved:

  1. Embed the question — the user's question is embedded with the same Voyage AI model (voyage-3) used for the document chunks, so both live in the same vector space.
  2. Retrieve top-5 chunks — a pgvector cosine-similarity query (embedding_vec <=> query_embedding, ordered ascending, limited to 5) scoped to that specific document. Notably, ivfflat.probes is bumped to 10 for this query — the ivfflat index trades recall for query speed by default, so probing more candidate lists at read time buys back accuracy, at a cost that's easy to afford for a query that runs once per chat message.
  3. Stream the answer — the 5 retrieved chunks are stitched into the prompt as labeled excerpts ([Page 3]: ...), with a system prompt that instructs Claude to answer only from those excerpts and say so explicitly if the answer isn't in them. The response streams back as Server-Sent Events, token by token, so the answer appears incrementally instead of after a multi-second wait.
  4. Cite sources on completion — once streaming finishes, a final done event carries the source chunks (page number + excerpt) that were actually used, which is what powers the "cited page numbers" in the UI.

Claude never sees the whole document at chat time, only the 5 chunks retrieval decided were relevant. That's a deliberate constraint — it keeps answers scoped to retrieved evidence instead of the model free-associating from training knowledge, and it's why every answer can point to a specific page.

Wrapping Up

That's the full round trip: CloudFront routes by path to a single domain, uploads bypass the API entirely, a queue-fed Lambda pair forks OCR work based on file type, Claude and pgvector do the AI heavy lifting, a WebSocket pushes status instead of making the browser ask for it, and chat is retrieval-constrained RAG streamed back token by token. No single piece here is exotic — the interesting part is how the pieces are wired together to avoid unnecessary work at every step.

Key code samples — the RAG service, the Lambda processor, and the CDK stacks — are on GitHub:

👉 github.com/sanjaypatoliya/ai-document-processor-sample

If you want this architecture as a starting point rather than building it from scratch, the full production template — auth, all 10 CDK stacks, the complete frontend, and deployment docs — is available on Gumroad:

👉 Get the AI Document Processor template

About the Author

I'm Sanjay Patoliya — AWS Certified Generative AI Developer Pro, Solutions Architect & DevOps Engineer building production-ready AI systems on AWS. If you're looking to build something similar or need a remote AWS + AI engineer, feel free to reach out.