Why RAG Hallucinates on PDF Tables - And How to Fix It | AI/ML Engineer Guide


Interview Q

You're building a RAG pipeline on a corpus of PDF documents. A user asks a question that requires data from a table inside one of those PDFs — but the response is hallucinated and wrong. What could be the reason behind this and how to overcome this?

Most candidates jump straight saying "the LLM started hallucinating, optimize the prompting". That's the wrong answer or rather, it's the symptom, not the cause. Answering, this type of question will separate engineers who've read about RAG from engineers who've just shipped it.

Lets look closely at the reason for the hallucination and how to overcome this issue.

The Five-Stage Failure: Where the Pipeline Actually Breaks

A RAG pipeline for PDF documents has five stages before an answer reaches the user. For plain text, all five stages work reasonably well. For tables, every single stage introduces a distinct failure mode and they compound.

Stage 1
PDF Parsing
PyPDFLoader · PyMuPDF · PyPDF2

Reads the document as a stream of characters, left-to-right, top-to-bottom. No awareness of table borders, column alignment, or row boundaries.

⚠ Failure — Table structure destroyed on read
Stage 2
Chunking
Fixed token splits · Character splits

Divides flattened text at fixed intervals with no awareness of table boundaries. A 10-row table can be split into 4 different chunks, spreading headers and values across chunks that will never be retrieved together.

⚠ Failure — Headers separated from values
Stage 3
Embedding
OpenAI text-embedding-3 · Sentence-BERT

Encodes a flat string into a dense vector. The vector captures semantic meaning of the text — but "Q2 4.2M 18% Net Revenue" has almost no semantic signal. The embedding is too weak to reliably surface on a meaningful query.

⚠ Failure — No positional/structural context in vector
Stage 4
Retrieval
Cosine similarity · Top-k FAISS / ChromaDB

Returns the top-k semantically similar chunks. For a query like "What was Q2 revenue?", the retriever often returns a paragraph about Q2 revenue — which scores higher than the raw data row — instead of the actual cell value.

⚠ Failure — Wrong chunk retrieved; right chunk buried or missed
Stage 5
Generation
GPT-4o · Claude · Gemini

Generates an answer from the corrupted context it was given. The LLM is not inventing from nothing — it is reacting to damaged evidence. It produces a fluent, confident, wrong answer with no uncertainty signal.

⚠ Failure — Fluent hallucination. No warning. Wrong number looks right.
From the field

"The most dangerous RAG failure isn't when the model says 'I don't know.' It's when it returns 4.2M when the answer is 42M — off by 10x, with full confidence. You only catch it when acutal business person calls out about the board deck."

Interview Q

Why does cosine similarity specifically fail for table content inside PDFs? Isn't semantic retrieval supposed to find contextually relevant content?

Cosine similarity measures the angle between two vectors in embedding space — which approximates semantic relatedness. This works brilliantly for prose, because paragraphs have rich contextual language that survives flattening. Tables don't.

A table cell's meaning is positional, not semantic. The string "4.2" in isolation carries zero semantic content. Its meaning only exists in relation to its column header ("Revenue"), its row identifier ("Q2"), and its unit context ("$M"). Standard text parsers collapse all of that relational structure into a token stream before embedding even begins.

The resulting embedding vector for a flattened table row is so semantically dilute that a query for "Q2 revenue" will often retrieve a paragraph about Q2 revenue projections — which has rich language and ranks higher — rather than the sparse data row containing the actual number.

The Fix: Rebuilding the Pipeline from the Ground Up

The fix has five components, each addressing a specific failure mode. You don't need all five immediately — but skipping Stage 1 makes every other fix irrelevant.

Fix 1 — Replace your flat parser with a structure-aware extractor

No single parser works universally on real-world PDFs. The choice depends on how the PDF was created:

Tool Best for Scanned PDFs Accuracy
Camelot (lattice) Bordered tables in financial reports, invoices No Excellent
pdfplumber Government docs, research papers, Word exports No Good
LlamaParse Complex mixed-layout, tables + text + charts Yes Excellent
Docling (IBM) Self-hosted batch pipelines, enterprise Yes Excellent
AWS Textract Scanned documents, forms, multi-column layouts Yes Excellent
PyMuPDF Clean digital text extraction (non-table) No Text only
Critical

Camelot does not work on scanned PDFs — it needs selectable text. If your document is image-based, run pytesseract or Azure Document Intelligence or AWS Textract for OCR first, then pass the output through Camelot or pdfplumber. Skipping this step gives you clean-looking garbage.

Fix 2 — Serialize tables as self-contained meaningful chunks

Once extracted, a table must be serialized into a labeled text block that carries its own context. The column headers, source document, page number, and table label all travel with the data. So, no chunk ever arrives at the LLM missing the information it needs to interpret a value.

Python
import camelot
import pdfplumber
from pathlib import Path

def extract_tables_from_pdf(pdf_path: str) -> list[dict]:
    """
    Extract tables from a PDF using Camelot (lattice mode).
    Falls back to pdfplumber for borderless tables.
    Returns a list of table dicts with metadata.
    """
    tables = []
    path = Path(pdf_path)

    # Try Camelot lattice first (best for bordered tables)
    try:
        raw = camelot.read_pdf(
            str(path),
            flavor="lattice",
            pages="all"
        )
        for i, tbl in enumerate(raw):
            if tbl.accuracy > 80:  # Skip low-confidence extractions
                tables.append({
                    "dataframe": tbl.df,
                    "page": tbl.page,
                    "table_index": i,
                    "accuracy": tbl.accuracy,
                    "source": path.name,
                    "extractor": "camelot-lattice"
                })
    except Exception:
        # Fallback to pdfplumber for borderless tables
        with pdfplumber.open(str(path)) as pdf:
            for page_num, page in enumerate(pdf.pages, 1):
                for i, tbl in enumerate(page.extract_tables() or []):
                    import pandas as pd
                    df = pd.DataFrame(tbl[1:], columns=tbl[0])
                    tables.append({
                        "dataframe": df,
                        "page": page_num,
                        "table_index": i,
                        "source": path.name,
                        "extractor": "pdfplumber"
                    })
    return tables


def serialize_table(table: dict) -> str:
    """
    Serialize a table into a self-contained labeled text block.
    Column headers and source metadata are preserved in every chunk.
    This is the single most important fix in the entire pipeline.
    """
    df = table["dataframe"]
    header = " | ".join(str(c) for c in df.columns.tolist())
    rows = "\n".join(
        " | ".join(str(v) for v in row)
        for row in df.values
    )

    return (
        f"[TABLE] Source: {table['source']} | "
        f"Page: {table['page']} | "
        f"Table #{table['table_index'] + 1}\n"
        f"Columns: {header}\n"
        f"{rows}\n"
        f"[END TABLE]"
    )

Fix 3 — Two-path ingestion: route text and tables separately

Don't treat your PDF as one homogenous document. Build two parallel ingestion paths: one for prose text, one for tables. Tag every chunk with content_type metadata so the retriever can route intelligently at query time.

Python
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import PyMuPDFLoader
from langchain.schema import Document

def two_path_ingestion(pdf_path: str) -> list[Document]:
    """
    Ingest a PDF document via two separate content-aware paths.
    - Text path: standard chunking with section metadata
    - Table path: atomic serialized chunks, never split mid-table
    """
    documents = []

    # ── PATH A: Text content via PyMuPDF ──────────────────────
    loader = PyMuPDFLoader(pdf_path)
    raw_pages = loader.load()

    splitter = RecursiveCharacterTextSplitter(
        chunk_size=800,
        chunk_overlap=80,
        separators=["\n\n", "\n", ". ", " "]
    )
    text_chunks = splitter.split_documents(raw_pages)

    for chunk in text_chunks:
        chunk.metadata["content_type"] = "text"
        documents.append(chunk)

    # ── PATH B: Table content via Camelot / pdfplumber ────────
    raw_tables = extract_tables_from_pdf(pdf_path)

    for table in raw_tables:
        serialized = serialize_table(table)
        doc = Document(
            page_content=serialized,
            metadata={
                "content_type": "table",      # critical tag for routing
                "source": table["source"],
                "page": table["page"],
                "extractor": table["extractor"],
                "table_index": table["table_index"]
            }
        )
        documents.append(doc)

    return documents

Even with clean, serialized table chunks, pure vector search has a critical weakness: it optimizes for semantic similarity, not keyword precision. When a user asks "What was Q2 2024 net revenue?", they're using exact terms — "Q2", "2024", "net revenue" — that need to match exactly in the chunk.

An embedding model can map "net revenue" and "turnover" to similar vectors and miss the exact row that uses different terminology. BM25 keyword search catches these exact matches that vector search drops.

Fix 4 — Hybrid BM25 + vector Search retrieval techinique with Reciprocal Rank Fusion

Combine sparse keyword retrieval (BM25) with dense vector retrieval and merge results using Reciprocal Rank Fusion (RRF). BM25 catches exact term matches; vector search catches semantic intent. Together they produce reliable recall for both types of query.

Python
from langchain.retrievers import BM25Retriever, EnsembleRetriever
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings

def build_hybrid_retriever(documents: list, k: int = 6):
    """
    Build a hybrid BM25 + vector retriever.
    BM25 handles exact keyword match for table terms.
    Vector handles semantic / paraphrased queries.
    RRF merges both rankings.
    """
    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

    # Vector retriever
    vectorstore = Chroma.from_documents(documents, embeddings)
    vector_retriever = vectorstore.as_retriever(
        search_kwargs={"k": k}
    )

    # BM25 keyword retriever
    bm25_retriever = BM25Retriever.from_documents(documents)
    bm25_retriever.k = k

    # Ensemble with equal weights — tune based on your query mix
    hybrid_retriever = EnsembleRetriever(
        retrievers=[bm25_retriever, vector_retriever],
        weights=[0.5, 0.5]
    )

    return hybrid_retriever

Fix 5 — Constrain the LLM with a strict grounding prompt

Even with clean retrieval, you need to close the final gap at generation. A standard prompt lets the LLM fill gaps with its parametric memory. For numerical table queries, that's fatal. Use a system prompt that makes interpolation impossible.

Python
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate

TABLE_SYSTEM_PROMPT = """You are a precise data retrieval assistant.
You answer questions strictly from the table data provided in the context.

Rules you must never break:
1. Only cite numbers explicitly present in the table context.
2. If the exact value is not in the context, respond: "Not found in the provided documents."
3. Never interpolate, estimate, average, or infer numerical values.
4. Always cite the source file, page number, and column you retrieved the answer from.
5. If multiple tables contain relevant data, cite each separately.
"""

def build_table_rag_chain(retriever):
    llm = ChatOpenAI(
        model="gpt-4o",
        temperature=0  # Non-negotiable for table queries
    )

    prompt = ChatPromptTemplate.from_messages([
        ("system", TABLE_SYSTEM_PROMPT),
        ("user", "Context:\n{context}\n\nQuestion: {question}")
    ])

    from langchain_core.runnables import RunnablePassthrough
    from langchain_core.output_parsers import StrOutputParser

    chain = (
        {
            "context": retriever | (lambda docs: "\n\n".join(d.page_content for d in docs)),
            "question": RunnablePassthrough()
        }
        | prompt
        | llm
        | StrOutputParser()
    )
    return chain
Production result

After implementing all five fixes — structure-aware parser, serialized chunks, two-path ingestion, hybrid retrieval, and constrained generation, table query accuracy on financial PDF corpora improves from effectively random to reliably grounded. The "4.2M vs 42M" class of error disappears because it was never an LLM problem to begin with.

Bonus Tips:

Why can't we gohead and "just extract the tables and use Text-to-SQL."

Text-to-SQL is a legitimate approach when you have a clean, consistent schema - like a relational database with typed columns. But PDF tables are the opposite of that. The schema itself must be inferred from the document, and that inference is unreliable.

Here's what breaks: a 50-page annual report might have 30 tables with different column names for the same concept across pages ("Revenue", "Net Sales", "Total Revenue", "Turnover"). Text-to-SQL has no way to resolve that unless you build a schema normalization layer — which is itself a hard NLP problem.

There's also a governance risk: a Text-to-SQL system allows users to query table structures they may not fully understand, which can expose metadata or column names that contain sensitive information. For PDF-native workflows, structured RAG retrieval with serialized chunks is the more robust path. Text-to-SQL belongs in the data warehouse layer, not the document layer.

Points to Remember for your next interview

"The hallucination isn't coming from the LLM — it's coming from the parser. By the time the model sees the data, the table structure has already been destroyed. Fix the ingestion layer first."

  • Use Camelot lattice or Docling along with Azure Doc intelligence if needed
  • Serialize every table as an atomic labeled chunk before embedding
  • Treat text and tables through separate ingestion paths, maintain metadata of source
  • Add BM25 Keyword + Sementic = hybrid retrieval for exact numerical term matching
  • Set LLM temperature=0 and make source grounding in your system prompt

The Fixed Pipeline at a Glance

Stage 1
Parsing
Camelot lattice · pdfplumber · Docling · LlamaParse

Structure-aware extraction preserves row-column relationships. Each table extracted as a DataFrame with headers intact.

✓ Table structure preserved
Stage 2
Chunking
Atomic table serialization · sentence-aware text splits

Tables serialized as single labeled blocks with headers. Text splits at sentence boundaries. Content-type metadata tagged on every chunk.

✓ No mid-table splits. Headers always co-located with values.
Stage 3
Embedding
text-embedding-3-small · BGE-M3

Serialized table chunks now carry structural context — "Source: Q2_Report.pdf | Page: 4 | Columns: Quarter | Revenue | Net Margin" — giving the embedding meaningful signal to work with.

✓ Structured context embedded alongside values
Stage 4
Retrieval
BM25 + vector · Reciprocal Rank Fusion

Hybrid retrieval combines exact keyword matching (BM25) with semantic similarity. Numerical values, dates, and column names are no longer missed by vector-only search.

✓ Exact term match + semantic retrieval combined
Stage 5
Generation
GPT-4o · Claude · temperature=0

Strict grounding prompt prohibits interpolation or estimation. LLM cites source file, page, and column for every numerical answer. Returns "Not found" rather than guessing.

✓ Grounded, cited, non-interpolating answers

Frequently Asked Questions

These are the follow-up questions that come up most often — in interviews, in code reviews, and in production postmortems.

Does LlamaParse work for all PDF types including scanned documents?

Yes. LlamaParse is a GenAI-native parser that handles scanned PDFs via built-in OCR, as well as complex layouts with mixed text, tables, and images. It is a managed API (not self-hosted). For self-hosted alternatives with similar capability, Docling from IBM Research is the strongest open-source option, using the DocLayNet layout model and TableFormer for table structure recognition.

What is two-path ingestion in RAG?

Two-path ingestion is an architecture pattern that routes PDF content by type during the ingestion stage. Text blocks follow a standard chunking and embedding path. Tables are extracted structurally, serialized as atomic labeled chunks with column headers intact, and indexed separately with a content_type: "table" metadata tag. At query time, a router determines whether to search the text index, the table index, or both.

Why is temperature=0 important for table RAG queries?

Temperature controls the randomness of LLM output. At higher temperatures, the model samples more freely from its probability distributionwhich means it can interpolate or estimate values not present in the retrieved context. At temperature=0, the model always picks the highest-probability token, making it maximally faithful to the literal context. For numerical answers from tables, this eliminates the interpolation class of hallucination.

What is Reciprocal Rank Fusion and why use it for PDF table retrieval?

Reciprocal Rank Fusion (RRF) is a rank aggregation algorithm that combines the result rankings from multiple retrieval systems. For each document, RRF computes a score based on its position in each ranked list, then sums them. This lets you merge BM25 keyword rankings (which catch exact numerical matches) and vector similarity rankings (which catch semantic intent) without needing to tune raw score scales, which differ between retrieval methods.

Conclusion

Every RAG failure on a PDF table is an ingestion problem wearing an LLM costume. Teams spend days tweaking prompts and swapping models when the fix was always one level upstream — in how the document was parsed.

The pattern I've shipped in production is always the same: structure-aware extractor → atomic serialized chunks → two-path indexing → hybrid retrieval → grounded generation prompt. It takes a day to implement correctly. It saves weeks of debugging wrong answers.

If you take one thing from this post: before you blame the LLM, print the chunk that was actually retrieved and look at it. If it's broken, the answer was always going to be wrong — and no amount of prompt engineering fixes a corrupted context.

Happy Learning!!!

Post a Comment

0 Comments