Tables are invisible to semantic search
A grid of numbers embeds to almost nothing. Nobody's question matches it, and no chunk size fixes that — the table has to be described before it can be found.
A pricing table sits in the corpus. Somebody asks "how much does the professional plan cost?" and retrieval returns three paragraphs about billing policy. The table, which literally contains the answer, is nowhere in the top fifty.
The table was indexed. Its embedding is just useless.
An embedding represents meaning in text. A grid of numbers has almost no textual meaning to represent, so it lands nowhere in particular and no question ever points at it.
What a table embeds to
Take a chunk containing this:
Plan Users Storage Price
Starter 5 10 GB $29
Professional 25 100 GB $99
Enterprise Unlimited 1 TB Custom
Stripped of layout — which is what the embedding model receives — that is a sequence of nouns and numbers with no grammar. The vector it produces is generic: vaguely about plans and prices, but with none of the structure that makes the content answerable.
Meanwhile the question "how much does the professional plan cost" embeds as a fluent English sentence about pricing. It is closer to a paragraph discussing pricing than to the table stating it. Retrieval is behaving correctly and returning the wrong thing.
This is a distinct problem from chunk size — no size fixes it — and from a row split across a page, which is an extraction problem. This one is about making a table findable at all.
Render the table as sentences
The fix is to index a textual description alongside the table, so there is something for a question to match:
function tableToProse(table: Table, context: { heading: string[] }): string {
const rows = table.rows.map((row) =>
table.headers
.map((h, i) => `${h}: ${row[i]}`)
.join(", "),
)
return [
// The breadcrumb. Without it the table has no subject.
`Table under ${context.heading.join(" > ")}.`,
`Columns: ${table.headers.join(", ")}.`,
...rows,
].join("\n")
}
Which produces:
Table under Pricing > Plan comparison.
Columns: Plan, Users, Storage, Price.
Plan: Starter, Users: 5, Storage: 10 GB, Price: $29
Plan: Professional, Users: 25, Storage: 100 GB, Price: $99
Plan: Enterprise, Users: Unlimited, Storage: 1 TB, Price: Custom
Each row is now a small self-contained statement that repeats its column names. "Plan: Professional ... Price: $99" is genuinely close to the question in embedding space, because the words that make the question specific now appear attached to the value.
Verbose, and worth it. This is one of the highest-yield changes available in a corpus with any tabular content.
Index rows, return the table
The refinement that makes it work properly: embed at row granularity for retrieval precision, but serve the whole table for the answer.
type TableChunk = {
id: string
kind: "table_row" | "table_summary"
/** What gets embedded. */
embedText: string
/** What gets sent to the model on a hit. The full table, always. */
payloadId: string // points at the complete table
rowIndex?: number
}
A question about the professional plan matches that row's vector. What reaches the model is the entire table, because answering "how does professional compare to starter" needs both rows, and you cannot know from the query which rows will be needed.
This is the retrieve small, return large pattern, and tables are the case where it matters most — a row in isolation frequently cannot be interpreted at all.
Add a summary chunk
Alongside the rows, index one chunk describing what the table is for:
const summary = await describe(table, {
prompt: "One paragraph: what question does this table answer? " +
"Name the entities and the quantities compared. Do not list values.",
})
// "Compares the Starter, Professional and Enterprise plans across user
// limits, storage allowance and monthly price."
That paragraph catches the queries no row does — "what plans are available", "how do the tiers compare", "is there an enterprise option". Rows answer lookups; the summary answers questions about the table itself.
Generating one summary per table is a one-off cost at ingestion, and tables are a small fraction of most corpora.
Lexical search does the rest
Tables are dense with exact tokens — product codes, part numbers, plan names, figures. That is precisely the query class embeddings are worst at and BM25 is best at, so hybrid search helps tabular content disproportionately.
A user searching for SKU-4471-B will find the row through lexical search and
never through the vector. If your corpus is catalogue-like, hybrid is not
optional.
Never split a table
Whatever chunking strategy you use, tables are atomic:
function chunk(doc: ParsedDoc, target = 800): Chunk[] {
return doc.blocks.flatMap((block) => {
// A table split in half produces two useless halves: one with headers
// and no data, one with data and no headers. Oversized is better.
if (block.kind === "table") return [tableChunk(block)]
return packToTarget(block, target)
})
}
A table exceeding your target size stays whole. If it is genuinely enormous — hundreds of rows — split it by rows and repeat the header on each piece, never by token count.
Check whether you have this problem
Add five questions to your eval set whose answers live only in tables, tag them, and look at the segment:
before after
recall@10 0.79 0.82
prose 0.86 0.86
table 0.24 0.88 <- the entire difference
The aggregate moves three points, which is why this gets skipped. The table segment moves sixty, and in a corpus with pricing, specifications or comparison tables, those are the questions users ask most.
More in chunk size is not your problem, why your RAG returns wrong answers, and retrieval systems that are actually evaluated.