RAG Architecture: Components, Timing & Design Patterns › Building a Complete RAG Pipeline

Michael BrenndoerferJanuary 21, 20265 min read
Language AI HandbookAll chapters
Part 44 of 67Retrieval-Augmented Generation
Chapter 333RAG Architecture: Components, Timing & Design Patterns › Building a Complete RAG Pipeline
Reading section 7 of 13 · Building a Complete RAG Pipeline · 5 minSection 7 of 13 · 5 min

Book contents

Section 7 of 13 · 5 min read

RAG Architecture: Components, Timing & Design Patterns › Building a Complete RAG PipelineOne section · Sections in this chapter7 / 13 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let's assemble the components into a working RAG system. We'll use a sparse retriever for simplicity; the next chapter covers dense retrieval in depth. This implementation demonstrates the core patterns you'll use in production systems, even though real systems would connect to actual language model APIs.

In[12]:
Code
from dataclasses import dataclass

import numpy as np
from rank_bm25 import BM25Okapi


@dataclass
class RetrievedDocument:
    """A document with its retrieval score."""

    content: str
    score: float
    doc_id: int


class BM25Retriever:
    """BM25-based sparse retriever."""

    def __init__(self, documents: list[str]):
        self.documents = documents
        self.tokenized_docs = [doc.lower().split() for doc in documents]
        self.bm25 = BM25Okapi(self.tokenized_docs)

    def retrieve(self, query: str, k: int = 3) -> list[RetrievedDocument]:
        """Retrieve top-k documents for a query."""
        tokenized_query = query.lower().split()
        scores = self.bm25.get_scores(tokenized_query)

        top_indices = np.argsort(scores)[::-1][:k]

        return [
            RetrievedDocument(
                content=self.documents[idx], score=scores[idx], doc_id=idx
            )
            for idx in top_indices
        ]
In[13]:
Code
class SimpleGenerator:
    """Placeholder generator (in practice, use an LLM API or local model)."""

    def __init__(self):
        pass

    def generate(self, prompt: str, max_tokens: int = 256) -> str:
        """Generate a response given a prompt.

        This is a placeholder. In practice, you would:
        - Call an API (OpenAI, Anthropic, etc.)
        - Run a local model (llama.cpp, vLLM, etc.)
        """
        return "[Generated response based on retrieved context]"
In[14]:
Code
class RAGPipeline:
    """Complete RAG pipeline combining retriever and generator."""

    def __init__(
        self,
        retriever: BM25Retriever,
        generator: SimpleGenerator,
        k: int = 3,
        include_scores: bool = False,
    ):
        self.retriever = retriever
        self.generator = generator
        self.k = k
        self.include_scores = include_scores

    def format_context(self, docs: list[RetrievedDocument]) -> str:
        """Format retrieved documents into context string."""
        parts = []
        for i, doc in enumerate(docs, 1):
            if self.include_scores:
                parts.append(f"[{i}] (score: {doc.score:.2f}) {doc.content}")
            else:
                parts.append(f"[{i}] {doc.content}")
        return "\n\n".join(parts)

    def build_prompt(self, query: str, context: str) -> str:
        """Construct the full prompt for the generator."""
        return f"""Answer the question based only on the following context. If the context doesn't contain the answer, say "I don't have enough information to answer this question."

Context:
{context}

Question: {query}

Answer:"""

    def answer(self, query: str) -> dict:
        """Run the full RAG pipeline."""
        retrieved_docs = self.retriever.retrieve(query, k=self.k)
        context = self.format_context(retrieved_docs)
        prompt = self.build_prompt(query, context)
        response = self.generator.generate(prompt)

        return {
            "query": query,
            "retrieved_documents": retrieved_docs,
            "prompt": prompt,
            "response": response,
        }
In[15]:
Code
# Build a knowledge base about ML concepts
knowledge_base = [
    "Transformers use self-attention mechanisms that allow each token to attend to all other tokens in the sequence. This enables capturing long-range dependencies without the sequential processing limitations of RNNs.",
    "The attention mechanism computes compatibility scores between queries and keys, then uses these scores to create weighted sums of values. This can be expressed as Attention(Q,K,V) = softmax(QK^T / sqrt(d_k))V.",
    "BERT (Bidirectional Encoder Representations from Transformers) uses masked language modeling for pre-training, randomly masking 15% of input tokens and training the model to predict them.",
    "GPT models are autoregressive, generating one token at a time while attending only to previously generated tokens. This causal attention prevents information leakage from future tokens.",
    "Retrieval-augmented generation grounds language models in external knowledge by retrieving relevant documents before generation. This reduces hallucination and enables access to up-to-date information.",
    "The key innovation of transformers is replacing recurrence with self-attention. While RNNs process sequences step-by-step, transformers process all positions in parallel during training.",
    "Multi-head attention runs several attention operations in parallel, letting the model to capture different types of relationships at different positions.",
    "Position encodings are necessary in transformers because self-attention is permutation-invariant. Sinusoidal encodings and learned position embeddings are common approaches.",
]

retriever = BM25Retriever(knowledge_base)
generator = SimpleGenerator()
rag = RAGPipeline(retriever, generator, k=3, include_scores=True)

result = rag.answer("What is the key innovation of transformers?")
Out[16]:
Console
Query: What is the key innovation of transformers?

============================================================
Retrieved Documents:
============================================================

[Doc 5] Score: 5.025
  The key innovation of transformers is replacing recurrence with self-attention. While RNNs process s...

[Doc 7] Score: 1.061
  Position encodings are necessary in transformers because self-attention is permutation-invariant. Si...

[Doc 0] Score: 0.820
  Transformers use self-attention mechanisms that allow each token to attend to all other tokens in th...

============================================================
Constructed Prompt:
============================================================
Answer the question based only on the following context. If the context doesn't contain the answer, say "I don't have enough information to answer this question."

Context:
[1] (score: 5.02) The key innovation of transformers is replacing recurrence with self-attention. While RNNs process sequences step-by-step, transformers process all positions in parallel during training.

[2] (score: 1.06) Position encodings are necessary in transformers because self-attention is permutation-invariant. Sinusoidal encodings and learned position embeddings are common approaches.

[3] (score: 0.82) Transformers use self-attention mechanisms that allow each token to attend to all other tokens in the sequence. This enables capturing long-range dependencies without the sequential processing limitations of RNNs.

Question: What is the key innovation of transformers?

Answer:

The pipeline retrieves the three most relevant documents (those mentioning "transformers," "attention," and related concepts) and then constructs a prompt instructing the model to answer based on this context. Notice how the BM25 scores reflect the degree of term overlap between the query and each document, with the highest-scoring document containing the exact phrase "key innovation of transformers."

Handling Edge Cases

Production RAG systems need to handle several edge cases gracefully. Not every query will have good matches in the knowledge base, and the system should behave appropriately in these situations rather than hallucinating or returning nonsensical results.

In[17]:
Code
def check_retrieval_quality(
    docs: list[RetrievedDocument], score_threshold: float = 1.0
) -> dict:
    """Assess whether retrieved documents are likely relevant."""
    if not docs:
        return {"status": "no_documents", "action": "fallback_to_parametric"}

    max_score = max(doc.score for doc in docs)
    avg_score = sum(doc.score for doc in docs) / len(docs)

    if max_score < score_threshold:
        return {
            "status": "low_confidence",
            "max_score": max_score,
            "action": "warn_user_or_expand_search",
        }

    return {
        "status": "good",
        "max_score": max_score,
        "avg_score": avg_score,
        "action": "proceed_with_generation",
    }


# Test with a query that has good matches
good_docs = retriever.retrieve("How does attention work in transformers?", k=3)
quality_good = check_retrieval_quality(good_docs)

# Test with a query outside the knowledge base
poor_docs = retriever.retrieve("What is the capital of France?", k=3)
quality_poor = check_retrieval_quality(poor_docs)
Out[18]:
Console
Query with good matches:
  Max score: 1.078
  Status: good
  Action: proceed_with_generation

Query outside knowledge base:
  Max score: 1.722
  Status: good
  Action: proceed_with_generation

When retrieval scores are low, the system might fall back to the model's parametric knowledge, return "I don't know" rather than hallucinating, or trigger a different retrieval strategy (expand query, search different index). The appropriate action depends on your use case. A customer support bot might need to always provide some answer, while a medical information system must be conservative and admit uncertainty. The threshold used in the quality check function should be tuned empirically on representative queries from your target domain, since BM25 scores are not standardized across corpora.