RAG Pipeline Template

Workflow

Production-ready RAG pipeline with LangChain.

RAG Pipeline Template

Overview

Production-ready RAG (Retrieval-Augmented Generation) pipeline template for building question-answering systems over your own data. This template provides a modular, extensible foundation for implementing retrieval-augmented generation with LangChain.

What is RAG?

Retrieval-Augmented Generation (RAG) is an AI architecture that combines:

  • Retrieval: Finding relevant documents from a knowledge base
  • Augmentation: Adding retrieved context to the prompt
  • Generation: Using an LLM to generate answers based on the context

RAG enables LLMs to answer questions about private data, stay up-to-date with recent information, and provide source citations for their answers.

Template Structure

rag-template/
├── src/
│   ├── __init__.py
│   ├── config.py              # Configuration management
│   ├── ingestion/
│   │   ├── __init__.py
│   │   ├── loaders.py         # Document loaders
│   │   ├── chunkers.py        # Chunking strategies
│   │   └── pipeline.py        # Ingestion pipeline
│   ├── retrieval/
│   │   ├── __init__.py
│   │   ├── embeddings.py      # Embedding models
│   │   ├── vector_store.py    # Vector store setup
│   │   ├── retrievers.py      # Retrieval strategies
│   │   └── ranking.py         # Re-ranking (optional)
│   ├── generation/
│   │   ├── __init__.py
│   │   ├── prompts.py         # Prompt templates
│   │   ├── chains.py          # QA chains
│   │   └── conversation.py    # Conversational QA
│   └── evaluation/
│       ├── __init__.py
│       ├── metrics.py         # Evaluation metrics
│       └── pipeline.py        # Evaluation pipeline
├── data/
│   ├── documents/             # Source documents
│   └── processed/             # Processed chunks
├── tests/
│   ├── test_ingestion.py
│   ├── test_retrieval.py
│   └── test_chains.py
├── main.py                    # Entry point
├── requirements.txt
└── README.md

Installation

# Create virtual environment
python -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate

# Install core dependencies
pip install langchain langchain-core langchain-community langchain-openai

# Install vector store
pip install langchain-chroma  # or faiss-cpu, pinecone-client, etc.

# Install optional dependencies
pip install langchain-huggingface  # For HuggingFace embeddings
pip install langchain-cohere       # For Cohere re-ranking
pip install llama-index            # For advanced retrieval

# Or install all
pip install -r requirements.txt

requirements.txt

# Core
langchain>=0.2.0
langchain-core>=0.2.0
langchain-community>=0.2.0
langchain-openai>=0.1.0

# Vector stores
langchain-chroma>=0.1.0
chromadb>=0.4.0

# Embeddings
langchain-huggingface>=0.0.3

# Document processing
pypdf>=4.0.0
python-docx>=1.1.0
markdown>=3.6
beautifulsoup4>=4.12.0

# Evaluation
langsmith>=0.1.0
ragas>=0.1.0

# Utilities
pydantic>=2.0.0
python-dotenv>=1.0.0
typer>=0.9.0

Configuration

Environment Variables (.env)

# OpenAI API
OPENAI_API_KEY=sk-...
OPENAI_API_BASE=https://api.openai.com/v1

# Embedding model
EMBEDDING_MODEL=text-embedding-3-small
EMBEDDING_DIMENSIONS=1536

# LLM model
LLM_MODEL=gpt-4o
LLM_TEMPERATURE=0.0
LLM_MAX_TOKENS=4096

# Vector store
VECTOR_STORE=chroma
CHROMA_PERSIST_DIRECTORY=./chroma_db

# Chunking
CHUNK_SIZE=1000
CHUNK_OVERLAP=200

# Retrieval
SEARCH_TYPE=similarity
SEARCH_K=4

# Logging
LOG_LEVEL=INFO
LANGCHAIN_TRACING_V2=true
LANGCHAIN_API_KEY=lsv2_...

Configuration Class

# src/config.py
from pydantic import BaseModel, Field
from typing import Literal
import os
from dotenv import load_dotenv

load_dotenv()

class EmbeddingConfig(BaseModel):
    model: str = Field(default="text-embedding-3-small", description="Embedding model name")
    dimensions: int = Field(default=1536, description="Embedding dimensions")
    provider: Literal["openai", "huggingface", "cohere", "google"] = Field(default="openai")

class ChunkingConfig(BaseModel):
    strategy: Literal["character", "recursive_character", "token", "semantic"] = Field(default="recursive_character")
    chunk_size: int = Field(default=1000, ge=100, le=5000)
    chunk_overlap: int = Field(default=200, ge=0, le=500)

class RetrievalConfig(BaseModel):
    vector_store: Literal["chroma", "faiss", "pinecone", "qdrant", "weaviate"] = Field(default="chroma")
    search_type: Literal["similarity", "mmr", "similarity_score_threshold"] = Field(default="similarity")
    search_k: int = Field(default=4, ge=1, le=20)
    score_threshold: float = Field(default=0.7, ge=0.0, le=1.0)

class LLMConfig(BaseModel):
    model: str = Field(default="gpt-4o", description="LLM model name")
    temperature: float = Field(default=0.0, ge=0.0, le=1.0)
    max_tokens: int = Field(default=4096, ge=100, le=8192)
    provider: Literal["openai", "anthropic", "google", "cohere", "ollama"] = Field(default="openai")

class RAGConfig(BaseModel):
    embedding: EmbeddingConfig = Field(default_factory=EmbeddingConfig)
    chunking: ChunkingConfig = Field(default_factory=ChunkingConfig)
    retrieval: RetrievalConfig = Field(default_factory=RetrievalConfig)
    llm: LLMConfig = Field(default_factory=LLMConfig)
    
    @classmethod
    def from_env(cls) -> "RAGConfig":
        return cls(
            embedding=EmbeddingConfig(
                model=os.getenv("EMBEDDING_MODEL", "text-embedding-3-small"),
                dimensions=int(os.getenv("EMBEDDING_DIMENSIONS", "1536")),
            ),
            chunking=ChunkingConfig(
                chunk_size=int(os.getenv("CHUNK_SIZE", "1000")),
                chunk_overlap=int(os.getenv("CHUNK_OVERLAP", "200")),
            ),
            retrieval=RetrievalConfig(
                search_k=int(os.getenv("SEARCH_K", "4")),
                score_threshold=float(os.getenv("SCORE_THRESHOLD", "0.7")),
            ),
            llm=LLMConfig(
                model=os.getenv("LLM_MODEL", "gpt-4o"),
                temperature=float(os.getenv("LLM_TEMPERATURE", "0.0")),
            ),
        )

config = RAGConfig.from_env()

Document Ingestion

Document Loaders

# src/ingestion/loaders.py
from typing import List
from langchain_core.documents import Document
from langchain_community.document_loaders import (
    PyPDFLoader,
    Docx2txtLoader,
    TextLoader,
    UnstructuredMarkdownLoader,
    WebBaseLoader,
    DirectoryLoader,
)
from pathlib import Path

def load_pdf(file_path: str) -> List[Document]:
    """Load a PDF document."""
    loader = PyPDFLoader(file_path)
    return loader.load()

def load_docx(file_path: str) -> List[Document]:
    """Load a Word document."""
    loader = Docx2txtLoader(file_path)
    return loader.load()

def load_text(file_path: str) -> List[Document]:
    """Load a text document."""
    loader = TextLoader(file_path, encoding="utf-8")
    return loader.load()

def load_markdown(file_path: str) -> List[Document]:
    """Load a Markdown document."""
    loader = UnstructuredMarkdownLoader(file_path)
    return loader.load()

def load_webpage(url: str) -> List[Document]:
    """Load a webpage."""
    loader = WebBaseLoader(url)
    return loader.load()

def load_directory(directory: str, recursive: bool = True) -> List[Document]:
    """Load all documents from a directory."""
    loader = DirectoryLoader(
        directory,
        glob="**/*.{pdf,docx,txt,md}",
        recursive=recursive,
        show_progress=True,
    )
    return loader.load()

def load_documents(sources: List[str]) -> List[Document]:
    """Load documents from various sources."""
    documents = []
    
    for source in sources:
        path = Path(source)
        
        if path.is_file():
            suffix = path.suffix.lower()
            if suffix == ".pdf":
                documents.extend(load_pdf(source))
            elif suffix == ".docx":
                documents.extend(load_docx(source))
            elif suffix == ".txt":
                documents.extend(load_text(source))
            elif suffix in [".md", ".markdown"]:
                documents.extend(load_markdown(source))
        elif path.is_dir():
            documents.extend(load_directory(source))
        elif source.startswith("http"):
            documents.extend(load_webpage(source))
        else:
            raise ValueError(f"Unsupported source: {source}")
    
    return documents

Chunking Strategies

# src/ingestion/chunkers.py
from typing import List
from langchain_core.documents import Document
from langchain_text_splitters import (
    RecursiveCharacterTextSplitter,
    CharacterTextSplitter,
    TokenTextSplitter,
    MarkdownHeaderTextSplitter,
)
from langchain_community.document_transformers import (
    EmbeddingsClusteringFilter,
    EmbeddingsRedundantFilter,
)
from src.config import ChunkingConfig

def create_recursive_chunker(config: ChunkingConfig):
    """Create a recursive character chunker."""
    return RecursiveCharacterTextSplitter(
        chunk_size=config.chunk_size,
        chunk_overlap=config.chunk_overlap,
        length_function=len,
        separators=[
            "\n\n",  # Paragraphs
            "\n",    # Lines
            "。",    # Chinese sentences
            "!",
            "?",
            ".",     # English sentences
            " ",     # Words
            "",      # Characters
        ],
    )

def create_character_chunker(config: ChunkingConfig):
    """Create a simple character chunker."""
    return CharacterTextSplitter(
        chunk_size=config.chunk_size,
        chunk_overlap=config.chunk_overlap,
        separator="\n\n",
    )

def create_token_chunker(config: ChunkingConfig):
    """Create a token-based chunker."""
    return TokenTextSplitter(
        chunk_size=config.chunk_size // 4,  # Approximate chars to tokens
        chunk_overlap=config.chunk_overlap // 4,
    )

def split_markdown(documents: List[Document]) -> List[Document]:
    """Split Markdown documents by headers."""
    splitter = MarkdownHeaderTextSplitter(
        headers_to_split_on=[
            ("#", "Header 1"),
            ("##", "Header 2"),
            ("###", "Header 3"),
        ],
    )
    
    split_docs = []
    for doc in documents:
        split_docs.extend(splitter.split_text(doc.page_content))
    
    return split_docs

def remove_redundant_chunks(
    documents: List[Document],
    embeddings,
    threshold: float = 0.95
) -> List[Document]:
    """Remove redundant chunks based on embedding similarity."""
    redundant_filter = EmbeddingsRedundantFilter(
        embeddings=embeddings,
        similarity_fn=lambda x, y: 1 - x.distance(y),
        threshold=threshold,
    )
    return redundant_filter.transform(documents)

def cluster_chunks(
    documents: List[Document],
    embeddings,
    n_clusters: int = 10
) -> List[Document]:
    """Cluster chunks for better retrieval."""
    clustering_filter = EmbeddingsClusteringFilter(
        embeddings=embeddings,
        num_clusters=n_clusters,
        num_closest=1,
        sorted=True,
    )
    return clustering_filter.transform(documents)

def chunk_documents(
    documents: List[Document],
    config: ChunkingConfig
) -> List[Document]:
    """Chunk documents according to configuration."""
    if config.strategy == "recursive_character":
        chunker = create_recursive_chunker(config)
    elif config.strategy == "character":
        chunker = create_character_chunker(config)
    elif config.strategy == "token":
        chunker = create_token_chunker(config)
    else:
        chunker = create_recursive_chunker(config)
    
    return chunker.split_documents(documents)

Ingestion Pipeline

# src/ingestion/pipeline.py
from typing import List, Optional
from langchain_core.documents import Document
from langchain_openai import OpenAIEmbeddings
from src.config import RAGConfig
from src.ingestion.loaders import load_documents
from src.ingestion.chunkers import chunk_documents

def ingest_documents(
    sources: List[str],
    config: RAGConfig,
    clean: bool = False
) -> List[Document]:
    """Run the complete ingestion pipeline."""
    
    # Load documents
    print(f"Loading documents from {len(sources)} sources...")
    documents = load_documents(sources)
    print(f"Loaded {len(documents)} documents")
    
    # Optional: Clean documents
    if clean:
        documents = clean_documents(documents)
    
    # Chunk documents
    print(f"Chunking documents with strategy: {config.chunking.strategy}...")
    chunks = chunk_documents(documents, config.chunking)
    print(f"Created {len(chunks)} chunks")
    
    # Add metadata
    for chunk in chunks:
        if "source" not in chunk.metadata:
            chunk.metadata["source"] = "unknown"
        if "chunk_id" not in chunk.metadata:
            chunk.metadata["chunk_id"] = hash(chunk.page_content) % 1000000
    
    return chunks

def clean_documents(documents: List[Document]) -> List[Document]:
    """Clean and normalize documents."""
    cleaned = []
    
    for doc in documents:
        # Remove extra whitespace
        content = " ".join(doc.page_content.split())
        
        # Remove common boilerplate
        content = remove_boilerplate(content)
        
        # Truncate if too long
        if len(content) > 100000:
            content = content[:100000]
        
        cleaned.append(Document(
            page_content=content,
            metadata=doc.metadata
        ))
    
    return cleaned

def remove_boilerplate(text: str) -> str:
    """Remove common boilerplate text."""
    import re
    
    # Remove common patterns
    patterns = [
        r'\n\s*\n',  # Multiple newlines
        r'\[.*?\]',  # Bracketed text (often navigation)
        r'http[s]?://\S+',  # URLs
    ]
    
    for pattern in patterns:
        text = re.sub(pattern, '\n', text)
    
    return text.strip()

Vector Store Setup

Embeddings

# src/retrieval/embeddings.py
from typing import Optional
from langchain_openai import OpenAIEmbeddings
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_cohere import CohereEmbeddings
from src.config import EmbeddingConfig

def get_embeddings(config: EmbeddingConfig):
    """Get embedding model based on configuration."""
    
    if config.provider == "openai":
        return OpenAIEmbeddings(
            model=config.model,
            dimensions=config.dimensions,
        )
    
    elif config.provider == "huggingface":
        return HuggingFaceEmbeddings(
            model_name=config.model,
            model_kwargs={"device": "cpu"},
        )
    
    elif config.provider == "cohere":
        return CohereEmbeddings(
            model=config.model,
        )
    
    else:
        raise ValueError(f"Unsupported embedding provider: {config.provider}")

Vector Store

# src/retrieval/vector_store.py
from typing import List, Optional
from langchain_core.documents import Document
from langchain_core.vectorstores import VectorStore
from langchain_chroma import Chroma
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings
from src.config import RetrievalConfig, EmbeddingConfig

def create_vector_store(
    documents: List[Document],
    embeddings: OpenAIEmbeddings,
    config: RetrievalConfig,
    persist_directory: Optional[str] = None
) -> VectorStore:
    """Create and populate vector store."""
    
    if config.vector_store == "chroma":
        return create_chroma(
            documents=documents,
            embeddings=embeddings,
            persist_directory=persist_directory,
        )
    
    elif config.vector_store == "faiss":
        return create_faiss(
            documents=documents,
            embeddings=embeddings,
        )
    
    else:
        raise ValueError(f"Unsupported vector store: {config.vector_store}")

def create_chroma(
    documents: List[Document],
    embeddings: OpenAIEmbeddings,
    persist_directory: Optional[str] = None
) -> Chroma:
    """Create Chroma vector store."""
    
    if persist_directory:
        vectorstore = Chroma.from_documents(
            documents=documents,
            embedding=embeddings,
            persist_directory=persist_directory,
        )
    else:
        vectorstore = Chroma.from_documents(
            documents=documents,
            embedding=embeddings,
        )
    
    return vectorstore

def create_faiss(
    documents: List[Document],
    embeddings: OpenAIEmbeddings,
) -> FAISS:
    """Create FAISS vector store."""
    
    vectorstore = FAISS.from_documents(
        documents=documents,
        embedding=embeddings,
    )
    
    return vectorstore

def load_vector_store(
    embeddings: OpenAIEmbeddings,
    config: RetrievalConfig,
    persist_directory: str
) -> VectorStore:
    """Load existing vector store."""
    
    if config.vector_store == "chroma":
        return Chroma(
            persist_directory=persist_directory,
            embedding_function=embeddings,
        )
    
    elif config.vector_store == "faiss":
        return FAISS.load_local(
            folder_path=persist_directory,
            embeddings=embeddings,
            allow_dangerous_deserialization=True,
        )
    
    else:
        raise ValueError(f"Unsupported vector store: {config.vector_store}")

Retrievers

# src/retrieval/retrievers.py
from typing import List
from langchain_core.vectorstores import VectorStore
from langchain_core.retrievers import BaseRetriever
from langchain_openai import OpenAIEmbeddings
from src.config import RetrievalConfig

def create_retriever(
    vectorstore: VectorStore,
    config: RetrievalConfig
) -> BaseRetriever:
    """Create retriever based on configuration."""
    
    if config.search_type == "similarity":
        return vectorstore.as_retriever(
            search_type="similarity",
            search_kwargs={"k": config.search_k},
        )
    
    elif config.search_type == "mmr":
        return vectorstore.as_retriever(
            search_type="mmr",
            search_kwargs={
                "k": config.search_k,
                "fetch_k": config.search_k * 3,
                "lambda_mult": 0.5,
            },
        )
    
    elif config.search_type == "similarity_score_threshold":
        return vectorstore.as_retriever(
            search_type="similarity_score_threshold",
            search_kwargs={
                "k": config.search_k,
                "score_threshold": config.score_threshold,
            },
        )
    
    else:
        return vectorstore.as_retriever(
            search_type="similarity",
            search_kwargs={"k": config.search_k},
        )

def create_hybrid_retriever(
    vectorstore: VectorStore,
    documents: List,
    alpha: float = 0.7
) -> BaseRetriever:
    """Create hybrid retriever combining vector and BM25 search."""
    from langchain_community.retrievers import BM25Retriever
    
    # Create BM25 retriever
    bm25_retriever = BM25Retriever.from_documents(documents)
    bm25_retriever.k = 5
    
    # Create vector retriever
    vector_retriever = vectorstore.as_retriever(
        search_type="similarity",
        search_kwargs={"k": 5},
    )
    
    # Combine with Reciprocal Rank Fusion
    from langchain_community.retrievers import EnsembleRetriever
    ensemble_retriever = EnsembleRetriever(
        retrievers=[bm25_retriever, vector_retriever],
        weights=[1 - alpha, alpha],
    )
    
    return ensemble_retriever

def create_self_query_retriever(
    vectorstore: VectorStore,
    embeddings: OpenAIEmbeddings,
    metadata_field_info: List[dict],
    document_content_description: str
) -> BaseRetriever:
    """Create self-querying retriever with metadata filtering."""
    from langchain.chains.query_constructor.base import AttributeInfo
    from langchain.retrievers.self_query.base import SelfQueryRetriever
    from langchain_openai import ChatOpenAI
    
    attribute_info = [
        AttributeInfo(
            name=field["name"],
            description=field["description"],
            type=field["type"],
        )
        for field in metadata_field_info
    ]
    
    retriever = SelfQueryRetriever.from_llm(
        ChatOpenAI(model="gpt-4o"),
        vectorstore,
        document_content_description,
        attribute_info,
        embeddings,
    )
    
    return retriever

Re-ranking

# src/retrieval/ranking.py
from typing import List
from langchain_core.documents import Document
from langchain_core.retrievers import BaseRetriever
from langchain_cohere import CohereRerank
from langchain_community.cross_encoders import CrossEncoder

def add_cohere_reranker(
    retriever: BaseRetriever,
    top_n: int = 5,
    model: str = "rerank-english-v3.0"
) -> BaseRetriever:
    """Add Cohere re-ranking to retriever."""
    from langchain.retrievers import ContextualCompressionRetriever
    
    compressor = CohereRerank(
        model=model,
        top_n=top_n,
    )
    
    compression_retriever = ContextualCompressionRetriever(
        base_compressor=compressor,
        base_retriever=retriever,
    )
    
    return compression_retriever

def add_cross_encoder_reranker(
    retriever: BaseRetriever,
    model_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2",
    top_n: int = 5
) -> BaseRetriever:
    """Add cross-encoder re-ranking to retriever."""
    from langchain.retrievers import ContextualCompressionRetriever
    from langchain.retrievers.document_compressors import CrossEncoderReranker
    
    cross_encoder = CrossEncoder(
        model_name=model_name,
    )
    
    compressor = CrossEncoderReranker(
        model=cross_encoder,
        top_n=top_n,
    )
    
    compression_retriever = ContextualCompressionRetriever(
        base_compressor=compressor,
        base_retriever=retriever,
    )
    
    return compression_retriever

Generation Chains

Prompt Templates

# src/generation/prompts.py
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.messages import HumanMessage

# Standard QA prompt
QA_PROMPT = ChatPromptTemplate.from_messages([
    ("system", """You are an assistant for question-answering tasks. 
Use the following pieces of retrieved context to answer the question. 
If you don't know the answer, say that you don't know. 
Use three sentences maximum and keep the answer concise.

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

# Detailed QA prompt
DETAILED_QA_PROMPT = ChatPromptTemplate.from_messages([
    ("system", """You are a helpful assistant that answers questions based on provided context.

Instructions:
1. Use ONLY the provided context to answer the question
2. If the answer is not in the context, say "I don't have enough information to answer this question"
3. Cite the source documents when possible
4. Be concise but thorough
5. If there are conflicting pieces of information, mention both

Context:
{context}

Question: {input}

Answer:"""),
])

# Conversational QA prompt
CONVERSATIONAL_PROMPT = ChatPromptTemplate.from_messages([
    ("system", """You are an assistant for question-answering tasks. 
Use the following pieces of retrieved context to answer the question. 
If you don't know the answer, say that you don't know. 
Use three sentences maximum and keep the answer concise.

Context: {context}

Previous conversation:
{chat_history}"""),
    ("human", "{input}"),
])

# Citations prompt
CITATIONS_PROMPT = ChatPromptTemplate.from_messages([
    ("system", """You are a helpful assistant. Answer the question based on the context.

For each claim you make, cite the relevant source using [1], [2], etc.

Context:
{context}

Question: {input}

Format your answer with citations like this:
- The sky is blue [1].
- Water boils at 100°C [2].

Answer:"""),
])

QA Chains

# src/generation/chains.py
from typing import Any, Dict
from langchain_core.language_models import BaseChatModel
from langchain_core.retrievers import BaseRetriever
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
from langchain_core.output_parsers import StrOutputParser
from src.generation.prompts import QA_PROMPT, DETAILED_QA_PROMPT

def create_qa_chain(
    llm: BaseChatModel,
    retriever: BaseRetriever,
    prompt=QA_PROMPT,
    return_source_documents: bool = False
):
    """Create a basic QA chain."""
    
    def format_docs(docs):
        return "\n\n".join(doc.page_content for doc in docs)
    
    chain = (
        {"context": retriever | format_docs, "input": RunnablePassthrough()}
        | prompt
        | llm
        | StrOutputParser()
    )
    
    if return_source_documents:
        def wrap_output(answer: str, docs) -> Dict[str, Any]:
            return {
                "answer": answer,
                "source_documents": docs,
            }
        
        chain = (
            {"context": retriever | format_docs, "input": RunnablePassthrough()}
            | prompt
            | llm
            | StrOutputParser()
            | lambda x: {"answer": x}
        )
    
    return chain

def create_refine_chain(
    llm: BaseChatModel,
    retriever: BaseRetriever,
):
    """Create a Refine chain for long documents."""
    from langchain.chains import RefineChain
    
    return RefineChain.from_llm(
        llm=llm,
        retriever=retriever,
        return_intermediate_steps=True,
    )

def create_map_reduce_chain(
    llm: BaseChatModel,
    retriever: BaseRetriever,
):
    """Create a Map-Reduce chain."""
    from langchain.chains import MapReduceDocumentsChain, ReduceDocumentsChain
    from langchain.chains.question_answering import load_qa_chain
    
    # Map chain
    map_chain = load_qa_chain(llm, chain_type="map_reduce")
    
    # Reduce chain
    reduce_documents_chain = ReduceDocumentsChain(
        combine_chain=map_chain,
        collapse_chain=map_chain,
    )
    
    # Map-reduce chain
    chain = MapReduceDocumentsChain(
        retriever=retriever,
        document_variable_name="context",
        reduce_documents_chain=reduce_documents_chain,
    )
    
    return chain

Conversational QA

# src/generation/conversation.py
from typing import List, Tuple
from langchain_core.language_models import BaseChatModel
from langchain_core.retrievers import BaseRetriever
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
from langchain_core.output_parsers import StrOutputParser
from langchain_core.messages import BaseMessage
from src.generation.prompts import CONVERSATIONAL_PROMPT

def format_chat_history(history: List[Tuple[str, str]]) -> str:
    """Format chat history for the prompt."""
    formatted = []
    for human, ai in history:
        formatted.append(f"Human: {human}")
        formatted.append(f"Assistant: {ai}")
    return "\n".join(formatted)

def create_conversational_chain(
    llm: BaseChatModel,
    retriever: BaseRetriever,
    memory=None,
):
    """Create a conversational QA chain."""
    
    def format_docs(docs):
        return "\n\n".join(doc.page_content for doc in docs)
    
    def format_history(history):
        if not history:
            return ""
        return format_chat_history(history)
    
    chain = (
        {
            "context": retriever | format_docs,
            "input": RunnablePassthrough(),
            "chat_history": RunnableLambda(lambda x: x.get("chat_history", [])) | format_history,
        }
        | CONVERSATIONAL_PROMPT
        | llm
        | StrOutputParser()
    )
    
    return chain

def create_conversation_with_memory(
    llm: BaseChatModel,
    retriever: BaseRetriever,
    memory_store,
):
    """Create a conversational QA chain with persistent memory."""
    from langchain_core.chat_history import BaseChatMessageHistory
    
    def get_session_history(session_id: str) -> BaseChatMessageHistory:
        return memory_store.get(session_id)
    
    # Use MemorySaver or other checkpointer
    from langgraph.checkpoint.memory import MemorySaver
    checkpointer = MemorySaver()
    
    # Create graph-based conversation
    from langgraph.graph import StateGraph, MessagesState
    
    def retrieve_and_answer(state: MessagesState):
        messages = state["messages"]
        last_message = messages[-1].content
        
        docs = retriever.invoke(last_message)
        context = "\n\n".join(d.page_content for d in docs)
        
        response = llm.invoke([
            ("system", f"Use this context: {context}"),
            *messages,
        ])
        
        return {"messages": [response]}
    
    builder = StateGraph(MessagesState)
    builder.add_node("answer", retrieve_and_answer)
    builder.set_entry_point("answer")
    builder.add_edge("answer", "__end__")
    
    graph = builder.compile(checkpointer=checkpointer)
    
    return graph

Main Pipeline

Complete RAG Pipeline

# main.py
from src.config import RAGConfig
from src.ingestion.pipeline import ingest_documents
from src.retrieval.embeddings import get_embeddings
from src.retrieval.vector_store import create_vector_store, load_vector_store
from src.retrieval.retrievers import create_retriever
from src.generation.chains import create_qa_chain
from langchain_openai import ChatOpenAI, OpenAIEmbeddings

def build_rag_pipeline(
    sources: list[str],
    config: RAGConfig,
    fresh: bool = True
):
    """Build the complete RAG pipeline."""
    
    # Get embeddings
    print("Loading embedding model...")
    embeddings = get_embeddings(config.embedding)
    
    if fresh:
        # Ingest documents
        print("Ingesting documents...")
        documents = ingest_documents(sources, config)
        
        # Create vector store
        print("Creating vector store...")
        vectorstore = create_vector_store(
            documents=documents,
            embeddings=embeddings,
            config=config.retrieval,
            persist_directory="./chroma_db",
        )
    else:
        # Load existing vector store
        print("Loading existing vector store...")
        vectorstore = load_vector_store(
            embeddings=embeddings,
            config=config.retrieval,
            persist_directory="./chroma_db",
        )
    
    # Create retriever
    print("Creating retriever...")
    retriever = create_retriever(vectorstore, config.retrieval)
    
    # Create LLM
    print("Loading LLM...")
    llm = ChatOpenAI(
        model=config.llm.model,
        temperature=config.llm.temperature,
        max_tokens=config.llm.max_tokens,
    )
    
    # Create QA chain
    print("Creating QA chain...")
    qa_chain = create_qa_chain(
        llm=llm,
        retriever=retriever,
        return_source_documents=True,
    )
    
    return qa_chain

def main():
    """Main entry point."""
    import argparse
    
    parser = argparse.ArgumentParser(description="RAG Pipeline")
    parser.add_argument("--sources", nargs="+", required=True, help="Document sources")
    parser.add_argument("--query", help="Query to answer")
    parser.add_argument("--fresh", action="store_true", help="Fresh ingestion")
    args = parser.parse_args()
    
    # Load config
    config = RAGConfig.from_env()
    
    # Build pipeline
    qa_chain = build_rag_pipeline(
        sources=args.sources,
        config=config,
        fresh=args.fresh,
    )
    
    # Run query
    if args.query:
        result = qa_chain.invoke(args.query)
        print(f"\nAnswer: {result['answer']}")
        print(f"\nSources: {len(result['source_documents'])} documents")
        for i, doc in enumerate(result['source_documents'], 1):
            print(f"  [{i}] {doc.metadata.get('source', 'unknown')}: {doc.page_content[:100]}...")

if __name__ == "__main__":
    main()

Usage Examples

Basic Question Answering

from main import build_rag_pipeline

# Build pipeline
qa_chain = build_rag_pipeline(
    sources=["./data/documents/*.pdf"],
    fresh=True,
)

# Ask a question
result = qa_chain.invoke("What is the return policy?")
print(result["answer"])
print("Sources:", result["source_documents"])

Conversational QA

from src.generation.conversation import create_conversational_chain
from langchain_openai import ChatOpenAI

# Create conversational chain
conversational_chain = create_conversational_chain(
    llm=ChatOpenAI(model="gpt-4o"),
    retriever=retriever,
)

# First question
response1 = conversational_chain.invoke({
    "input": "What is the return policy?",
    "chat_history": [],
})
print(response1)

# Follow-up question
response2 = conversational_chain.invoke({
    "input": "What about exchanges?",
    "chat_history": [("What is the return policy?", response1)],
})
print(response2)

API Server

# api/server.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from main import build_rag_pipeline

app = FastAPI()

# Build pipeline once
qa_chain = build_rag_pipeline(
    sources=["./data/documents/*.pdf"],
    fresh=False,
)

class QueryRequest(BaseModel):
    query: str
    conversation_id: str | None = None

class QueryResponse(BaseModel):
    answer: str
    sources: list[dict]

@app.post("/ask", response_model=QueryResponse)
async def ask_question(request: QueryRequest):
    try:
        result = qa_chain.invoke(request.query)
        return QueryResponse(
            answer=result["answer"],
            sources=[
                {"content": doc.page_content[:200], "source": doc.metadata.get("source")}
                for doc in result["source_documents"]
            ],
        )
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

Evaluation

RAGAS Evaluation

# src/evaluation/pipeline.py
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision
from datasets import Dataset

def evaluate_rag(
    qa_chain,
    test_dataset: list[dict],
):
    """Evaluate RAG pipeline using RAGAS."""
    
    # Run queries
    answers = []
    contexts = []
    questions = []
    
    for item in test_dataset:
        result = qa_chain.invoke(item["question"])
        answers.append(result["answer"])
        contexts.append([doc.page_content for doc in result["source_documents"]])
        questions.append(item["question"])
    
    # Create dataset
    dataset = Dataset.from_dict({
        "question": questions,
        "answer": answers,
        "contexts": contexts,
        "ground_truth": [item["answer"] for item in test_dataset],
    })
    
    # Evaluate
    results = evaluate(
        dataset=dataset,
        metrics=[faithfulness, answer_relevancy, context_precision],
    )
    
    return results

def calculate_metrics(results):
    """Calculate evaluation metrics."""
    return {
        "faithfulness": results["faithfulness"],
        "answer_relevancy": results["answer_relevancy"],
        "context_precision": results["context_precision"],
    }

Troubleshooting

Low Retrieval Quality

  1. Check embeddings: Ensure same embedding model for indexing and retrieval
  2. Adjust chunk size: Try 500-2000 characters
  3. Increase k: Retrieve more documents
  4. Add re-ranking: Use Cohere or cross-encoder re-ranking
  5. Try hybrid search: Combine vector and BM25 search

Slow Performance

  1. Use smaller embeddings: text-embedding-3-small
  2. Enable caching: Cache embeddings
  3. Use FAISS: Faster than Chroma for local search
  4. Async processing: For ingestion
  5. Batch operations: For vector store

Memory Issues

  1. Process in batches: Don't load all documents at once
  2. Smaller chunks: Reduce chunk size
  3. Clear store: Between runs
  4. Streaming: For long documents

Resources


Last updated: May 2026

View on GitHub

Related Templates