QRefAI
Contents
Advanced RAG

Part 6 — The Retrieval Plane: Why Retrieval Fails

What are the eight ways retrieval silently returns the wrong answer, and what is the specific pattern that kills each one?

11 min · Updated July 2026

Retrieval is where most operational RAG accuracy is actually lost — and where it is most silently lost. A parse failure is visible (an empty table). A retrieval failure is not: the system returns a confident, fluent, fully cited answer — grounded in the wrong chunks.

One shape underlies almost everything here: retrieve wide, rank precise. Five of the nine patterns below are variations on it. And if you ship only three patterns to start, ship R1 (hybrid), R2 (rerank), and R8 (ACL pre-filter) — the first two are the core accuracy move, the third is a security floor that applies to every query. The rest are additive, activated as specific failures show up.

6.1 “My retrieval is bad” isn’t actionable — here are the eight failures that are

Naming these precisely is the prerequisite for designing against them. Each is written below as the symptom you’ll actually observe, with its F-code kept as a tag so it cross-references Parts 1 and 3.

F1 — “The answer is clearly in there, but search worded it differently and missed it.”
The question and the answer live in different vocabulary — “declining quarterly performance” vs. a section titled “revenue shortfall in Q3.” Dense vector similarity handles small gaps but fails when technical domain language creates a large lexical distance.
F2 — “I searched for an exact error code and got generic troubleshooting instead.”
Some queries can’t be paraphrased — “part number XR-7741,” “ISO 27001 Clause 9.4.2,” “error code 0x8007045D.” Dense embeddings scatter these tokens into neighbourhoods shared with dozens of unrelated concepts. BM25 exact-match retrieval solves it directly.
F3 — “The right document exists, but it ranked 15th and I only fetched the top 10.”
The bi-encoder driving dense retrieval discriminates coarsely at scale, so the correct document is often retrieved just outside the top-k window. The fix isn’t a bigger k — it’s retrieve wide, then rerank precisely.
F4 — “The question needs three facts from three documents, and one lookup can’t chain them.”
“Which vendors did the CFO who joined in 2021 approve?” needs three steps: find the CFO, find their approvals, find the vendors. A single vector lookup retrieves one topic; no embedding captures the inference chain.
F5 — “I retrieved the right chunk and the model still ignored it.”
The correct chunk is in the context, but at position 10 of 20. Models attend to the head and tail of long contexts and underweight the middle — so a system that buries the best chunk in the middle has effectively hidden the answer.
F6 — “One wrong-but-plausible chunk sneaked in and the model blended it into the answer.”
A distractor retrieved alongside correct chunks gets synthesized in by a model trained to be helpful. Especially dangerous for numerical claims.
F7 — “The answer isn’t in our corpus at all, but the system made one up anyway.”
The corpus lacks the answer, or holds a stale version, and the pipeline synthesizes from the closest available content regardless. Static RAG’s most systemic failure: no “I don’t know” path, no freshness awareness.
F8 — “I pointed every query at GraphRAG and my simple lookups got worse.”
GraphRAG is superior for multi-hop and narrative questions but empirically inferior by ~13% on simple factoids (Han et al., 2025). Applying the heaviest tool to every query class degrades simple queries while adding latency and cost.

6.2 “So what do I actually build?” — the nine patterns, and which failure each kills

The table is the reference; the three highest-leverage patterns get code below it. Note the coverage: every failure is killed by at least one pattern, but F5 is killed only by R7 — it’s the easiest gap to leave open.

PatternNameFailures killedMechanism
R1Hybrid retrieval — dense + BM25 + RRFF1, F2Dense and BM25 run in parallel over every query; scores fused via RRF server-side in Qdrant. Neither channel is optional.
R2Wide retrieve then cross-encoder rerankF3, F6Retrieve top-100 to 200 for recall; cross-encoder (BGE-Reranker-v2-m3) cuts to a precise top-8 to 10.
R3Query transformation (HyDE / multi-query / step-back)F1, F4HyDE embeds a hypothetical answer; multi-query paraphrases; step-back abstracts for broad recall. Fused with RRF.
R4Adaptive routing (complexity classifier)F8Lightweight classifier routes to: no-retrieval, single-hop, multi-hop, GraphRAG, SQL, or real-time. ~40% cost reduction.
R5Multi-hop iterative retrievalF4Decompose the compound question; retrieve per sub-query; use each sub-answer to form the next.
R6GraphRAG (gated) + RAPTORF4, thematic queriesKuzu graph traversal for entity chains; RAPTOR recursive summaries for global themes. Activated only by R4.
R7Contextual compression and reorderingF5, F6Drop chunks below a relevance floor; reorder so top-scoring chunks sit at the head and tail of the window.
R8Metadata / ACL pre-filteringF6, cross-tenant leakagetenant_id, acl_set, date range applied as Qdrant payload filters inside the vector query — never post-hoc.
R9Retrieval confidence gatingF7If the top reranked score is below threshold, suppress synthesis; route to web-search fallback or return no-answer.

R2 — wide retrieve, then rerank is the single highest-ROI upgrade. Retrieve generously so the right chunk is somewhere in the candidate set (fixes F3), then let a cross-encoder read query and chunk together to reorder precisely (fixes F6):

# R2: retrieve wide for recall, rerank narrow for precision. The core retrieval move.
from sentence_transformers import CrossEncoder

reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")

def retrieve_and_rerank(query, store, wide_k=150, final_k=8):
    candidates = store.hybrid_search(query, k=wide_k)          # recall: cast wide
    pairs = [(query, c.text) for c in candidates]
    scores = reranker.predict(pairs)                            # precision: read together
    ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
    return [c for c, _ in ranked[:final_k]]                     # precise top-8

R4 — adaptive routing is where the ~40% cost reduction comes from: don’t run the heavy path on every query. A cheap classifier sends each query to the minimum sufficient strategy (fixes F8):

# R4: route each query to the cheapest strategy that can answer it.
def route(query: str, classify) -> str:
    kind = classify(query)          # small/fast LLM or fine-tuned classifier
    return {
        "factoid":   "no_retrieval",   # model already knows it — skip retrieval
        "simple":    "single_hop",     # one hybrid lookup
        "compound":  "multi_hop",      # R5 iterative
        "relational":"graph",          # R6 GraphRAG — only here, not everywhere
        "numeric":   "sql",            # text-to-SQL over source tables
        "temporal":  "web_search",     # freshness path
    }[kind]

R7 — reorder the context is the only fix for F5, and it’s nearly free: after reranking, place the strongest chunks where the model actually attends — the head and tail — and the weaker ones in the middle:

# R7: defeat lost-in-the-middle. Best chunks go to the edges, not the center.
def reorder_for_attention(chunks):        # chunks arrive sorted best → worst
    head, tail = [], []
    for i, c in enumerate(chunks):
        (head if i % 2 == 0 else tail).append(c)
    return head + tail[::-1]               # strongest at both ends, weakest in middle

For the RRF fusion that powers R1 and R3, see the worked example in Part 2 — it’s the same algorithm, so it isn’t repeated here.

Found this useful?