This article is part of the **PDF Parsing for RAG* series. In Part 1, we explored how multi-column reading order braiding corrupts embedding vectors. Today we examine what happens when tabular data is flattened into plain text.*
When evaluating retrieval failures in document RAG pipelines, numerical and tabular queries account for a disproportionate share of hallucinations. A user asks: "What was the operating margin in Q3?" The retrieval model returns the exact page containing the financial breakdown, yet the generator outputs the gross margin from Q2 or invents a plausible number altogether.
The failure rarely lies in the embedding model or the LLM's arithmetic ability. It stems directly from the table extraction layer: converting a two-dimensional grid of coordinates into a one-dimensional sequence of characters discards the spatial relationships that give numbers meaning.
How Untagged PDFs Draw Tables
PDFs possess no native concept of a table cell, row, or column unless explicitly compiled as Tagged PDF (PDF/UA). Instead, a visual table in a PDF is rendered through two disconnected streams:
- Vector Drawing Commands: Path operators such as
m(moveto),l(lineto), andre(append rectangle) draw horizontal and vertical divider lines on the canvas. - Text Positioning Operators: Text operators (
BT,Td,Tj,ET) place character strings at absolute(x, y)coordinate positions.
Crucially, borderless tables—common in scientific papers, quarterly reports, and analyst notes—contain zero vector path operators. The visual alignment of columns exists solely through the author's spatial placement of text strings.
The Three Failure Modes of Naive Table Extraction
When a standard text extractor reads a table without spatial cell clustering, three structural failures corrupt the output before chunking:
1. Wrapped Header Splitting
Multi-line column headers (for example, Revenue (USD) wrapping to Revenue on line 1 and (USD) on line 2) are sorted purely by Y-coordinates. The second line of every header frequently gets interleaved into the first data row, shifting subsequent numeric cells out of phase.
2. Borderless Column Merging
In borderless tables, extractors rely on character-spacing heuristics to distinguish column gutters from word spaces. When numeric strings have varying lengths or negative parentheses, spacing heuristics collapse adjacent numbers into a single token (turning 14.2 and 88.5 into 14.2 88.5 without column delimiters, or misinterpreting indentation as a paragraph indent).
3. Coordinate Loss and Token Window Chunking
When a 10-column financial statement is flattened into space-separated text, an embedding model processes tokens like:
Metric Q1 Q2 Q3 Q4 Revenue 120 135 142 150 Operating Income 24 28 31 33 Net Margin 12% 14% 15% 16% If your chunker cuts this table at token 512, the column header definitions (Q1 Q2 Q3 Q4) land in chunk A, while the bottom metric rows (Net Margin 12% 14% 15% 16%) land in chunk B. Chunk B contains four orphan percentages with zero semantic connection to the quarters they describe.
Practical Reconstruction: Spatial Column Boundary Binning
To preserve tabular semantics for LLMs, an ingestion pipeline must identify column boundaries and project text tokens into discrete (row, col) cells before emitting Markdown.
Here is a practical Python algorithm using PyMuPDF (fitz) word bounding boxes to reconstruct borderless table grids via horizontal projection binning:
from collections import defaultdict import fitz # PyMuPDF def extract_table_to_markdown(page, table_bbox=None, y_tolerance=3.0): """ Extracts text tokens within a table region and projects them into a structured Markdown table using spatial coordinate clustering. Args: page: fitz.Page object table_bbox: (x0, y0, x1, y1) bounding box of the table area, or None to process the entire page. y_tolerance: Vertical distance in points to consider words on the same row. """ # Extract words: (x0, y0, x1, y1, word, block_no, line_no, word_no) words = page.get_text("words") if table_bbox: x0, y0, x1, y1 = table_bbox words = [w for w in words if x0 <= w[0] and w[2] <= x1 and y0 <= w[1] and w[3] <= y1] if not words: return "" # Step 1: Cluster words into rows based on baseline Y coordinates words.sort(key=lambda w: (w[1], w[0])) rows = [] current_row = [words[0]] current_y = words[0][1] for w in words[1:]: if abs(w[1] - current_y) <= y_tolerance: current_row.append(w) else: rows.append(sorted(current_row, key=lambda item: item[0])) current_row = [w] current_y = w[1] if current_row: rows.append(sorted(current_row, key=lambda item: item[0])) # Step 2: Detect column boundaries using X-coordinate projection x_starts = sorted([w[0] for w in words]) col_clusters = [] col_gap_threshold = 18.0 # minimum points between distinct columns for x in x_starts: if not col_clusters or (x - col_clusters[-1][-1]) > col_gap_threshold: col_clusters.append([x]) else: col_clusters[-1].append(x) col_bounds = [min(c) for c in col_clusters] def assign_col(x): # Find nearest left column bound idx = 0 for i, bound in enumerate(col_bounds): if x >= bound - 4.0: idx = i else: break return idx # Step 3: Populate 2D grid num_cols = len(col_bounds) grid = [] for r in rows: row_cells = defaultdict(list) for w in r: col_idx = assign_col(w[0]) row_cells[col_idx].append(w[4]) row_line = [" ".join(row_cells[c]) if c in row_cells else "" for c in range(num_cols)] grid.append(row_line) if not grid: return "" # Step 4: Format as standard GitHub Flavored Markdown table header = "| " + " | ".join(grid[0]) + " |" separator = "| " + " | ".join(["---"] * num_cols) + " |" body = ["| " + " | ".join(row) + " |" for row in grid[1:]] return "\n".join([header, separator] + body) Linearization Tradeoffs: Markdown vs. HTML vs. JSON
Once cell coordinates are recovered, choose the serialization format that fits your downstream architecture:
| Format | Token Cost | Structural Expressiveness | Recommended Use Case |
|---|---|---|---|
| Markdown Pipe Table | Low (minimal syntax overhead) | Flat 2D grid; cannot represent rowspan or colspan | Standard RAG pipelines with clean, non-merged tables |
HTML (<table>) | Medium (~25% more tokens) | Full support for hierarchical headers and spanning cells | Financial reports with grouped headers and merged notes |
| JSON Row Objects | High (~40–60% more tokens) | Each row repeats column keys ({"Metric": "Margin", "Q3": "18%"}) | Direct vector indexing where chunks require standalone context |
For general LLM retrieval, Markdown pipe tables deliver the optimal trade-off: language models are pretrained extensively on Markdown tables and parse column-row intersections accurately within prompt context windows.
Tabular RAG Audit Checklist
Before launching an indexing job across thousands of PDF documents, run this validation checklist on your extracted table chunks:
- Verify Column Alignment: Ensure headers match numerical values across all sample pages; check whether multi-line column headers merged into a single cell or created ghost rows.
- Audit Negative Numbers and Currency Symbols: Confirm that parenthesized negatives (e.g.,
(14.5)) did not split into isolated punctuation tokens. - Table Chunk Completeness: Do not split tables across arbitrary token limits. If a table exceeds your chunk window, repeat the header row in every subsequent chunk slice.
- Compare Query Recall: Test 10 specific numeric lookup queries (e.g., cell value at row X, col Y). If the LLM misses or swaps columns, inspect the raw Markdown grid before touching retriever parameters.
- Expose Layout Confidence Metadata: Following the uncertainty flag pattern discussed in Part 1's reader feedback, tag table segments where word coordinates sit near column boundary margins (within ±3 points of
col_gap_threshold) with anextraction_confidence: lowmetadata flag. This alerts downstream rerankers or human auditors before ambiguous cell alignments pollute retrieval indices.

