RAG Evaluation: Metrics for Retrieval and Generation Quality › Code Implementation

Michael BrenndoerferJanuary 13, 20259 min read
Language AI HandbookAll chapters
Part 44 of 67Retrieval-Augmented Generation
Chapter 345RAG Evaluation: Metrics for Retrieval and Generation Quality › Code Implementation
Reading section 7 of 11 · Code Implementation · 9 minSection 7 of 11 · 9 min

Book contents

Section 7 of 11 · 9 min read

RAG Evaluation: Metrics for Retrieval and Generation Quality › Code ImplementationOne section · Sections in this chapter7 / 11 ↓
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 us implement these evaluation metrics from first principles, then show the RAGAS framework approach. We will create a minimal RAG evaluation pipeline and compute all the metrics discussed.

First, we establish our imports and set up the environment:

Now we define the retrieval metrics. We implement Precision@K, Recall@K, MRR, and MAP as standalone functions that operate on lists of document identifiers:

In[9]:
Code
from typing import List, Set


def precision_at_k(retrieved: List[str], relevant: Set[str], k: int) -> float:
    """Calculate Precision@K for binary relevance judgments."""
    if k == 0:
        return 0.0
    retrieved_k = set(retrieved[:k])
    relevant_retrieved = retrieved_k.intersection(relevant)
    return len(relevant_retrieved) / len(retrieved_k)


def recall_at_k(retrieved: List[str], relevant: Set[str], k: int) -> float:
    """Calculate Recall@K."""
    if not relevant:
        return 0.0
    retrieved_k = set(retrieved[:k])
    relevant_retrieved = retrieved_k.intersection(relevant)
    return len(relevant_retrieved) / len(relevant)


def mean_reciprocal_rank(
    retrieved_list: List[List[str]], relevant_list: List[Set[str]]
) -> float:
    """Calculate MRR across multiple queries."""
    rr_sum = 0.0

    for retrieved, relevant in zip(retrieved_list, relevant_list):
        rank = None
        for i, doc in enumerate(retrieved, 1):
            if doc in relevant:
                rank = i
                break
        if rank:
            rr_sum += 1.0 / rank

    return rr_sum / len(retrieved_list) if retrieved_list else 0.0


def average_precision(retrieved: List[str], relevant: Set[str]) -> float:
    """Calculate Average Precision for a single query."""
    if not relevant:
        return 0.0

    hits = 0
    sum_precisions = 0.0

    for i, doc in enumerate(retrieved, 1):
        if doc in relevant:
            hits += 1
            sum_precisions += hits / i

    return sum_precisions / len(relevant) if relevant else 0.0


def mean_average_precision(
    retrieved_list: List[List[str]], relevant_list: List[Set[str]]
) -> float:
    """Calculate MAP across multiple queries."""
    ap_scores = [
        average_precision(ret, rel)
        for ret, rel in zip(retrieved_list, relevant_list)
    ]
    return np.mean(ap_scores) if ap_scores else 0.0

The MAP implementation illustrates the key design decision: we accumulate precision at each relevant document position and normalize by the total number of relevant documents (not the number retrieved). This means systems that miss relevant documents are penalized even if their retrieved set has perfect precision.

Next, we implement NDCG for graded relevance judgments. This requires relevance scores rather than binary labels:

In[10]:
Code
from typing import Dict, List


def dcg_at_k(relevance_scores: List[float], k: int) -> float:
    """Calculate Discounted Cumulative Gain."""
    dcg = 0.0
    for i, rel in enumerate(relevance_scores[:k], 1):
        dcg += (2**rel - 1) / np.log2(i + 1)
    return dcg


def ndcg_at_k(
    retrieved: List[str], relevance_map: Dict[str, float], k: int
) -> float:
    """Calculate Normalized DCG@K."""
    # Get relevance scores for retrieved items (0 if not in map)
    retrieved_scores = [relevance_map.get(doc, 0.0) for doc in retrieved[:k]]

    # Calculate actual DCG
    actual_dcg = dcg_at_k(retrieved_scores, k)

    # Calculate ideal DCG (perfect ranking of all relevant documents)
    ideal_scores = sorted(relevance_map.values(), reverse=True)[:k]
    ideal_dcg = dcg_at_k(ideal_scores, k)

    return actual_dcg / ideal_dcg if ideal_dcg > 0 else 0.0

The ndcg_at_k function handles an important edge case: if no relevant documents exist (IDCG is zero), we return 0 to avoid division by zero. The ideal ranking uses all relevant documents across the full corpus, not just those that were retrieved. This keeps systems missing relevant documents are penalized.

Now let us create a simulated RAG evaluation dataset and compute these metrics:

In[11]:
Code
# Simulated evaluation data for the Canada carbon pricing example
# Retrieved document IDs (in order of retrieval rank)
retrieved_docs = [
    "doc_eu_carbon",  # Rank 1: Irrelevant (EU, not Canada)
    "doc_canada_2019",  # Rank 2: Relevant (Canada 2019 system)
    "doc_canada_2023",  # Rank 3: Highly relevant (Canada 2023 contract)
    "doc_carbon_mechanisms",  # Rank 4: Marginally relevant
    "doc_canada_provinces",  # Rank 5: Marginally relevant
]

# Binary relevance judgments
relevant_binary = {
    "doc_canada_2019",
    "doc_canada_2023",
    "doc_carbon_mechanisms",
    "doc_canada_provinces",
}

# Graded relevance (0-3 scale)
relevance_graded = {
    "doc_canada_2019": 2.0,  # Relevant but not 2023-specific
    "doc_canada_2023": 3.0,  # Directly answers the question
    "doc_carbon_mechanisms": 1.0,  # Background context only
    "doc_canada_provinces": 1.0,  # Related but not answering
}
Out[12]:
Console
K=3: Precision=0.667, Recall=0.500, NDCG=0.574
K=5: Precision=0.800, Recall=1.000, NDCG=0.632
MRR: 0.500

We can see that the MRR of 0.5 shows the irrelevant EU document at rank 1, while NDCG captures both this ranking problem and the sub-optimal placement of the most relevant document at rank 3. These metrics together identify the retrieval weaknesses more precisely than any single number could.

Now we implement a simplified faithfulness checker using an entailment heuristic. In practice, this would use NLI models or LLM prompting, but the word-overlap heuristic shows the core logic:

In[13]:
Code
def extract_claims(answer: str) -> List[str]:
    """Simple sentence segmentation to extract claims."""
    import re

    sentences = re.split(r"(?<=[.!?])\s+", answer)
    return [s.strip() for s in sentences if len(s.strip()) > 10]


def check_entailment_simple(claim: str, context: str) -> bool:
    """Simplified entailment check using word overlap.

    In production, replace with an NLI model or LLM judge.
    Word overlap is a rough proxy that works for clear entailment
    and clear contradiction but struggles with paraphrases.
    """
    claim_words = set(claim.lower().split())
    context_words = set(context.lower().split())

    if not claim_words:
        return False

    overlap = len(claim_words.intersection(context_words))
    return overlap / len(claim_words) > 0.7  # 70% word overlap heuristic


def faithfulness_score(answer: str, contexts: List[str]) -> float:
    """Calculate faithfulness as ratio of supported claims."""
    claims = extract_claims(answer)
    if not claims:
        return 0.0

    supported = 0
    for claim in claims:
        # Check if claim is supported by ANY context chunk
        if any(check_entailment_simple(claim, ctx) for ctx in contexts):
            supported += 1

    return supported / len(claims)
Out[14]:
Console
Faithful answer score: 1.00
Hallucinated answer score: 0.00

The faithfulness checker correctly distinguishes between these cases: the faithful answer shares key terms ("Paris", "capital", "France", "987") with the context, while the hallucinated answer claims 1789 (a date not present in the context). In practice, the 70% word overlap threshold would be replaced by a proper entailment model that handles paraphrasing and implicit entailment.

Now let us show a complete RAGAS-style evaluation pipeline using an LLM judge simulation based on TF-IDF similarity:

In[15]:
Code
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity


class SimpleRAGASEvaluator:
    """Simplified RAGAS evaluator using heuristic judges.

    This class simulates the RAGAS framework using lightweight
    heuristics. For production use, replace each method's
    similarity computation with actual LLM judge calls.
    """

    def context_precision(self, query: str, retrieved: List[str]) -> float:
        """Fraction of retrieved chunks relevant to query."""
        # Simulate LLM judgment with keyword overlap
        query_words = set(query.lower().split())
        relevant_count = 0

        for doc in retrieved:
            doc_words = set(doc.lower().split())
            overlap = len(query_words.intersection(doc_words))
            if overlap > 0:
                relevant_count += 1

        return relevant_count / len(retrieved) if retrieved else 0.0

    def answer_relevance(self, query: str, answer: str) -> float:
        """Semantic similarity between query and answer via TF-IDF cosine."""
        vectorizer = TfidfVectorizer()
        try:
            vectors = vectorizer.fit_transform([query, answer])
            similarity = cosine_similarity(vectors[0:1], vectors[1:2])[0][0]
            return float(similarity)
        except Exception:
            return 0.0

    def faithfulness(self, answer: str, contexts: List[str]) -> float:
        """Check if answer is grounded in contexts using word overlap."""
        return faithfulness_score(answer, contexts)
Out[16]:
Console
RAGAS-style Evaluation:
  Context Precision: 1.00
  Answer Relevance:  0.72
  Faithfulness:      1.00

Interpretation:
  All 3 chunks mention 'France', so context precision is 1.0.
  The answer directly addresses the query, so answer relevance is high.
  The answer is grounded in the first retrieved chunk.
Out[17]:
Visualization
4x4 heatmap with red-to-green color scale showing combined end-to-end RAG scores at different retrieval and generation quality levels, with annotated cell values.
End-to-end RAG performance as a function of retrieval and generation quality, displayed as a heatmap. Each cell shows the combined system score when retrieval quality (rows) and generation quality (columns) are at specified levels. The multiplicative interaction structure means that even excellent generation cannot fully compensate for poor retrieval (left column remains low), and excellent retrieval cannot save poor generation (bottom row remains low). Both components must perform well together for high end-to-end performance.

The heatmap visualization makes the multiplicative nature of RAG performance viscerally clear. Even perfect generation (right column, generation quality 1.0) cannot rescue poor retrieval: with retrieval quality 0.2, end-to-end performance is capped at 0.18. Similarly, perfect retrieval cannot save poor generation. Both components must work well simultaneously for the system to deliver high-quality answers.