Runbooks/RAG RunbookTrack A · Ingestion and chunkingLLM Inference Runbook →0%
  1. 00 Start
  2. /
  3. 01 Chunking
  4. 02 Parsing
  5. 03 Identity
  6. 04 Access
  7. /
  8. 05 Models
  9. 06 Vectors
  10. 07 Limits
  11. 08 Model ops
  12. 09 Index I
  13. 10 Index II
  14. 11 Tuning
  15. 12 Capacity
  16. /
  17. 13 Sharding
  18. 14 Filtering
  19. 15 Hybrid
  20. /
  21. 16 Proof
RAG Runbook · Document 02 of 16 · Track A — Ingestion and chunking

Track A · Document 02 · Ingestion and chunking

Parsing Hard Content: PDFs, Tables and OCR

Where enterprise RAG actually dies: a page format that records ink rather than meaning, tables that lose their labels, and twelve source systems that all want their own code path.

Reads in about 30 minutes · 7 figures, 2 of them interactive · 9 interview questions · prints to clean A4

What is in this document

  1. Why PDFs are the hardest part
  2. The pipeline that fixes it
  3. Handling tables
  4. A table, end to end
  5. Scanned documents and OCR
  6. Figures, charts and images
  7. One canonical form, twelve sources
  8. Tiered parsing and the cost decision
  9. Contextual chunking as an ingest job
  10. Choosing a parser
  11. The whole ingestion path
  12. Symptom → cause
  13. Interview questions
  14. FAQ
  15. Cheat sheet

1 · Why PDFs are the hardest part

A PDF is a description of where ink goes on a page. It has no concept of a paragraph, a heading, a table, or a reading order. Run a naive text extractor over it and you get characters in roughly the order they were drawn — which, for anything other than a single column, is not the order a human reads them.

The framing that lands

Chunking a PDF is a parsing problem before it is a splitting problem, and your chunking strategy is downstream of parse quality. Candidates who jump to chunk sizes have skipped the place where the failure actually happens. In most enterprise RAG projects, more engineering time should go into this document than into everything in Track B combined.

FAILURE 1 · MULTI-COLUMN READING ORDER Revenue grew 12% in the third quarter, driven by strong demand in the north. Headcount fell by 40 across the region, following the restructuring. two columns; a human reads down the left, then down the right naive “Revenue grew 12% in Headcount fell by 40 the third quarter, across the region, driven by strong demand in the north. following the restructuring.” half a sentence about revenue and half about headcount, interleaved layout block 1 “Revenue grew 12% in the third quarter…” block 2 “Headcount fell by 40 across the region…” reading order comes from the geometry, not the draw order A PDF is a description of where ink goes on a page. It has no concept of a paragraph, a heading, a table or a reading order. Naive extraction returns characters roughly in the order they were drawn, which for anything but a single column is not the order a human reads them. No chunk size fixes this. The embedding of that middle box is meaningless whatever length you cut it to. FAILURE 2 · TABLES FLATTENED INTO PROSE Region Q1 Q2 Q3 North120145160 South9088102 East647069 Region Q1 Q2 Q3 North 120 145 160 South 90 88 102 East 64 70 69 The numbers survive. The association between a number, its row and its column does not. Q: “What was South’s Q2 revenue?” The chunk contains 88. It also contains 90, 102, 145 and 160. The number 88 has become a free-floating token that could attach to anything. And the model will confidently attach it to something. This is the mechanism behind almost every “the bot gets numbers wrong” report, and it is an ingestion bug rather than a model bug — which is why the diagnosis starts by reading the retrieved chunk. Fix: extract the table as a typed block, and serialise it so every value keeps its labels. FAILURE 3 · TABLES SPLIT MID-WAY chunk 1 — rows 1 to 12 Region Q1 Q2 Q3 ← the header row North 120 145 160 South 90 88 102 … ten more labelled rows chunk 2 — rows 13 to 30, and no header at all 144 92 118 87 210 64 311 45 72 … a grid of numbers with no labels Chunk 2 is unretrievable and, if retrieved, unusable. Nothing in it says what the columns mean, so it matches almost no query — and on the rare occasion it does surface, the model guesses at the labels. It is worse than missing, because a wrong answer with a citation looks like a right one. The rule: a token splitter must never see a table. If a table genuinely must be split, every fragment repeats the header row, carries the table title and a “part 2 of 4” marker, and shares a parent ID pointing at the whole table so parent–child can reassemble it at generation time. FAILURE 4 · HEADERS, FOOTERS AND PAGE NUMBERS Acme India HR Policy 2024 Eligible employees are those who have completed 80 days of service… Confidential · page 14 of 96 × 96 pages every chunk now begins and ends with furniture “Acme India HR Policy 2024 … Eligible employees are those who… Confidential page 14 of 96” Noise in every embedding, and the document title in every chunk makes all 96 pages look alike. strip it — but keep the page number text: “Eligible employees are those who have completed 80 days…” page_no: 14 classification: confidential furniture out of the text, into the metadata Repeated furniture flattens your ranking. If every chunk carries the document title, every chunk in that document is superficially similar to every other one, and the model has less signal to separate them. Detect the repetition — the same line at the same position on most pages — and move it into metadata, where the page number is genuinely useful as a citation and useless as embedded text.

Four ways a PDF defeats a chunker, and none of them are fixed by a chunk-size setting. Chunking a PDF is a parsing problem before it is a splitting problem — and candidates who jump straight to chunk sizes have skipped where the failure actually happens.

The library version

Somebody has photographed every page of every book and thrown away the books. The photographs are perfectly sharp, and they contain no information about which marks are a chapter title, which are a footnote, and which two columns are separate thoughts. Your first job is not to catalogue the library. It is to reconstruct what the pages actually said.

2 · The pipeline that fixes it

Four stages, and the third one — the router — is the one that removes two of the four failure modes outright.

ONE PIPELINE, FOUR STAGES, AND A ROUTER 1 · layout model geometry in, typed blocks out 2 · strip furniture headers, footers, page numbers 3 · the router one path per block type 4 · canonical document typed block tree, stored in object storage, not just passed through prose the normal chunker tables atomic, always figures and charts caption + pointer furniture dropped The splitter never sees a table. If you remember one sentence from this document, that is the one. Everything else here is refinement. That single routing decision removes two of the four failure modes outright, and it is the difference between a financial-document assistant that can be trusted with a number and one that cannot.

Layout detection is a different operation from text extraction, and costs twenty to fifty times more. Section 8 is about deciding which documents deserve it.

StageWhat it doesWhat happens if you skip it
Layout detection Reads page geometry and emits typed blocks with a reading order derived from the layout Failure 1. Columns interleave, and no chunk size recovers it
Strip the furniture Detects repeated headers, footers and page numbers and moves them to metadata Failure 4. Every embedding carries the same noise, and the whole document looks self-similar
Route by block type Prose to the chunker, tables to table handling, figures to caption handling Failures 2 and 3. The splitter cuts a table and a number loses its labels
Emit canonical form One structure regardless of source format, persisted rather than passed through Twelve chunkers, and every future migration re-crawls the sources

Several tools do the first stage — Docling, Unstructured, LlamaParse and Azure Document Intelligence among them. Section 9 is about how to pick, and the answer is never a name without a bake-off.

3 · Handling tables

Tables are where the numbers live, and numbers are what people ask about. There are three serialisations and the right answer is usually more than one of them at the same time.

THREE SERIALISATIONS — AND THE ANSWER IS USUALLY ALL THREE AT ONCE A · whole table, Markdown Table 4: Quarterly revenue by region, FY24 (₹ crore) | Region | Q1 | Q2 | Q3 | |--------|----|----|----| | North |120 |145 |160 | | South | 90 | 88 |102 | | East | 64 | 70 | 69 | under ~20 rows answers: lookup, and comparison B · one chunk per row Acme India FY24 quarterly revenue by region (₹ crore). Region: South | Q1: 90 | Q2: 88 | Q3: 102 … one of these per row over ~20 rows answers: precise lookup C · a summary chunk This table gives FY24 quarterly revenue by region in ₹ crore, covering North, South, East and West across Q1 to Q3. carries the words a row never contains always, alongside A or B answers: discovery None of the three answers “which region grew fastest?” or “what was total revenue?” — that is aggregation, and RAG is the wrong tool. Rows answer lookup questions. The summary answers discovery questions. Aggregation requires computing over every row at once, which means the table wants to be in a database with a text-to-SQL path in front of it. Knowing where that boundary sits is the senior answer.

Once tables are extracted as structured rows you are one step from loading them into a warehouse. The mature architecture stores every table twice — as chunks for semantic lookup, and as rows for aggregation — with a classifier routing aggregation-shaped queries to SQL.

If a table genuinely must be split

Sometimes a table is two hundred rows and row-level chunking is not appropriate — a matrix where the rows only mean something together, for instance. If it must be split, never split blind:

That last point is the one that turns a bad situation into a workable one: search can hit fragment three, and the model still reads the whole table.

The diagnosis worth memorising

“Users say the bot gets numbers wrong from our financial reports.” Walk the pipeline backwards, starting with what the retrieved chunk actually contains. Nine times out of ten the table was flattened into prose or split mid-way, so the number has lost its row and column labels. That is an ingestion bug, not a model bug, and no amount of prompt work or model upgrading will fix it.

Then add an ingestion test that asserts specific known values are retrievable from specific documents, so it cannot regress silently. Volunteering the regression test is what separates the answer from the diagnosis.

4 · A table, end to end

Interviewers often ask you to trace one concrete example rather than describe a policy. This is the trace worth having ready, and it takes about ninety seconds to deliver.

ONE TABLE, TRACED END TO END Step 1 — what the layout model emits { type: "table", page: 15, caption: "Table 4: Quarterly revenue by region, FY24 (₹ crore)", preceding_text: "Regional performance diverged sharply in the second half.", section_path: "Annual Report 2024 > 5 Financials > 5.2 Regional", headers: ["Region","Q1","Q2","Q3"], rows: [["North","120","145","160"], ["South","90","88","102"], ["East","64","70","69"], ["West","145","150","171"]] } Note what came with it: the caption, the introducing sentence, and the section path. Those three are what make the chunks findable. Step 2 — decide the serialisation four rows — under the ~20-row threshold keep the whole table as one chunk, and add a summary chunk if it had 200 rows row-level chunks plus a summary, and the whole-table chunk is dropped — it would not fit The threshold is a judgement, not a law. It is set by how much of the context window one chunk may occupy, and by whether within-table comparison matters for this corpus. State it as a configurable, because an interviewer may push on the number. Either way the structured rows are also written to a database, because the aggregation request always arrives eventually. chunk A — the whole table Annual Report 2024 > 5 Financials > 5.2 Regional Table 4: Quarterly revenue by region, FY24 (₹ crore) | Region | Q1 | Q2 | Q3 | | North | 120 | 145 | 160 | | South | 90 | 88 | 102 | | East | 64 | 70 | 69 | | West | 145 | 150 | 171 | every value keeps its row and column chunk B — the summary Annual Report 2024 > 5 Financials > 5.2 Regional This table gives FY24 quarterly revenue by region in ₹ crore, covering North, South, East and West across Q1 to Q3. Regional performance diverged sharply in the second half. carries “revenue”, “regional”, “FY24” — words no individual row contains the introducing sentence earns its place here Step 4 — what each chunk actually answers “What was South’s Q2 revenue?” chunk A Yes — the labels are intact “Do you have regional revenue figures?” chunk B Yes — the summary has the discovery words “Which region grew fastest?” chunk A Partly — the model must compute across rows. Fine for four rows, unreliable for two hundred “Total FY24 revenue across all regions?” No. Aggregation. Route it to SQL. The fourth row is the one worth volunteering. Knowing which questions your design cannot answer is the senior move. Interviewers frequently ask you to trace one concrete example rather than describe a policy. This is the trace worth having ready.
  1. What the layout model emits. A typed table block with its caption, the sentence that introduced it, its section path, headers and four rows.
  2. Decide the serialisation. Four rows is under the ~20-row threshold, so keep the whole table as one chunk and add a summary chunk. Two hundred rows would go row-level plus summary, with no whole-table chunk. Either way the rows are also written to a database.
  3. The chunks produced. Chunk A is the Markdown table with its section path and caption. Chunk B is the summary, carrying the discovery keywords that no individual row contains.
  4. What each answers. Lookup hits A, discovery hits B, a growth comparison works partly from A, and a total is aggregation — route it to SQL.

Four rows, two chunks, one database write. The database write is the part nobody mentions and the part that stops the next requirement becoming a rebuild.

The routing decision to state explicitly

Once tables are extracted as structured rows, you are one step from loading them into a warehouse. The mature architecture stores every table twice: as chunks for semantic lookup, and as rows in a database for aggregation, with a classifier routing aggregation-shaped queries to SQL.

Saying that you would persist the structured form even though RAG does not need it shows you are thinking about the next requirement — and the aggregation requirement always arrives.

5 · Scanned documents and OCR

A scanned PDF has no text layer at all, so extraction returns nothing and you have to OCR. That is a different problem from parsing, and it has a property none of the others do: the errors are permanent.

Errors become ground truth

A misread digit is now what your system believes, forever.

Nothing downstream can detect it. Retrieval, reranking and the model will all faithfully report the wrong number.

There is no structure to detect

Layout detection has to work off the image rather than off any embedded hints.

Table extraction from a scan is materially harder than from a native PDF, and it fails differently.

Confidence is available — use it

Store a confidence score per block.

Route low-confidence pages to human review rather than silently ingesting them.

It is a tier of its own

Roughly 300× the cost of text extraction per document.

Which is why section 8 exists.

The line to use

OCR quality is an ingestion SLO, not a detail. If your OCR is 94 percent accurate on numbers, your financial assistant is 94 percent accurate at best, no matter how good the retrieval and the model are. That ceiling is set at ingest and cannot be recovered afterwards.

PDFs and stable identity

PDFs are the worst case for the identity problem in document 03, because there are no anchors and parse output can shift between runs: a different library version detects headings differently, and every derived ID changes with them. So for PDFs specifically:

6 · Figures, charts and images

Often skipped, and a good differentiator when it comes up — because there are four answers with genuinely different cost profiles and most people know one.

ApproachHow it worksCostWhen
Caption-based Index the caption plus the surrounding narrative as a chunk, with a pointer to the image None The baseline. Captions are usually descriptive, and this covers most needs
Vision description at ingest Send the image to a multimodal model and store its description as the chunk text One call per figure, plus a permanent hallucination surface Charts where the caption says “Figure 7” and nothing else
Multimodal embeddings Embed the image itself into the same space as the text Another model, and a whole second evaluation problem Diagram-heavy corpora such as engineering documentation
Pass the image at generation Retrieve on the caption, hand the actual image to a vision-capable model when answering Tokens at generation time only Often the best quality per unit of effort

What to say when asked “how do you handle charts?”

“Baseline is caption plus surrounding text with a pointer to the image, and pass the image at generation time if the model is multimodal. I would only add vision-generated descriptions at ingest if I measured that caption-based retrieval was missing figure-related queries — because it is a model call per figure and it introduces a hallucination surface that is permanent once indexed.”

7 · One canonical form, twelve sources

Enterprise corpora are never one format. Confluence pages, Word documents, native and scanned PDFs, SharePoint lists, HTML exports, email, Markdown in Git, spreadsheets, chat threads, tickets. The naive path is one chunking implementation per format, and it makes ingestion the least maintainable part of the system.

TWELVE SOURCES. ONE CHUNKER. Confluence SharePoint native PDF scanned PDF email Git Markdown tickets … and five more connector the canonical document document: doc_id, source_system, source_url, version, acl_tags, parser_version blocks: - type: heading, level: 2, text: "3.2 Maternity Leave", anchor: "a91c", page: 14 - type: paragraph, text: "…", page: 14 - type: table, rows: […], page: 15 - type: figure, caption: "…", page: 16 nothing downstream ever learns what format this came from stored in object storage · reprocess without re-fetching · inspect it when an answer is wrong · a parser upgrade becomes a diff one chunker format-agnostic, because the format was resolved upstream change the size, edit one code path The naive path is one chunking implementation per format. Twelve sources, twelve chunkers, each drifting and carrying its own bugs. Normalisation is lossy, and that is the real design decision: converting a Confluence page drops macros and embedded dashboards; converting a spreadsheet drops formulas. Start deliberately thin — heading, paragraph, table, list, figure — and extend only when a measured retrieval failure traces back to something you dropped.

Stable IDs need a section path. Atomic tables need a typed block. Parent–child needs a hierarchy. ACLs need a connector that understands the source’s permission model. Without a canonical form, every one of those has to be reimplemented per format.

Why this underpins everything else

Downstream needWhat the canonical form provides
Stable identityA section path, whatever the source was
Atomic tablesAn explicit block type, so the chunker never has to guess
ACL enforcementGroup tags attached at parse time by the connector that understands the source’s permission model
Parent–child retrievalA section hierarchy
CitationsPage numbers and anchors carried on every block

Without normalisation, every one of those has to be reimplemented per format — and they will drift apart, because nobody updates twelve implementations in lockstep.

The connector layer

Each source needs a connector doing four jobs. Naming all four signals experience, because this is where most ingestion engineering time actually goes:

1. fetchget the content, respecting rate limits and pagination
2. detect changewebhook, change-data-capture, etag, or polling
3. extract permissionsmap the source’s ACL model onto your group tags
4. parseemit the canonical form

The tradeoff to name before you are asked

Normalisation is lossy. Converting a Confluence page drops macros and embedded dashboards; converting a spreadsheet drops formulas. So the canonical schema is a real design decision: too thin and you lose information you later need, too rich and every parser becomes complex.

Start deliberately thin — heading, paragraph, table, list, figure — and extend only when a measured retrieval failure traces back to something you dropped. And watch for the failure mode: if adding a source forces a change to the chunker, the abstraction is wrong, and you are on your way to twelve chunkers wearing a trenchcoat.

8 · Tiered parsing, and the cost decision nobody prepares for

Layout models are twenty to fifty times slower than raw text extraction, and OCR is slower again. At a million documents, that ratio is not a detail — it is the whole ingestion architecture. So you do not apply one strategy to everything. You route.

tier costs: 0.05 s · 2 s · 15 s per document
TRIAGE IS CHEAP. THE TIERS ARE NOT. Tier 1 · text extraction native PDFs with a clean text layer, Markdown, HTML, Word 0.05 s per document Tier 2 · layout model multi-column, tables, complex structure detected at triage 2 s per document — 40× Tier 3 · OCR scans and images, no text layer to extract at all 15 s per document — 300× your mix, by share of corpus tiered — your mix 1.29 s/doc 357 compute-hours 22 hours wall-clock on 16 workers everything through tier 2 2.00 s/doc 556 compute-hours 35 hours wall-clock everything through tier 3 15.00 s/doc 4,167 compute-hours 260 hours wall-clock Routing is where the ingestion budget is won, and the ratio is what makes it an architecture decision rather than a tuning one. Compute-hours = documents × seconds ÷ 3,600, single-threaded. Divide by the worker count for wall-clock. Shares are normalised to 100%.

The escalation rule matters as much as the tiers. Triage will misroute some documents, so tier 1 needs a sanity check — no headings detected, a suspiciously low text-to-page ratio, obvious column interleaving — that escalates the document to tier 2 automatically and logs it. Without escalation, a misrouted document is badly parsed forever and nobody finds out.

How triage decides

SignalPoints to
File type is Markdown, HTML, Word or a native PDF with a text layerTier 1
Text-to-page ratio is near zeroTier 3 — there is no text layer, it is a scan
Extracted text shows column interleaving, or no headings at allTier 2
The document contains ruled regions or many aligned numbersTier 2 — there are tables to lose
The source system is known to produce clean structureTier 1, by configuration

The escalation rule is as important as the tiers

Triage will misroute some documents. Without an escape hatch, a misrouted document is badly parsed forever and nobody ever finds out. So tier 1 output gets a sanity check — no headings detected, suspiciously low text-to-page ratio, obvious column interleaving — and anything that fails it is escalated to tier 2 automatically and logged. The log is what lets you tune the triage rules instead of guessing at them.

9 · Contextual chunking as an ingest job

Document 01 covers what contextual chunking is and where it sits on the strategy ladder. This section is about the part that decides whether you can actually run it: what it costs, and the four ways it goes wrong in production.

The recap in one line: at ingest, send each chunk plus its surrounding document to a cheap model, ask for one or two sentences situating the chunk, prepend that, and embed the combined text. The chunk that says “It may be extended by a further 4 weeks on medical grounds” now carries the words maternity, leave, extension, Acme and policy in its vector, so the query finally matches it.

The costWhat it actually meansThe mitigation
Ingest spend One model call per chunk. Ten million chunks is ten million calls Use a small cheap model, and prompt-cache the document — you send the same full document with each of its chunks, so cache it once and pay a fraction thereafter. That caching is what makes the technique economically viable at all. Use the batch API for the backfill
Ingest latency A model call now sits in your write path, fighting any real-time freshness requirement Apply it asynchronously: index the plain chunk immediately so it is searchable, then upgrade it when context generation completes. Or apply it only to a high-value subset
Nondeterminism Generated context varies between runs, so re-ingesting an unchanged document produces a different hash and triggers a spurious re-embed of the whole corpus Hash the original chunk text, not the augmented text, and only regenerate context when the original changed. This is the one that bites, and it ties straight back to the diff in document 03
Hallucinated context The model writes “this section concerns termination benefits” for a chunk about leave. That chunk is now silently retrieved for the wrong queries, permanently Constrain the prompt hard — derive from the document only, no inference, one to two sentences, no speculation. Sample-audit the generated contexts. Treat a spike in retrieval of a previously cold chunk as a signal worth investigating

Try the free version first

Structural prefixing. Mechanically prepend the section path you are already storing: Acme HR Policy 2024 > 3 Leave > 3.2 Maternity Leave in front of the chunk. Zero cost, fully deterministic, and it recovers most of the missing keywords. This should be your baseline, and contextual chunking is only justified if it beats this measurably.

Two other cheap approximations: overlap carries some antecedent context, crudely; and parent–child solves the generation half of the problem but not the retrieval half, because the child vector is still context-free. Contextual chunking and parent–child are complementary, not alternatives — a point worth making, because interviewers sometimes offer them as a choice.

10 · Choosing a parser: the evaluation you would actually run

Interviewers often ask which tool you would pick. Naming one is the weaker answer; describing the bake-off is the stronger one, because the right tool genuinely depends on the corpus.

1. build a gold set of 20–30 representative documents — a clean native PDF, a two-column report, a scanned contract, a form, a slide export, a table spanning pages
2. define what “correct” means per capability — reading order matches human order, tables extracted with headers and correct cell alignment, headings at the right level, furniture stripped, text-to-page ratio sane
3. score per capability, not as one accuracy number — because the failures matter differently
4. measure latency and cost per page — this is what sets the tiering and the worker count
5. check determinism — run the same document twice and diff the output

Two of those five are the ones people skip

Scoring per capability. A parser that is excellent at text and poor at tables is perfectly fine for a policy corpus and disqualifying for a financial one. A single accuracy number hides exactly the distinction you need.

The determinism check. Run the same document through twice and diff. Non-deterministic output means unstable chunk boundaries, which means unstable chunk IDs, which means silent full re-embeds on every ingest run. It takes ten minutes to check and almost nobody runs it — and it connects straight to document 03.

The scorecard

CapabilityHow to score itDisqualifying for
Reading orderDoes the extracted text match the human reading order on the two-column report?Any multi-column corpus
Table fidelityHeaders present, cells aligned, no rows merged or dropped Financial, scientific or operational corpora
Heading detectionCorrect level, and stable between runs Anything relying on structural chunking or parent–child
Furniture strippingAre page numbers and running heads out of the text? Long documents with heavy furniture
Latency and cost per pageMeasured, at your page sizes Large corpora — it sets the whole tiering design
DeterminismSame input twice, diff the output Any system with incremental updates — which is all of them

11 · The whole ingestion path

Everything above, on one line, plus the return path that makes it affordable to run twice.

THE WHOLE INGESTION PATH, ON ONE LINE sources 12 systems connector fetch · detect triage + parse tier 1 / 2 / 3 canonical store object storage chunk + diff only what changed embed + write into the collection rebuild from storage — a chunk-size change or a model migration never re-crawls a source Version stamps make all of this tractable Every canonical document records the parser version that produced it. Every chunk records the chunker config and the embedding model. Then “we upgraded the PDF parser” is a selective reprocess of known documents, not a leap of faith across the whole corpus. The two questions this diagram answers in an interview “How do you add a thirteenth source?” — write one connector; if the schema is right, nothing downstream changes. “Your nightly ingestion takes eight hours and is growing” — profile first; it is almost always parsing or OCR, never embedding.

Persisting the canonical layer is what makes every later migration affordable. It is one line on a diagram and it is the difference between a model change that takes hours and one that takes a week of re-crawling.

12 · Symptom → cause

SymptomMost likely causeWhat to check first
The assistant reports wrong numbers from reports Table flattened into prose, or split without its header Read the retrieved chunk. If the number has no adjacent row and column label, it is an ingestion bug
Answers from one document all sound similar and rank similarly Running heads and footers not stripped, so every chunk shares the document title The first and last 60 characters of ten chunks from the same document
Sentences in retrieved chunks are spliced together nonsensically Multi-column reading order Whether the parser is layout-aware, and whether that document went through tier 1
Some documents return nothing at all, ever Scans with no text layer that were routed to tier 1 and produced empty output Text-to-page ratio at ingest, and whether the escalation rule fired
Nightly ingestion time is growing faster than the corpus Everything is going through the expensive tier, or unchanged documents are being reprocessed Profile the stage, then the tier mix, then the diff
A dependency bump changed a large number of answers A parser upgrade moved heading detection, so chunk boundaries and derived IDs moved parser_version on the canonical documents, and whether the upgrade was treated as a reindex
Re-ingesting an unchanged document re-embeds everything The hash covers generated context, which is nondeterministic Whether the content hash is taken over the original chunk text or the augmented text
Figure-heavy documents answer poorly Captions are uninformative — “Figure 7” and nothing else Sample the captions before reaching for a vision model

13 · Interview questions

ArchitectUsers say the bot gets numbers wrong from our financial reports. Diagnose it.

I would walk the pipeline backwards, starting with what the retrieved chunk actually contains. Nine times out of ten the table was flattened into prose or split mid-way, so the number has lost its row and column labels — which makes it an ingestion bug rather than a model bug.

The fix is layout-aware parsing with tables extracted atomically and serialised row-level with headers repeated. Then I would add an ingestion test asserting that specific known values are retrievable from specific documents, so this cannot regress silently.

ArchitectHow would you handle a 300-page PDF with mixed content?

Layout-parse into typed blocks, strip repeated headers and footers, then route by type: paragraphs through normal chunking, tables atomically, figures by caption. Use parent–child so retrieval is precise but generation sees the surrounding section.

And I would expect to spend more engineering time on this stage than on the retrieval side, because that is where the quality actually comes from.

ArchitectWhich PDF parser would you choose?

I would not name one without seeing the corpus. I would build a gold set of twenty representative documents with known correct extractions, and score candidates per capability — table fidelity and reading order especially — rather than on one accuracy number.

Cost and latency matter as much as accuracy, because layout models are twenty to fifty times slower than text extraction, and at a million documents that changes the ingestion architecture. I would expect to end up tiering: cheap extraction for simple documents, the expensive layout model only where triage says it is needed. And I would run the determinism check, because non-deterministic parse output means unstable chunk IDs.

ArchitectYou have twelve source systems. How do you structure ingestion?

One connector per source, each doing four jobs: fetch, detect change, extract permissions, parse. All of them emit one canonical representation, and there is a single chunking path downstream that is format-agnostic.

And I would persist that canonical layer in object storage, because rebuilding from storage rather than re-crawling is what makes model migrations and chunking changes practical at all. Re-crawling a million sources is slow, rate-limited, and often something the source system will not tolerate.

ArchitectHow do you add a thirteenth source system?

Write one connector. If the canonical schema is right, nothing downstream changes. If adding a source forces changes to the chunker, that is a signal the abstraction is wrong — either the schema is too thin, or someone has leaked format-specific logic downstream.

ArchitectWhat is the risk of the canonical-form design?

The schema becomes a bottleneck. Every new source pressures it to grow, and a badly scoped schema either loses information you later need or accumulates special cases until it is twelve chunkers wearing a trenchcoat.

I would keep it deliberately minimal — heading, paragraph, table, list, figure — and extend only when a measured retrieval failure traces back to something the schema dropped. The discipline is that the schema grows on evidence, not on request.

Eng managerYour nightly ingestion takes eight hours and is growing. What do you do?

Profile first, because the intuition is usually wrong: it is almost always layout parsing or OCR, not embedding. Then in order: only reprocess documents that actually changed, which is the diff; parallelise per document, since they are independent; and tier the parsing so the expensive path is reserved for documents that need it.

If that is still not enough, separate the pipelines so incremental edits jump ahead of bulk backfill, because those have genuinely different freshness requirements and queueing them together means a backfill delays every edit behind it.

Eng managerHow much of the team’s time should ingestion get?

More than anyone expects, and I would say so at planning rather than discover it at week six. In my experience the pilot exposes parsing problems, not retrieval problems — the retrieval stack is largely commodity and the ingestion stack is entirely specific to your corpus.

Concretely I would staff one engineer on connectors and parsing for the whole first quarter, and I would resist the pull towards retrieval tuning until the ingestion quality is measured. Tuning retrieval on badly parsed content optimises the wrong thing and looks like progress while doing it.

Eng managerHow do you justify the parsing spend to someone who thinks this is an AI project?

With the ceiling argument and one number. If OCR is 94 percent accurate on digits, the assistant is at best 94 percent accurate on any question about a number, no matter which model we buy. That ceiling is set at ingest and cannot be recovered later, so parsing is not a preliminary to the AI work — it is the accuracy budget.

Then I would show the tiering arithmetic, because it reframes the conversation from “why is this expensive” to “which five percent of documents deserve the expensive path”, which is a decision the business can actually participate in.

14 · FAQ

Can I just use a text extractor and accept the errors?

For a corpus of clean, single-column, table-free documents, yes — and you should, because it is three hundred times cheaper than OCR and forty times cheaper than a layout model. The point of tiering is precisely that. What you cannot do is assume the corpus is that shape without checking, because the failure is silent: text comes out, it just is not the text that was on the page.

How do I detect running heads and footers automatically?

Position plus repetition. Collect the text blocks in the top and bottom bands of every page, normalise digits to a placeholder so page numbers collapse together, and drop any line that appears in the same band on more than about half the pages. Keep the page number itself as metadata — it is what a citation points at, and it is very hard to recover afterwards.

What is the right row threshold for whole-table versus row-level?

Around twenty rows is a reasonable default, but treat it as a configurable and be ready to justify it. It is really set by two things: how much of the context window one chunk may occupy, and whether within-table comparison matters for this corpus. A financial corpus where users compare regions wants whole tables for longer; a parts catalogue where every query is a single lookup wants row-level almost immediately.

Should the canonical form be Markdown or a typed block tree?

A block tree internally, serialised to Markdown when handing content to the model. Markdown is simple, human-readable and models read it natively, but it cannot carry page numbers, anchors or block-level confidence scores — and those are exactly what citation and OCR triage need. Using both, in the right places, is the answer that shows you have built it.

Is storing the canonical layer expensive?

No, and this is worth being concrete about. It is text in object storage — the cheapest tier in the whole system, orders of magnitude below the RAM the vectors occupy. Against that, it removes re-crawling from every future migration. It is close to the best cost-to-benefit ratio available in the ingestion design.

How do I handle a document that is one enormous table?

Row-level chunks with the header repeated, a summary chunk, and the structured rows loaded into a database. Then, importantly, check whether this corpus should be in the RAG system at all: if most queries against it are lookups by key or aggregations, a text-to-SQL path will beat semantic retrieval on both accuracy and cost. Knowing when to route away from RAG is part of designing the ingestion.

Do I need OCR confidence scores if the vendor does not expose per-block ones?

Use what you can get, and construct a proxy where you cannot. Page-level confidence, the proportion of characters that are not in the dictionary, the ratio of digits to letters against what the document type predicts — any of these will separate the obviously bad pages from the rest. The goal is not a precise score, it is a routing decision: which pages does a human need to look at?

Our PDFs are generated from Word by a known template. Does any of this apply?

Much less, and you should exploit that. A known generator means a known structure, so a template-specific extractor will beat a general layout model on both accuracy and cost. Tier that source at tier 1 by configuration rather than by triage. The general machinery in this document is for corpora you do not control — and every enterprise has both kinds.

How do I test the ingestion pipeline?

Golden-output tests on a small fixed set of documents. For each, store the expected canonical form and diff against it on every change — a parser bump, a config change, a new rule. Add value-level assertions for the corpus-specific things that matter: “the Q2 South revenue figure is retrievable from the FY24 annual report”. Those two together catch nearly everything, and they are what turns a parser upgrade from a leap of faith into a diff you can read.

15 · Cheat sheet

The numbers

text extraction ~0.05 s per document
layout model ~2 s per document — 40×
OCR ~15 s per document — 300×
a 70 / 25 / 5 tier mix ~1.29 s per document blended
1M documents, tiered ~357 compute-hours; all through OCR, ~4,167
whole-table threshold ~20 rows
parser gold set 20–30 representative documents

The one-liners

The ninety-second version

“A PDF only records where ink went, so the first job is reconstructing what the page said. I use a layout model that emits typed blocks with a reading order from the geometry, strip the repeated headers and footers into metadata, and then route by block type — prose to the chunker, tables handled atomically, figures by caption. The splitter never sees a table.

Tables get serialised three ways depending on size: whole as Markdown under about twenty rows, row-level with headers repeated above that, and always a summary chunk, because discovery queries do not match any individual row. The structured rows also go to a database, because the aggregation question always arrives and RAG is the wrong tool for it.

Layout models are forty times slower than text extraction and OCR is three hundred times slower, so I tier: cheap extraction by default, the expensive path only where triage says so, and an escalation rule for what triage gets wrong. And everything lands in one canonical form that I persist — because rebuilding from storage instead of re-crawling is what makes the next migration affordable.”

Where this connects

Thread from this documentResolved in
Parser upgrades move boundaries, and boundaries carry identity 03 · Identity, updates and deletes
Connectors extract permissions; where do they get enforced? 04 · Access control and freshness
Running the embedder over millions of chunks 07 · Token limits and the pipeline
Row-level chunks multiply the vector count 12 · Quantisation and capacity
Table lookups by part number or code 15 · Hybrid retrieval and reranking
The parser bake-off, as a measurement discipline 16 · Evaluation and observability

Questions to ask them