Design problems
AI-era systemsMediumSDE-2 35 min

Design a document ingestion and embedding pipeline

Asked at
AtlassianFreshworksGoogleMicrosoftSalesforceZoho

The interviewer asks

“Design the pipeline that feeds a RAG platform: customers upload files or connect sources, and every PDF, Word file, slide deck, scanned image and web page must be parsed, chunked, embedded and indexed so it can be searched. Handle 2 million documents a day, re-embedding when the model changes, and deletions that must take effect quickly.”

What a strong answer looks like

A strong answer designs a staged, idempotent pipeline with clear contracts between stages: land the raw file in object storage, emit an event, then run per-document workflows through detect → extract (text, layout, OCR for scans) → clean → chunk (structure-aware) → enrich (metadata, ACLs) → embed (batched, rate-limited) → index (vector + keyword) → publish a new document version atomically. Content hashes make every stage skippable when nothing changed, and versioned chunk sets make updates and deletes clean. The pipeline needs priority lanes (a user's upload in seconds vs a 10M-file backfill over days), backpressure against embedding API quotas, a dead-letter path for poison files, and observability per stage. The senior parts are re-embedding the whole corpus with a new model using a parallel index and an alias switch, deletion SLAs, OCR cost, and measuring that chunking changes actually improve retrieval.

Concepts to mention

raw file in S3, event-driven per-document workflowcontent hash to skip unchanged workdeterministic chunk ids for idempotent writesversioned publish with an atomic pointer fliplayout-aware extraction and selective OCRstructure-aware chunking with heading path and overlapbatched, rate-limited embedding with a cacheinteractive vs bulk lanes with per-tenant fairnessdead-letter queue for poison documentsblue-green re-embedding with alias switchdeletion SLA with query-time filter and reconciler

1 Requirements

Functional

  • Accept uploads (up to 200 MB) and connector syncs (Drive, Confluence, S3, websites)
  • Extract text and structure from PDF, DOCX, PPTX, XLSX, HTML, Markdown and images; OCR scanned pages including Hindi and other Indian scripts
  • Chunk by document structure (headings, sections, tables) with metadata: title, heading path, page, ACL, language
  • Embed chunks and write them to the vector index and keyword index
  • Updates replace the old version atomically; deletes remove all chunks
  • Re-process the whole corpus when chunking or the embedding model changes, without downtime
  • Per-document status visible to users (queued, processing, ready, failed with reason)

Non-functional

  • Interactive uploads searchable within 60 s p95 for a 20-page PDF
  • 2M documents/day (~30M pages), with backfills of 50M documents per tenant onboarding
  • Deletion reflected in search within 5 minutes
  • Every stage idempotent; no duplicate or orphaned chunks after retries
  • Embedding spend and OCR spend tracked per tenant
  • Pipeline lag alerting: bulk lane may lag hours, interactive lane may not

Out of scope (say it out loud)

  • The online question-answering path (see the RAG chatbot design)
  • Internal design of the vector database
  • Connector OAuth details for each source

2 Estimates

Documents~23/s avg, ~100/s peak2M/day ÷ 86,400 ≈ 23/s; business-hour uploads and connector sync bursts ~4×; backfills run in a separate lane
Pages and OCR~30M pages/day, ~6M OCR pagesAverage 15 pages per document; ~20% are scans or images that need OCR
Chunks~50M chunks/day, ~580/s~25 chunks of ~500 tokens per document × 2M
Embedding tokens~25B tokens/day50M chunks × ~500 tokens (plus heading prefix)
Embedding cost~$500/day via API, or ~10 GPUs self-hostedAt $0.02 per 1M tokens (small embedding model): 25,000M × $0.02/M = $500/day. Self-hosted: a mid-size embedding model does very roughly 20-50k tokens/s per GPU → 290k tokens/s needs ~6-15 GPUs
OCR cost~$9k/day at cloud OCR prices6M pages × ~$1.5 per 1,000 pages ≈ $9,000/day; often the largest line item, so detect born-digital PDFs and skip OCR when a text layer exists
Storage~2 TB/day raw files, ~150 GB/day vectors2M × ~1 MB average file; 50M × 768 dims × 4 B ≈ 150 GB (≈ 40 GB with int8 quantization)

3 API

EndpointWhat it does
POST/v1/documents/uploadsGet a pre-signed S3 URL for direct upload; returns document_id. Upload completion triggers processing via S3 event.
GET/v1/documents/{id}Status per stage, version, chunk count, error reason.
DELETE/v1/documents/{id}Delete document; returns after it is marked deleted (hidden from search immediately); purge runs async.
POST/v1/connectors/{id}/syncTrigger an incremental sync; changes become per-document jobs in the bulk lane.
POST/admin/v1/reindexStart a re-embedding job: target index name, embedding model, chunker version, tenant filter, rate.
GET/admin/v1/pipeline/healthPer-stage queue depth, lag, error rate, DLQ size per lane.

4 Data model

documentsPostgres
document_id (PK)tenant_idsourcesource_reftitlecontent_hashcurrent_versionstatusacl_groups[]deleted_atupdated_at

Source of truth for what should be searchable. current_version is the pointer flipped at publish time.

document_versionsPostgres
document_idversioncontent_hashparser_versionchunker_versionembed_modelstage_status jsonbchunk_counterror

Lets you tell exactly how each version was produced and which need re-processing after a pipeline change.

raw and extracted filesS3
raw/{tenant}/{doc}/{hash}; extracted/{tenant}/{doc}/{version}.json (text blocks with page, bbox, type)

Keyed by content hash so re-uploads of identical files skip work. Extracted JSON lets you re-chunk without re-parsing or re-OCR.

chunksVector DB + keyword index (or Postgres pgvector + tsvector)
chunk_id = hash(doc, version, ordinal)tenant_iddocument_idversiontextheading_pathpageacl_groups[]embedding

Deterministic ids make writes idempotent. Queries filter on version = current_version (or old versions are deleted after publish).

embedding_cacheRedis / DynamoDB
key = hash(embed_model, chunk_text) → vector

Unchanged chunks in an edited document are not re-embedded; typical edits change only a few chunks.

jobsTemporal (or SQS per stage + Postgres status)
workflow per (document_id, version)

Durable, retryable stages; workflow id dedupes concurrent jobs for the same version.

5 Architecture

CLIENTSEDGESERVICESASYNCDATAEXTERNALget upload URLPUT fileobject createdversion, statusstage tasksscanned pagesextracted JSONskip unchangedbatches of 256upsert chunkspublish / purgeUploads + connectorsIngest API(pre-signed)Pipeline orchestratorChunk + enrichworkersExtract + OCR workersQueues:interactive/bulkEmbedding batcherS3: raw + extractedPostgres: docs,versionsVector + keywordindexEmbedding cacheOCR serviceEmbedding model / API
ClientEdge / gatewayService / workerQueue / streamCacheDatabase / storageAI / modelThird partyrequestasync (event / queue)

Hover a box to highlight its connections.

6 Request flows

Upload a 20-page PDF

  1. 1The user requests an upload URL; the API creates document_id with status=queued and returns a pre-signed S3 PUT URL. The browser uploads directly to S3.
  2. 2S3 emits an object-created event; the orchestrator computes the content hash. Same hash as the current version? Mark ready and stop.
  3. 3It starts a workflow for (document_id, version 3) on the interactive lane.
  4. 4Extract: the worker detects a born-digital PDF with a text layer, so it extracts text blocks with layout (headings, lists, tables) without OCR; two scanned appendix pages go to OCR. Result saved as extracted JSON in S3.
  5. 5Chunk: a structure-aware chunker splits by headings into ~500-token chunks with ~50-token overlap, keeps tables whole (or as row groups with the header repeated), prepends the heading path, and attaches ACL groups and language.
  6. 6Embed: the batcher checks the embedding cache by hash(model, text); misses are batched (up to 256 chunks) and sent within the tenant's and provider's rate limits.
  7. 7Index: chunks are upserted with deterministic ids and version=3. Then the orchestrator flips documents.current_version to 3 in one transaction and deletes version 2 chunks asynchronously.
  8. 8Status becomes ready about 25 s after upload; the user can now ask questions over it.

Delete a document

  1. 1The user deletes the document; the API sets deleted_at and status=deleted in Postgres.
  2. 2Search filters exclude deleted documents immediately (query-time check against a deleted-ids cache refreshed every few seconds).
  3. 3A purge task deletes all chunks for the document from the vector and keyword indexes, then the extracted JSON and raw file from S3.
  4. 4A nightly reconciler compares index document ids with Postgres and removes any orphans left by failed purges.

7 Deep dives

Idempotency and versioned publishing

Every stage will run more than once: workers crash, queues redeliver, users upload the same file twice, connectors report the same change repeatedly. Make each stage a pure function of its inputs and key its outputs deterministically. The raw file is stored by content hash, so identical uploads are free. Extraction output is keyed by (document, version, parser_version). Chunk ids are hash(document, version, ordinal), so re-running the chunk stage overwrites the same rows instead of adding duplicates. The embedding cache is keyed by (model, chunk text hash), so edited documents only pay for changed chunks. The important part is publishing: never mix chunks of two versions in search. Write all chunks of the new version first, invisible to queries, then flip documents.current_version in one database transaction; queries filter on the current version or join through it. Old-version chunks are deleted afterwards. If the pipeline fails halfway, the old version stays fully searchable and the new one is retried. Use one workflow per (document, version) with that pair as the workflow id, so concurrent duplicate events cannot start two pipelines for the same version, and a newer version cancels an older one still in progress.

Extraction and chunking quality

Most RAG quality problems start here, not in the model. PDFs are a drawing format: text can come out in the wrong reading order, with headers and footers on every page, hyphenated words split, and tables flattened into nonsense. Use a layout-aware parser (for example Unstructured, Apache Tika plus a layout model, or a cloud document AI service) that returns typed blocks: title, heading, paragraph, list, table, figure caption, with page numbers. Detect scanned pages by the absence of a text layer and only OCR those; OCR for Indian scripts (Devanagari, Tamil, Bengali) needs models trained on them, and quality must be checked per language. Clean: remove repeated headers and footers, fix hyphenation, normalise Unicode. Chunk by structure: split at headings, then at paragraph boundaries to a target of ~300-800 tokens with small overlap; never split a table row from its header; prepend the heading path ('Leave Policy > Maternity > Eligibility') so each chunk carries its context. Keep the chunker versioned and measure: a change to chunking should be evaluated on a retrieval eval set (recall@k on golden questions) before re-processing millions of documents, because 'looks better' is not evidence.

Throughput, priority lanes and backpressure

Two kinds of work share the pipeline: interactive uploads where a user is waiting, and bulk work (connector backfills of 50M files, re-indexing) where throughput matters and latency does not. If they share one FIFO queue, a new enterprise tenant's backfill delays everyone's uploads by hours. Use separate queues per lane with reserved worker capacity for interactive work, and fair scheduling across tenants within the bulk lane, so one tenant cannot take all workers. Each stage scales on its own queue depth (KEDA-style), since extraction is CPU-heavy, OCR is an external API with its own quota, and embedding is limited by GPU or provider tokens per minute. The embedding stage is usually the choke point: batch chunks (100-256 per request) to get high throughput, run a token-bucket limiter shared across workers to stay under provider limits, and on 429s slow down instead of retry-storming. When the index write path is slow, upstream stages must stop pulling (bounded queues, not unbounded buffering). Poison documents (encrypted PDFs, zip bombs, 10,000-page scans, malformed files) get strict limits on size, pages and processing time, and after N failures go to a dead-letter queue with the error, and the user sees a clear 'failed: password protected' status.

Re-embedding the corpus without downtime

Vectors from different embedding models are not comparable, so changing the model means re-embedding everything, possibly billions of chunks, while search keeps working. Do it blue-green. Create a new index (or collection) for model B. Start dual-writing: new and updated documents are embedded with both models. Run a bulk backfill in the low-priority lane reading extracted JSON from S3 (no re-parsing or OCR needed, which is why you keep it), re-chunking only if the chunker also changed, and embedding with model B at a controlled rate. Track progress per tenant. Before switching, run the retrieval eval set against both indexes and confirm B is better; then switch the read alias per tenant (or for a percentage of traffic) and watch answer-quality metrics. Keep index A for a rollback window, then delete it and stop dual-writing. Cost estimate before starting: 1B chunks × 500 tokens × $0.02/M ≈ $10,000 via API, plus index build time, which is often the slower part. The same mechanism handles chunker or parser upgrades, because document_versions records which parser, chunker and model produced each version.

8 Trade-offs

Temporal workflows vs queue per stage

A queue per stage (SQS or Kafka topics) is simple and scales well, but tracking a document's overall status, retries and cancellation is hand-built. A durable workflow per document gives status, retries, timeouts and cancellation of superseded versions for free, at the cost of running Temporal. At millions of documents per day either works; many teams use workflows for orchestration and queues for heavy fan-out.

Embedding API vs self-hosted model

APIs need no GPUs and are cheap for small models, but bring rate limits, data-residency questions and dependence on one vendor. Self-hosting an open embedding model gives control and predictable cost at high volume and keeps data in-region, but you run GPU capacity. For a big backfill a self-hosted fleet often wins.

Fixed-size vs structure-aware chunks

Fixed-size chunking is simple and predictable but splits sentences and tables. Structure-aware chunking gives better retrieval and citations but needs a good parser and more code. Use structure-aware with a fixed-size fallback for unstructured text.

9 Bottlenecks

OCR cost and latency at 10×

OCR becomes the biggest cost and slowest stage. Skip pages with a text layer, downscale images sensibly, cache by page hash, use a self-hosted OCR model for bulk backfills, and OCR only on demand for rarely used archives.

Index write amplification

Heavy upserts and deletes slow vector index segments and compaction. Batch writes, publish versions in bulk, and schedule bulk re-indexing in off-peak hours with write-rate limits.

10 Failure handling

Embedding provider throttles or goes down

The embedding stage queue grows; interactive lane keeps its reserved quota share, bulk lane pauses. Documents stay on their previous version, so search is stale but correct. Alert on interactive lag above 2 minutes.

Parser bug corrupts extraction for one file type

Because document_versions records parser_version, you can find every affected document, fix the parser, and re-run from extraction for just those, through the bulk lane.

Purge fails after a delete

Query-time filtering on deleted ids hides the content within seconds anyway; the reconciler removes orphans nightly, and a metric counts orphans so failures are visible.

11 What impresses

  • Keeps extracted JSON so re-chunking and re-embedding never redo parsing or OCR
  • Publishes versions atomically so search never mixes old and new chunks
  • Separates interactive and bulk lanes with reserved capacity
  • Points out OCR, not embedding, is often the biggest cost
  • Evaluates chunking changes on retrieval metrics before reprocessing
  • Gives a costed plan for re-embedding the whole corpus

12 Mistakes

  • Doing parsing and embedding synchronously in the upload request
  • Random chunk ids, so retries create duplicates
  • Deleting old chunks before new ones are indexed, causing gaps in search
  • One shared queue where a backfill starves user uploads
  • Mixing vectors from two embedding models in one index
  • Ignoring scanned PDFs and tables

13 Follow-ups

Q1How would you handle a 5,000-page PDF?
Q2How do you make spreadsheets and tables searchable?
Q3How would you support images and diagrams inside documents (multimodal)?
Q4How do you prove to an enterprise customer that a deleted file is gone everywhere?
Q5How would you prioritise which documents to re-embed first?
Q6How do you detect and skip near-duplicate documents?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free