← Back to DevBytes

Monitoring RAG Retrieval Quality Over Time

Monitoring RAG Retrieval Quality Over Time

Retrieval-Augmented Generation (RAG) systems are only as good as the documents they retrieve. When you first ship a RAG pipeline, your retrieval quality may look excellent on a held-out test set. But over weeks and months, content drifts, queries shift, embeddings get stale, and chunking strategies that once worked start to fail. Monitoring retrieval quality over time is the practice of continuously measuring how well your retriever surfaces the right context, so you can catch degradation before users notice.

What Is RAG Retrieval Quality Monitoring?

Retrieval quality monitoring is the systematic, ongoing measurement of how relevant and complete the retrieved context is for real user queries. Unlike one-off evaluation, it tracks metrics across time windows — daily, weekly, or per release — so you can detect regressions, seasonal patterns, and slow drift.

The core dimensions you typically track include:

Why It Matters

RAG systems degrade silently. A vector index that performed well at launch can quietly lose accuracy as new documents are added, as the underlying content changes, or as user queries evolve toward topics your embeddings represent poorly. Without monitoring, you only learn about these problems through support tickets and churn.

Key reasons to monitor continuously:

How to Use It: A Practical Implementation

The most effective approach combines offline evaluation on a golden dataset with online monitoring of real production queries. Below is a complete, runnable example using Python that logs retrieval events, computes relevance scores with an LLM-as-a-judge, and tracks metrics over time.

1. Define a Golden Evaluation Set

Start with a curated set of queries paired with the documents that should be retrieved. This is your regression baseline.

golden_set = [
    {
        "query": "How do I reset my password?",
        "expected_doc_ids": ["auth-12", "auth-15"],
        "expected_keywords": ["reset", "password", "email"]
    },
    {
        "query": "What is the refund policy for annual plans?",
        "expected_doc_ids": ["billing-04"],
        "expected_keywords": ["refund", "annual", "prorate"]
    },
    {
        "query": "How do I invite team members to my workspace?",
        "expected_doc_ids": ["teams-02", "teams-07"],
        "expected_keywords": ["invite", "workspace", "member"]
    }
]

2. Wrap Your Retriever With Logging

Instrument your retriever so every call records the query, retrieved chunks, scores, and latency. This becomes your online monitoring data.

import time
import json
from datetime import datetime, timezone
from pathlib import Path

LOG_FILE = Path("retrieval_events.jsonl")

def retrieve_with_logging(query, retriever, top_k=5):
    start = time.perf_counter()
    results = retriever.search(query, top_k=top_k)
    latency_ms = (time.perf_counter() - start) * 1000

    event = {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "query": query,
        "retrieved": [
            {"doc_id": r.doc_id, "score": float(r.score), "text": r.text[:200]}
            for r in results
        ],
        "latency_ms": latency_ms,
        "top_k": top_k
    }

    with LOG_FILE.open("a") as f:
        f.write(json.dumps(event) + "\n")

    return results

3. Score Relevance With an LLM-as-a-Judge

For each retrieved chunk, ask an LLM to rate relevance on a 0–3 scale. This gives you a continuous signal even for queries not in your golden set.

import openai

JUDGE_PROMPT = """You are evaluating retrieval relevance for a RAG system.

Query: {query}
Retrieved chunk: {chunk}

Rate the relevance of the chunk to the query on a scale of 0-3:
0 = completely irrelevant
1 = marginally relevant
2 = relevant but incomplete
3 = highly relevant

Respond with only a single integer."""

def score_relevance(query, chunk_text, model="gpt-4o-mini"):
    resp = openai.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": JUDGE_PROMPT.format(
            query=query, chunk=chunk_text
        )}],
        temperature=0
    )
    try:
        return int(resp.choices[0].message.content.strip())
    except ValueError:
        return 0

4. Compute Metrics Over a Time Window

Aggregate logged events into daily or weekly metrics so you can plot trends and set alerts.

from collections import defaultdict
from statistics import mean

def compute_daily_metrics(log_path, judge_fn=None, sample_rate=1.0):
    by_day = defaultdict(list)
    with open(log_path) as f:
        for line in f:
            event = json.loads(line)
            day = event["timestamp"][:10]
            by_day[day].append(event)

    report = {}
    for day, events in sorted(by_day.items()):
        latencies = [e["latency_ms"] for e in events]
        empty_count = sum(1 for e in events if len(e["retrieved"]) == 0)

        relevance_scores = []
        if judge_fn:
            for e in events[::int(1/sample_rate)]:
                for r in e["retrieved"][:3]:
                    relevance_scores.append(judge_fn(e["query"], r["text"]))

        report[day] = {
            "query_count": len(events),
            "avg_latency_ms": round(mean(latencies), 2),
            "p95_latency_ms": round(sorted(latencies)[int(len(latencies)*0.95)-1], 2),
            "empty_result_rate": round(empty_count / len(events), 4),
            "avg_relevance": round(mean(relevance_scores), 3) if relevance_scores else None
        }
    return report

5. Run the Golden Set as a Regression Check

On every deploy or nightly, run your golden queries and compare against expected documents. This catches regressions before they reach users.

def run_golden_regression(retriever, golden_set, top_k=5):
    results = []
    for item in golden_set:
        retrieved_ids = [r.doc_id for r in retriever.search(item["query"], top_k)]
        expected = set(item["expected_doc_ids"])
        hit = expected & set(retrieved_ids)
        recall = len(hit) / len(expected) if expected else 1.0
        mrr = 0.0
        for rank, doc_id in enumerate(retrieved_ids, 1):
            if doc_id in expected:
                mrr = 1.0 / rank
                break
        results.append({
            "query": item["query"],
            "recall@5": round(recall, 3),
            "mrr": round(mrr, 3),
            "retrieved_ids": retrieved_ids
        })
    avg_recall = mean(r["recall@5"] for r in results)
    avg_mrr = mean(r["mrr"] for r in results)
    return {"per_query": results, "avg_recall@5": avg_recall, "avg_mrr": avg_mrr}

6. Alert on Degradation

Define thresholds and trigger alerts when metrics cross them. Simple rules work well to start.

def check_alerts(daily_report, thresholds):
    alerts = []
    latest_day = max(daily_report.keys())
    metrics = daily_report[latest_day]

    if metrics["empty_result_rate"] > thresholds["max_empty_rate"]:
        alerts.append(f"Empty result rate {metrics['empty_result_rate']} exceeds threshold")
    if metrics["p95_latency_ms"] > thresholds["max_p95_latency"]:
        alerts.append(f"P95 latency {metrics['p95_latency_ms']}ms exceeds threshold")
    if metrics["avg_relevance"] is not None and metrics["avg_relevance"] < thresholds["min_relevance"]:
        alerts.append(f"Avg relevance {metrics['avg_relevance']} below threshold")

    return alerts

thresholds = {
    "max_empty_rate": 0.05,
    "max_p95_latency": 800,
    "min_relevance": 2.0
}

Best Practices

Conclusion

Monitoring RAG retrieval quality over time turns a fragile system into a maintainable one. By combining a curated golden set for regression testing, LLM-judged relevance scoring on production traffic, and clear alerting thresholds, you create a feedback loop that catches drift before users do. The implementation above gives you a starting point — instrument your retriever today, build a small golden set, and begin logging. The metrics you collect this week become the baseline that tells you whether next month's changes made things better or worse.

— Ad —

Google AdSense will appear here after approval

← Back to all articles