Teaching a RAG Pipeline to Read Spreadsheets: An Escalation Ladder from Parsing to Vision Models

Cover Image for Teaching a RAG Pipeline to Read Spreadsheets: An Escalation Ladder from Parsing to Vision Models
AI & Machine Learning5 min read

Spreadsheets are the part of RAG that nobody's demo covers. Clean prose chunks beautifully. A spreadsheet with merged header cells, three different date formats in the same column, and a pivot table someone pasted as values does not. Worse, a spreadsheet's meaning lives in the grid — which cell is a header, which row is a subtotal, which column is a dimension — and a stream of chunked text is precisely where all of that gets lost. Flatten a report that already contains its own subtotal rows into chunk text, and every later SUM over the retrieved rows silently double-counts. The number that comes back is plausible-looking and roughly twice the truth, which is a much more dangerous failure than an obvious crash.

The approach that actually held up in production wasn't a smarter parser. It was accepting that no single parsing strategy would ever cover the input space, and building an escalation ladder — four rungs of increasing cost and decreasing determinism, each one gated behind a validation step that checks the numbers, not just whether the code happened to run without throwing.

The four rungs, and why the order is counter-intuitive

Rung 1: TEMPLATE       reuse a script that already passed validation
                        on a file of this exact structural shape — no model call at all
Rung 2: LLM             model sees a corner of the grid, describes the structure,
                        generates a normalization script, runs it in a sandbox
Rung 3: LLM_REPAIR      same as above, but the validation gate's specific
                        complaint is quoted back into the repair prompt
Rung 4: DETERMINISTIC   no model anywhere — header-row detection, multi-level
                        header reads, aggregate-row heuristics. The floor.

The order looks backwards at first: shouldn't the dumb, deterministic parser run first, as the cheap common case, with the model as a last resort? In practice, generic heuristic parsing is the least reliable rung for a grid with any real structure to it — multi-level headers, merged cells, an inconsistent mix of raw rows and computed subtotal rows defeat a one-size-fits-all heuristic constantly. So it's kept, deliberately, as the floor — the guaranteed fallback that makes sure no file the old, heuristic-only pipeline could index comes out of the new one completely unindexed — rather than the first thing tried.

What actually runs first is a cache lookup, not a parser.

Fingerprinting: caching the shape, never the data

Every incoming spreadsheet gets hashed into a structural fingerprint — header labels, merge-cell topology, inferred column types, grid shape — deliberately excluding the actual values. Two months of the exact same recurring report, with completely different numbers, hash to the same fingerprint. That's the entire point: the expensive part (figuring out how this report is laid out) only has to happen once per shape, not once per file.

file.xlsx ──► structural fingerprint ──► cache lookup
                                              │
                            hit ──► Rung 1 (TEMPLATE): replay the
                                    cached, already-validated script
                                              │
                            miss ─► Rung 2 (LLM): discover structure,
                                    generate a new script, cache it
                                    on success for next time

The cache is scoped per tenant, never shared globally, for a reason that's easy to miss: the cached artifact is executable code, generated by a model, based on one tenant's file. Running tenant A's generated script against tenant B's spreadsheet is a boundary with no upside — saving one model call is not worth collapsing a security boundary between tenants, even if the structural fingerprint happens to collide.

Rung 2 and 3: showing the model a corner, never the whole sheet

When the fingerprint misses, a model is given only a corner of the grid — a handful of rows and columns, with explicit row/column indices — never the full sheet. Handing a model 40,000 rows doesn't get you a better structural description; it gets a model that starts answering questions about the data instead of describing the structure, which is the one thing this rung is supposed to produce. The model's job is narrow: say where the real header row is, which rows are comments or subtotals, what format a given column is in — and then generate a normalization script based on that description.

The script never runs in the main worker process. It runs in a disposable sandbox — one per sheet, never reused across sheets, with no network access and a hard time limit. Reuse across sheets is explicitly avoided even though it would be cheaper: a stray output file left behind by one sheet's script could leak into the next sheet's read, and the next sheet might belong to an entirely different document, or an entirely different tenant. The sandbox's contract is narrow on purpose: input exactly one sheet as a typed columnar file, output a normalized columnar file plus a small structured report, nothing else in, nothing else out.

If the output fails validation, Rung 3 is not a blind retry — the validation gate's specific complaint ("column revenue has 40 empty values after row 12" or "computed total doesn't match source") gets quoted directly into a repair prompt, so the next attempt is targeted at the actual discrepancy instead of generating a fresh guess from scratch.

The validation gate is the real architecture

None of the four rungs matter if "success" just means "the code ran without an exception." A script that silently drops a column, a parser that returns zero rows, or a model that hallucinates a row that was never in the source all look identical to a check that only watches for exceptions. The validation gate runs in the worker process, deliberately never inside the sandbox that produced the output — a check scored by the code being checked is not a check — and every output has to clear three independent comparisons:

  1. Source-stated totals vs. computed totals. If the sheet has its own Total or Subtotal row, the normalized table's column sums have to match it within a tight relative tolerance. This is the single highest-value check: it catches both a surviving subtotal row that would double-count, and a header row that got misread as a data row.
  2. Row count, after subtracting source-declared aggregate rows. Catches a silent filter or a dropped-null operation that quietly removed real data rows along with the intended aggregate rows.
  3. Columns with values in the source that came out empty in the output. Catches off-by-one column reads and bad type coercion that a totals check alone wouldn't surface.

A real bug from this gate is worth walking through, because the fix generalizes past spreadsheets: a generated script was handed every raw column, including some that were header-only in the source file — labeled, but never actually filled in. The validation baseline it was compared against had already dropped those empty columns during its own, separate read of the source. Every single attempt failed — correctly reproducing exactly what it was told to do — because the comparison set of columns didn't match what the model had seen. The fix was to compute the set of empty columns on the raw grid specifically, and subtract by column name, not by column count. Subtracting by count would have made this particular failure disappear, but it would also have let the real failure mode this check exists for — reading one column position off by one — slip straight through, since the totals would still happen to balance by coincidence. The lesson: a validation check that passes for the wrong reason is worse than no check, because it looks green right up until the one time it matters.

Three outcomes, not two

It's tempting to collapse the result of this whole ladder into "indexed" or "failed." The real design has three distinct outcomes, and conflating any two of them produces a system that either throws away easy data or claims evidence that doesn't exist:

  • Normalized and verified. Totals matched the source's own stated total row. Only this case is allowed to be labeled "verified" anywhere downstream, including in anything a retrieval result surfaces to a user.
  • Normalized but unverifiable. The source simply has no total row to check against — the most common real-world shape, e.g. a flat transaction log with no subtotals at all. Refusing to index this case would throw away the easy majority of files. Calling it "verified" would invent evidence that was never there. It gets indexed, searchable, and explicitly labeled as unverified.
  • Fell to the deterministic floor. Every model-driven rung failed validation. The file is still searchable via the heuristic floor parser, but explicitly not treated as arithmetically trustworthy.

Failure classes that aren't data problems at all

One failure mode is easy to misdiagnose as "the model wrote bad code": the sandbox infrastructure itself failing to provision — a backend that's unreachable, or a container that can't be scheduled. This has to be classified as a completely different error class from a script that ran and produced wrong output, because escalating up the ladder on a provisioning failure just burns a retry rung against a sandbox that was never going to come back for that attempt anyway. A provisioning failure is instead re-dispatched straight to the deterministic floor without consuming the sheet's escalation budget — preserving the full ladder for the next sheet, where the sandbox will presumably be healthy again.

A real incident makes the cost of misclassifying this concrete: a sandbox had a stale container image whose workspace directory looked executable but silently rejected writes — a permissions fault with zero connection to the model or the data. Because the system's first instinct was "the model probably generated something that crashed," an 8-sheet workbook burned eight separate sandboxes, eight model completions, and roughly a dozen storage round-trips before anyone realized nothing had actually executed — it was an infrastructure fault from the very first sheet, not a model quality problem. The fix was a health probe run once per worker-pool startup — write a file, read it back — specifically because that's cheap to check once and expensive to discover per-sheet after every rung above the floor has already paid for a model completion before finding out the sandbox was never going to run it.

One environment variable as the rollback

Every escalation above the deterministic floor is gated by a single configuration flag that routes every sheet straight to the floor rung, no deploy required. This isn't a hypothetical "we could disable it if needed" — it's kept, deliberately, as the standing rollback: if the model-driven rungs start misbehaving in a way that isn't caught by validation, or a provider outage makes rungs 2 and 3 unreliable, flipping one variable degrades the system to "search still works, arithmetic trust goes away" instead of an outage.

Query time never re-pays the ingestion cost

Parsing a spreadsheet once at ingestion and then re-parsing it on every query would be wasteful, and would reintroduce exactly the nondeterminism this whole ladder exists to avoid. Every successfully validated extraction is materialized once into a columnar file alongside the original. Queries read that columnar artifact — fast, typed, and consistent — and only fall back to re-running the raw file through the ladder if the artifact is missing or has been invalidated by a re-ingestion. The expensive, nondeterministic rungs run exactly once per file, get validated, get frozen into a deterministic artifact, and everything downstream of that point is cheap and consistent.

What this actually buys you

No parsing strategy covers 100% of real-world spreadsheets, and pretending otherwise means either a high rejection rate or silent data corruption that's far more expensive to discover later. An escalation ladder with a validation gate that actually checks the numbers — not just whether the code ran — means the cheap, cached common case never touches a model at all; every escalation happens because something specifically and measurably failed, not because of a generic exception; infrastructure failures are classified apart from genuine data problems so they don't burn retry budget pointlessly; and the three-way outcome (verified / unverifiable / floor-only) means the system can tell the difference between "I checked this arithmetic and it's right" and "this is searchable text with no arithmetic guarantee" — which is a distinction most pipelines never make, and the one that actually matters when someone downstream trusts a number that came out of a chunk.