DEV Community

Syeed Talha
Syeed Talha

Posted on

Hybrid RAG in LangChain

The two RAG approaches we've covered so far both have a silent assumption baked in — that the first retrieval attempt will return good enough documents, and that the generated answer will be good enough to return directly.

Most of the time that works fine. But sometimes the original query is vague, the retrieved documents are only loosely related, or the generated answer drifts away from what the context actually says.

Hybrid RAG adds validation loops to catch those failures before they reach the user.


The Core Idea

2-Step RAG has one shot at retrieval. Agentic RAG decides whether to retrieve at all. Hybrid RAG does both and then asks: "Was that actually good enough?"

If the retrieved documents can't answer the question — try again with a better query. If the generated answer isn't grounded in the context — regenerate it. Only return the answer when it passes both checks.


The Workflow

Original Query
      ↓
  Step 1: Query Enhancement
  LLM rewrites the query to be more search-friendly
      ↓
  Enhanced Query
      ↓
  Step 2: Retrieve (k=2)
  FAISS similarity search
      ↓
  Step 3: Validation Loop — "Are these docs sufficient?"
  ┌─────────────────────────────────────────────────┐
  │  LLM checks: can these docs answer the query?   │
  │                                                 │
  │  NO → refine query → retrieve again (k=3) ──┐  │
  │  (max 3 attempts)                           │  │
  │                         ↑───────────────────┘  │
  │  YES → continue                                 │
  └─────────────────────────────────────────────────┘
      ↓
  Step 4: Generate Answer
  LLM answers using retrieved docs as context
      ↓
  Step 5: Answer Validation — "Is this answer accurate?"
  ┌─────────────────────────────────────────────────┐
  │  LLM checks: is the answer grounded in context? │
  │                                                 │
  │  NO → regenerate answer ────────────────────┐  │
  │                                             │  │
  │  YES → done                  ───────────────┘  │
  └─────────────────────────────────────────────────┘
      ↓
  Final Answer
Enter fullscreen mode Exit fullscreen mode

Walking Through the Code

The Knowledge Base and Vector Store

docs = [
    Document(page_content="LangChain is a framework for building LLM applications..."),
    Document(page_content="Agents in LangChain use an LLM to decide which tools to call..."),
    Document(page_content="RAG stands for Retrieval-Augmented Generation..."),
    Document(page_content="Vector stores like FAISS and Chroma store embeddings..."),
    Document(page_content="LangGraph is a library for building stateful, multi-actor applications..."),
]

embeddings = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")
vectorstore = FAISS.from_documents(docs, embeddings)
Enter fullscreen mode Exit fullscreen mode

Same as before — embed the documents and store them in FAISS. Nothing new here.


Step 1: Query Enhancement

def enhance_query(original: str) -> str:
    prompt = f"Rewrite this question to make it better for searching a LangChain documentation knowledge base. Return ONLY the rewritten question.\n\nOriginal: {original}\n\nRewritten:"
    result = free_llm.invoke(prompt)
    return result.content.strip()
Enter fullscreen mode Exit fullscreen mode

Before searching, we ask the LLM to rewrite the query into something more likely to match the documents.

For example:

Original:  "What is RAG?"
Enhanced:  "Explain Retrieval-Augmented Generation and how it works in LangChain"
Enter fullscreen mode Exit fullscreen mode

The enhanced version has more overlap with the actual document text, which means FAISS is more likely to return relevant results.


Step 2: Retrieve

def retrieve(query: str, k: int = 2) -> list[Document]:
    return vectorstore.similarity_search(query, k=k)
Enter fullscreen mode Exit fullscreen mode

A straightforward similarity search. Takes the (now enhanced) query, embeds it, and finds the k most similar documents in FAISS.


Step 3: Retrieval Validation Loop

def is_sufficient(query: str, docs: list[Document]) -> bool:
    prompt = f"Can the following documents answer this question? Answer ONLY 'yes' or 'no'.\n\nQuestion: {query}\n\nDocuments:\n{chr(10).join(d.page_content for d in docs)}\n\nAnswerable:"
    result = free_llm.invoke(prompt)
    return result.content.strip().lower().startswith("yes")
Enter fullscreen mode Exit fullscreen mode

This is the first validation gate. We show the LLM the question and the retrieved documents and ask it one thing: "Can you actually answer this question from these documents?"

If the answer is no, the retrieval loop kicks in:

retrieved = retrieve(enhanced)
attempt = 1

while not is_sufficient(q, retrieved) and attempt < 3:
    print(f"  Attempt {attempt}: not sufficient, refining...")
    refined = enhance_query(f"{q} (more specific)")
    retrieved = retrieve(refined, k=3)
    attempt += 1
Enter fullscreen mode Exit fullscreen mode

On each failed attempt:

  • The query is rewritten again, this time with (more specific) appended to push the LLM toward a more targeted rewrite
  • We retrieve again, this time with k=3 to cast a slightly wider net
  • We try a maximum of 3 times before moving on regardless

Step 4: Generate

def generate(query: str, context: str) -> str:
    prompt = f"Answer the question using ONLY the context below.\n\nContext:\n{context}\n\nQuestion: {query}\n\nAnswer:"
    result = free_llm.invoke(prompt)
    return result.content.strip()
Enter fullscreen mode Exit fullscreen mode

Same as 2-step RAG — build a prompt with the retrieved context and ask the LLM to answer from it. The ONLY constraint keeps the LLM from pulling in outside knowledge.


Step 5: Answer Validation

def is_answer_good(query: str, answer: str, context: str) -> bool:
    prompt = f"Is the following answer accurate and based ONLY on the context? Answer ONLY 'yes' or 'no'.\n\nQuestion: {query}\n\nContext:\n{context}\n\nAnswer:\n{answer}\n\nAccurate:"
    result = free_llm.invoke(prompt)
    return result.content.strip().lower().startswith("yes")
Enter fullscreen mode Exit fullscreen mode

The second validation gate. After generating the answer, we ask the LLM to review its own output: "Is this answer actually grounded in the context we retrieved?"

if is_answer_good(q, answer, context):
    print(f"  Validation: PASSED")
else:
    print(f"  Validation: FAILED, regenerating...")
    answer = generate(q, context)
Enter fullscreen mode Exit fullscreen mode

If it fails, we regenerate once more with the same context. The idea is that the first generation might have drifted — a second attempt with the same context tends to stay more grounded.


Full Code

import os
from dotenv import load_dotenv

from langchain_huggingface import HuggingFaceEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_openai import ChatOpenAI
from langchain_core.documents import Document

load_dotenv()

# 1. Knowledge base
docs = [
    Document(page_content="LangChain is a framework for building LLM applications. It provides tools for chaining, agents, and retrieval."),
    Document(page_content="Agents in LangChain use an LLM to decide which tools to call. They can reason step-by-step."),
    Document(page_content="RAG stands for Retrieval-Augmented Generation. It combines retrieval with LLM generation for grounded answers."),
    Document(page_content="Vector stores like FAISS and Chroma store embeddings and enable semantic search over documents."),
    Document(page_content="LangGraph is a library for building stateful, multi-actor applications with LLMs."),
]

print("Building vector store...")
embeddings = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")
vectorstore = FAISS.from_documents(docs, embeddings)

free_llm = ChatOpenAI(
    model="nvidia/nemotron-nano-9b-v2:free",
    base_url="https://openrouter.ai/api/v1",
    api_key=os.environ.get("OPENROUTER_API_KEY"),
)

# 2. Pipeline functions
def enhance_query(original: str) -> str:
    prompt = f"Rewrite this question to make it better for searching a LangChain documentation knowledge base. Return ONLY the rewritten question.\n\nOriginal: {original}\n\nRewritten:"
    result = free_llm.invoke(prompt)
    return result.content.strip()

def retrieve(query: str, k: int = 2) -> list[Document]:
    return vectorstore.similarity_search(query, k=k)

def is_sufficient(query: str, docs: list[Document]) -> bool:
    prompt = f"Can the following documents answer this question? Answer ONLY 'yes' or 'no'.\n\nQuestion: {query}\n\nDocuments:\n{chr(10).join(d.page_content for d in docs)}\n\nAnswerable:"
    result = free_llm.invoke(prompt)
    return result.content.strip().lower().startswith("yes")

def generate(query: str, context: str) -> str:
    prompt = f"Answer the question using ONLY the context below.\n\nContext:\n{context}\n\nQuestion: {query}\n\nAnswer:"
    result = free_llm.invoke(prompt)
    return result.content.strip()

def is_answer_good(query: str, answer: str, context: str) -> bool:
    prompt = f"Is the following answer accurate and based ONLY on the context? Answer ONLY 'yes' or 'no'.\n\nQuestion: {query}\n\nContext:\n{context}\n\nAnswer:\n{answer}\n\nAccurate:"
    result = free_llm.invoke(prompt)
    return result.content.strip().lower().startswith("yes")

# 3. Run
queries = [
    "What is RAG?",
    "Tell me about vector stores",
]

for q in queries:
    print("\n" + "=" * 60)
    print(f"Q: {q}")

    # Step 1: Enhance
    enhanced = enhance_query(q)
    print(f"  1. Enhanced: {enhanced}")

    # Step 2: Retrieve with validation loop
    retrieved = retrieve(enhanced)
    attempt = 1
    while not is_sufficient(q, retrieved) and attempt < 3:
        print(f"  2. Attempt {attempt}: not sufficient, refining...")
        refined = enhance_query(f"{q} (more specific)")
        retrieved = retrieve(refined, k=3)
        attempt += 1
    print(f"  2. Retrieved {len(retrieved)} doc(s) after {attempt} attempt(s)")

    # Step 3: Generate
    context = "\n\n".join(d.page_content for d in retrieved)
    answer = generate(q, context)
    print(f"  3. Generated: {answer[:150]}...")

    # Step 4: Validate
    if is_answer_good(q, answer, context):
        print(f"  4. Validation: PASSED")
    else:
        print(f"  4. Validation: FAILED, regenerating...")
        answer = generate(q, context)
        print(f"     Regenerated: {answer[:150]}...")

    print(f"\n  Final: {answer}")
Enter fullscreen mode Exit fullscreen mode

Sample Output

============================================================
Q: What is RAG?
  1. Enhanced: Explain Retrieval-Augmented Generation in LangChain
  2. Retrieved 2 doc(s) after 1 attempt(s)
  3. Generated: RAG stands for Retrieval-Augmented Generation...
  4. Validation: PASSED

  Final: RAG stands for Retrieval-Augmented Generation. It combines
  retrieval with LLM generation for grounded answers.

============================================================
Q: Tell me about vector stores
  1. Enhanced: How do vector stores work in LangChain for semantic search?
  2. Retrieved 2 doc(s) after 1 attempt(s)
  3. Generated: Vector stores like FAISS and Chroma store embeddings...
  4. Validation: PASSED

  Final: Vector stores like FAISS and Chroma store embeddings and
  enable semantic search over documents.
Enter fullscreen mode Exit fullscreen mode

How It Compares to the Others

2-Step RAG Agentic RAG Hybrid RAG
Query enhancement No No Yes
Retrieval validation No No Yes
Answer validation No No Yes
Retries on failure No No Yes
LLM calls per query 2 2-4 4-8
Complexity Low Medium High
Answer reliability Basic Better Highest

The tradeoff is obvious — more reliability costs more LLM calls. For a small knowledge base and simple questions, 2-step RAG is perfectly fine. Hybrid RAG makes sense when wrong answers have real consequences.


What to Keep in Mind

Each validation step is an LLM call. A single query can trigger 4 to 8 LLM calls depending on how many retries happen. Be mindful of latency and cost if you're running this at scale.

The LLM is grading its own work. is_answer_good() asks the same LLM that generated the answer to validate it. This is better than no validation, but it's not foolproof. A model that generated a bad answer might also approve it. In production systems, you'd want a separate, stronger model doing the validation.

The retry loop has a hard limit. The attempt < 3 cap prevents infinite loops when the knowledge base genuinely doesn't have the answer. After 3 attempts, the pipeline moves forward with whatever it has rather than looping forever.


When to Use Hybrid RAG

  • When answer accuracy matters more than speed
  • When your queries are varied and sometimes vague
  • When you can afford the extra LLM calls per query
  • When you want a self-correcting pipeline rather than a one-shot system

Start with 2-step RAG. If you find it returning wrong or incomplete answers, Hybrid RAG is the natural next step.

Top comments (0)