When building Retrieval-Augmented Generation (RAG) pipelines, the standard ingestion step usually looks like this:
- Read a PDF file.
- Dump all text content into a single flat string.
- Split the string into 500-token chunks with some overlap.
- 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..."
}
]
}
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}
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)
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.
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:
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:
If you are using LangChain, our loader also supports this directly:
This delivers the best of both worlds: dense semantic context for vector retrieval + exact multi-quad visual grounding for citations.
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.
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.
This is top-tier feedback Ahmet, thank you!
I took your feedback directly to heart and formalized the coordinate contract in RasgaPDF:
Your suggestion of a render test with 0 vs 90 degrees plus non-default CropBox was spot on.
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.