n4nAI

LlamaIndex retrieval evaluation dataset generation

Generate synthetic evaluation datasets for LlamaIndex retrieval systems using LLMs, with runnable code for question generation, relevance labeling, and dataset validation.

n4n Team3 min read769 words

Audio narration

Coming soon — every post will get a voice note here.

Building a retrieval system is straightforward. Knowing whether it actually works is harder. Most teams skip evaluation because creating a labeled dataset feels like a separate research project. This llamaindex evaluation dataset generation tutorial shows you how to use an LLM to synthesize realistic question–context–answer triples from your existing documents, then validate the output before you run a single metric. The approach works with any document corpus and requires no human annotation to start.

Step 1: Prepare your document corpus

LlamaIndex expects a list of Document objects. If you already have a vector index, extract the underlying nodes. Otherwise, load raw files and chunk them to a size that matches your retrieval window — typically 512–1024 tokens for dense embeddings.

from llama_index.core import SimpleDirectoryReader, SentenceSplitter
from llama_index.core.schema import Document

reader = SimpleDirectoryReader(input_dir="./data", recursive=True)
raw_docs = reader.load_data()

splitter = SentenceSplitter(chunk_size=512, chunk_overlap=64)
nodes = splitter.get_nodes_from_documents(raw_docs)

# Optional: persist for reproducibility
import json
with open("corpus_nodes.json", "w") as f:
    json.dump([n.to_dict() for n in nodes], f)

Verify success: len(nodes) should be in the hundreds for a modest corpus. Inspect a few node.text values to confirm chunk boundaries make sense.

Step 2: Define the generation prompt

The quality of your synthetic dataset depends on the prompt. You want the LLM to produce questions that a real user would ask, grounded strictly in the provided context. Use a structured output schema so you can parse results reliably.

from pydantic import BaseModel, Field
from typing import List

class QAItem(BaseModel):
    question: str = Field(..., description="A natural-language question answerable from the context.")
    answer: str = Field(..., description="Concise answer derived only from the context.")
    reasoning: str = Field(..., description="One-sentence justification linking question to context.")

class QASet(BaseModel):
    items: List[QAItem] = Field(..., min_items=3, max_items=5)

Prompt template (adjust num_questions per chunk):

from llama_index.core.prompts import PromptTemplate

QA_GEN_PROMPT = PromptTemplate(
    "Context:\n{context_str}\n\n"
    "Generate {num_questions} diverse question-answer pairs that a user might ask about this context. "
    "Each question must be answerable *only* from the given text. "
    "Return a JSON object matching this schema:\n{schema}\n\n"
    "Do not include any extra commentary."
)

Step 3: Generate questions per chunk

Iterate over your nodes, call the LLM, and collect structured outputs. Use a capable instruction-following model — GPT-4o, Claude 3.5 Sonnet, or a local Llama-3.1-70B-Instruct all work. If you route through a gateway that supports automatic fallback (for example, n4n.ai forwards to 240+ models and retries on provider degradation), you avoid halting the pipeline when one provider is rate-limited.

from llama_index.core.llms import OpenAI
from llama_index.core.output_parsers import PydanticOutputParser
from tqdm.auto import tqdm

llm = OpenAI(model="gpt-4o", temperature=0.3)
parser = PydanticOutputParser(output_cls=QASet)

all_qa = []
for node in tqdm(nodes, desc="Generating QA pairs"):
    prompt = QA_GEN_PROMPT.format(
        context_str=node.text,
        num_questions=3,
        schema=parser.schema()
    )
    try:
        response = llm.complete(prompt)
        qa_set = parser.parse(response.text)
        for item in qa_set.items:
            all_qa.append({
                "question": item.question,
                "answer": item.answer,
                "reasoning": item.reasoning,
                "source_node_id": node.node_id,
                "source_text": node.text[:500]  # truncate for storage
            })
    except Exception as e:
        print(f"Failed on node {node.node_id}: {e}")
        continue

import pandas as pd
df = pd.DataFrame(all_qa)
df.to_parquet("synthetic_qa.parquet", index=False)

Verify success: df.shape[0] should equal roughly 3 * len(nodes). Spot-check 10 rows — questions should be specific, answers should appear verbatim or near-verbatim in source_text.

Step 4: Filter low-quality pairs

LLMs hallucinate or produce trivial questions (“What is the first word of this text?”). Apply automated filters before human review.

def is_trivial(q: str) -> bool:
    trivial_patterns = [
        r"^what is the first",
        r"^what is the last",
        r"^how many words",
        r"^what is the length",
    ]
    return any(re.search(p, q.lower()) for p in trivial_patterns)

def answer_in_context(row) -> bool:
    # Loose containment check; tune threshold as needed
    return row["answer"].lower() in row["source_text"].lower()

df["trivial"] = df["question"].apply(is_trivial)
df["grounded"] = df.apply(answer_in_context, axis=1)

clean = df[~df["trivial"] & df["grounded"]].copy()
clean.to_parquet("synthetic_qa_clean.parquet", index=False)
print(f"Kept {len(clean)} / {len(df)} pairs")

Verify success: retention rate typically 70–90%. If it drops below 60%, tighten the prompt (add “non-trivial” instruction) or lower the temperature.

Step 5: Add negative samples for retrieval evaluation

Retrieval metrics (Recall@k, MRR, NDCG) need negatives — queries where the correct chunk is not the top result. The simplest approach: for each question, sample k other chunks as hard negatives using embedding similarity.

from llama_index.core.embeddings import OpenAIEmbedding
from llama_index.core import VectorStoreIndex
import numpy as np

embed_model = OpenAIEmbedding(model="text-embedding-3-small")
index = VectorStoreIndex(nodes, embed_model=embed_model)
retriever = index.as_retriever(similarity_top_k=20)

negatives = []
for _, row in clean.iterrows():
    retrieved = retriever.retrieve(row["question"])
    # Exclude the true source node
    neg_nodes = [n for n in retrieved if n.node_id != row["source_node_id"]][:5]
    for n in neg_nodes:
        negatives.append({
            "question": row["question"],
            "negative_node_id": n.node_id,
            "negative_text": n.text[:500],
            "score": n.score
        })

neg_df = pd.DataFrame(negatives)
neg_df.to_parquet("synthetic_negatives.parquet", index=False)

Verify success: each question should have 3–5 negatives with non-zero similarity scores. Inspect a few to confirm they are semantically related but not answer-bearing.

Step 6: Split into train / dev / test

Hold out a test set that never touches your prompt engineering or hyperparameter tuning. A 70/15/15 split by question (not by chunk) prevents leakage.

from sklearn.model_selection import train_test_split

questions = clean["question"].unique()
train_q, test_q = train_test_split(questions, test_size=0.15, random_state=42)
train_q, dev_q = train_test_split(train_q, test_size=0.176, random_state=42)  # 0.15 of total

def split_df(df, q_list):
    return df[df["question"].isin(q_list)].reset_index(drop=True)

train_set = split_df(clean, train_q)
dev_set = split_df(clean, dev_q)
test_set = split_df(clean, test_q)

for name, ds in [("train", train_set), ("dev", dev_set), ("test", test_set)]:
    ds.to_parquet(f"eval_{name}.parquet", index=False)
    print(f"{name}: {len(ds)} pairs, {ds['question'].nunique()} unique questions")

Verify success: no question appears in more than one split. test_set should have at least 50 unique questions for stable metric confidence intervals.

Step 7: Run a baseline retrieval evaluation

Now you have a dataset. Plug it into LlamaIndex’s evaluation module (or your own metric code) to get a baseline before you change chunking, embedding model, or reranker.

from llama_index.core.evaluation import RetrieverEvaluator

evaluator = RetrieverEvaluator.from_metric_names(
    ["hit_rate", "mrr", "precision", "recall"],
    retriever=retriever
)

# Evaluate on dev set
dev_questions = dev_set["question"].unique().tolist()
expected_ids = dev_set.groupby("question")["source_node_id"].apply(list).to_dict()

results = evaluator.evaluate(dev_questions, expected_ids)
print(results)

Typical baseline on a clean corpus with text-embedding-3-small and 512-token chunks: Hit Rate@5 ~ 0.75, MRR ~ 0.65. Your numbers will differ — what matters is the delta after each experiment.

Step 8: Version and store the dataset

Treat the synthetic dataset as a first-class artifact. Commit the parquet files (or a manifest with hashes) to DVC or Git LFS. Record the generation metadata:

{
  "generation_date": "2025-01-15",
  "llm_model": "gpt-4o",
  "embedding_model": "text-embedding-3-small",
  "chunk_size": 512,
  "chunk_overlap": 64,
  "questions_per_chunk": 3,
  "filter_retention_rate": 0.82,
  "splits": {"train": 1240, "dev": 265, "test": 265}
}

When you regenerate (new corpus, better prompt, different LLM), bump the version. This lets you bisect retrieval regressions to data changes vs. model changes.

Common pitfalls

Overfitting to synthetic style. LLMs generate questions with a distinct “textbook” flavor. Mitigate by adding a style-diversity instruction: “Vary question style: some direct, some conversational, some keyword-only, some multi-hop.”

Leakage via chunk metadata. If your nodes carry file_name or page_label, the LLM can cheat by asking “What is the title of document X?” Strip metadata from the context string passed to the generator.

Single-hop bias. Most generated questions answerable from one chunk. Explicitly request multi-hop: “Generate 1 question requiring synthesis across two non-adjacent sections.” Then pair those questions with both source node IDs.

No human spot-check. Automation gets you 80% of the way. Spend 30 minutes reading 50 random QA pairs. You will find systematic errors no filter catches.

Next steps

With a versioned evaluation set in hand, you can now:

  • Sweep chunk sizes (256, 512, 1024) and measure Hit Rate@k delta
  • Compare embedding models (OpenAI, Cohere, BGE, E5) on the same queries
  • Test rerankers (Cohere Rerank, BGE-Reranker, cross-encoder) on the dev set
  • Run ablation: remove negatives, add BM25 hybrid, swap retriever top-k

Each experiment is a single script run because the dataset is fixed. That is the point — evaluation becomes a fast feedback loop, not a quarterly project.

Tagsllamaindexevaluation-datasetretrievalevaluation

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All llamaindex retrieval evaluation & metrics posts →