DEV Community

Hossein Hezami
Hossein Hezami

Posted on

Your RAG Retrieved the Right Document — So Why Was the Answer Wrong?

You check the logs. The vector search returned the exact policy document the user asked about. The similarity score was 0.94. The context window was populated with the correct text.

Yet, the LLM confidently gave the wrong answer.

When a Retrieval-Augmented Generation (RAG) system fails, the immediate instinct is to blame the retrieval pipeline. We tweak chunk sizes, switch embedding models, or add hybrid search. But retrieval is only half the battle. If the retriever successfully found the right document and the generation step still failed, you are looking at a completely different class of engineering problem.

A working RAG system is not just a search engine bolted to a chatbot. It is a complex pipeline where text topology, attention mechanics, parametric memory, and prompt grounding all collide. When the right document is in the context but the answer is still wrong, the failure lives in the space between the retrieved text and the model's final token generation.

TL;DR

  • Finding the right document does not guarantee the model will read it correctly.
  • Attention mechanisms still suffer from positional bias, even with massive context windows.
  • Naive chunking destroys anaphora and semantic bridges, leaving the model with orphaned facts.
  • LLMs will confidently override your context with their pre-trained parametric memory if not strictly grounded.
  • Multi-hop reasoning and tabular data require specialized ingestion and prompting strategies.
  • You cannot fix generation failures without evaluating the RAG triad (Context, Groundedness, Answer).

📋 Table of Contents

1. The "Lost in the Middle" Trap: Position Matters More Than Presence

Scenario:

Your retriever fetches 20 chunks to provide comprehensive context. The exact answer to the user's question is sitting in chunk 14. The model ignores it, bases its answer on chunk 2, and hallucinates the rest.

Why it matters:

It is a common misconception that modern 128k or 1M+ token context windows process all information equally. They don't. Transformer attention mechanisms still exhibit a well-documented U-shaped performance curve. Models pay the most attention to the beginning of the prompt (the primacy effect) and the end of the prompt (the recency effect). Information buried in the middle of a massive context window is often compressed or ignored by the attention sinks.

Solution:

Do not just append retrieved chunks in order of similarity score. Use a contextual wrapping strategy that places the most relevant chunks at the very beginning and the very end of the context block, pushing the lower-ranked (but still relevant) chunks to the middle.

def wrap_context_by_relevance(chunks: list[str]) -> str:
    """
    Places the highest scoring chunks at the start and end 
    of the context window to exploit primacy and recency effects.
    """
    if len(chunks) <= 2:
        return "\n\n".join(chunks)

    # chunks are already sorted by similarity score descending
    middle_start = 1
    middle_end = len(chunks) - 1

    # The highest score chunk goes first
    primacy_chunk = chunks[0]
    # The second highest goes last
    recency_chunk = chunks[middle_end]

    # The rest go in the middle
    middle_chunks = chunks[middle_start:middle_end]

    wrapped = [primacy_chunk] + middle_chunks + [recency_chunk]
    return "\n\n---\n\n".join(wrapped)
Enter fullscreen mode Exit fullscreen mode

Why this works:

This forces the model's attention mechanism to anchor on the most critical evidence at both boundaries of the context window. It is a cheap, zero-cost architectural tweak that significantly reduces "lost in the middle" hallucinations without requiring a smaller context window.

💡 Practical note:

If you are using a re-ranker (like Cohere Rerank or BGE-Reranker), apply this wrapping logic after re-ranking, not after the initial vector search. Vector similarity scores are notoriously poor at ranking the top 5 results accurately.

2. Right Document, Wrong Slice: When Chunking Breaks Anaphora

Scenario:

The user asks about the database connection limit. The retriever fetches a chunk that says: "Therefore, the maximum limit is 500. Exceeding this will result in a timeout." The model answers "500", but the user asked about the Redis limit, and that chunk was actually talking about PostgreSQL. The subject was in the previous chunk.

Why it matters:

Fixed-size or naive recursive chunking destroys anaphora resolution. When you slice a document at arbitrary token boundaries, you sever the semantic bridge between a pronoun (or a concluding statement) and its subject. The retriever found the right document, but it handed the LLM an orphaned fact.

Solution:

Implement Small-to-Big retrieval (also known as Parent-Child or Auto-merging retrieval). You embed small, highly specific chunks for precise vector matching, but when a chunk is retrieved, you inject its larger parent context into the LLM.

from dataclasses import dataclass

@dataclass
class DocumentNode:
    doc_id: str
    text: str
    parent_id: str | None
    child_ids: list[str]

class ParentChildRetriever:
    def __init__(self, vector_store, doc_store):
        self.vector_store = vector_store
        self.doc_store = doc_store

    def retrieve(self, query: str, top_k: int = 3) -> list[str]:
        # 1. Search against the small, highly specific child chunks
        child_hits = self.vector_store.search(query, top_k=top_k)

        parent_ids = set()
        for hit in child_hits:
            node = self.doc_store.get(hit.doc_id)
            if node.parent_id:
                parent_ids.add(node.parent_id)
            else:
                parent_ids.add(node.doc_id)

        # 2. Fetch the larger parent documents for the LLM context
        parent_docs = [self.doc_store.get(pid).text for pid in parent_ids]
        return parent_docs
Enter fullscreen mode Exit fullscreen mode

Why this works:

The vector search gets the precise semantic match (the child), but the LLM gets the full surrounding paragraph or section (the parent). The model now has the subject, the context, and the conclusion, eliminating anaphora-induced hallucinations.

⚠️ Gotcha:

Small-to-big retrieval increases the number of tokens sent to the LLM. If multiple child chunks map to the same parent, ensure your pipeline deduplicates the parent documents before building the final prompt, or you will waste context window space and money.

3. Parametric Ego: When the Model's Pre-Training Overrides Your Data

Scenario:

Your internal wiki states that your company's API rate limit is 100 requests per minute. The retrieved context clearly says "100 requests per minute." The LLM answers: "The standard API rate limit is 60 requests per minute."

Why it matters:

LLMs have strong parametric priors. If your private data contradicts the public internet data the model was trained on, the model will often side with its pre-trained weights, especially if the prompt doesn't strictly forbid it. The model isn't hallucinating from nowhere; it is hallucinating from its training data, overriding your context.

Solution:

You must break the model's parametric ego through strict grounding instructions and citation enforcement. Do not just say "use the context." Force the model to prove it is using the context.

SYSTEM_PROMPT = """
You are an internal assistant. Answer the user's question using ONLY the provided context.

Rules:
1. If the context contradicts your pre-trained knowledge, the context is ALWAYS correct.
2. You must cite the exact text snippet from the context that supports your answer.
3. If the answer is not in the context, reply exactly with: "I cannot answer this based on the provided documents."
4. Do not use outside knowledge to fill in gaps.

Context:
{context}
"""
Enter fullscreen mode Exit fullscreen mode

Why this works:

By forcing the model to output exact text snippets as citations, you constrain its decoding path. It is computationally much harder for a transformer to hallucinate a parametric fact while simultaneously being forced to generate a verbatim citation from the context that contradicts that fact.

4. Temporal Drift and the Contradiction Trap

Scenario:

The user asks for the current pricing of the Enterprise plan. The retriever pulls three chunks: one from the 2022 pricing page, one from the 2024 pricing page, and one from a 2025 draft. The model reads all three, gets confused, and averages the prices or picks the oldest one.

Why it matters:

Vector databases do not understand time. They only understand semantic proximity. If a document has been updated multiple times and older versions were not purged from the index, the retriever will happily fetch contradictory temporal snapshots. The generation step then fails because it lacks the logic to resolve temporal conflicts.

Solution:

Implement strict temporal metadata filtering at the retrieval stage, and provide temporal awareness in the prompt.

from datetime import datetime, UTC

def search_pricing(query: str):
    current_year = datetime.now(UTC).year

    # Filter out outdated documents at the vector DB level
    metadata_filter = {
        "doc_type": "pricing",
        "status": "published",
        "year": {"$gte": current_year - 1} # Only fetch current or immediate past
    }

    return vector_store.search(
        query=query, 
        filter=metadata_filter, 
        top_k=3
    )
Enter fullscreen mode Exit fullscreen mode

Why this works:

You prevent the generation failure by ensuring the LLM never sees the contradictory evidence in the first place. If you must keep older documents for historical queries, your system prompt must explicitly instruct the model to look for temporal markers (e.g., "Effective Date", "Last Updated") and prefer the most recent one.

5. The Multi-Hop Synthesis Failure

Scenario:

The user asks: "Which engineers in the Platform team have access to the production database?"
Chunk A contains the list of Platform team engineers. Chunk B contains the list of employees with production database access. The model reads both chunks and fails to intersect the two lists, instead just summarizing Chunk A.

Why it matters:

LLMs are notoriously bad at implicit set intersection and multi-hop reasoning in a single forward pass. If the answer requires combining facts from two different chunks using logical deduction, a standard RAG generation step will usually fail or hallucinate.

Solution:

For complex analytical queries, move from standard RAG to Agentic RAG with query decomposition. Break the user's question into sub-queries, retrieve context for each, and synthesize.

def decompose_and_answer(query: str, agent):
    # Step 1: Decompose the query
    sub_queries = agent.llm.generate(
        f"Break this question into independent sub-queries: {query}"
    )
    # e.g., ["List of Platform team engineers", "List of employees with prod DB access"]

    # Step 2: Retrieve context for each sub-query
    sub_contexts = []
    for sub_q in sub_queries:
        chunks = agent.retriever.search(sub_q)
        sub_contexts.append(agent.llm.summarize(chunks, sub_q))

    # Step 3: Synthesize the final answer
    final_prompt = f"""
    Sub-query 1 results: {sub_contexts[0]}
    Sub-query 2 results: {sub_contexts[1]}

    Original Question: {query}
    Intersect the results to answer the original question.
    """
    return agent.llm.generate(final_prompt)
Enter fullscreen mode Exit fullscreen mode

Why this works:

It transforms a difficult cognitive task (implicit set intersection across noisy text) into a sequence of simpler tasks (retrieval, summarization, explicit intersection). The final generation step now has clean, pre-processed facts to work with.

🧠 The important part:

Query decomposition adds latency and cost. Only route complex, analytical queries through this path. Use an intent-classifier to send simple factual queries through the standard, faster RAG pipeline.

6. Mangled Topology: When Tables and Code Become Prose

Scenario:

The user asks about the error codes for the payment gateway. The retriever fetches the correct document, which contains a markdown table of error codes. The LLM reads the text and tells the user that error code 4002 means "Success", when it actually means "Insufficient Funds", because the columns got misaligned during text extraction.

Why it matters:

Standard RAG pipelines use text splitters that treat everything as a 1D string of characters. When a PDF or HTML table is parsed into plain text, the 2D topology is destroyed. Columns bleed into rows. The LLM, reading left-to-right, completely misinterprets the structural relationship between the data points.

Solution:

Preserve structural topology during ingestion. Convert tables to Markdown, HTML, or structured JSON before embedding and passing them to the LLM.

import pandas as pd

def serialize_table_for_rag(df: pd.DataFrame) -> str:
    """
    Converts a dataframe into a format the LLM can 
    reliably parse without losing column alignment.
    """
    # Markdown is generally the best format for LLM table comprehension
    md_table = df.to_markdown(index=False)

    # Add a semantic wrapper so the model knows what it's looking at
    return f"<table_context>\n{md_table}\n</table_context>"
Enter fullscreen mode Exit fullscreen mode

Why this works:

Modern LLMs are heavily trained on Markdown and HTML. By preserving the table structure using pipe characters (|) or HTML tags, you allow the model's attention mechanism to track column alignment. Never pass raw, unformatted CSV or space-separated text to an LLM if you expect it to understand tabular relationships.

7. Weak Grounding: You Didn't Tell It How to Read

Scenario:

The retrieved context is slightly adjacent to the user's question, but doesn't contain the exact answer. Instead of saying "I don't know," the model politely hallucinates a plausible answer to be helpful.

Why it matters:

LLMs are RLHF-tuned to be helpful assistants. Their default optimization function prioritizes providing an answer over providing a strictly factual answer. If your system prompt doesn't explicitly penalize helpfulness in favor of groundedness, the model will fill in the blanks.

Solution:

Use structured outputs to force the model to evaluate its own groundedness before generating the final answer.

from pydantic import BaseModel, Field

class RAGResponse(BaseModel):
    is_grounded: bool = Field(description="True if the answer is fully supported by the context.")
    missing_info: list[str] = Field(description="List of facts needed to answer that are missing from the context.")
    final_answer: str = Field(description="The answer to the user, or a refusal if not grounded.")

def generate_strict_answer(query: str, context: str):
    prompt = f"""
    Context: {context}
    Question: {query}

    Evaluate if the context fully answers the question. 
    If not, set is_grounded to false and list the missing info.
    """

    # Assuming an OpenAI-style client with structured output support
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": prompt}],
        response_format=RAGResponse
    )
    return response.choices[0].message.parsed
Enter fullscreen mode Exit fullscreen mode

Why this works:

By forcing the model to output a boolean is_grounded flag and a missing_info array before it generates the final_answer, you force it to perform a chain-of-thought verification. If is_grounded is false, your application layer can intercept the response and return a standard "I don't have enough information" message to the user, completely bypassing the hallucination.

8. Proving the Failure: Isolating Generation from Retrieval

Scenario:

Users complain the bot is giving wrong answers. You check the vector DB and see the right documents are there. You blame the LLM. Your product manager blames the search. You have no data to prove who is right.

Why it matters:

You cannot fix a RAG pipeline if you cannot isolate where the failure occurred. "The answer is wrong" is not an actionable metric. You need to know if the retriever failed to find the document, or if the generator failed to read it.

Solution:

Implement the RAG Triad evaluation framework in your observability pipeline (using tools like Ragas, TruLens, or custom LLM-as-a-judge scripts).

The triad measures three distinct dimensions:

Metric What it measures Who is at fault if it fails?
Context Relevance Does the retrieved text actually pertain to the query? The Retriever (Embeddings, Chunking, Search)
Groundedness Is every claim in the final answer supported by the retrieved text? The Generator (Prompting, Model capability)
Answer Relevance Does the final answer actually address the user's original question? The Generator (Prompting, Instruction following)
def evaluate_rag_triplet(query, retrieved_context, generated_answer):
    # This is typically done via an LLM-as-a-judge
    groundedness_score = llm_judge.evaluate(
        f"Rate from 0-1 how fully the generated answer is supported "
        f"by the context. Context: {retrieved_context} Answer: {generated_answer}"
    )

    if groundedness_score < 0.8:
        log_failure(reason="GENERATION_FAILURE", query=query)
    else:
        # If it's grounded, but the answer is still wrong, 
        # the context itself was wrong.
        log_failure(reason="RETRIEVAL_FAILURE", query=query)
Enter fullscreen mode Exit fullscreen mode

Why this works:

It shifts RAG debugging from guesswork to engineering. If Groundedness is high but the answer is wrong, you know the retriever fed the model bad data. If Groundedness is low, you know the retriever did its job, but the model hallucinated.

🔍 Why this matters:

Never deploy a RAG system to production without automated Groundedness scoring on a sample of live traffic. It is the only way to catch generation drift before your users do.

The Pre-Flight Checklist for RAG Generation

If your retriever is fetching the right documents but your answers are still wrong, stop tweaking your vector database. Run through this generation-focused checklist instead:

  1. Context Positioning: Are the highest-ranked chunks placed at the very beginning and very end of the prompt?
  2. Semantic Bridges: Are you using Small-to-Big retrieval to ensure the LLM sees the parent context of highly specific child chunks?
  3. Parametric Override: Does your system prompt explicitly force citation and penalize outside knowledge?
  4. Temporal Awareness: Are you filtering out outdated document versions via metadata before they reach the LLM?
  5. Multi-Hop Logic: Are you attempting complex set-intersection queries in a single pass instead of using query decomposition?
  6. Structural Topology: Are tables and code blocks being passed as preserved Markdown/HTML rather than flattened plain text?
  7. Strict Grounding: Are you using structured outputs to force the model to verify its own groundedness before answering?
  8. Observability: Are you measuring Groundedness independently of Context Relevance in your evaluation pipeline?

RAG is not a single technology; it is a pipeline of compromises. The model is only as good as the reading environment you build for it. When you stop treating the context window like a text dump and start treating it like a structured workspace, the "dumb" model suddenly becomes a lot smarter.

Top comments (0)