Chunking tables, code and transcripts

General chunking strategies assume prose: a linear sequence of sentences where any sentence boundary is a plausible cut. Three common content types violate that assumption badly enough that they need their own handling, and if your corpus contains them, they are almost certainly your worst-retrieving material.

They fail for different reasons, so they need different fixes.

Tables

A table is two-dimensional content flattened into a linear format. The header row is not a row — it’s a schema that every other row depends on. Cut it away and the remaining rows are lists of values with no indication of what they are.

--- chunk 1 ---
## Plan limits

The following limits apply per billing period.

| Plan | API calls | Seats | Storage |
| --- | --- | --- | --- |
| Free | 1,000 | 1 | 500 MB |
| Team | 50,000 | 10 | 50 GB |
--- chunk 2 ---
| Business | 500,000 | 50 | 500 GB |
| Enterprise | Custom | Custom | Custom |

Overages are billed at the rate shown in your contract.

Chunk 2 is four numbers and a sentence. A query asking “how many seats on the Business plan” has to match against text containing neither the word “seats” nor the word “plan.” Even if it somehow retrieves, the model sees | Business | 500,000 | 50 | 500 GB | and has to guess which column is which.

The fixes, in order of effort:

Keep small tables whole. If a table fits in a chunk, never split it. This means table detection has to happen before size-based splitting, not after — the splitter needs to treat the table as an atomic unit that it either includes or defers to the next chunk.

Repeat the header on every fragment. For tables too large to keep whole, split by rows and prepend the header row (and the table’s caption or preceding heading) to each fragment:

--- chunk 2 (header repeated) ---
## Plan limits

| Plan | API calls | Seats | Storage |
| --- | --- | --- | --- |
| Business | 500,000 | 50 | 500 GB |
| Enterprise | Custom | Custom | Custom |

Now the fragment is a valid, self-describing table. This is the single highest-value table fix and it’s a few lines of code once you can detect tables at all.

Serialise rows to sentences. For tables where each row is a record, emit one chunk per row in natural language: “The Business plan includes 500,000 API calls, 50 seats, and 500 GB of storage per billing period.” Embeddings handle this far better than pipe-delimited values, because it looks like the prose the model was trained on. Costs you the table’s visual structure, so it suits lookup tables and suits comparison tables poorly.

Keep both. Index the serialised rows for matching, store the original table markup for display. The reader gets the table; the retriever gets sentences.

What still breaks: tables that require comparison across rows (“which plan has the most storage”) are not answerable from any single chunk, and no chunking strategy fixes that. Merged cells, nested headers, and tables whose meaning depends on a footnote elsewhere in the document are also beyond this. Genuinely tabular questions want a database, not a retriever.

Code

Code has structure that a text splitter can see (blank lines, braces) and structure it can’t (scope, imports, class membership). Cutting on the visible structure while ignoring the invisible kind produces fragments that are syntactically plausible and semantically orphaned.

--- chunk 1 ---
import hashlib
from datetime import datetime, timedelta

CACHE_TTL = timedelta(hours=6)

class TokenStore:
    def __init__(self, backend):
        self.backend = backend

    def issue(self, user_id):
        token = hashlib.sha256(
--- chunk 2 ---
            f"{user_id}{datetime.utcnow()}".encode()
        ).hexdigest()
        self.backend.put(token, user_id, ttl=CACHE_TTL)
        return token

    def revoke(self, token):
        self.backend.delete(token)

Chunk 1 ends mid-expression. Chunk 2 begins with an orphaned string literal, then two methods with no class, no imports, and a reference to CACHE_TTL that’s defined in another chunk. Someone retrieving chunk 2 to answer “how do I revoke a token” gets a method whose surrounding class name — the thing they’d need to actually call it — is absent.

The fixes:

Split on syntactic boundaries. Parse the code and cut at function and class definitions, never inside them. Most languages have a parser available; several splitting libraries ship language-specific rules built on exactly this. A function is the natural chunk for code the way a paragraph is for prose.

Carry the signature context. Every chunk containing a method should state what it’s a method of:

--- chunk 2 (with context header) ---
# file: auth/tokens.py
# class TokenStore:

    def revoke(self, token):
        self.backend.delete(token)

Include imports and module-level constants with each chunk, or at minimum reference them. This duplicates a little, and it’s worth it — code without its imports is code you can’t run and often can’t understand.

Keep the docstring with its function. Obvious, and character splitters break it constantly. The docstring is usually the most retrievable text in the whole file, since it’s the part written in natural language; separating it from its implementation wastes the best matching signal you have.

Chunk large functions by logical block, not by line count, if you must split them — and prepend the signature to each part.

What still breaks: call graphs. “Where is this function used” requires knowing about other files, and chunking cannot represent that. Deeply nested code where a five-line block depends on twenty lines of enclosing setup. And generated or minified code, which should usually be excluded from the index rather than chunked.

Transcripts

Meeting recordings, interviews, support calls, podcast episodes. Two properties make these hard.

Turns are short and topics are long. A single speaker turn is often a sentence, or the word “right.” A topic spans dozens of turns. Neither the turn nor the whole transcript is a good chunk.

Reference is almost entirely pronominal. Spoken language doesn’t restate its subject. A twenty-minute discussion of a specific customer may name that customer twice.

--- chunk 1 ---
Sam: So the migration timeline — where did we land?
Alex: End of Q3, assuming the schema work finishes.
Sam: And if it doesn't?
Alex: Then we're looking at Q4, but I don't think
--- chunk 2 ---
that's likely. The blocker was the index rebuild and
that's done now.
Sam: Okay. And the rollback plan?
Alex: Snapshot before, restore after. Standard.

Chunk 2 is unretrievable for any query about migration timelines. It contains no proper nouns, no project name, no date — just pronouns pointing at things in chunk 1. “That’s done now.” What is?

The fixes:

Never split mid-turn. The turn is the atomic unit. Cutting through a speaker’s sentence is worse here than in prose, because the attribution goes with it — chunk 2 above opens with words that appear to be Sam’s and are actually Alex’s.

Group turns into topic segments. Aim for chunks that cover one subject across several turns. This can be done crudely by turn count with sentence alignment, better by detecting speaker or topic shifts, and best with an explicit segmentation step. It is the single biggest improvement available for transcripts.

Prepend a context header to every chunk. Meeting title, date, participants, and a one-line topic label:

--- chunk 2 (with header) ---
[Platform sync — 2026-06-14 — Sam, Alex — topic: migration
timeline and rollback]

Alex: ...that's not likely. The blocker was the index
rebuild and that's done now.
Sam: Okay. And the rollback plan?
Alex: Snapshot before, restore after. Standard.

This is the fix that matters most, because it supplies the nouns the speech omitted. It’s also where generated metadata genuinely earns its cost: a model-written topic label per segment converts unsearchable dialogue into something a query can match. Just remember generated fields can be wrong, and sample before trusting them.

Keep timestamps so retrieved segments can link back to the recording, and so neighbouring segments can be expanded on demand.

What still breaks: decisions that emerge across a whole meeting rather than in one segment. Crosstalk and interruption. Anything conveyed by tone. And poor diarisation — if your speaker labels are wrong, every fix above propagates the error.

The common thread

Three types, three mechanisms, one pattern: each needs its unit of meaning identified before size is considered. A table’s unit is the table (or the row, with its header). Code’s is the function, with its signature and imports. A transcript’s is the topic segment, with its participants and subject.

Which is the same principle as structural splitting, applied to formats where the structure isn’t textual hierarchy.

Two practical consequences.

Detect content type at ingest and route accordingly. One splitter for everything is the thing that produced all three failures above. A dispatcher — markdown to the structural splitter, source files to the code splitter, transcripts to the segmenter, everything else to the default — is straightforward and it’s where most of the gain is.

Handle mixed documents. Real technical documentation contains prose, tables and code blocks in the same file, and the splitter has to switch modes mid-document rather than picking one for the whole thing. Extract the atomic units first (tables, code blocks), chunk the prose around them, and reattach each unit to the prose that introduced it.

Then check your work the direct way: pull the chunks for one table-heavy document, one source file, and one transcript, and read them. If you can’t tell what they’re about, neither can the retriever.