← Back to DevBytes

LangChain Retrieval Chains: RAG Done Right

LangChain Retrieval Chains: RAG Done Right

Retrieval Augmented Generation (RAG) has become the standard pattern for building AI applications that need to reason over external knowledge. LangChain's retrieval chains provide a structured, composable way to implement RAG that goes far beyond naive document lookups. This tutorial walks you through the complete workflow—from basic setup to production-grade patterns—with practical code you can run today.

What Are Retrieval Chains?

A retrieval chain is a pipeline that takes a user query, retrieves relevant documents from a knowledge store, and passes them as context to a language model for answer generation. Unlike fine-tuning, which bakes knowledge into model weights, retrieval chains keep knowledge external and dynamically inject only what's relevant at inference time. This makes them dramatically cheaper to update, easier to audit, and capable of handling vastly larger knowledge bases than any single model context window could hold.

In LangChain, a retrieval chain is typically built using the create_retrieval_chain function or by composing lower-level components manually. The core insight is that retrieval and generation are decoupled concerns—you can swap vector stores, change embedding models, or upgrade your LLM independently without rewriting the entire pipeline.

Why Retrieval Chains Matter

Naive RAG implementations often fail in subtle ways. Documents may be retrieved that are tangentially related but miss the precise answer. The LLM may hallucinate despite having correct context. Context windows can overflow. Retrieval chains in LangChain address these failure modes through structured composition: you can insert reranking steps, add filtering logic, implement hybrid search (combining sparse and dense retrieval), and attach guardrails—all while keeping your code modular and testable.

The key advantages over ad-hoc implementations include:

Core Components of a Retrieval Chain

Before diving into code, let's understand the building blocks. Every retrieval chain has three essential layers:

1. The Retriever – An interface that takes a query string and returns a list of relevant documents. LangChain provides many retriever implementations: vector store retrievers, BM25 retrievers, ensemble retrievers that combine multiple strategies, and even retrievers that call external APIs like Wikipedia or Arxiv.

2. The Document Store / Vector Store – Where your knowledge lives. Documents are chunked, embedded into vector space, and indexed. When a query arrives, the store performs similarity search (cosine, dot product, or Euclidean distance) to find the nearest chunks.

3. The Generation Chain – Takes the retrieved documents and the original query, formats them into a prompt, and calls the LLM. LangChain provides create_stuff_documents_chain for straightforward "stuff all documents into the prompt" approaches, but also supports map-reduce and refine strategies for handling very large document sets.

Setting Up Your Environment

First, install the required dependencies. We'll use OpenAI for embeddings and chat, plus Chroma as our local vector store for simplicity:

pip install langchain langchain-openai langchain-chroma chromadb tiktoken

Set your OpenAI API key as an environment variable:

export OPENAI_API_KEY="sk-your-key-here"

Building Your First Retrieval Chain

Let's build a complete RAG pipeline that can answer questions about a set of documents. We'll index some technical articles, then query them:

from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_chroma import Chroma
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader
from langchain.chains import create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain_core.prompts import ChatPromptTemplate

# 1. Load and split documents
loader = TextLoader("knowledge_base/technical_docs.txt")
documents = loader.load()

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
    separators=["\n\n", "\n", ". ", " ", ""]
)
chunks = text_splitter.split_documents(documents)
print(f"Created {len(chunks)} chunks from {len(documents)} documents")

# 2. Create embeddings and store
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    collection_name="tech_docs",
    persist_directory="./chroma_db"
)

# 3. Build the retriever
retriever = vector_store.as_retriever(
    search_type="similarity",  # or "mmr" for diversity
    search_kwargs={"k": 4}     # fetch top 4 chunks
)

# 4. Create the prompt template for the LLM
system_prompt = (
    "You are an assistant that answers questions based solely on the provided context. "
    "If the answer cannot be found in the context, say 'I don't have enough information to answer that.' "
    "Always cite the source document sections when possible.\n\n"
    "Context:\n{context}"
)

prompt = ChatPromptTemplate.from_messages([
    ("system", system_prompt),
    ("human", "{input}")
])

# 5. Create the chains
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.2)

combine_docs_chain = create_stuff_documents_chain(llm, prompt)
rag_chain = create_retrieval_chain(retriever, combine_docs_chain)

# 6. Query the chain
response = rag_chain.invoke({
    "input": "What are the key differences between Redis and Memcached?"
})

print(response["answer"])

This basic setup works well for straightforward Q&A, but production systems need more sophistication. The create_stuff_documents_chain simply concatenates all retrieved documents into the context window—which works until you try to retrieve 20 chunks and blow past the model's token limit.

Understanding Retrieval Strategies

The retriever's search_type parameter dramatically affects result quality. LangChain supports several strategies:

Here's how to configure each:

# MMR retrieval - good for summarization tasks
mmr_retriever = vector_store.as_retriever(
    search_type="mmr",
    search_kwargs={
        "k": 5,
        "fetch_k": 20,       # fetch more candidates, then diversify
        "lambda_mult": 0.6   # 0 = max diversity, 1 = max relevance
    }
)

# Threshold-based retrieval - good for strict Q&A
threshold_retriever = vector_store.as_retriever(
    search_type="similarity_score_threshold",
    search_kwargs={
        "score_threshold": 0.5,
        "k": 10
    }
)

# Filtered retrieval - good when you have metadata
filtered_retriever = vector_store.as_retriever(
    search_kwargs={
        "k": 5,
        "filter": {"source": "user_manual", "version": "2.0"}
    }
)

Advanced: Multi-Step Retrieval with Reranking

For complex queries, a single retrieval step often isn't enough. You might fetch 20 candidate documents, then rerank them with a more powerful (but slower) model. LangChain supports this through EnsembleRetriever and custom reranking chains:

from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain.retrievers import ContextualCompressionRetriever
from langchain_cohere import CohereRerank

# Step 1: Combine sparse (BM25) and dense (vector) retrieval
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 10

dense_retriever = vector_store.as_retriever(
    search_type="similarity",
    search_kwargs={"k": 10}
)

ensemble_retriever = EnsembleRetriever(
    retrievers=[bm25_retriever, dense_retriever],
    weights=[0.3, 0.7],  # give more weight to dense retrieval
    c=20                  # combine top 20 from each
)

# Step 2: Rerank with Cohere's reranker (or a local cross-encoder)
compressor = CohereRerank(
    top_n=5,  # keep only top 5 after reranking
    model="rerank-english-v3.0"
)

compression_retriever = ContextualCompressionRetriever(
    base_retriever=ensemble_retriever,
    base_compressor=compressor
)

# Use the compressed retriever in your chain
rag_chain_advanced = create_retrieval_chain(
    compression_retriever, 
    combine_docs_chain
)

response = rag_chain_advanced.invoke({
    "input": "Explain the transaction isolation levels in PostgreSQL"
})
print(response["answer"])

This hybrid approach—sparse retrieval catching exact keyword matches, dense retrieval capturing semantic meaning, and a reranker refining the final selection—consistently outperforms any single strategy alone.

Conversational Retrieval with Memory

Most real-world applications require maintaining conversation context. LangChain provides create_history_aware_retriever to reformulate follow-up questions into standalone queries:

from langchain.chains import create_history_aware_retriever
from langchain_core.prompts import MessagesPlaceholder
from langchain_core.messages import HumanMessage, AIMessage

# Prompt to reformulate questions based on chat history
contextualize_q_prompt = ChatPromptTemplate.from_messages([
    ("system", (
        "Given the chat history and the latest user question, "
        "reformulate the question into a standalone question "
        "that can be answered without the chat history. "
        "Do NOT answer the question, only reformulate it."
    )),
    MessagesPlaceholder("chat_history"),
    ("human", "{input}")
])

# Create the history-aware retriever
history_aware_retriever = create_history_aware_retriever(
    llm, 
    vector_store.as_retriever(search_kwargs={"k": 4}),
    contextualize_q_prompt
)

# QA prompt with context
qa_prompt = ChatPromptTemplate.from_messages([
    ("system", "Answer based on context:\n\n{context}"),
    MessagesPlaceholder("chat_history"),
    ("human", "{input}")
])

combine_docs = create_stuff_documents_chain(llm, qa_prompt)
conversational_rag_chain = create_retrieval_chain(
    history_aware_retriever, 
    combine_docs
)

# Simulate a conversation
chat_history = []

# First turn
response1 = conversational_rag_chain.invoke({
    "input": "What is Docker?",
    "chat_history": chat_history
})
chat_history.extend([
    HumanMessage(content="What is Docker?"),
    AIMessage(content=response1["answer"])
])

# Follow-up - the retriever reformulates this automatically
response2 = conversational_rag_chain.invoke({
    "input": "How does it differ from traditional VMs?",
    "chat_history": chat_history
})
print(response2["answer"])

The create_history_aware_retriever internally takes the chat history, uses the LLM to produce a self-contained query like "How does Docker differ from traditional virtual machines?" and then performs retrieval against that refined query. This prevents the retriever from getting confused by pronouns or implicit references in follow-up questions.

Best Practices for Chunking

Chunking strategy is perhaps the single most impactful design decision in a RAG system. Poor chunking leads to fragmented context that the LLM can't reason over effectively. Here are the key principles:

1. Choose chunk size based on content density – Technical documentation often works well with 500–1000 tokens per chunk. Narrative content may need larger chunks (1500–2000) to preserve flow. Dense reference material benefits from smaller chunks (250–500).

2. Use semantic splitting over naive character splitting – LangChain's RecursiveCharacterTextSplitter respects natural boundaries like paragraphs and sentences before falling back to character-level splits:

from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_experimental.text_splitter import SemanticChunker

# Recursive splitter respects document structure
recursive_splitter = RecursiveCharacterTextSplitter(
    chunk_size=800,
    chunk_overlap=150,
    separators=["\n\n\n", "\n\n", "\n", ". ", "?", "!", " ", ""],
    length_function=len,
    is_separator_regex=False
)

# Semantic chunker uses embeddings to find natural breakpoints
semantic_splitter = SemanticChunker(
    embeddings=OpenAIEmbeddings(model="text-embedding-3-small"),
    breakpoint_threshold_type="percentile",
    breakpoint_threshold_amount=0.85  # split at 85th percentile of dissimilarity
)

# Always include metadata for traceability
chunks = recursive_splitter.split_documents(documents)
for i, chunk in enumerate(chunks):
    chunk.metadata["chunk_index"] = i
    chunk.metadata["total_chunks"] = len(chunks)
    chunk.metadata["source_section"] = chunk.metadata.get("section", "unknown")

3. Chunk overlap is non-negotiable – An overlap of 10–20% of chunk size ensures that important concepts spanning chunk boundaries are captured in at least one complete chunk. Too little overlap causes information loss; too much creates redundant retrieval results.

4. Consider hierarchical chunking for large documents – Maintain both small chunks (for precise retrieval) and larger parent chunks (for context expansion). LangChain's ParentDocumentRetriever handles this pattern elegantly:

from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore

# Small chunks go into the vector store for similarity search
child_splitter = RecursiveCharacterTextSplitter(chunk_size=400, chunk_overlap=80)
# Larger parent chunks provide full context to the LLM
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=2000, chunk_overlap=300)

parent_retriever = ParentDocumentRetriever(
    vectorstore=Chroma(
        collection_name="full_docs",
        embedding_function=embeddings
    ),
    docstore=InMemoryStore(),
    child_splitter=child_splitter,
    parent_splitter=parent_splitter,
    search_kwargs={"k": 5}  # retrieve 5 child chunks, expand to parents
)

# When you retrieve, you get the full parent document context
# while the child chunks ensure precise similarity matching
retrieved_docs = parent_retriever.get_relevant_documents("query about a specific detail")
# Each doc is a large, coherent parent chunk containing the matched detail

Best Practices for Prompts in RAG

The prompt template that combines context with the query is critical. A well-designed RAG prompt should:

# A robust RAG prompt template
rag_prompt = ChatPromptTemplate.from_messages([
    ("system", """You are a precise, factual assistant operating on provided context.
    
Rules:
1. Answer ONLY using information from the context documents below.
2. If the context doesn't contain the answer, respond: "The provided documents do not contain sufficient information to answer this question."
3. When answering, cite specific document numbers in your response, e.g., [Doc 2, Para 3].
4. If context documents contradict each other, acknowledge the contradiction and present both viewpoints.
5. Do not use external knowledge or make assumptions beyond the context.

Context documents:
{context}"""),
    ("human", "{input}")
])

Handling Large Context Windows

Modern models like GPT-4o and Claude 3 support 128K+ token context windows, but stuffing 50+ retrieved chunks still degrades answer quality. The "Lost in the Middle" phenomenon shows that models attend poorly to content in the middle of long contexts. Use these strategies:

1. Limit total context size – Even with large context windows, cap retrieved content at 30K–50K tokens for optimal quality:

from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain_core.document_transformers import trim_documents

# Trim documents to fit within a token budget
def trim_to_token_budget(docs, max_tokens=30000):
    trimmed = []
    current_count = 0
    for doc in docs:
        # Rough estimate: 1 token ≈ 4 characters
        doc_tokens = len(doc.page_content) // 4
        if current_count + doc_tokens > max_tokens:
            break
        trimmed.append(doc)
        current_count += doc_tokens
    return trimmed

# In your chain processing
raw_docs = retriever.get_relevant_documents(query)
trimmed_docs = trim_to_token_budget(raw_docs, max_tokens=25000)
response = combine_docs_chain.invoke({
    "context": trimmed_docs,
    "input": query
})

2. Use map-reduce for very large document sets – When you have many relevant documents that can't fit in a single context, process them in batches:

from langchain.chains.combine_documents import MapReduceDocumentsChain, ReduceDocumentsChain
from langchain_core.prompts import PromptTemplate

# Map prompt: extract key information from each chunk
map_template = """Given this document chunk, extract all facts relevant to the question.
Question: {query}
Document: {document}
Extracted facts:"""

map_prompt = PromptTemplate(template=map_template, input_variables=["document", "query"])

# Reduce prompt: synthesize extracted facts into final answer
reduce_template = """Synthesize these extracted facts into a coherent answer.
Question: {query}
Extracted facts from multiple documents:
{document_summaries}
Final answer:"""

reduce_prompt = PromptTemplate(template=reduce_template, input_variables=["document_summaries", "query"])

# Build the map-reduce chain
map_llm_chain = LLMChain(llm=llm, prompt=map_prompt)
reduce_chain = ReduceDocumentsChain(
    llm_chain=LLMChain(llm=llm, prompt=reduce_prompt),
    document_variable_name="document_summaries",
    return_intermediate_steps=False
)

map_reduce_chain = MapReduceDocumentsChain(
    llm_chain=map_llm_chain,
    reduce_documents_chain=reduce_chain,
    document_variable_name="document",
    return_intermediate_steps=True
)

Error Handling and Production Considerations

Production RAG systems face real-world challenges: empty retrievals, malformed queries, rate limits, and embedding failures. Build resilience into your chains:

from langchain_core.runnables import RunnableLambda, RunnableBranch
import logging

logger = logging.getLogger(__name__)

# Fallback retriever that returns a graceful degradation message
def fallback_retriever(query: str):
    logger.warning(f"No documents retrieved for query: {query}")
    return [{"page_content": "No relevant documents found.", "metadata": {"source": "fallback"}}]

# Chain with error handling and retry logic
from tenacity import retry, stop_after_attempt, wait_exponential

@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
def robust_retrieve(query: str, retriever, min_docs=1):
    docs = retriever.get_relevant_documents(query)
    if len(docs) < min_docs:
        logger.info(f"Only {len(docs)} docs retrieved, below minimum {min_docs}")
        # Expand search: try with a simplified query
        simplified = " ".join(query.split()[:5])  # first 5 words
        additional = retriever.get_relevant_documents(simplified)
        docs.extend(additional)
    return docs

# Validate retrieved content before passing to LLM
def validate_documents(docs):
    validated = []
    for doc in docs:
        # Filter out empty or very short chunks
        if len(doc.page_content.strip()) > 20:
            # Deduplicate near-identical chunks
            content_hash = doc.page_content[:100]
            if not any(d.page_content[:100] == content_hash for d in validated):
                validated.append(doc)
    return validated

# Build the robust chain
retrieve_docs = RunnableLambda(lambda x: robust_retrieve(x["input"], retriever))
validate = RunnableLambda(validate_documents)
format_context = RunnableLambda(lambda docs: {"context": docs, "input": docs[0].metadata.get("query", "")})

robust_chain = (
    retrieve_docs | 
    validate | 
    format_context | 
    combine_docs_chain
)

Testing Your Retrieval Pipeline

A retrieval chain is only as good as its retrieval quality. Before trusting it in production, evaluate it systematically:

from langchain.evaluation import load_evaluator
from langchain_core.retrievers import BaseRetriever
import json

# Create a test set: list of (query, expected_answer_or_document_ids)
test_queries = [
    {
        "query": "How do I configure SSL in Nginx?",
        "expected_doc_id": "nginx_ssl_config.md",
        "relevant_content_keywords": ["ssl_certificate", "443", "TLS"]
    },
    {
        "query": "What's the default PostgreSQL port?",
        "expected_doc_id": "postgres_basics.md",
        "relevant_content_keywords": ["5432", "listen_addresses"]
    }
]

# Evaluate retrieval precision
def evaluate_retrieval(retriever, test_queries, k=5):
    results = []
    for test in test_queries:
        retrieved = retriever.get_relevant_documents(test["query"])[:k]
        retrieved_ids = [doc.metadata.get("source", "") for doc in retrieved]
        
        # Check if expected doc is in top-k
        hit = test["expected_doc_id"] in retrieved_ids
        
        # Check keyword coverage
        combined_content = " ".join([doc.page_content for doc in retrieved])
        keyword_matches = sum(
            1 for kw in test["relevant_content_keywords"] 
            if kw.lower() in combined_content.lower()
        )
        
        results.append({
            "query": test["query"],
            "hit@k": hit,
            "rank": retrieved_ids.index(test["expected_doc_id"]) if hit else None,
            "keyword_coverage": keyword_matches / len(test["relevant_content_keywords"]),
            "retrieved_count": len(retrieved)
        })
    
    # Aggregate metrics
    hits = sum(1 for r in results if r["hit@k"])
    avg_keyword_coverage = sum(r["keyword_coverage"] for r in results) / len(results)
    
    return {
        "precision@k": hits / len(results),
        "avg_keyword_coverage": avg_keyword_coverage,
        "details": results
    }

metrics = evaluate_retrieval(retriever, test_queries, k=5)
print(json.dumps(metrics, indent=2))

For more rigorous evaluation, use LangSmith's built-in evaluators that can assess answer correctness, faithfulness to context, and retrieval precision using LLM-as-judge methods.

Streaming and Async for Responsive UIs

User-facing applications need streaming responses. LangChain retrieval chains support both synchronous streaming and full async execution:

# Synchronous streaming
for chunk in rag_chain.stream({"input": "Explain Kubernetes pods"}):
    if "answer" in chunk:
        print(chunk["answer"], end="", flush=True)

# Async execution for high-throughput servers
import asyncio

async def async_rag_query(query: str):
    async_chain = rag_chain.with_config({"run_name": "async_rag"})
    
    # Async streaming
    async for chunk in async_chain.astream({"input": query}):
        if "answer" in chunk:
            yield chunk["answer"]

# FastAPI endpoint example
from fastapi import FastAPI
from fastapi.responses import StreamingResponse

app = FastAPI()

@app.post("/rag/query")
async def query_endpoint(request: dict):
    async def event_stream():
        async for token in async_rag_query(request["query"]):
            yield f"data: {token}\n\n"
        yield "data: [DONE]\n\n"
    
    return StreamingResponse(event_stream(), media_type="text/event-stream")

Multi-Source Retrieval

Real applications often need to pull context from multiple sources—a vector store for technical docs, a SQL database for structured data, and a web search for recent information. LangChain makes this composable:

from langchain_community.retrievers import WikipediaRetriever, TavilySearchAPIRetriever
from langchain_community.tools.sql_database.tool import QuerySQLDataBaseTool
from langchain_community.utilities import SQLDatabase

# Multiple retrievers for different knowledge sources
vector_retriever = vector_store.as_retriever(search_kwargs={"k": 3})
wiki_retriever = WikipediaRetriever(load_all_available_meta=True, top_k_results=2)
web_search_retriever = TavilySearchAPIRetriever(k=2)

# Custom multi-source retrieval function
def multi_source_retrieve(query: str):
    results = []
    
    # Source 1: Internal knowledge base
    internal_docs = vector_retriever.get_relevant_documents(query)
    for doc in internal_docs:
        doc.metadata["source_type"] = "internal_kb"
    results.extend(internal_docs)
    
    # Source 2: Wikipedia for general knowledge
    try:
        wiki_docs = wiki_retriever.get_relevant_documents(query)
        for doc in wiki_docs:
            doc.metadata["source_type"] = "wikipedia"
        results.extend(wiki_docs)
    except Exception as e:
        logger.warning(f"Wikipedia retrieval failed: {e}")
    
    # Source 3: Web search for recent information
    try:
        web_docs = web_search_retriever.get_relevant_documents(query)
        for doc in web_docs:
            doc.metadata["source_type"] = "web_search"
        results.extend(web_docs)
    except Exception as e:
        logger.warning(f"Web search failed: {e}")
    
    # Sort by relevance or interleave sources
    return results[:8]  # cap total documents

# Use in a chain
multi_chain = create_retrieval_chain(
    RunnableLambda(multi_source_retrieve),
    combine_docs_chain
)

Customizing the Document Processing Pipeline

Between retrieval and generation, you often need to post-process documents. LangChain provides DocumentTransformers for this:

from langchain_core.document_transformers import BaseDocumentTransformer

class RedactPiiTransformer(BaseDocumentTransformer):
    """Redact emails, phone numbers, and SSNs from documents before LLM sees them."""
    
    def transform_documents(self, documents, **kwargs):
        import re
        redacted = []
        for doc in documents:
            content = doc.page_content
            # Redact emails
            content = re.sub(r'[\w\.-]+@[\w\.-]+\.\w+', '[EMAIL_REDACTED]', content)
            # Redact phone numbers
            content = re.sub(r'\b\d{3}[-.]?\d{3}[-.]?\d{4}\b', '[PHONE_REDACTED]', content)
            # Redact SSNs
            content = re.sub(r'\b\d{3}-\d{2}-\d{4}\b', '[SSN_REDACTED]', content)
            
            doc.page_content = content
            redacted.append(doc)
        return redacted

# Integrate into the pipeline
from langchain_core.runnables import RunnableLambda

redact = RedactPiiTransformer()
clean_retriever = RunnableLambda(
    lambda x: redact.transform_documents(retriever.get_relevant_documents(x["input"]))
)

secure_rag_chain = create_retrieval_chain(clean_retriever, combine_docs_chain)

Conclusion

LangChain's retrieval chains elevate RAG from a simple pattern to a robust, production-grade architecture. By composing retrievers, rerankers, document transformers, and generation chains, you build systems that are modular, testable, and adaptable to evolving requirements. The key takeaways are: invest in your chunking strategy—it's the foundation everything else builds on; use hybrid retrieval (sparse + dense + reranking) for the best recall; design prompts that explicitly constrain the model to the provided context; and always evaluate your retrieval quality before deploying. With these patterns in place, you'll have a RAG system that delivers accurate, grounded, and trustworthy answers at scale.

— Ad —

Google AdSense will appear here after approval

← Back to all articles