Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Gate RAG pipelines in CI with versioned golden eval sets, per-metric thresholds, baseline drift detection, and a build that fails when retrieval or answer quality regresses.
.claude/skills/pramoddutta-rag-regression-testing/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 53% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 22% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 172% | 0% |
| case-11 | ✗→✓ | ▲ Improved | 71% | 0% |
| case-14 | ✗→✓ | ▲ Improved | 161% | 0% |
You are an expert in shipping RAG systems without quality regressions. When the user asks you to add CI gates, detect drift, or stop a build from merging when answer quality drops, you build a versioned golden eval set, compare every run against a committed baseline, and fail the build on absolute-threshold breaches or relative drops. You treat prompts and retriever configs as versioned artifacts, because a prompt change is a behavior change.
baseline_metrics.json in the repo. A score is only meaningful as a delta against a known-good baseline.top_k. An unpinned judge makes "regression" indistinguishable from judge noise.rag-evals/
golden/
dataset.v3.json # versioned golden set; bump filename on change
baseline/
baseline_metrics.json # committed known-good scores
config/
eval_config.py # pinned models, thresholds, drift budget
run_eval.py # produces scores, writes report.json
gate.py # compares scores vs baseline + floors -> exit code
update_baseline.py # regenerates baseline (run intentionally)
.github/
workflows/
rag-regression.ymlpython# config/eval_config.py from dataclasses import dataclass @dataclass(frozen=True) class EvalConfig: # Everything that influences a score is pinned. judge_model: str = "gpt-4o-mini" judge_temperature: float = 0.0 embedding_model: str = "text-embedding-3-small" top_k: int = 5 # Identifies the system under test for traceability. prompt_version: str = "answer-v4" retriever_version: str = "hybrid-bm25+dense-v2" dataset_path: str = "rag-evals/golden/dataset.v3.json" baseline_path: str = "rag-evals/baseline/baseline_metrics.json" # Absolute floors: build fails if a metric drops below these, ever. HARD_FLOORS = { "faithfulness": 0.88, "context_precision": 0.78, "context_recall": 0.78, "answer_relevancy": 0.72, } # Drift budget: build fails if a metric drops more than this vs baseline, # even if still above the hard floor. Catches slow erosion. MAX_REGRESSION = { "faithfulness": 0.03, "context_precision": 0.05, "context_recall": 0.05, "answer_relevancy": 0.05, } CONFIG = EvalConfig()
python# run_eval.py import json from datasets import Dataset from ragas import evaluate from ragas.metrics import ( context_precision, context_recall, faithfulness, answer_relevancy, ) from ragas.llms import LangchainLLMWrapper from langchain_openai import ChatOpenAI, OpenAIEmbeddings from config.eval_config import CONFIG from my_rag_app import rag_pipeline def load_golden(path: str) -> list[dict]: with open(path) as f: return json.load(f)["samples"] def main() -> None: golden = load_golden(CONFIG.dataset_path) rows = {"question": [], "answer": [], "contexts": [], "ground_truth": []} for s in golden: out = rag_pipeline(s["question"], top_k=CONFIG.top_k) rows["question"].append(s["question"]) rows["answer"].append(out["answer"]) rows["contexts"].append(out["contexts"]) rows["ground_truth"].append(s["ground_truth"]) judge = LangchainLLMWrapper( ChatOpenAI(model=CONFIG.judge_model, temperature=CONFIG.judge_temperature) ) result = evaluate( Dataset.from_dict(rows), metrics=[context_precision, context_recall, faithfulness, answer_relevancy], llm=judge, embeddings=OpenAIEmbeddings(model=CONFIG.embedding_model), ) df = result.to_pandas() means = { m: round(float(df[m].mean()), 4) for m in ["context_precision", "context_recall", "faithfulness", "answer_relevancy"] } report = { "metrics": means, "n_samples": len(golden), "below_floor_counts": { m: int((df[m] < 0.5).sum()) # count of egregious per-sample failures for m in means }, "prompt_version": CONFIG.prompt_version, "retriever_version": CONFIG.retriever_version, "judge_model": CONFIG.judge_model, } with open("report.json", "w") as f: json.dump(report, f, indent=2) print(json.dumps(report, indent=2)) if __name__ == "__main__": main()
python# gate.py import json import sys from config.eval_config import CONFIG, HARD_FLOORS, MAX_REGRESSION def load(path: str) -> dict: with open(path) as f: return json.load(f) def main() -> int: report = load("report.json") current = report["metrics"] baseline = load(CONFIG.baseline_path)["metrics"] failures: list[str] = [] for metric, score in current.items(): floor = HARD_FLOORS.get(metric) if floor is not None and score < floor: failures.append(f"[FLOOR] {metric}={score:.3f} < hard floor {floor:.2f}") base = baseline.get(metric) budget = MAX_REGRESSION.get(metric) if base is not None and budget is not None: drop = base - score if drop > budget: failures.append( f"[DRIFT] {metric} dropped {drop:.3f} " f"(baseline {base:.3f} -> {score:.3f}, budget {budget:.2f})" ) if failures: print("RAG REGRESSION DETECTED:\n " + "\n ".join(failures)) print(f"\nprompt={report['prompt_version']} retriever={report['retriever_version']}") return 1 print("RAG eval passed. No regression vs baseline.") for m, s in current.items(): print(f" {m}: {s:.3f} (baseline {baseline.get(m, float('nan')):.3f})") return 0 if __name__ == "__main__": sys.exit(main())
python# update_baseline.py """Run ONLY when a quality change is intended and reviewed. The resulting baseline_metrics.json must be committed in the same PR.""" import json import shutil from config.eval_config import CONFIG # run_eval.py must have been run first to produce report.json with open("report.json") as f: report = json.load(f) shutil.copy(CONFIG.baseline_path, CONFIG.baseline_path + ".bak") with open(CONFIG.baseline_path, "w") as f: json.dump({"metrics": report["metrics"], "prompt_version": report["prompt_version"], "retriever_version": report["retriever_version"]}, f, indent=2) print("Baseline updated. Commit this file with a justification in the PR.")
yaml# .github/workflows/rag-regression.yml name: RAG Regression Gate on: pull_request: paths: - "rag-evals/**" - "src/prompts/**" - "src/retriever/**" - "src/rag/**" workflow_dispatch: concurrency: group: rag-eval-${{ github.ref }} cancel-in-progress: true jobs: rag-eval-gate: runs-on: ubuntu-latest timeout-minutes: 20 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install deps run: | python -m pip install --upgrade pip pip install -r requirements.txt # ragas, datasets, langchain-openai, etc. - name: Run RAG evaluation env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} run: python rag-evals/run_eval.py - name: Gate on thresholds + drift run: python rag-evals/gate.py # non-zero exit fails the job - name: Upload eval report if: always() uses: actions/upload-artifact@v4 with: name: rag-eval-report path: report.json - name: Comment metrics on PR if: always() && github.event_name == 'pull_request' uses: actions/github-script@v7 with: script: | const fs = require('fs'); const r = JSON.parse(fs.readFileSync('report.json', 'utf8')); const rows = Object.entries(r.metrics) .map(([k, v]) => `| ${k} | ${v.toFixed(3)} |`).join('\n'); const body = `### RAG Eval (\`${r.prompt_version}\` / \`${r.retriever_version}\`)\n` + `| metric | score |\n|---|---|\n${rows}`; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body, });
The gate runs on PRs that touch prompts, retriever, or the eval set. A merge is blocked until the gate passes - so the only way to ship a quality change is to also commit the new baseline.
For nightly scheduled runs against production traffic samples, append each run's metrics to a time series and alert on a moving-window drop:
python# drift_alert.py import statistics def detect_trend_drift(history: list[dict], metric: str, window: int = 7) -> str | None: """history: list of {date, metrics:{...}} newest last.""" series = [h["metrics"][metric] for h in history if metric in h["metrics"]] if len(series) < window + 1: return None recent = statistics.mean(series[-3:]) baseline_window = statistics.mean(series[-(window + 1):-3]) drop = baseline_window - recent if drop > 0.04: return (f"{metric} trending down: {baseline_window:.3f} -> {recent:.3f} " f"over {window} days (drop {drop:.3f})") return None
dataset.v3.json). Bumping the version is a reviewable, deliberate act.paths: to keep paid LLM-judge calls off unrelated PRs; upload report.json as an artifact and comment scores on the PR.continue-on-error: true) to unblock a deadline. A non-blocking quality gate is theater.Trigger when the user asks to:
For the definitions and scoring of the underlying metrics (faithfulness, context precision/recall, answer relevancy), use the RAG Evaluation Metrics skill. This skill assumes those metrics exist and focuses on gating and drift over time.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 28,240 | 33,702 | +19% | 1 | 1 | 0% | 6,148 | 9,396 | +53% | 0 | 0 | — |
case-02 | fail→pass | 51,165 | 38,626 | -25% | 1 | 1 | 0% | 8,281 | 10,107 | +22% | 0 | 0 | — |
case-03 | pass→pass | 25,168 | 25,268 | +0% | 1 | 1 | 0% | 3,253 | 6,723 | +107% | 0 | 0 | — |
case-04 | pass→pass | 22,443 | 23,212 | +3% | 1 | 1 | 0% | 2,434 | 6,106 | +151% | 0 | 0 | — |
case-05 | pass→pass | 28,438 | 27,354 | -4% | 1 | 1 | 0% | 3,428 | 6,869 | +100% | 0 | 0 | — |
case-06 | pass→pass | 21,263 | 18,875 | -11% | 1 | 1 | 0% | 2,339 | 5,418 | +132% | 0 | 0 | — |
case-07 | pass→pass | 27,570 | 28,135 | +2% | 1 | 1 | 0% | 3,382 | 7,684 | +127% | 0 | 0 | — |
case-08 | pass→pass | 17,420 | 15,631 | -10% | 1 | 1 | 0% | 1,840 | 5,189 | +182% | 0 | 0 | — |
case-09 | pass→pass | 17,537 | 17,641 | +1% | 1 | 1 | 0% | 1,703 | 5,356 | +215% | 0 | 0 | — |
case-10 | fail→pass | 18,487 | 9,157 | -50% | 1 | 1 | 0% | 1,810 | 4,924 | +172% | 0 | 0 | — |
case-11 | fail→pass | 19,408 | 17,527 | -10% | 1 | 1 | 0% | 3,282 | 5,608 | +71% | 0 | 0 | — |
case-12 | pass→pass | 19,063 | 16,353 | -14% | 1 | 1 | 0% | 2,261 | 5,426 | +140% | 0 | 0 | — |
case-13 | pass→pass | 11,159 | 14,448 | +29% | 1 | 1 | 0% | 1,459 | 5,049 | +246% | 0 | 0 | — |
case-14 | fail→pass | 22,363 | 17,595 | -21% | 1 | 1 | 0% | 2,601 | 6,797 | +161% | 0 | 0 | — |
case-15 | fail→pass | 27,226 | 28,639 | +5% | 1 | 1 | 0% | 3,404 | 7,997 | +135% | 0 | 0 | — |
case-16 | pass→pass | 17,139 | 14,044 | -18% | 1 | 1 | 0% | 2,650 | 6,044 | +128% | 0 | 0 | — |
case-17 | pass→pass | 11,280 | 7,235 | -36% | 1 | 1 | 0% | 987 | 4,658 | +372% | 0 | 0 | — |
case-18 | pass→pass | 19,549 | 21,305 | +9% | 1 | 1 | 0% | 2,129 | 5,700 | +168% | 0 | 0 | — |
case-19 | pass→pass | 21,240 | 15,793 | -26% | 1 | 1 | 0% | 2,671 | 5,445 | +104% | 0 | 0 | — |
case-20 | fail→pass | 24,869 | 24,846 | -0% | 1 | 1 | 0% | 3,412 | 7,054 | +107% | 0 | 0 | — |
case-21 | fail→fail | 25,049 | 22,640 | -10% | 1 | 1 | 0% | 3,424 | 7,135 | +108% | 0 | 0 | — |
case-22 | pass→pass | 21,761 | 21,039 | -3% | 1 | 1 | 0% | 2,457 | 5,948 | +142% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +32 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.