Skip to content

Handling Failures

Every retrieval pipeline fails sometimes. The query is about a topic not in your corpus. The relevant document exists but was split across chunk boundaries. The user asks a question in a language your embedding model doesn't support well. The failure itself isn't the problem -- every system has gaps. The problem is what your system does next.

An LLM that receives an empty or irrelevant context will still generate an answer. It doesn't know the context is bad. It sees "no documents found" and fills in the gaps from its training data, which might be outdated, wrong, or invented. Handling retrieval failures means detecting when the pipeline didn't find good results and responding appropriately: fall back, ask for clarification, or admit you don't know.

Owl mascot

What you'll learn

  • An LLM given empty or irrelevant retrieval context will still answer -- it hallucinates from training data instead of refusing; you must detect the failure before the LLM sees it
  • A fallback chain (hybrid -> keyword-only -> broader search -> "I don't know") gives each strategy a chance before admitting defeat
  • Admitting ignorance is a feature, not a failure: "I couldn't find information about X in the documentation" is more trustworthy than a confident wrong answer

The problem: the LLM will answer regardless

Consider this pipeline:

Here is a concrete example demonstrating the implementation details.

python
results = hybrid_search("What is the warranty on the WD-40-BLACK-12PK?")
context = "\n".join(doc["content"] for doc in results)
answer = call_llm(f"Answer using this context:\n{context}\n\nQuestion: What is the warranty?")

If your documents contain zero warranty information, results might be empty or contain marginally related documents about shipping and defects. The LLM still generates an answer because that's what it's trained to do. It might invent a plausible-sounding warranty policy drawn from its training data about other products. The user has no way to know the answer isn't grounded in your actual documents.

The fix is a retrieval confidence check before the LLM receives the context.

Options & when to use each

StrategyWhat it doesWhen to use
Relevance thresholdReject results below a minimum relevance score (from cross-encoder or vector distance)You have reliable relevance scores and can set a meaningful threshold
Fallback chainTry multiple retrieval strategies in sequence: hybrid, then keyword-only, then broader searchDifferent query types fail in different ways; a chain catches more cases
Admit ignoranceReturn "I don't know" or "I couldn't find that in the documentation"The query is out of scope or your corpus genuinely doesn't contain the answer
Ask clarifying questionsPrompt the user to rephrase or be more specificThe query is ambiguous and a rephrasing might match
Semantic "out of domain" detectionClassify whether the query is about a topic your corpus covers at allYou want to filter before any retrieval attempt

Build it: a fallback chain with confidence thresholds

Step 1: define relevance thresholds

Here is a concrete example demonstrating the implementation details.

python
from dataclasses import dataclass

@dataclass
class RetrievalResult:
    documents: list[dict]
    strategy: str           # which retrieval method produced these
    confidence: float       # 0.0 to 1.0, how confident we are these are good

# Thresholds
HIGH_CONFIDENCE = 0.5       # Reranker score: good enough to use directly
LOW_CONFIDENCE = 0.2        # Below this, results are likely irrelevant
VECTOR_DISTANCE_OK = 0.3    # Cosine distance threshold (lower is better)

Step 2: build the fallback chain

Here is a concrete example demonstrating the implementation details.

python
def retrieve_with_fallback(
    query: str,
    top_k: int = 5,
) -> RetrievalResult:
    """
    Try retrieval strategies in sequence until we get confident results.
    Returns the best result set found, or an empty result with low confidence.
    """

    # Strategy 1: hybrid search with re-ranking
    candidates = hybrid_search(query, top_k=20)
    if candidates:
        reranked = retrieve_and_rerank(query)  # from re-ranking lesson
        if reranked and reranked[0].get("relevance_score", 0) >= HIGH_CONFIDENCE:
            return RetrievalResult(
                documents=reranked[:top_k],
                strategy="hybrid+rerank",
                confidence=reranked[0]["relevance_score"],
            )

    # Strategy 2: keyword-only search (vector search might have missed)
    text_results = conn.execute("""
        SELECT id, content,
               ts_rank(text_search, websearch_to_tsquery('english', %s)) AS rank
        FROM documents
        WHERE text_search @@ websearch_to_tsquery('english', %s)
        ORDER BY rank DESC
        LIMIT %s
    """, (query, query, top_k)).fetchall()

    if text_results:
        return RetrievalResult(
            documents=[{"id": r[0], "content": r[1], "score": r[2]} for r in text_results],
            strategy="keyword-only",
            confidence=0.5,  # Moderate: we found keyword matches
        )

    # Strategy 3: broader vector search with lower threshold
    embedding = get_embedding(query)
    broad_results = conn.execute("""
        SELECT id, content, embedding <=> %s::vector AS distance
        FROM documents
        WHERE embedding <=> %s::vector < %s
        ORDER BY embedding <=> %s::vector
        LIMIT %s
    """, (embedding, embedding, VECTOR_DISTANCE_OK * 2, embedding, top_k)).fetchall()

    if broad_results:
        return RetrievalResult(
            documents=[{"id": r[0], "content": r[1], "distance": r[2]} for r in broad_results],
            strategy="broad-vector",
            confidence=0.3,  # Low: these are distant matches
        )

    # Strategy 4: admit we found nothing
    return RetrievalResult(
        documents=[],
        strategy="none",
        confidence=0.0,
    )

Step 3: build the response handler

Here is a concrete example demonstrating the implementation details.

python
def answer_with_fallback(query: str) -> str:
    """
    Full pipeline: retrieve with fallback, then decide how to respond
    based on retrieval confidence.
    """
    retrieval = retrieve_with_fallback(query)

    if retrieval.confidence >= HIGH_CONFIDENCE:
        # Confident: generate a normal answer with citations
        context = "\n---\n".join(
            f"[{i+1}] {doc['content']}" for i, doc in enumerate(retrieval.documents)
        )
        return call_llm(
            system="Answer the question using only the provided context. "
                    "Cite sources with [1], [2], etc. If the context doesn't "
                    "contain the answer, say so.",
            message=f"Context:\n{context}\n\nQuestion: {query}"
        )

    elif retrieval.confidence >= LOW_CONFIDENCE:
        # Moderate: answer but flag low confidence
        context = "\n---\n".join(
            f"[{i+1}] {doc['content']}" for i, doc in enumerate(retrieval.documents)
        )
        return call_llm(
            system="Answer the question using the provided context. "
                    "If the context doesn't fully answer the question, "
                    "state what you found and what's missing. "
                    "Begin your response with 'I found partial information...' "
                    "if the answer is incomplete.",
            message=f"Context:\n{context}\n\nQuestion: {query}"
        )

    else:
        # No relevant results: admit ignorance
        return (
            f"I couldn't find information about '{query}' in the documentation. "
            "This topic may not be covered, or you might try rephrasing your question."
        )

Step 4: add query classification

Before retrieval, classify whether the query is even in your domain:

python
def is_in_domain(query: str, domain_description: str) -> bool:
    """Check if a query is about a topic the corpus covers."""
    system = (
        "You classify whether a user query is about JWT authentication "
        "and token management. Reply with only YES or NO."
    )
    response = call_llm(system=system, message=query)
    return "YES" in response.upper()

Here is a concrete example demonstrating the implementation details.

python
def answer_safely(query: str) -> str:
    """Full safe answering pipeline with domain check."""
    if not is_in_domain(query, "JWT authentication and token management"):
        return (
            "I'm a JWT documentation assistant and can't answer questions "
            "outside that domain. Try asking about JWT tokens, authentication, "
            "or session management."
        )

    return answer_with_fallback(query)

What goes wrong

MistakeHow you notice itThe fix
Threshold too strictLegitimate queries return "I don't know" too often; users stop trusting the systemLower the threshold. Log rejected queries and manually check whether relevant documents existed. If >20% of rejections were wrong, your threshold is too high
Threshold too looseLow-confidence answers are presented as authoritative; users get incorrect informationRaise the threshold. When confidence is moderate, always include a hedge: "I found partial information..." as shown above
Fallback chain is too slowThree retrieval strategies take 2+ seconds before returningRun strategies in parallel instead of sequentially. Return the first result that crosses the confidence threshold. If the primary strategy is fast and usually works, skip the fallback for that case
"I don't know" response is unhelpfulUsers get a dead end with no path forwardAlways suggest next steps: "Try rephrasing your question to include specific terms like 'token expiration' or 'refresh token.'" Or: "Here are topics I can help with: JWT structure, token signing, expiration, refresh tokens."
LLM ignores the "admit ignorance" instructionEven when told to say "I don't know," the LLM invents an answerUse structured output: force the LLM to output JSON with a found_answer boolean. If false, return the canned response. If true, return the answer. This is more reliable than prompting alone

Confirm it worked

Here is a concrete example demonstrating the implementation details.

python
# Test 1: in-domain query with good retrieval
response = answer_safely("How do I set a JWT to expire after 1 hour?")
print("In-domain:", response[:150])
# Expected: a specific answer with citation markers [1], [2]

# Test 2: in-domain query with no relevant documents
response = answer_safely("What is the warranty on JWT tokens?")
print("No-match:", response[:150])
# Expected: "I couldn't find information about..." or similar admission

# Test 3: out-of-domain query
response = answer_safely("What's the weather in Tokyo?")
print("Out-of-domain:", response[:150])
# Expected: "I'm a JWT documentation assistant..." domain refusal

# Test 4: verify the chain runs without errors
result = retrieve_with_fallback("xyzzy123_nonexistent_term_999")
assert result.strategy in ("broad-vector", "none"), \
    f"Expected fallback to broad-vector or none, got {result.strategy}"
print(f"Fallback strategy used: {result.strategy}, confidence: {result.confidence}")
# Expected: strategy="none", confidence=0.0 for a nonsense query

Next: Day 4 -- Advanced RAG Patterns