Skip to content
← All posts
Retrieval 9 min read

How to Chunk Invoices, Plans and Contracts for Retrieval

Structured documents already tell you where the boundaries are. Splitting them by token count throws that away and you pay for it at query time.

A 900-token recursive splitter run over an invoice will cut the tax line away from the subtotal, put half the line items in one chunk and half in the next, and drop the vendor name entirely after chunk one. Then someone asks "what did we pay for the electrical rough-in on invoice 4471?" and retrieval returns three fragments, none of which contain both the invoice number and the line.

This is not a model problem. The document already told you where its boundaries were — a line item, a clause, a sheet, a schedule — and the splitter ignored all of it in favor of a token count. We have run OCR and document pipelines feeding a vector store of about 21,000 embedded chunks for in-app retrieval and chat, across construction plans, subcontracts, purchase orders and supplier invoices. Almost every retrieval bug we chased back to its root was a boundary we chose badly.

The fix is unglamorous: parse structure first, chunk along it, and carry the parent context into every child chunk. What follows is how we do that for the three families of structured document we deal with most, and where the approach stops being worth it.

Classify the document before you split it

Chunking strategy is a per-document-type decision, not a global setting. Before any splitter runs, the pipeline routes on document family, because each family has a different natural unit:

  • Transactional / tabular — invoices, purchase orders, pay applications, packing slips. A header of shared facts plus N repeating rows. Natural unit: the row, plus the header.
  • Spatial — architectural and MEP drawings, site plans, shop drawings. Text is a label attached to a location on a sheet, not a sentence in a paragraph. Natural unit: the sheet, then the region within it.
  • Hierarchical / prose — contracts, subcontracts, specifications, change orders. A numbered tree of clauses where meaning depends on ancestry. Natural unit: the leaf clause, plus its path.

Routing is a classification step, and it is cheap: filename patterns, MIME type, page dimensions, and a small text-shape heuristic (ratio of digits to words, presence of a numbered clause pattern, drawing title-block detection) get you most of the way. We only fall back to a model call when the heuristics disagree, which is the same logic we apply when deciding whether a feature needs an LLM or just a query.

Get the routing wrong and everything downstream is wrong in a way that is invisible until a user asks a question you did not anticipate.

Invoices: one chunk per line item, with the header stapled on

An invoice has exactly two kinds of information: facts that apply to the whole document (vendor, invoice number, date, terms, job or cost code, currency, totals) and facts that apply to one row (description, quantity, unit, unit price, extended amount).

The rule we use: the chunk is the line item; the header is denormalized into every chunk. Yes, that duplicates the vendor name 40 times on a 40-line invoice. Duplication is cheap; a retrieved fragment that cannot identify its own parent document is useless.

So the embedded text for a single row reads something like: Invoice 4471 · Ridgeline Electric · 2024-03-14 · Job 118 Tower B · Line 12: Electrical rough-in, 2nd floor, 1 LS @ 8,450.00 = 8,450.00. That embeds well because it contains every term a user might combine in a question. It also renders well in a citation, which matters more than people expect — a chunk that a human cannot verify at a glance is a chunk that generates support tickets.

Two extra chunks per invoice earn their keep:

  1. A summary chunk containing the header plus subtotal, tax, retainage and total. This is what answers "how much was invoice 4471?" without dragging in 40 rows.
  2. A rollup chunk listing all line descriptions with amounts, no other prose. This answers "what was on invoice 4471?" in one hop instead of forcing the retriever to find all 40 fragments and hope the top-k window is wide enough.

And the important non-retrieval point: if the question is arithmetic — totals by vendor, spend by cost code, month over month — do not answer it from a vector store at all. Extract the invoice into rows in Postgres and answer with SQL. Embeddings are for finding the document; the database is for computing over it. We keep the two side by side deliberately.

Plans: the sheet is the container, the region is the chunk

Construction drawings break every text-first assumption. A single sheet holds a title block, a plan view, several detail callouts, a door or fixture schedule as a table, general notes, and revision clouds. Flatten that to a page of OCR text and you get a soup where a door tag from the schedule sits next to a note about concrete cover.

In BuildAEC we treat the sheet as the retrieval container and detect regions inside it. Each region becomes a chunk with its page number, bounding box and region type. That gives three things a flat page of text cannot:

  • Type-aware retrieval. A schedule region is chunked row-wise like an invoice. A general-notes region is chunked as prose. A detail region carries its callout reference so "detail 3/A-501" resolves.
  • Citations that point at pixels. A chunk with a bounding box can be shown to a reviewer as a crop of the drawing. That is the difference between an answer someone trusts and an answer someone re-checks by hand.
  • A join with the takeoff. Our vision-model quantity takeoff emits measured lines with per-line confidence scores. Because both the takeoff line and the retrieval chunk are keyed to sheet plus bounding box, a low-confidence quantity can be shown next to the exact region it came from. We wrote more about why that pairing matters in confidence scoring makes AI output reviewable.

The title block is the header, and it gets denormalized into every region chunk on the sheet: project, sheet number, sheet title, discipline, revision, date, scale. Without the revision, a retrieved note is worse than nothing — someone will act on a superseded drawing.

Contracts: chunk on the clause tree, carry the ancestry

Contract clauses are meaningless out of their path. "Such payment shall be withheld until the conditions in 7.2 are satisfied" tells you nothing on its own. So the chunk boundary is the leaf clause, and the embedded text is prefixed with its full numbered path and heading trail.

Three rules that came out of getting this wrong:

  1. Never split mid-clause unless you must. If a leaf clause exceeds your token ceiling, split on sentence boundaries and mark the parts with an ordinal (part 2 of 3) plus the same clause path, so a reader can reassemble them.
  2. Never merge two sibling clauses to fill a chunk. A 40-token clause is a fine chunk. Padding it with the next clause creates a chunk that matches two different questions and answers neither cleanly.
  3. Resolve cross-references at index time. When a clause cites another (see 7.2), append the cited clause's heading — not its full body — to the chunk. Costs a few tokens, kills a whole class of dangling-reference answers.

Defined terms deserve their own treatment. Emit the definitions section as one chunk per defined term, and where a term appears in a clause that is retrieved on its own, the answer step should be able to fetch that definition as a second hop rather than guessing.

The chunk record, not just the chunk text

Most chunking code we have inherited stores a string and an embedding. That is what makes retrieval un-debuggable. Store a record. This is roughly the shape we index, across all three document families:

type Chunk = {
  id: string;
  tenant_id: string;          // never trust a global index
  doc_id: string;
  doc_type: 'invoice' | 'drawing' | 'contract';
  unit: 'line_item' | 'summary' | 'rollup' |
        'region' | 'clause' | 'definition';
  path: string;               // "7.2.1" | "A-501/detail-3" | "line-12"
  parent_context: string;     // header / title block / clause trail
  text: string;               // parent_context + unit text, as embedded
  page: number | null;
  bbox: [number, number, number, number] | null;
  revision: string | null;    // supersession matters
  part: [number, number] | null; // [2, 3] when a leaf was split
  source_hash: string;        // re-index detection
};

tenant_id on the chunk is not optional. We operate 100+ per-tenant Postgres databases behind 30+ Node microservices, and the same isolation discipline has to reach the vector store — a leaked chunk is a leaked contract. unit lets you filter retrieval by shape: a totals question searches summary and rollup only, which cuts noise more than any reranker will.

A chunk that cannot name its own parent document is a chunk that will eventually produce a confident, unattributable, wrong answer.

Where this is not worth the effort

Structure-aware chunking costs real engineering. You need a parser per family, region detection for drawings, a clause tokeniser for contracts, and re-index logic when any of those change. Three cases where we would not do it:

  • Unstructured corpora. Support tickets, email threads, meeting notes, marketing pages. There is no reliable tree. Use recursive splitting with overlap and spend your time on the retrieval eval set instead — the reasoning is in retrieval quality is a chunking problem.
  • Small, homogeneous document sets. Under a few hundred pages of one template, a whole-document chunk with a long-context model often beats a chunking pipeline, and takes an afternoon rather than a fortnight.
  • Documents where the answer is always a number. If every question is aggregation, skip retrieval. Extract to tables, expose a query tool, and put your effort into tool design so the agent chooses correctly.

There is also an honest cost: denormalizing headers into every chunk inflates your index. A 40-line invoice with a 60-token header carries 2,400 tokens of duplication. On tens of thousands of chunks that is measurable storage and embedding spend. We accept it because the alternative — a second lookup to hydrate parent context at query time, on every hit — adds latency to the path a user is actually waiting on, and latency is the thing they notice.

Prove the boundaries with adversarial queries

Chunking changes are impossible to evaluate by eyeballing. Before shipping a new strategy, we write a fixed set of queries designed to break boundaries specifically:

  1. A question that needs header plus row ("who invoiced us for the rough-in on job 118?").
  2. A question that needs two siblings compared ("how does 7.2 differ from 7.3 on retainage?").
  3. A question that needs a cross-reference resolved ("what conditions gate payment under 7.4?").
  4. A question answerable only from the correct revision of a drawing.
  5. A question that is pure arithmetic, to confirm it routes to SQL and not to the vector store.

Score them on whether the right chunk appeared in the top-k, separately from whether the final answer was right. Conflating the two is how teams end up swapping models to fix a retrieval bug. If you want the longer version of how we structure that evaluation loop, it sits alongside our other engineering essays — and if you are staring at a folder of PDFs and a deadline, tell us what the documents look like.

Start from the document's own structure. It was designed by someone who wanted the information to be findable. Your splitter should not be the thing that undoes that.

Frequently asked

What chunk size should I use for invoices and contracts?

For structured documents, size is an outcome, not an input. Chunk on the natural unit — one line item, one leaf clause, one drawing region — and let length fall where it falls. Only impose a token ceiling as a safety valve, splitting on sentence boundaries and tagging the parts so they can be reassembled. A 40-token clause is a perfectly good chunk.

Should I use chunk overlap with structured documents?

Rarely. Overlap exists to stop a hard cut severing a sentence, which is a problem you have already solved by cutting on structural boundaries. Instead of overlap, denormalize parent context — the invoice header, the title block, the clause path — into every child chunk. That gives you the continuity overlap was approximating, without duplicating body text.

How do I chunk PDFs of construction drawings?

Treat the sheet as the container and detect regions inside it: title block, plan view, details, schedules, general notes. Chunk each region by its own type — schedules row-wise, notes as prose — and store page number and bounding box so citations can be shown as a crop. Carry the sheet number and revision into every chunk so nobody acts on a superseded drawing.

Do I need a vector store if my documents are tables?

Often not. If the questions are aggregations — spend by vendor, totals by cost code, variance month over month — extract the rows into Postgres and answer with SQL. Use embeddings to find the right document or clause, and the database to compute over the data. Mixing the two roles is how you get plausible answers that do not add up.

How do I know my new chunking strategy is better?

Write a fixed set of adversarial queries that target boundaries: one needing header plus row, one comparing sibling clauses, one resolving a cross-reference, one requiring the correct revision, and one that is pure arithmetic. Score retrieval hit rate in the top-k separately from final answer correctness, or you will end up swapping models to fix a chunking bug.