Dev.to · 4 min read

Why Flattened PDF Tables Break RAG (and How to Preserve Cell Semantics in Markdown)

Why Flattened PDF Tables Break RAG (and How to Preserve Cell Semantics in Markdown)

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), and re (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

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More AI & Machine Learning News