DEV Community

MartinsMessias
MartinsMessias

Posted on AI-assisted

Why Flat-Text PDF Parsing Breaks RAG (And How Spatial 2D Grounding Fixes It)

When building Retrieval-Augmented Generation (RAG) pipelines, the standard ingestion step usually looks like this:

  1. Read a PDF file.
  2. Dump all text content into a single flat string.
  3. Split the string into 500-token chunks with some overlap.
  4. Generate vector embeddings and store them in a vector database.

While this works for simple blog posts or single-column articles, it breaks down completely when applied to production documents like financial reports, scientific papers, and legal contracts.

The Three Hidden Failures of Flat-Text Ingestion

1. Table Flattening

PDF files do not store tables as HTML table elements or markdown grids. They store independent text strings positioned at absolute coordinates, combined with vector stroke lines.

When a standard parser strips away positioning, rows and columns collapse into a single stream. A table comparing quarterly revenues across three divisions becomes a scrambled sequence of numbers. By the time your chunk reaches an LLM, the relational context is destroyed.

2. Multi-Column Reading Order

Dense academic papers and annual reports frequently use two-column or three-column layouts. A naive parser reads across horizontal bands, interweaving sentences from the left column with sentences from the right column. The resulting embeddings represent garbled nonsense.

3. The Lack of Visual Provenance

When an LLM produces a critical answer, such as an interest rate or an EBITDA figure, users need to verify it. If your RAG system only stores raw text chunks, the best you can show the user is a quoted string. You cannot point to the exact page, line, or table cell where the
fact originated.

The Current Trade-off: Fast and Blind vs. Accurate and Slow

Right now, developers face two extremes:

• Legacy C-based Parsers (PyMuPDF, Poppler, PDFium): Extremely fast, but originally designed for rendering pixels onto desktop screens rather than feeding structured spatial trees into AI pipelines. They also introduce heavy C/C++ compilation toolchains, breaking in
lightweight container environments.
• Vision-Language Models and OCR (Marker, Docling, Nougat): These rasterize pages into images and run deep learning models to predict layout. While accurate, they require GPUs, cost significant compute per page, and introduce seconds of latency per document.

The Alternative: Spatial RAG with Native 2D Grounding

The coordinate geometry needed to reconstruct document structure already exists inside the PDF content streams, you do not need a GPU to rediscover it.

This is why I built RasgaPDF (https://github.com/MartinsMessias/rasgapdf): a lightweight, pure Rust PDF engine built specifically for Spatial RAG.

Instead of outputting an unstructured string, the engine reconstructs the document as a spatial 2D hierarchy with exact coordinates:

{
  "page_number": 1,
  "width": 612.0,
  "height": 792.0,
  "rotation": 0,
  "text_blocks": [
    {
      "bbox": { "x0": 60.0, "y0": 70.6, "x1": 384.6, "y1": 82.7 },
      "lines": [
        {
          "bbox": { "x0": 60.0, "y0": 70.6, "x1": 384.6, "y1": 82.7 },
          "text": "Revenue increased by 14% year-over-year..."
        }
      ]
    }
  ],
  "tables": [
    {
      "bbox": { "x0": 60.0, "y0": 121.0, "x1": 540.0, "y1": 245.0 },
      "markdown": "| Metric | 2025 | 2026 |\n| --- | --- | --- |\n..."
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Visual Citation in User Interfaces

Because every chunk retained its physical bounding box ([x0, y0, x1, y1]), your frontend can render the original PDF page and draw an exact translucent highlight over the source passage. The user gets instant, trustworthy verification without hunting through a 50-page
document.

Pure Rust: Zero C/C++ Toolchains

By implementing the ISO 32000-1 specification natively in safe Rust:

• cargo build and pip install work without MSBuild or C compiler installations.
• Memory safety is enforced at compile time.
• A dense 12-page academic paper parses in approximately 15 ms on a single CPU core.

Native Integration with LangChain and LlamaIndex

You do not need to write custom spatial wrappers. RasgaPDF integrates directly into standard AI orchestrators:

    from rasgapdf import RasgaPDFLoader

    # Load with physical 2D metadata preserved in every Document chunk
    loader = RasgaPDFLoader("financial_report.pdf", mode="elements")
    docs = loader.load()

    first_chunk = docs[0]
    print(first_chunk.page_content)
    print(first_chunk.metadata["bbox"])
   # {'x0': 60.0, 'y0': 70.6, 'x1': 384.6, 'y1': 82.7}
Enter fullscreen mode Exit fullscreen mode

Conclusion

If your RAG system is hallucinating data or failing on complex documents, the problem is often not your embedding model or prompt, it is flat-text ingestion destroying spatial context before the model ever sees it.

Preserving 2D physical coordinates gives you reliable tables, accurate reading order, and verifiable visual citations at native speed.

• GitHub: https://github.com/MartinsMessias/rasgapdf
• Interactive Playground: https://martinsmessias.github.io/rasgapdf/

How are you currently handling table extraction and citation verification in your RAG pipelines? Let us discuss in the comments below.

Top comments (6)

Collapse
 
recallrundev profile image
RecallRun •

The boundary problem shows up even on clean HTML docs, before tables and columns make it worse. On the FastAPI docs I measured fixed-size chunks crossing a section boundary 59% of the time, and splitting at headings instead roughly doubled hit@1. Fixed chunks also degraded much faster with size: hit@1 went from 0.58 at 200 characters to 0.16 at 2,000, while heading-aware chunks only went from 0.88 to 0.76. So keeping the structure you describe probably matters more than any chunk-size tuning people do afterwards.

Question on the bbox side: when a chunk ends up spanning two text blocks, or a block plus part of a table, do you keep a list of boxes per chunk, or split chunks so each maps to exactly one region? The second keeps highlighting simple but I'd expect it to hurt retrieval on short blocks.

Collapse
 
martinsmessias profile image
MartinsMessias •

Thank you for sharing those empirical metrics!

Regarding your question about bounding boxes:

We strictly keep a list of bounding boxes per chunk (bboxes: [[x0, y0, x1, y1], ...]) rather than forcing 1:1 block splits.

You diagnosed the exact trade-off: forcing 1-to-1 splits destroys retrieval quality on short blocks. If a page has a 1-line figure caption, a sub-clause, or a table footnote, forcing it into its own isolated chunk leaves embedding models with virtually zero semantic context
to match against user queries.

To solve this natively without losing visual citation fidelity, we introduced SpatialChunk and to_spatial_chunks() in RasgaPDF:

import rasgapdf

doc = rasgapdf.parse_pdf("document.pdf")

#Aggregates contiguous semantic blocks into chunks up to max_chars,
#while preserving the full list of constituent bounding boxes!
chunks = rasgapdf.to_spatial_chunks(doc, max_chars=1200, overlap_blocks=1)
for chunk in chunks:
  # 1. Feed rich semantic text to your embedding model
  embedding_text = chunk["text"]

# 2. Store both the envelope and the individual multi-quad boxes in your vector DB metadata
  metadata = {
        "page": chunk["page_number"],
        "bboxes": chunk["bboxes"],  # [[x0, y0, x1, y1], [x0, y0, x1, y1], ...]
        "overall_bbox": chunk["overall_bbox"],  # [min_x0, min_y0, max_x1, max_y1]
        "block_count": chunk["block_count"],
    }
Enter fullscreen mode Exit fullscreen mode

How this works on the frontend viewer:
When the LLM retrieves and cites that chunk, the frontend (PDF.js canvas, SVG overlay, or DOM layer) doesn't have to guess or stretch a single huge inaccurate rectangle:

  1. The client reads citation.bboxes.
  2. It simply loops through the array and draws N tight, pixel-perfect highlight rectangles across paragraphs, captions, or table slices.

If you are using LangChain, our loader also supports this directly:

from rasgapdf import RasgaPDFLoader

mode="chunks" produces Documents with pre-calculated multi-rect bboxes in metadata

loader = RasgaPDFLoader("paper.pdf", mode="chunks", chunk_max_chars=1200)
documents = loader.load()
Enter fullscreen mode Exit fullscreen mode

This delivers the best of both worlds: dense semantic context for vector retrieval + exact multi-quad visual grounding for citations.

Collapse
 
recallrundev profile image
RecallRun •

Thanks, that's a clean split: one text for the embedder, N boxes for the viewer. Drawing tight rectangles per block beats one stretched envelope, especially when a chunk spans a column break.

One thing I'd test with overlap_blocks=1: the overlapping block's box shows up in two chunks, so if both chunks get cited for one answer the viewer highlights that block twice. Deduping boxes per citation set on the client (same page, same coords) is probably enough.

Collapse
 
ahmetozel profile image
Ahmet Özel •

The page metadata already includes rotation, which is an important detail for the citation interface. I would make the coordinate contract explicit too: origin, units, crop box, and whether each bbox is before or after rotation. Otherwise a correct extracted passage can be highlighted in the wrong location by a PDF viewer.

A small render test with the same page at zero and ninety degrees, plus a non-default crop box, would exercise that boundary. The extraction benchmark and citation alignment then remain separately measurable rather than treating successful parsing as proof of a correct visual citation.

Collapse
 
martinsmessias profile image
MartinsMessias • • Edited

This is top-tier feedback Ahmet, thank you!
I took your feedback directly to heart and formalized the coordinate contract in RasgaPDF:

  1. Origin & Axes: We normalize all coordinates to the Top-Left origin (0, 0) with +X pointing right and +Y pointing downward, matching browser DOM and Canvas standards rather than raw PostScript bottom-left.
  2. Units: Standard typographic points (1/72 inch).
  3. CropBox vs MediaBox: CropBox takes strict precedence over MediaBox (per ISO 32000-1 section 14.11.2) including recursive inheritance from /Parent nodes. When a document has non-zero crop offsets (e.g. CropBox [36, 54, 576, 756]), we subtract the origin offsets: norm_x = x - origin_x norm_y = max_y - y This ensures (0, 0) is always the exact top-left corner of the visible screen area without visual drift. Both crop_box and media_box are now also exposed directly in the Page metadata.
  4. Rotation (Pre vs Post-Rotation): By default, bboxes remain in unrotated page space while page.rotation (0, 90, 180, 270) reports viewer orientation. However, we also added an apply_rotation=True option (and .with_applied_rotation() / page.rotated()) which transforms all bboxes clockwise into the final viewer orientation and swaps width/height on 90/270 degree pages so frontend viewers can paint highlights directly without manual trigonometry.

Your suggestion of a render test with 0 vs 90 degrees plus non-default CropBox was spot on.

Collapse
 
ahmetozel profile image
Ahmet Özel •

Exposing both boxes and making rotation an explicit option gives the renderer a usable contract. The remaining edge case I would test is accidental double rotation: a caller requests applied coordinates, then also applies page.rotation while drawing.

I would expose the coordinate-space state alongside the bboxes, so the consumer can tell source-page coordinates from viewer-oriented coordinates without inferring it from swapped dimensions. A crop-offset fixture at 90 and 270 degrees should verify that the rendered highlight and the source span still point to the same text.