惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

宝玉的分享
宝玉的分享
小众软件
小众软件
J
Java Code Geeks
I
InfoQ
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
腾讯CDC
L
LangChain Blog
博客园 - 司徒正美
量子位
Y
Y Combinator Blog
C
Check Point Blog
T
Tailwind CSS Blog
D
DataBreaches.Net
Blog — PlanetScale
Blog — PlanetScale
N
Netflix TechBlog - Medium
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
F
Fortinet All Blogs
云风的 BLOG
云风的 BLOG
A
About on SuperTechFans
B
Blog RSS Feed
酷 壳 – CoolShell
酷 壳 – CoolShell
大猫的无限游戏
大猫的无限游戏
V
V2EX
阮一峰的网络日志
阮一峰的网络日志

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Your RAG Eval Set Is Probably Wrong. The Test That Catche...
Gabriel Anha · 2026-04-27 · via DEV Community

Picture a composite scenario drawn from several RAG postmortems: a team ships a legal-research assistant with a Ragas faithfulness score in the mid-0.9s and an answer-relevance score not far behind. Two weeks after launch, customer-success starts forwarding screenshots of the bot citing the wrong jurisdiction. The eval scores never move.

The eval set is a few hundred questions hand-written months earlier. Production users are running tax-court queries with citation patterns that do not exist anywhere in the eval set. The evals are measuring how well the system answered last quarter's questions, not this morning's. The dashboard is green for a system nobody is actually using.

Most teams blame the retriever. It is almost never the retriever. Your eval set is wrong in three ways at once, and only one of them shows up in your metrics. Below: the three failure modes, and a single drift test that catches the worst of them in 40 lines of Python.

The three ways your eval set lies to you

Each of these ships to production regularly. Each one looks like a great eval score on the dashboard.

1. Leakage into training data

If you generated your eval questions with GPT-4 against your corpus, and your corpus is on the public internet, your eval is half-leaked already. The model has seen the documents and probably seen something close to your "synthetic" questions. The PremAI 2026 RAG eval guide says it directly: optimizing for Ragas scores on a leaked set produces systems that look good in CI and break the day a real user asks a real question.

The signal is subtle. Faithfulness is high because the model has memorized the answers. Context recall is high because the retriever finds the chunks the model already knows. The eval is measuring memorization, with a thin retrieval check on top.

To check, ask your model the eval question with no retrieved context. If the closed-book answer score is within 5 points of the RAG answer score, the retriever is not adding value, and the eval is measuring something else.

2. Drift from real production queries

This is the one that bit the legal team above, and it is the one that is most often invisible. Your eval set was hand-written or LLM-generated against a snapshot of intent that existed at one point in time. Your production traffic is a moving target. New segments show up, the vocabulary shifts, novel failure modes appear. The eval set freezes; reality does not.

Atlan's framework comparison makes the same point: knowledge bases that change daily drift faster than the eval sets watching them. The evals stay green while the product gets worse.

This is the failure mode the 40-line test below is designed to catch.

3. Judge bias

Most modern RAG evals (Ragas, DeepEval, TruLens) use an LLM as the grader. That works until the judge has the same blind spot as the system under test. If both are GPT-4o, both share opinions about what "faithful" looks like, both prefer answers that hedge, both penalize the same kind of curt phrasing. You are scoring with a ruler made by the same factory that produced the thing being measured.

The Ragas paper discusses this directly, and the LLM-judge bias literature (Zheng et al., "Judging LLM-as-a-Judge") shows that swapping the judge model can move metric scores meaningfully, often into the high single digits, without any change to the system being evaluated. Run one cross-judge sanity check: grade with gpt-4o, grade with claude-sonnet-4, compare. Without that, your scores have a confidence interval you cannot see.

Drift is the one that hits production hardest because it gets worse over time without any code change. You ship green. Traffic shifts. The eval stays green. Customers churn. The other two are bad on day one. Drift is bad on day 90.

The query-distribution drift test

Here is the test, in 40 lines. It compares the distribution of your eval-set queries against the distribution of your production queries along three axes (embedding-space centroid distance, length distribution, and intent-class distribution) and alerts when any of the three crosses a threshold.

import os
from collections import Counter

import numpy as np
from openai import OpenAI
from scipy.stats import wasserstein_distance

client = OpenAI()
EMBED_MODEL = "text-embedding-3-large"


def embed(texts):
    out = client.embeddings.create(input=texts, model=EMBED_MODEL)
    return np.array([d.embedding for d in out.data])


def centroid_cosine(a, b):
    ca, cb = a.mean(axis=0), b.mean(axis=0)
    return float(
        1 - (ca @ cb) / (np.linalg.norm(ca) * np.linalg.norm(cb))
    )


def length_emd(a, b):
    la = np.array([len(x.split()) for x in a], dtype=float)
    lb = np.array([len(x.split()) for x in b], dtype=float)
    return float(wasserstein_distance(la, lb))


def intent_tv(a_labels, b_labels):
    keys = set(a_labels) | set(b_labels)
    ca, cb = Counter(a_labels), Counter(b_labels)
    pa = np.array([ca[k] / len(a_labels) for k in keys])
    pb = np.array([cb[k] / len(b_labels) for k in keys])
    return float(0.5 * np.abs(pa - pb).sum())  # total variation


def drift_report(eval_q, eval_intents, prod_q, prod_intents):
    ea, eb = embed(eval_q), embed(prod_q)
    return {
        "centroid_cosine": centroid_cosine(ea, eb),
        "length_emd":      length_emd(eval_q, prod_q),
        "intent_tv":       intent_tv(eval_intents, prod_intents),
    }


THRESHOLDS = {
    "centroid_cosine": 0.05,
    "length_emd":      4.0,
    "intent_tv":       0.15,
}


def alert_if_drift(report):
    fired = [
        (k, v) for k, v in report.items()
        if v > THRESHOLDS[k]
    ]
    if fired:
        print("DRIFT", fired)
        return True
    return False

Enter fullscreen mode Exit fullscreen mode

Three signals, one report. Each one catches a different shape of drift.

Centroid cosine distance is the headline number. It says: the average semantic location of your eval queries has moved this far from the average semantic location of your production queries. A sane starting point is around 0.05 in text-embedding-3-large space for short queries, but tune it on a baseline week of your own data. The Evidently embedding drift writeup covers the same idea with reference data versus current data.

The second signal catches a specific failure mode. Length earth-mover distance flags it when your eval set is mostly short, well-formed questions and your production traffic is long, copy-pasted dumps from internal Slack threads. Same intent, very different retrieval shape. The retriever performs differently on a 6-word question and a 90-word one, and a length-blind eval misses that entirely.

The case where vocabulary stays similar but the user task changes is what intent total-variation distance catches. If 30% of your eval set is "definition of X" and 50% of your prod traffic is "compare X and Y," the eval score on definition questions does not predict the system's behavior on comparison questions. Use a small classifier (even a 5-class zero-shot one) and compare label distributions. As a starting threshold, anything past 0.15 total variation is a different product.

Wiring it into a periodic check

The 40 lines above are a function. The piece you actually ship is the cron that runs them and the alert that pages someone.

# Run nightly. Sample 500 prod queries from the last 24h.
import json
import sqlite3

def load_eval():
    with open("eval/queries.jsonl") as f:
        rows = [json.loads(l) for l in f]
    return [r["q"] for r in rows], [r["intent"] for r in rows]


def sample_prod(db_path, n=500):
    conn = sqlite3.connect(db_path)
    cur = conn.execute(
        "SELECT query, intent FROM rag_logs"
        " WHERE ts > datetime('now', '-1 day')"
        " ORDER BY RANDOM() LIMIT ?", (n,))
    rows = cur.fetchall()
    return [r[0] for r in rows], [r[1] for r in rows]


if __name__ == "__main__":
    eq, ei = load_eval()
    pq, pi = sample_prod("rag.db")
    report = drift_report(eq, ei, pq, pi)
    print(report)
    if alert_if_drift(report):
        # post to Slack, page on-call, open a ticket.
        ...

Enter fullscreen mode Exit fullscreen mode

The thresholds above are sane defaults for a corpus of business documents and short user queries. Tune them on your own data: run the script for seven days against a frozen eval set with stable traffic, take the 95th percentile of each signal, set the threshold there.

What the alert should actually do

A drift alert is not a bug ticket. It is a "your evaluation is no longer measuring what your users do" alert. The right response is to refresh the eval set, not to chase a fix in the system.

Three actions to bake into the runbook:

  1. Sample 200 production queries from the drift window for human labeling (a smaller, manually reviewed slice than the 500 the cron uses for the drift signal), label them with the same intent classifier, add them to a prod_addendum slice of the eval set.
  2. Re-run all metrics against the addendum specifically. If your faithfulness drops 10 points on the new slice, the system is failing on the new query shape. That is the bug to fix.
  3. Decay the original eval set. Mark every question with a created_at. After 90 days, drop questions whose intent class no longer appears in production. Eval sets should age out the same way feature flags do.

Run this loop and you have a system you can trust instead of a dashboard you watch. The Galileo data drift docs push the same idea: an eval set is a living artifact, not a fixture file.

The headline

Your eval scores can be high while your product is broken. The likeliest reason is not your retriever or your model. It is that your eval set has drifted away from the queries your users are actually sending. The 40 lines above will not fix the drift. They will tell you it happened, the day it happened, before customer-success forwards the screenshot.

If your dashboard is green and your support queue is red, the test runs in 40 lines. Start there.

If this was useful

The eval and observability chapters of RAG Pocket Guide and LLM Observability Pocket Guide cover the full version of the test above: proper baselines, judge-swap protocols, the slice-by-slice breakdown that catches the failure modes Ragas alone misses, and the on-call runbook for drift alerts. If your evals are green and your users are not, both books are written for that gap.