Ragas — Test Sets & Experiments¶
Two jobs surround scoring: getting a test set (questions with reference answers and gold passages) and keeping the results of each run so you can compare them. Ragas generates test sets from your documents with a knowledge-graph pipeline and stores runs with the @experiment decorator.
Test Set Generation¶
flowchart LR
D["documents / chunks"] --> N["nodes<br/>(document, chunk)"]
N --> T["transforms<br/>headlines, summary, themes,<br/>entities, embeddings"]
T --> K["KnowledgeGraph<br/>nodes + relationships"]
K --> P["personas<br/>(generated or given)"]
K --> S["query synthesizers<br/>single-hop, multi-hop"]
P --> S
S --> Q["Testset<br/>user_input, reference,<br/>reference_contexts"]
| Stage | What happens |
|---|---|
| Transforms | An LLM extracts headlines (long documents are split at headlines), summaries, themes and named entities per node; an embedding model embeds summaries. Relationship builders link nodes by summary cosine similarity and entity overlap |
| Personas | Up to num_personas (default 3) personas are generated from the graph, or you pass persona_list |
| Synthesizers | Each synthesizer picks node clusters, matches personas to themes, writes a query in a style and length, then an answer from the cluster text |
| Output | One sample per scenario: question, reference answer, the passages it came from |
The default distribution (default_query_distribution) weights three synthesizers equally (1/3 each): SingleHopSpecificQuerySynthesizer (a fact in one chunk), MultiHopAbstractQuerySynthesizer (a theme spread over several chunks) and MultiHopSpecificQuerySynthesizer (specific facts joined across chunks). Multi-hop synthesizers need relationships in the graph, so a tiny or unrelated corpus yields mostly single-hop questions or fewer samples than requested.
from langchain_community.document_loaders import DirectoryLoader # needs langchain-community<0.4.2
from openai import AsyncOpenAI
from ragas.embeddings.base import embedding_factory
from ragas.llms import llm_factory
from ragas.testset import TestsetGenerator
client = AsyncOpenAI()
llm = llm_factory("gpt-4o-mini", client=client)
emb = embedding_factory("openai", model="text-embedding-3-small", client=client)
docs = DirectoryLoader("kb/", glob="**/*.md").load() # LangChain Documents
generator = TestsetGenerator(llm=llm, embedding_model=emb)
testset = generator.generate_with_langchain_docs(docs, testset_size=30, raise_exceptions=False)
df = testset.to_pandas()
The generator accepts an llm_factory judge with embedding_factory embeddings, or the legacy LangchainLLMWrapper / LangchainEmbeddingsWrapper pair; both work.
| Entry point | Input |
|---|---|
generate_with_langchain_docs(documents, testset_size, ...) |
LangChain Document list |
generate_with_llamaindex_docs(documents, testset_size, ...) |
LlamaIndex documents (use TestsetGenerator.from_llama_index(llm, embedding_model)) |
generate_with_chunks(chunks, testset_size, ...) |
Plain strings or Documents that are already chunks; skips splitting |
generate(testset_size, query_distribution, num_personas) |
A knowledge graph you built or loaded (TestsetGenerator(..., knowledge_graph=kg)) |
Common arguments: transforms (custom list instead of default_transforms), query_distribution (list of (synthesizer, weight)), run_config, raise_exceptions (default True; False skips failed samples), token_usage_parser, return_executor. Without transforms, the default set depends on document length: when at least a quarter of the documents exceed 500 tokens they are split at headlines; otherwise, when at least a quarter are 101-500 tokens, documents are used whole; if neither holds, ValueError: Documents appears to be too short (ie 100 tokens or less) is raised.
Plain text, custom personas and a single synthesizer:
from ragas.testset.persona import Persona
from ragas.testset.synthesizers.single_hop.specific import SingleHopSpecificQuerySynthesizer
chunks = ["Refunds are accepted within 30 days of purchase. Sale items are final.", "Shipping takes 3-5 business days within the EU."]
generator = TestsetGenerator(
llm=llm, embedding_model=emb,
persona_list=[Persona(name="Support agent", role_description="Answers customer questions about orders")],
)
testset = generator.generate_with_chunks(
chunks, testset_size=20,
query_distribution=[(SingleHopSpecificQuerySynthesizer(llm=llm), 1.0)],
)
Persona names are keys in the persona-to-theme matching step, so keep them unique.
The Knowledge Graph Is Reusable¶
Extraction calls (several per node) are the main cost; question writing is smaller. Build and save the graph once, then generate more samples from it.
from ragas.testset.graph import KnowledgeGraph
generator.knowledge_graph.save("kb_graph.json")
kg = KnowledgeGraph.load("kb_graph.json") # KnowledgeGraph(nodes: 3, relationships: 4)
more = TestsetGenerator(llm=llm, embedding_model=emb, knowledge_graph=kg).generate(testset_size=20)
Re-run extraction only when the documents change.
Output and Review¶
testset.to_pandas() columns: user_input, reference_contexts, reference, synthesizer_name, plus persona_name, query_style and query_length on single-hop samples. The samples hold no response or retrieved_contexts: your pipeline fills those at evaluation time. testset.to_evaluation_dataset() returns an EvaluationDataset; to_jsonl(path) and Testset.from_jsonl(path) round-trip.
Generated questions are drafts. Review them before they become a golden set:
| Check | How |
|---|---|
| Duplicates | df[~df["user_input"].duplicated()] |
| Reference grounded in its passages | Score reference as the response of Faithfulness against reference_contexts; inspect rows that score low |
| Question answerable | Read a sample per synthesizer_name; multi-hop questions are the usual weak spot |
| Coverage | Count rows per source document and per query_style |
| Leakage | Remove questions that quote a chunk verbatim if retrieval is what you test |
| Sign-off | A person marks each kept row, then commit the JSONL |
import asyncio
from openai import AsyncOpenAI
from ragas.llms import llm_factory
from ragas.metrics.collections import Faithfulness
async def ground_check(df, concurrency=8):
llm = llm_factory("gpt-4o-mini", client=AsyncOpenAI(), temperature=0) # client created inside the loop
faith, sem = Faithfulness(llm=llm), asyncio.Semaphore(concurrency)
async def one(row):
async with sem:
result = await faith.ascore(user_input=row.user_input, response=row.reference,
retrieved_contexts=row.reference_contexts)
return result.value
return await asyncio.gather(*[one(r) for r in df.itertuples()])
df["ref_grounded"] = asyncio.run(ground_check(df))
suspect = df[df["ref_grounded"] < 0.8]
Freeze the reviewed set as evals/datasets/golden.jsonl (the layout Dataset with local/jsonl reads). Regenerating changes the questions, so every score changes with it; regenerate on purpose and re-baseline.
Experiments¶
An experiment runs your application over a Dataset row by row, computes metrics, and saves one result row per input. @experiment wraps an async function; .arun(dataset, name=...) runs it.
import asyncio
from openai import AsyncOpenAI
from pydantic import BaseModel
from ragas import Dataset, experiment
from ragas.llms import llm_factory
from ragas.metrics.collections import ContextRecall, Faithfulness
class Result(BaseModel):
user_input: str
response: str
faithfulness: float
context_recall: float
sem = asyncio.Semaphore(8) # arun starts every row at once: bound it yourself
def build(llm):
faith, recall = Faithfulness(llm=llm), ContextRecall(llm=llm)
@experiment(Result, name_prefix="rag")
async def run(row, model: str):
async with sem:
response, contexts = await my_rag_app(row["user_input"], model=model) # your system under test
f = await faith.ascore(user_input=row["user_input"], response=response, retrieved_contexts=contexts)
r = await recall.ascore(user_input=row["user_input"], retrieved_contexts=contexts, reference=row["reference"])
return Result(user_input=row["user_input"], response=response, faithfulness=f.value, context_recall=r.value)
return run
async def main():
llm = llm_factory("gpt-4o-mini", client=AsyncOpenAI(), temperature=0)
run = build(llm)
ds = Dataset.load("golden", "local/jsonl", root_dir="evals") # rows are dicts
base = await run.arun(ds, name="baseline", model="small") # saved as evals/experiments/rag-baseline.jsonl
cand = await run.arun(ds, name="candidate", model="large")
return base, cand
base, cand = asyncio.run(main())
How arun behaves:
| Aspect | Behaviour |
|---|---|
| Name | name_prefix-name; a memorable random name when name is omitted |
| Extra arguments | arun(ds, name="x", model="large") passes model="large" to every call of your function |
| Return value | A Pydantic model (validated), or a plain dict when no model is given; None rows are skipped |
| Concurrency | All rows are scheduled at once; there is no worker limit |
| Failures | An exception in one row prints Warning: Task failed with error: ... and the row is dropped; the run still succeeds |
| Order | Rows are stored in completion order, not dataset order |
| Storage | <root_dir>/experiments/<name>.jsonl through the dataset's backend, or backend= on @experiment / arun |
Because failures drop rows silently, always compare lengths:
assert len(base) == len(ds) and len(cand) == len(ds), "rows were dropped by failures"
Comparing two runs is a pandas join on the input key:
b, c = base.to_pandas().set_index("user_input"), cand.to_pandas().set_index("user_input")
cmp = b[["faithfulness", "context_recall"]].join(c[["faithfulness", "context_recall"]], lsuffix="_base", rsuffix="_cand")
cmp["d_faithfulness"] = cmp["faithfulness_cand"] - cmp["faithfulness_base"]
print(cmp[["faithfulness_base", "faithfulness_cand", "d_faithfulness"]].sort_values("d_faithfulness").head(10)) # worst regressions first
print(cmp.mean())
ds.backend.list_experiments() lists saved runs and Experiment.load("baseline", "local/jsonl", root_dir="evals") (from ragas import Experiment) reloads one, for comparing with an earlier commit's results. Available backends are local/jsonl, local/csv (flattens nested fields), inmemory and gdrive; the package contains no hosted-service backend.
version_experiment("baseline_v1") (needs ragas[git]) commits changes to tracked files on your current branch and creates the branch ragas/baseline_v1 at that commit, returning the commit hash. Run it on a throwaway branch or in CI, not on a branch with unfinished work.
Integrations¶
Metrics only need strings and lists, so integration means mapping your framework's output into sample fields.
| Framework | Mapping |
|---|---|
| LangChain (LangChain) | docs = retriever.invoke(q); retrieved_contexts=[d.page_content for d in docs]; response is the chain output |
| LlamaIndex | response = await query_engine.aquery(q); response.response is the answer, [n.get_text() for n in response.source_nodes] the contexts. ragas.integrations.llama_index.evaluate(query_engine, dataset, metrics, llm, embeddings) automates this and calls the legacy evaluate(); it needs LlamaIndex LLM and embedding objects |
| LangGraph (LangGraph) | ragas.integrations.langgraph.convert_to_ragas_messages(state["messages"]) gives the message list for agent metrics |
ragas.integrations.langchain.EvaluatorChain cannot be imported on LangChain 1.x because it needs langchain.chains; call metrics from your own function instead:
def to_sample(question: str, docs, answer: str) -> dict:
return {"user_input": question, "retrieved_contexts": [d.page_content for d in docs], "response": answer}
Tracing and score storage:
| Tool | Use with Ragas |
|---|---|
| LangSmith | Set LANGCHAIN_TRACING_V2=true, LANGCHAIN_API_KEY and LANGCHAIN_PROJECT, then run the legacy evaluate() with a LangchainLLMWrapper(ChatOpenAI(...)) judge; the Ragas docs describe the metric traces appearing in the project |
| Langfuse, Phoenix, MLflow | Score rows locally, then record value and reason per trace, span or run with that tool's own score or metric API, keyed by the sample ID; see each guide for datasets and experiments |
Keep the metric code and dataset in your repository and treat the tool as storage and a viewer; an upstream UI change then cannot alter what a score means.
Common Mistakes¶
| Mistake | Effect | Fix |
|---|---|---|
| Generating 500 questions from 3 short pages | Duplicates, few multi-hop samples, high cost | Larger corpus, smaller testset_size, review |
| Skipping review | Wrong references make good systems look bad | Ground-check references, sample by synthesizer |
| Regenerating before every run | Scores are not comparable | Freeze evals/datasets/golden.jsonl; save the knowledge graph |
Unbounded arun on a large dataset |
429 errors and dropped rows | asyncio.Semaphore, then compare len(exp) to len(ds) |
local/csv for rows with lists |
Lists come back as strings | local/jsonl |
version_experiment on a working branch |
Unrelated tracked changes get committed | Throwaway branch or CI |
EvaluatorChain import |
ModuleNotFoundError: langchain.chains |
Map outputs yourself |
See also¶
- Ragas — Evaluation for LLM, RAG and Agent Applications
- Ragas — Datasets, Samples & Setup
- Ragas — Testing & CI
- DeepEval — Datasets and Goldens
- LangChain — LLM Application Framework
- LangGraph — Stateful Agent Orchestration
- LangSmith — LLM Tracing, Prompts & Evals
- Langfuse — LLM Tracing, Prompts & Evals
- MLflow — Experiment Tracking, LLM Tracing & Evaluation
- Arize Phoenix — LLM Tracing & Evaluation