Ragas — Testing & CI¶
Two things need tests. Your harness (data loading, metric wiring, gate logic) is ordinary code and must run offline, fast and free. Your system's quality needs a real judge LLM, costs money and varies a little between runs, so it runs in a separate job with explicit thresholds. This page builds both on pytest (pytest) and GitHub Actions.
Test Layers¶
| Layer | Judge | Checks | Runs |
|---|---|---|---|
| Harness unit tests | InstructorBaseRagasLLM subclass with scripted answers |
Metric arithmetic, prompt contents, gate logic | Every commit, no key |
| Request-path test | Local OpenAI-compatible fake server | llm_factory, Instructor, SDK and client settings work end to end |
Every commit, no key |
| Free retrieval metrics | none (IDBased*, NonLLM*, 02) |
Retriever recall and precision against gold passages | Every commit |
| Judged quality gate | Real model, fixed and cached | Mean scores, per-sample floors, baseline regression | Nightly, on demand, labelled PRs |
Files: evals/datasets/golden.jsonl (reviewed rows with user_input, reference, retrieved_contexts), evals/harness.py (ascore_dataset from 01), evals/scoring.py, evals/fake_judge.py, tests/conftest.py, tests/baseline.json, tests/test_pipeline_unit.py, tests/test_quality_gate.py. pytest.ini holds pythonpath = ., testpaths = tests, addopts = -m "not ragas" and markers = ragas: calls the judge LLM.
Offline Tests of Your Harness¶
InstructorBaseRagasLLM has two methods (generate, agenerate), so a scripted judge is a few lines. Metrics call agenerate(prompt, ResponseModel) and expect a Pydantic object back, so the script returns objects keyed by model name and records every prompt. Response-model names (StatementGeneratorOutput, NLIStatementOutput, ContextRecallOutput, ...) come from each metric's util module.
import asyncio
import pytest
from openai import AsyncOpenAI
from ragas.llms import InstructorBaseRagasLLM, llm_factory
from ragas.metrics.collections import Faithfulness
from ragas.metrics.collections.faithfulness.util import (
NLIStatementOutput, StatementFaithfulnessAnswer, StatementGeneratorOutput,
)
from evals.fake_judge import FakeJudge
class ScriptedJudge(InstructorBaseRagasLLM):
"""Returns prepared Pydantic objects keyed by response-model name and records the prompts."""
def __init__(self, script):
self.script, self.prompts = script, []
def generate(self, prompt, response_model):
raise NotImplementedError("metrics call agenerate")
async def agenerate(self, prompt, response_model):
self.prompts.append(prompt)
return self.script[response_model.__name__]
def test_faithfulness_is_fraction_of_supported_statements():
flags = [1, 1, 1, 0]
judge = ScriptedJudge({
"StatementGeneratorOutput": StatementGeneratorOutput(statements=["a", "b", "c", "d"]),
"NLIStatementOutput": NLIStatementOutput(statements=[
StatementFaithfulnessAnswer(statement=s, reason="r", verdict=v) for s, v in zip("abcd", flags)]),
})
result = Faithfulness(llm=judge).score(
user_input="q", response="Paris is in France.", retrieved_contexts=["The capital is Paris."])
assert result.value == pytest.approx(0.75)
assert "Paris is in France." in judge.prompts[0] # statement generation sees the response
assert "The capital is Paris." in judge.prompts[1] # verdicts see the retrieved context
def test_request_path_through_instructor_and_the_openai_sdk():
fake = FakeJudge({
"StatementGeneratorOutput": {"statements": ["a", "b"]},
"NLIStatementOutput": {"statements": [{"statement": "a", "reason": "r", "verdict": 1},
{"statement": "b", "reason": "r", "verdict": 0}]},
})
async def run():
client = AsyncOpenAI(base_url=fake.base_url, api_key="sk-test", max_retries=0) # created inside the loop
llm = llm_factory("gpt-4o-mini", client=client)
return await Faithfulness(llm=llm).ascore(user_input="q", response="r", retrieved_contexts=["c"])
assert asyncio.run(run()).value == 0.5
assert [title for title, _ in fake.calls] == ["StatementGeneratorOutput", "NLIStatementOutput"]
The second test sends real HTTP through Instructor and the OpenAI SDK to FakeJudge, so it catches wiring mistakes (wrong base_url, a sync client) that the scripted judge cannot. The fake reads the schema Instructor puts after json_schema: in the system message, looks up the scripted answer by schema title and returns a chat completion. An unscripted call fails with KeyError, which doubles as an assertion that the number of judge calls did not change.
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class FakeJudge:
"""OpenAI-compatible server; `script` maps a response-model name to a dict or a callable(prompt) -> dict."""
def __init__(self, script):
self.script, self.calls = script, []
outer = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["content-length"])))
system = next(m["content"] for m in body["messages"] if "json_schema:" in str(m["content"]))
title = json.loads(system.split("json_schema:")[1].split("Make sure to return")[0])["title"]
prompt = body["messages"][-1]["content"]
outer.calls.append((title, prompt))
answer = outer.script[title]
content = json.dumps(answer(prompt) if callable(answer) else answer)
data = json.dumps({
"id": "fake", "object": "chat.completion", "created": 0, "model": body["model"],
"choices": [{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": content}}],
"usage": {"prompt_tokens": 100, "completion_tokens": 20, "total_tokens": 120},
}).encode()
self.send_response(200)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(data)))
self.end_headers()
self.wfile.write(data)
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
self.base_url = f"http://127.0.0.1:{self.server.server_address[1]}/v1"
The Judged Quality Gate¶
run_suite calls your system for each golden row and returns one scored DataFrame. The session fixture runs it once inside one event loop; every test asserts on that frame.
import json
import os
from pathlib import Path
import pandas as pd
from openai import AsyncOpenAI
from ragas import EvaluationDataset, SingleTurnSample
from ragas.cache import DiskCacheBackend
from ragas.llms import llm_factory
from ragas.metrics.collections import ContextRecall, Faithfulness
from evals.harness import ascore_dataset
GOLDEN = Path("evals/datasets/golden.jsonl") # {"user_input", "reference", "retrieved_contexts"} per line
async def run_suite(answer_fn, concurrency: int = 4) -> pd.DataFrame:
rows = [json.loads(line) for line in GOLDEN.read_text().splitlines() if line.strip()]
samples = [SingleTurnSample(response=answer_fn(r["user_input"]), **r) for r in rows] # run the system under test
model = os.getenv("JUDGE_MODEL", "gpt-4o-mini")
llm = llm_factory(
model,
client=AsyncOpenAI(timeout=60, max_retries=2), # created inside the running loop; reads OPENAI_API_KEY / OPENAI_BASE_URL
temperature=0,
cache=None if os.getenv("RAGAS_NO_CACHE") else DiskCacheBackend(f".ragas_cache/{model}"), # key ignores the model: one dir per model
)
metrics = [Faithfulness(llm=llm), ContextRecall(llm=llm)]
return await ascore_dataset(EvaluationDataset(samples=samples), metrics, concurrency=concurrency)
import asyncio
import os
import pytest
from evals.scoring import run_suite
def my_rag_app(question: str) -> str:
return "Refunds are accepted within 30 days of purchase." # replace with your pipeline
@pytest.fixture(scope="session")
def scores():
if not os.getenv("OPENAI_API_KEY"):
pytest.skip("OPENAI_API_KEY not set")
df = asyncio.run(run_suite(my_rag_app)) # one event loop, one run, many assertions
df.to_csv("eval-results.csv", index=False) # uploaded as a CI artifact
return df
import json
from pathlib import Path
import pytest
pytestmark = pytest.mark.ragas
THRESHOLDS = {"faithfulness": 0.85, "context_recall": 0.80} # aggregate gate: mean over the golden set
SAMPLE_FLOOR = {"faithfulness": 0.50} # per-sample gate: no single answer below this
MAX_NAN_RATE = 0.05
BASELINE = Path(__file__).parent / "baseline.json"
TOLERANCE = 0.03
def test_judge_calls_succeeded(scores):
for metric in THRESHOLDS:
nan_rate = scores[metric].isna().mean()
assert nan_rate <= MAX_NAN_RATE, f"{metric}: {nan_rate:.0%} of judge calls failed"
@pytest.mark.parametrize("metric,threshold", THRESHOLDS.items())
def test_mean_score(scores, metric, threshold):
mean = scores[metric].mean() # pandas skips NaN, so test_judge_calls_succeeded guards them
assert mean >= threshold, f"{metric} mean {mean:.3f} < {threshold}"
@pytest.mark.parametrize("metric,floor", SAMPLE_FLOOR.items())
def test_no_catastrophic_sample(scores, metric, floor):
bad = scores[scores[metric] < floor]
assert bad.empty, "below floor:\n" + bad[["user_input", metric]].to_string()
def test_no_regression_against_baseline(scores):
baseline = json.loads(BASELINE.read_text())
for metric, old in baseline.items():
assert scores[metric].mean() >= old - TOLERANCE, f"{metric} dropped from {old:.3f}"
Run offline tests with pytest and judged tests with pytest -m ragas -o addopts="". Without OPENAI_API_KEY the judged tests are skipped, not failed.
| Gate | Catches | Weakness |
|---|---|---|
| Mean per metric | Broad quality loss | A few terrible answers hide behind many good ones |
| Per-sample floor | Single hallucinations, empty answers | One noisy verdict can fail the build: keep the floor low, print the rows |
| NaN rate | Judge outages, bad data, schema failures | None; without it a failing judge makes the mean look fine |
| Baseline regression | Slow drift that never crosses an absolute threshold | Needs a deliberate baseline update |
Non-Determinism and Baselines¶
| Source | Control |
|---|---|
| Judge sampling | temperature=0, one fixed judge model, disk cache (identical prompt, identical verdict) |
| System under test | Fix its temperature or seed; for stochastic systems store response in the golden file and score the stored answers |
| Small sample | With 30 rows one flipped verdict moves a mean by about 0.03; derive TOLERANCE from measured spread |
| Judge change | New judge model or prompt shifts every score: re-baseline in the same pull request |
Measure the spread by running the suite five times with RAGAS_NO_CACHE=1 and taking the standard deviation of each metric mean; set TOLERANCE to at least twice that. Rewrite tests/baseline.json with a small script that first asserts df[metrics].notna().all().all(), so a failed judge cannot record a baseline, and commit the diff in a reviewed pull request so a lower number is a visible decision.
GitHub Actions¶
name: rag-evals
on:
pull_request:
types: [opened, synchronize, reopened, labeled]
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
env:
RAGAS_DO_NOT_TRACK: "true"
jobs:
unit: # no judge calls, no secrets
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: uv sync --locked
- run: uv run pytest -q # pytest.ini deselects -m ragas
judged: # nightly, on demand, or on PRs labelled "run-evals"
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-evals')
needs: unit
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency: { group: "rag-evals-${{ github.ref }}", cancel-in-progress: true }
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: uv sync --locked
- uses: actions/cache@v4
with:
path: .ragas_cache
key: ragas-${{ hashFiles('uv.lock', 'evals/datasets/golden.jsonl') }}
restore-keys: ragas-
- run: uv run pytest -q -m ragas -o addopts=""
env: { OPENAI_API_KEY: "${{ secrets.OPENAI_API_KEY }}", JUDGE_MODEL: gpt-4o-mini }
- uses: actions/upload-artifact@v4
if: always()
with: { name: eval-results, path: eval-results.csv }
- Secrets are not exposed to pull requests from forks, so the judged job effectively runs for branches in the repository, on the schedule and on demand.
- The cache is keyed by prompt content, so a stale hit cannot answer a different prompt; it can answer with another model's verdict if one directory serves several models, hence one directory per judge model in
scoring.py.
Cost Control¶
| Lever | Effect |
|---|---|
| 20-50 golden rows, fewer metrics per row | Judge calls scale linearly with rows; NoiseSensitivity costs 2k + 3 calls and ContextPrecision k (02); run them nightly or on a slice |
| Cheaper judge on PRs, stronger judge nightly | Separate JUDGE_MODEL and baseline per job |
| Disk cache | Reruns of the same commit cost nothing |
llm_factory(..., max_retries=1) |
Each Instructor retry is billed; cap the waste on a model that cannot produce the schema |
result.total_cost() works only for legacy evaluate() with a LangChain-wrapped judge. For the ascore path, count tokens at the HTTP layer; the OpenAI SDK accepts an httpx client with response hooks:
import httpx
from openai import AsyncOpenAI
tokens = {"calls": 0, "input": 0, "output": 0}
async def count(response: httpx.Response): # runs for every judge response
await response.aread()
usage = response.json().get("usage") or {}
tokens["calls"] += 1
tokens["input"] += usage.get("prompt_tokens", 0)
tokens["output"] += usage.get("completion_tokens", 0)
client = AsyncOpenAI(http_client=httpx.AsyncClient(event_hooks={"response": [count]}))
# build the client inside the coroutine that runs the suite, pass it to llm_factory, compare `tokens` with a budget
Common Failures¶
| Symptom | Cause | Fix |
|---|---|---|
ModuleNotFoundError: langchain_community.chat_models.vertexai on import ragas |
langchain-community 0.4.2 |
uv add "langchain-community<0.4.2" |
TypeError: Cannot use agenerate() with a synchronous client |
OpenAI() or litellm.completion |
AsyncOpenAI(), litellm.acompletion |
Intermittent InstructorRetryException: Connection error |
One async client reused across several asyncio.run() calls |
Build the client inside the coroutine; one asyncio.run per session |
unexpected keyword argument 'temperature' |
Anthropic SDK without sampling parameters | Pop temperature and top_p (01) |
InstructorRetryException with a validation error |
Judge reply does not match the schema (weak or local model, truncated output) | Stronger model, larger max_tokens, JSON-mode support on the server |
| NaN in some or all rows | Empty or missing field (ValueError: response is missing), failed judge call, wrong key, base_url or model name |
Read the printed exception; run one ascore call outside the helper |
Legacy evaluate(): NaN and Exception raised in Job[N] |
Timeout, parsing failure, rate limit | Raise RunConfig.timeout, lower max_workers, raise_exceptions=True while debugging |
| HTTP 429 | Too many concurrent judge calls | Lower concurrency; keep AsyncOpenAI(max_retries=2) |
| Scores unchanged after switching the judge | Cache directory shared between models | One directory per model, or clear it |
QA Checklist¶
langchain-community<0.4.2andragaspinned in the lock file;RAGAS_DO_NOT_TRACK=truein CI.- Harness unit tests run on every commit with no API key.
- Async client created inside the loop; concurrency bounded by a semaphore.
- Judge model, temperature 0 and prompts fixed; one cache directory per judge model.
- Golden set reviewed by a person, stored as JSONL, changed only on purpose.
- Gates: mean per metric, per-sample floor, NaN rate, baseline with a measured tolerance.
- Baseline updated by a script that refuses partial data, in a reviewed pull request.
- Judged job separated from the unit job (timeout,
concurrencygroup, uploadedeval-results.csv); token usage counted against a budget.
See also¶
- Ragas — Evaluation for LLM, RAG and Agent Applications
- Ragas — Datasets, Samples & Setup
- Ragas — RAG Metrics
- Pytest — Python Testing Framework
- Eval Harness — Tools, Testing & CI
- DeepEval — LLM Testing Guide
- promptfoo — LLM Evals & Red Teaming from YAML
- CI/CD
- OWASP LLM Security