DEV Community

Phil IPS
Phil IPS

Posted on Edited on

RAG Evaluation & Monitoring: Metrics, Frameworks & Production Observability

RAG Evaluation & Monitoring: Metrics, Frameworks & Production Observability

Comprehensive guide to evaluating and monitoring Retrieval-Augmented Generation systems in production — from offline benchmarks to real-time observability.

Introduction

RAG systems combine retrieval + generation. Evaluation must cover both components plus the end-to-end pipeline. This guide covers metrics, frameworks, and production monitoring.

RAG Evaluation Dimensions

RAG PIPELINE:
Query → Retrieval → Rerank → Context → Generation → Answer

EVALUATION LAYERS:
1. Retrieval Quality    (precision@k, recall@k, MRR, nDCG)
2. Generation Quality   (faithfulness, relevance, hallucination)
3. End-to-End          (answer correctness, user satisfaction)
4. System Metrics       (latency, cost, throughput, errors)
Enter fullscreen mode Exit fullscreen mode

Part 1: Retrieval Metrics

Offline Retrieval Evaluation

# rag_eval/retrieval_metrics.py
from typing import List, Dict, Set
import numpy as np
from rank_bm25 import BM25Okapi

def precision_at_k(retrieved: List[str], relevant: Set[str], k: int) -> float:
    """Precision@K: fraction of top-K retrieved that are relevant."""
    if k == 0:
        return 0.0
    top_k = retrieved[:k]
    relevant_retrieved = sum(1 for doc in top_k if doc in relevant)
    return relevant_retrieved / k

def recall_at_k(retrieved: List[str], relevant: Set[str], k: int) -> float:
    """Recall@K: fraction of relevant docs retrieved in top-K."""
    if len(relevant) == 0:
        return 0.0
    top_k = retrieved[:k]
    relevant_retrieved = sum(1 for doc in top_k if doc in relevant)
    return relevant_retrieved / len(relevant)

def mrr(retrieved: List[str], relevant: Set[str]) -> float:
    """Mean Reciprocal Rank: 1/rank of first relevant doc."""
    for i, doc in enumerate(retrieved, 1):
        if doc in relevant:
            return 1.0 / i
    return 0.0

def ndcg_at_k(retrieved: List[str], relevant: Dict[str, float], k: int) -> float:
    """nDCG@K: normalized discounted cumulative gain with graded relevance."""
    def dcg(retrieved_docs: List[str], rel_scores: Dict[str, float], k: int) -> float:
        score = 0.0
        for i, doc in enumerate(retrieved_docs[:k], 1):
            rel = rel_scores.get(doc, 0.0)
            score += (2**rel - 1) / np.log2(i + 1)
        return score

    ideal_dcg = dcg(sorted(relevant.keys(), key=relevant.get, reverse=True), relevant, k)
    if ideal_dcg == 0:
        return 0.0
    return dcg(retrieved, relevant, k) / ideal_dcg

def hit_rate(retrieved: List[str], relevant: Set[str], k: int) -> float:
    """Hit Rate@K: whether at least one relevant doc in top-K."""
    top_k = set(retrieved[:k])
    return 1.0 if top_k & relevant else 0.0

# Batch evaluation
def evaluate_retrieval(
    queries: List[str],
    retrieval_fn,  # query -> List[doc_ids]
    ground_truth: Dict[str, Set[str]],  # query -> relevant doc_ids
    k_values: List[int] = [1, 3, 5, 10, 20]
) -> Dict:
    """Evaluate retrieval across multiple queries."""
    results = {k: {"precision": [], "recall": [], "mrr": [], "hit_rate": []} for k in k_values}

    for query in queries:
        retrieved = retrieval_fn(query)
        relevant = ground_truth.get(query, set())

        for k in k_values:
            results[k]["precision"].append(precision_at_k(retrieved, relevant, k))
            results[k]["recall"].append(recall_at_k(retrieved, relevant, k))
            results[k]["hit_rate"].append(hit_rate(retrieved, relevant, k))

        results[k_values[0]]["mrr"].append(mrr(retrieved, relevant))  # MRR uses full list

    # Aggregate
    aggregated = {}
    for k in k_values:
        aggregated[f"precision@{k}"] = np.mean(results[k]["precision"])
        aggregated[f"recall@{k}"] = np.mean(results[k]["recall"])
        aggregated[f"hit_rate@{k}"] = np.mean(results[k]["hit_rate"])
    aggregated["mrr"] = np.mean(results[k_values[0]]["mrr"])

    return aggregated
Enter fullscreen mode Exit fullscreen mode

Retrieval Ablation Studies

# rag_eval/retrieval_ablation.py
def ablation_study(
    base_retriever,
    query_set: List[str],
    ground_truth: Dict[str, Set[str]],
    variants: Dict[str, dict]
) -> Dict:
    """Compare retrieval variants: chunk size, embedding model, reranker, etc."""

    results = {}
    for name, config in variants.items():
        retriever = base_retriever.clone_with_config(config)
        metrics = evaluate_retrieval(query_set, retriever.retrieve, ground_truth)
        results[name] = metrics
        print(f"{name}: {metrics}")

    return results

# Example variants
ABLATION_VARIANTS = {
    "baseline": {"chunk_size": 512, "overlap": 50, "embed_model": "bge-small", "rerank": False},
    "larger_chunks": {"chunk_size": 1024, "overlap": 100, "embed_model": "bge-small", "rerank": False},
    "better_embedder": {"chunk_size": 512, "overlap": 50, "embed_model": "bge-large", "rerank": False},
    "with_reranker": {"chunk_size": 512, "overlap": 50, "embed_model": "bge-small", "rerank": "cross-encoder"},
    "full_upgrade": {"chunk_size": 1024, "overlap": 100, "embed_model": "bge-large", "rerank": "cross-encoder"},
}
Enter fullscreen mode Exit fullscreen mode

Part 2: Generation Metrics

Faithfulness (Groundedness)

# rag_eval/generation_metrics.py
from transformers import AutoModelForSequenceClassification, AutoTokenizer
import torch

class FaithfulnessEvaluator:
    """NLI-based faithfulness: does answer entail from context?"""

    def __init__(self, model_name: str = "microsoft/deberta-v3-large-mnli"):
        self.tokenizer = AutoTokenizer.from_pretrained(model_name)
        self.model = AutoModelForSequenceClassification.from_pretrained(model_name)
        self.model.eval()

    def score(self, context: str, answer: str) -> float:
        """Entailment probability: context → answer."""
        # Premise: context, Hypothesis: answer
        inputs = self.tokenizer(
            context, answer,
            return_tensors="pt",
            truncation=True,
            max_length=512,
            padding=True
        )
        with torch.no_grad():
            logits = self.model(**inputs).logits
            probs = torch.softmax(logits, dim=-1)
            # MNLI: 0=entailment, 1=neutral, 2=contradiction
            entailment_prob = probs[0, 0].item()
        return entailment_prob

class HallucinationDetector:
    """Detect unsupported claims in generated answer."""

    def __init__(self):
        self.nli = FaithfulnessEvaluator()

    def detect(self, context: str, answer: str, threshold: float = 0.5) -> Dict:
        """Return hallucination score and flagged spans."""
        sentences = self._split_sentences(answer)
        results = []

        for sent in sentences:
            score = self.nli.score(context, sent)
            results.append({
                "sentence": sent,
                "faithfulness": score,
                "hallucinated": score < threshold
            })

        overall = np.mean([r["faithfulness"] for r in results])
        hallucinated_spans = [r["sentence"] for r in results if r["hallucinated"]]

        return {
            "overall_faithfulness": overall,
            "hallucination_rate": len(hallucinated_spans) / len(sentences),
            "flagged_spans": hallucinated_spans,
            "per_sentence": results
        }
Enter fullscreen mode Exit fullscreen mode

Relevance & Answer Quality

# rag_eval/relevance_metrics.py
from sentence_transformers import SentenceTransformer, util

class RelevanceEvaluator:
    """Semantic similarity between query and answer."""

    def __init__(self, model_name: str = "sentence-transformers/all-MiniLM-L6-v2"):
        self.model = SentenceTransformer(model_name)

    def query_answer_relevance(self, query: str, answer: str) -> float:
        """Cosine similarity between query and answer embeddings."""
        q_emb = self.model.encode(query, convert_to_tensor=True)
        a_emb = self.model.encode(answer, convert_to_tensor=True)
        return util.cos_sim(q_emb, a_emb).item()

    def context_answer_relevance(self, context: str, answer: str) -> float:
        """How well answer addresses context."""
        c_emb = self.model.encode(context, convert_to_tensor=True)
        a_emb = self.model.encode(answer, convert_to_tensor=True)
        return util.cos_sim(c_emb, a_emb).item()

class AnswerCorrectnessEvaluator:
    """LLM-as-judge for answer correctness."""

    def __init__(self, judge_model: str = "gpt-4o-mini"):
        self.judge_model = judge_model
        # Use OpenAI API or local model

    def evaluate(
        self,
        query: str,
        ground_truth: str,
        generated: str,
        context: str = None
    ) -> Dict:
        """Score 1-5 on correctness, completeness, conciseness."""
        prompt = f"""Rate the generated answer (1-5) on:
1. Correctness: factually accurate vs ground truth
2. Completeness: covers all key points from ground truth
3. Conciseness: no fluff, direct answer

Query: {query}
Ground Truth: {ground_truth}
Generated: {generated}
Context: {context or 'N/A'}

Return JSON: {{"correctness": 1-5, "completeness": 1-5, "conciseness": 1-5, "reasoning": "..."}}"""

        # Call judge model
        return self._call_judge(prompt)
Enter fullscreen mode Exit fullscreen mode

Part 3: End-to-End Frameworks

Ragas — Reference-Free Evaluation

# rag_eval/ragas_integration.py
from ragas import evaluate
from ragas.metrics import (
    faithfulness,
    answer_relevancy,
    context_precision,
    context_recall,
    context_relevancy,
    answer_correctness,
    answer_similarity,
)
from datasets import Dataset

def evaluate_rag_with_ragas(
    questions: List[str],
    answers: List[str],
    contexts: List[List[str]],
    ground_truths: List[str] = None
) -> Dict:
    """Evaluate RAG using Ragas metrics."""

    data = {
        "question": questions,
        "answer": answers,
        "contexts": contexts,
    }
    if ground_truths:
        data["ground_truth"] = ground_truths

    dataset = Dataset.from_dict(data)

    # Select metrics based on available data
    metrics = [
        faithfulness,
        answer_relevancy,
        context_precision,
        context_recall,
    ]
    if ground_truths:
        metrics.extend([answer_correctness, answer_similarity])

    result = evaluate(dataset, metrics=metrics)
    return result.to_pandas().to_dict(orient="records")[0]

# Ragas metric explanations
RAGAS_METRICS = {
    "faithfulness": "Answer grounded in context? (0-1, higher better)",
    "answer_relevancy": "Answer relevant to question? (0-1)",
    "context_precision": "Retrieved contexts ranked by relevance? (0-1)",
    "context_recall": "All needed info retrieved? (0-1)",
    "context_relevancy": "Context relevant to question? (0-1)",
    "answer_correctness": "Answer matches ground truth? (0-1, needs GT)",
    "answer_similarity": "Semantic similarity to ground truth? (0-1, needs GT)",
}
Enter fullscreen mode Exit fullscreen mode

DeepEval — Unit Testing for LLMs

# rag_eval/deepeval_integration.py
from deepeval import evaluate
from deepeval.metrics import (
    AnswerRelevancyMetric,
    FaithfulnessMetric,
    ContextualPrecisionMetric,
    ContextualRecallMetric,
    ContextualRelevancyMetric,
    HallucinationMetric,
    ToxicityMetric,
    BiasMetric,
)
from deepeval.test_case import LLMTestCase

def evaluate_rag_with_deepeval(
    test_cases: List[LLMTestCase],
    threshold: float = 0.7
) -> Dict:
    """Evaluate with DeepEval's comprehensive metrics."""

    metrics = [
        AnswerRelevancyMetric(threshold=threshold),
        FaithfulnessMetric(threshold=threshold),
        ContextualPrecisionMetric(threshold=threshold),
        ContextualRecallMetric(threshold=threshold),
        ContextualRelevancyMetric(threshold=threshold),
        HallucinationMetric(threshold=threshold),
        ToxicityMetric(threshold=0.5),
    ]

    results = evaluate(test_cases, metrics)
    return {
        "passed": all(r.success for r in results),
        "scores": {m.__class__.__name__: r.score for r in results for m in r.metrics},
        "details": results
    }

# Test case creation
def create_test_case(
    query: str,
    answer: str,
    retrieval_context: List[str],
    expected_output: str = None
) -> LLMTestCase:
    return LLMTestCase(
        input=query,
        actual_output=answer,
        retrieval_context=retrieval_context,
        expected_output=expected_output,
    )
Enter fullscreen mode Exit fullscreen mode

TruLens — Instrumentation & Feedback

# rag_eval/trulens_integration.py
from trulens_eval import Tru, Feedback, OpenAI as fOpenAI
from trulens_eval.feedback import Groundedness
import numpy as np

def setup_trulens():
    """Configure TruLens feedback functions."""
    provider = fOpenAI()

    # Groundedness: answer supported by context
    grounded = Groundedness(groundedness_provider=provider)
    f_groundedness = Feedback(
        grounded.groundedness_measure_with_cot_reasons,
        name="Groundedness"
    ).on_input_output()

    # Answer relevance
    f_answer_relevance = Feedback(
        provider.relevance,
        name="Answer Relevance"
    ).on_input_output()

    # Context relevance
    f_context_relevance = Feedback(
        provider.context_relevance,
        name="Context Relevance"
    ).on_input()

    return [f_groundedness, f_answer_relevance, f_context_relevance]

def instrument_rag_chain(rag_chain, feedback_funcs):
    """Wrap RAG chain for TruLens logging."""
    from trulens_eval import TruChain

    tru_chain = TruChain(
        rag_chain,
        app_id="rag_qa",
        feedbacks=feedback_funcs
    )
    return tru_chain
Enter fullscreen mode Exit fullscreen mode

Part 4: Production Monitoring

Real-Time Metrics Pipeline

# rag_monitoring/production_metrics.py
from dataclasses import dataclass, field
from typing import List, Optional
from datetime import datetime
import time
import asyncio
from prometheus_client import Counter, Histogram, Gauge, Summary

# Prometheus metrics
RAG_REQUESTS_TOTAL = Counter(
    "rag_requests_total",
    "Total RAG requests",
    ["status"]  # success, error, timeout
)

RAG_LATENCY = Histogram(
    "rag_latency_seconds",
    "RAG request latency",
    buckets=[0.5, 1, 2, 5, 10, 30, 60]
)

RETRIEVAL_LATENCY = Histogram(
    "rag_retrieval_latency_seconds",
    "Retrieval step latency"
)

GENERATION_LATENCY = Histogram(
    "rag_generation_latency_seconds",
    "Generation step latency"
)

RAG_TOKENS = Counter(
    "rag_tokens_total",
    "Total tokens used",
    ["type"]  # prompt, completion
)

RAG_COST = Counter(
    "rag_cost_usd_total",
    "Total cost in USD"
)

ACTIVE_REQUESTS = Gauge(
    "rag_active_requests",
    "Currently processing requests"
)

QUALITY_SCORE = Histogram(
    "rag_quality_score",
    "Quality scores from evaluators",
    ["metric"],  # faithfulness, relevance, etc.
    buckets=[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
)

@dataclass
class RAGRequestLog:
    request_id: str
    timestamp: datetime
    query: str
    retrieved_docs: List[str]
    answer: str
    latency_ms: float
    retrieval_latency_ms: float
    generation_latency_ms: float
    prompt_tokens: int
    completion_tokens: int
    cost_usd: float
    quality_scores: dict = field(default_factory=dict)
    error: Optional[str] = None

class ProductionMonitor:
    """Production RAG monitoring with alerting."""

    def __init__(
        self,
        alert_webhook: str = None,
        quality_threshold: float = 0.6,
        latency_p99_threshold: float = 30.0,
        error_rate_threshold: float = 0.05
    ):
        self.alert_webhook = alert_webhook
        self.quality_threshold = quality_threshold
        self.latency_threshold = latency_p99_threshold
        self.error_rate_threshold = error_rate_threshold
        self.request_logs: List[RAGRequestLog] = []
        self.window_logs: List[RAGRequestLog] = []

    async def log_request(self, log: RAGRequestLog):
        """Log request and update metrics."""
        self.request_logs.append(log)
        self.window_logs.append(log)

        # Update Prometheus
        RAG_REQUESTS_TOTAL.labels(
            status="error" if log.error else "success"
        ).inc()

        RAG_LATENCY.observe(log.latency_ms / 1000)
        RETRIEVAL_LATENCY.observe(log.retrieval_latency_ms / 1000)
        GENERATION_LATENCY.observe(log.generation_latency_ms / 1000)

        RAG_TOKENS.labels(type="prompt").inc(log.prompt_tokens)
        RAG_TOKENS.labels(type="completion").inc(log.completion_tokens)
        RAG_COST.inc(log.cost_usd)

        for metric, score in log.quality_scores.items():
            QUALITY_SCORE.labels(metric=metric).observe(score)

        # Check alerts
        await self._check_alerts(log)

    async def _check_alerts(self, log: RAGRequestLog):
        """Check and fire alerts."""
        alerts = []

        # Quality alert
        for metric, score in log.quality_scores.items():
            if score < self.quality_threshold:
                alerts.append(f"Quality alert: {metric}={score:.2f} < {self.quality_threshold}")

        # Latency alert
        if log.latency_ms > self.latency_threshold * 1000:
            alerts.append(f"Latency alert: {log.latency_ms}ms > {self.latency_threshold}s")

        # Error rate alert (rolling window)
        await self._check_error_rate()

        for alert in alerts:
            await self._send_alert(alert)

    async def _check_error_rate(self):
        """Check error rate in last 100 requests."""
        if len(self.window_logs) >= 100:
            recent = self.window_logs[-100:]
            errors = sum(1 for r in recent if r.error)
            rate = errors / 100
            if rate > self.error_rate_threshold:
                await self._send_alert(f"Error rate alert: {rate:.1%} > {self.error_rate_threshold:.1%}")

    async def _send_alert(self, message: str):
        """Send alert to webhook."""
        if self.alert_webhook:
            # Implement webhook call
            print(f"ALERT: {message}")

    def get_dashboard_data(self, hours: int = 24) -> Dict:
        """Get data for Grafana dashboard."""
        cutoff = datetime.now() - timedelta(hours=hours)
        recent = [r for r in self.request_logs if r.timestamp > cutoff]

        if not recent:
            return {}

        return {
            "total_requests": len(recent),
            "success_rate": sum(1 for r in recent if not r.error) / len(recent),
            "avg_latency_ms": np.mean([r.latency_ms for r in recent]),
            "p99_latency_ms": np.percentile([r.latency_ms for r in recent], 99),
            "avg_cost_per_request": np.mean([r.cost_usd for r in recent]),
            "total_cost": sum(r.cost_usd for r in recent),
            "avg_quality": {
                metric: np.mean([r.quality_scores.get(metric, 0) for r in recent])
                for metric in ["faithfulness", "relevance", "correctness"]
            },
        }
Enter fullscreen mode Exit fullscreen mode

Distributed Tracing

# rag_monitoring/tracing.py
from opentelemetry import trace
from opentelemetry.exporter.jaeger import JaegerExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor

def setup_tracing(service_name: str = "rag-service"):
    """Setup OpenTelemetry tracing."""

    trace.set_tracer_provider(TracerProvider())
    jaeger_exporter = JaegerExporter(
        agent_host_name="jaeger",
        agent_port=6831,
    )
    trace.get_tracer_provider().add_span_processor(
        BatchSpanProcessor(jaeger_exporter)
    )

    return trace.get_tracer(__name__)

class TracedRAGChain:
    """RAG chain with full distributed tracing."""

    def __init__(self, retriever, generator, tracer):
        self.retriever = retriever
        self.generator = generator
        self.tracer = tracer

    async def answer(self, query: str) -> dict:
        with self.tracer.start_as_current_span("rag_query") as span:
            span.set_attribute("query", query)

            # Retrieval span
            with self.tracer.start_as_current_span("retrieval") as retr_span:
                start = time.time()
                docs = await self.retriever.retrieve(query)
                retr_span.set_attribute("num_docs", len(docs))
                retr_span.set_attribute("latency_ms", (time.time() - start) * 1000)

            # Generation span
            with self.tracer.start_as_current_span("generation") as gen_span:
                start = time.time()
                answer = await self.generator.generate(query, docs)
                gen_span.set_attribute("answer_length", len(answer))
                gen_span.set_attribute("latency_ms", (time.time() - start) * 1000)

            span.set_attribute("total_latency_ms", (time.time() - start) * 1000)
            return {"answer": answer, "sources": docs}
Enter fullscreen mode Exit fullscreen mode

Part 5: Automated Evaluation Pipeline

CI/CD Integration

# rag_eval/ci_pipeline.py
import pytest
from ragas import evaluate
from datasets import Dataset

class RAGEvalTestSuite:
    """Pytest-style RAG evaluation tests."""

    @pytest.fixture(scope="class")
    def test_data(self):
        return [
            {
                "question": "What is LoRA?",
                "ground_truth": "LoRA is Low-Rank Adaptation for efficient fine-tuning...",
                "contexts": ["LoRA freezes base weights and adds low-rank adapters..."],
            },
            # ... more test cases
        ]

    def test_faithfulness_threshold(self, rag_chain, test_data):
        """Faithfulness must exceed threshold."""
        results = []
        for case in test_data:
            answer = rag_chain.answer(case["question"])
            score = self._compute_faithfulness(case["contexts"][0], answer)
            results.append(score)
            assert score >= 0.7, f"Faithfulness {score:.2f} below threshold for: {case['question']}"

    def test_retrieval_precision(self, rag_chain, test_data):
        """Retrieval precision@5 must exceed threshold."""
        for case in test_data:
            retrieved = rag_chain.retrieve(case["question"])
            relevant = self._get_relevant_docs(case["question"])
            p5 = precision_at_k(retrieved, relevant, 5)
            assert p5 >= 0.6, f"Precision@5 {p5:.2f} below threshold for: {case['question']}"

    def test_latency_sla(self, rag_chain, test_data):
        """P99 latency must be under 10 seconds."""
        latencies = []
        for case in test_data:
            start = time.time()
            rag_chain.answer(case["question"])
            latencies.append(time.time() - start)

        p99 = np.percentile(latencies, 99)
        assert p99 <= 10.0, f"P99 latency {p99:.1f}s exceeds 10s SLA"

    def test_cost_per_query(self, rag_chain, test_data):
        """Average cost per query under $0.01."""
        costs = [rag_chain.get_last_cost() for _ in test_data]
        avg_cost = np.mean(costs)
        assert avg_cost <= 0.01, f"Avg cost ${avg_cost:.4f} exceeds $0.01"

# Regression detection
def detect_regression(
    current_scores: Dict,
    baseline_scores: Dict,
    threshold: float = 0.05
) -> List[str]:
    """Detect metric regressions vs baseline."""
    regressions = []
    for metric, current in current_scores.items():
        baseline = baseline_scores.get(metric)
        if baseline and current < baseline - threshold:
            regressions.append(
                f"REGRESSION: {metric} dropped from {baseline:.3f} to {current:.3f} "
                f"(delta: {current-baseline:.3f})"
            )
    return regressions
Enter fullscreen mode Exit fullscreen mode

A/B Testing Framework

# rag_eval/ab_testing.py
import random
from dataclasses import dataclass
from typing: Callable, Dict, List
import numpy as np
from scipy import stats

@dataclass
class ABTestResult:
    variant_a: str
    variant_b: str
    metric: str
    a_mean: float
    b_mean: float
    p_value: float
    significant: bool
    winner: str

class RAGABTester:
    """A/B test RAG variants in production."""

    def __init__(self, traffic_split: float = 0.5):
        self.traffic_split = traffic_split
        self.variants: Dict[str, Callable] = {}
        self.results: Dict[str, List[float]] = {}

    def register_variant(self, name: str, chain_factory: Callable):
        self.variants[name] = chain_factory
        self.results[name] = []

    def route_request(self, query: str) -> tuple:
        """Route to variant based on traffic split."""
        variant_name = "A" if random.random() < self.traffic_split else "B"
        chain = self.variants[variant_name]()
        return variant_name, chain

    def record_result(self, variant: str, score: float):
        self.results[variant].append(score)

    def analyze(self, metric: str = "quality") -> ABTestResult:
        """Statistical significance test."""
        a_scores = self.results["A"]
        b_scores = self.results["B"]

        if len(a_scores) < 30 or len(b_scores) < 30:
            return ABTestResult(
                "A", "B", metric,
                np.mean(a_scores), np.mean(b_scores),
                1.0, False, "insufficient_data"
            )

        # Welch's t-test (unequal variance)
        t_stat, p_value = stats.ttest_ind(a_scores, b_scores, equal_var=False)

        a_mean, b_mean = np.mean(a_scores), np.mean(b_scores)
        winner = "A" if a_mean > b_mean else "B"

        return ABTestResult(
            "A", "B", metric,
            a_mean, b_mean,
            p_value, p_value < 0.05, winner
        )
Enter fullscreen mode Exit fullscreen mode

Part 6: Data Quality & Drift Detection

Embedding Drift Monitoring

# rag_monitoring/drift_detection.py
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
import numpy as np
from scipy.spatial.distance import jensenshannon

class EmbeddingDriftDetector:
    """Detect drift in query/document embeddings."""

    def __init__(self, reference_embeddings: np.ndarray, window_size: int = 1000):
        self.reference = reference_embeddings
        self.window_size = window_size
        self.recent_embeddings: List[np.ndarray] = []

        # Fit PCA on reference for visualization
        self.pca = PCA(n_components=2)
        self.pca.fit(reference_embeddings)

    def add_embeddings(self, embeddings: np.ndarray):
        self.recent_embeddings.append(embeddings)
        if len(self.recent_embeddings) > self.window_size:
            self.recent_embeddings.pop(0)

    def compute_drift_score(self) -> Dict:
        """Compute multiple drift metrics."""
        if not self.recent_embeddings:
            return {"drift_detected": False}

        recent = np.vstack(self.recent_embeddings)

        # 1. Mean shift (L2 distance between centroids)
        ref_centroid = self.reference.mean(axis=0)
        recent_centroid = recent.mean(axis=0)
        mean_shift = np.linalg.norm(ref_centroid - recent_centroid)

        # 2. Covariance shift (Frobenius norm)
        ref_cov = np.cov(self.reference.T)
        recent_cov = np.cov(recent.T)
        cov_shift = np.linalg.norm(ref_cov - recent_cov, 'fro')

        # 3. JS divergence on PCA projections
        ref_pca = self.pca.transform(self.reference)
        recent_pca = self.pca.transform(recent)

        # 2D histogram
        hist_range = [[ref_pca[:,0].min(), ref_pca[:,0].max()],
                      [ref_pca[:,1].min(), ref_pca[:,1].max()]]
        ref_hist, _, _ = np.histogram2d(ref_pca[:,0], ref_pca[:,1], bins=20, range=hist_range, density=True)
        recent_hist, _, _ = np.histogram2d(recent_pca[:,0], recent_pca[:,1], bins=20, range=hist_range, density=True)

        # Flatten and add epsilon
        ref_flat = ref_hist.flatten() + 1e-10
        recent_flat = recent_hist.flatten() + 1e-10

        js_div = jensenshannon(ref_flat, recent_flat)

        return {
            "drift_detected": js_div > 0.1 or mean_shift > 0.5,
            "mean_shift": float(mean_shift),
            "cov_shift": float(cov_shift),
            "js_divergence": float(js_div),
            "ref_centroid": ref_centroid.tolist(),
            "recent_centroid": recent_centroid.tolist(),
        }
Enter fullscreen mode Exit fullscreen mode

Data Quality Checks

# rag_monitoring/data_quality.py
def validate_rag_inputs(
    query: str,
    retrieved_docs: List[str],
    answer: str
) -> Dict:
    """Validate inputs for quality issues."""

    issues = []

    # Query checks
    if len(query.strip()) < 3:
        issues.append("Query too short")
    if len(query) > 2000:
        issues.append("Query extremely long")

    # Retrieval checks
    if not retrieved_docs:
        issues.append("No documents retrieved")
    else:
        avg_doc_len = np.mean([len(d) for d in retrieved_docs])
        if avg_doc_len < 50:
            issues.append("Retrieved docs very short")
        if avg_doc_len > 5000:
            issues.append("Retrieved docs very long")

        # Check for duplicate docs
        unique_docs = set(retrieved_docs)
        if len(unique_docs) < len(retrieved_docs) * 0.8:
            issues.append("High duplicate rate in retrieval")

    # Answer checks
    if not answer.strip():
        issues.append("Empty answer")
    if len(answer) < 10:
        issues.append("Answer too short")
    if answer.count("I don't know") > 0:
        issues.append("Model abstained")

    # Repetition check
    words = answer.split()
    if len(words) > 10:
        unique_ratio = len(set(words)) / len(words)
        if unique_ratio < 0.3:
            issues.append("High repetition in answer")

    return {
        "valid": len(issues) == 0,
        "issues": issues,
        "severity": "high" if any("Empty" in i or "No documents" in i for i in issues) else "low"
    }
Enter fullscreen mode Exit fullscreen mode

Part 7: Dashboards & Alerting

Grafana Dashboard JSON

# rag_monitoring/grafana_dashboard.json
{
  "dashboard": {
    "title": "RAG Production Monitoring",
    "panels": [
      {
        "title": "Request Rate",
        "targets": [{"expr": "rate(rag_requests_total[5m])"}]
      },
      {
        "title": "Error Rate",
        "targets": [{"expr": "rate(rag_requests_total{status=\"error\"}[5m]) / rate(rag_requests_total[5m])"}]
      },
      {
        "title": "Latency P50/P95/P99",
        "targets": [
          {"expr": "histogram_quantile(0.50, rag_latency_seconds)"},
          {"expr": "histogram_quantile(0.95, rag_latency_seconds)"},
          {"expr": "histogram_quantile(0.99, rag_latency_seconds)"}
        ]
      },
      {
        "title": "Quality Scores",
        "targets": [
          {"expr": "avg by (metric) (rag_quality_score)"}
        ]
      },
      {
        "title": "Cost per Request",
        "targets": [{"expr": "rate(rag_cost_usd_total[1h]) / rate(rag_requests_total[1h])"}]
      },
      {
        "title": "Tokens per Request",
        "targets": [
          {"expr": "rate(rag_tokens_total{type=\"prompt\"}[5m]) / rate(rag_requests_total[5m])"},
          {"expr": "rate(rag_tokens_total{type=\"completion\"}[5m]) / rate(rag_requests_total[5m])"}
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Alert Rules

# rag_monitoring/alerts.yaml
groups:
- name: rag_alerts
  rules:
  - alert: RAGHighErrorRate
    expr: rate(rag_requests_total{status="error"}[5m]) / rate(rag_requests_total[5m]) > 0.05
    for: 5m
    labels:
      severity: critical
    annotations:
      summary: "RAG error rate > 5%"

  - alert: RAGHighLatency
    expr: histogram_quantile(0.99, rag_latency_seconds) > 30
    for: 5m
    labels:
      severity: warning
    annotations:
      summary: "RAG P99 latency > 30s"

  - alert: RAGLowFaithfulness
    expr: avg(rag_quality_score{metric="faithfulness"}) < 0.6
    for: 10m
    labels:
      severity: warning
    annotations:
      summary: "RAG faithfulness dropped below 0.6"

  - alert: RAGHighCost
    expr: rate(rag_cost_usd_total[1h]) > 10
    for: 15m
    labels:
      severity: warning
    annotations:
      summary: "RAG cost > $10/hour"
Enter fullscreen mode Exit fullscreen mode

Part 8: Continuous Improvement Loop

# rag_eval/improvement_loop.py
class RAGImprovementLoop:
    """Automated RAG improvement via evaluation feedback."""

    def __init__(self, rag_system, evaluator, optimizer):
        self.rag = rag_system
        self.evaluator = evaluator
        self.optimizer = optimizer
        self.history = []

    async def run_iteration(self, eval_queries: List[str]):
        """One improvement cycle."""

        # 1. Evaluate current system
        current_metrics = await self.evaluator.evaluate(self.rag, eval_queries)

        # 2. Identify weak spots
        weak_spots = self._identify_weaknesses(current_metrics)

        # 3. Generate optimization hypotheses
        hypotheses = self.optimizer.generate_hypotheses(weak_spots)

        # 4. Test hypotheses (A/B or shadow)
        best_variant = await self._test_hypotheses(hypotheses, eval_queries)

        # 5. Deploy if significant improvement
        if best_variant and best_variant.improvement > 0.02:
            await self._deploy_variant(best_variant)
            self.history.append({
                "iteration": len(self.history),
                "metrics": current_metrics,
                "deployed": best_variant.name,
                "improvement": best_variant.improvement
            })

        return current_metrics

    def _identify_weaknesses(self, metrics: Dict) -> List[str]:
        """Identify which metrics need improvement."""
        thresholds = {
            "faithfulness": 0.8,
            "answer_relevancy": 0.75,
            "context_precision": 0.7,
            "context_recall": 0.7,
        }

        return [
            metric for metric, threshold in thresholds.items()
            if metrics.get(metric, 0) < threshold
        ]
Enter fullscreen mode Exit fullscreen mode

Part 9: Cost Optimization

Token & Cost Tracking

# rag_monitoring/cost_optimization.py
class CostOptimizer:
    """Optimize RAG costs while maintaining quality."""

    def __init__(self, pricing: Dict):
        self.pricing = pricing  # {"gpt-4o": {"input": 5, "output": 15}, ...}

    def estimate_cost(self, prompt_tokens: int, completion_tokens: int, model: str) -> float:
        """Estimate cost in USD."""
        rates = self.pricing.get(model, {"input": 0, "output": 0})
        return (prompt_tokens * rates["input"] + completion_tokens * rates["output"]) / 1_000_000

    def optimize_model_selection(
        self,
        query: str,
        complexity_score: float,  # 0-1
        quality_requirement: float  # 0-1
    ) -> str:
        """Route to cheapest model meeting quality."""

        models = [
            ("gpt-4o-mini", 0.7, 0.15),   # quality, cost_factor
            ("gpt-4o", 0.9, 1.0),
            ("gpt-4-turbo", 0.95, 2.0),
        ]

        for model, quality, cost_factor in models:
            if quality >= quality_requirement:
                # Check if we can use cheaper for simple queries
                if complexity_score < 0.3 and model != "gpt-4o-mini":
                    continue
                return model

        return "gpt-4o"  # fallback

    def optimize_retrieval(self, retrieval_config: Dict) -> Dict:
        """Find optimal retrieval config for cost/quality."""
        # Fewer docs = lower prompt tokens = lower cost
        # But too few = lower quality
        # Use Bayesian optimization
        pass
Enter fullscreen mode Exit fullscreen mode

Resources


RAG evaluation is not one-time — it's continuous. Automate metrics, alert on regressions, and close the loop with A/B testing. The best RAG system is the one you can measure and improve.


📦 Want the production-ready version?

This article covers the fundamentals. The RAG Pipeline Patterns pack goes further — production chunking, reranking, evaluation and observability patterns, plus full source code and deployment configs.

→ Get RAG Pipeline Patterns

Extracted from real production systems, not toy examples.

Top comments (1)

Collapse
 
alex_qamentor profile image
alex •

In the code, the faithfulness NLI model truncates the input to max_length=512, and the context goes in as the premise, so when there are several chunks the tail of the context is silently dropped. Statements that rely on the cut-off part get flagged as hallucinations, even though this is an artifact of how the context is packed, not an error in generation. One fix is to split the context into parts and take the maximum entailment score for each sentence of the answer. Cosine similarity between query and answer mostly measures topical overlap, so a confidently wrong answer gets a high score. It's worth adding traps (questions with no answer in the knowledge base, where the correct behavior is a refusal) and tracking the refusal rate separately, since neither retriever metrics nor similarity will show this failure. Disclosure, we build EvaliQA, an AI evaluation platform, which evaluates the full context rather than truncating it.