This llamaindex retrieval evaluation hit rate mrr tutorial builds a small corpus, wires up a VectorStoreIndex, and uses LlamaIndex’s RetrieverEvaluator to compute hit rate and MRR on real queries. You leave with a repeatable pattern for measuring whether your retriever surfaces the right context before that context reaches an LLM.
Prerequisites
- Python 3.10 or newer
llama-indexandllama-index-embeddings-openaiinstalled- An OpenAI API key exported as
OPENAI_API_KEY
pip install llama-index llama-index-embeddings-openai
export OPENAI_API_KEY=sk-...
We use OpenAI embeddings because they are deterministic and well-documented. Swap in any LlamaIndex-supported embed model if you run local inference.
Build a minimal index and retriever
Create three text documents with distinct topics. Each becomes one node in the index. We later query for a fact that lives in exactly one of them.
from llama_index.core import Document, VectorStoreIndex, Settings
from llama_index.embeddings.openai import OpenAIEmbedding
Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")
docs = [
Document(text="Postgres uses MVCC to handle concurrent transactions without read locks."),
Document(text="Redis is an in-memory key-value store often used for caching and pub/sub."),
Document(text="Kafka partitions logs to enable horizontal scaling of event streams."),
]
index = VectorStoreIndex.from_documents(docs)
retriever = index.as_retriever(similarity_top_k=2)
The retriever returns the top-2 nodes by cosine similarity. That similarity_top_k directly shapes both hit rate and MRR. The index stores nodes in index.docstore; each node has a stable id_ used for matching during evaluation.
Create an evaluation dataset
LlamaIndex expects a RetrieverEvalDataset pairing each query with the nodes that should be retrieved. Grab the node objects from the docstore to avoid fragile string matching.
from llama_index.evaluation import RetrieverEvalDataset
nodes = index.docstore.get_nodes(list(index.docstore.docs.keys()))
text_to_node = {n.get_content(): n for n in nodes}
relevant_node = text_to_node[
"Postgres uses MVCC to handle concurrent transactions without read locks."
]
dataset = RetrieverEvalDataset(
queries=["How does Postgres handle concurrent transactions?"],
relevant_docs=[[relevant_node]],
)
This dataset asserts that the Postgres node is the only correct context for the query. In a real project, you would hand-label dozens of such pairs.
Run the retriever evaluator
Use RetrieverEvaluator.from_metric_names to load hit rate and MRR. Both metrics compare the node IDs returned by the retriever against your labeled relevant nodes.
from llama_index.evaluation import RetrieverEvaluator
evaluator = RetrieverEvaluator.from_metric_names(
["hit_rate", "mrr"],
retriever=retriever,
)
result = evaluator.evaluate_dataset(dataset)
print(result.metric_dict)
Expected output:
{"hit_rate": 1.0, "mrr": 1.0}
The retriever put the correct node in the top-2, so hit rate is 1.0. Because it was rank 1, MRR is also 1.0 (1/1).
Inspect what the retriever returns
Before trusting the score, print the actual retrieved nodes. This catches silent embedding mismatches.
retrieved = retriever.retrieve("How does Postgres handle concurrent transactions?")
for r in retrieved:
print(r.node.get_content(), round(r.score, 3))
Sample output:
Postgres uses MVCC to handle concurrent transactions without read locks. 0.832
Redis is an in-memory key-value store often used for caching and pub/sub. 0.611
The Postgres node leads with a clear margin. The second node is irrelevant but within top-k=2, which is fine for hit rate.
Understand hit rate and MRR outputs
Hit rate measures the fraction of queries where at least one relevant node appears in the top-k. It is binary per query:
hit = 1 if any(retrieved_node.id in relevant_ids) else 0
MRR (Mean Reciprocal Rank) averages the reciprocal of the rank of the first relevant node across queries:
mrr = mean(1 / rank_of_first_relevant)
If the relevant node is rank 2, that query contributes 0.5. If it is rank 3, 0.333. If absent, it contributes 0. Hit rate would be 0 for that query too, but MRR additionally penalizes retrievers that surface the right context late.
Tune retriever top_k and re-evaluate
Drop similarity_top_k to 1 and rerun the same dataset to confirm the metrics hold when only the top hit is kept. To see a failure, query for Redis but mark Postgres as relevant.
bad_dataset = RetrieverEvalDataset(
queries=["What is Redis used for?"],
relevant_docs=[[relevant_node]], # wrong node marked relevant
)
bad_result = evaluator.evaluate_dataset(bad_dataset)
print(bad_result.metric_dict)
Expected output:
{"hit_rate": 0.0, "mrr": 0.0}
The retriever correctly returns Redis content, but our labels say Postgres is relevant. Both metrics drop to zero. Evaluation is only as good as your relevance labels.
Evaluate over multiple queries
Real evaluations aggregate across many queries. Extend the dataset and compute corpus-level metrics in one call.
queries = [
"How does Postgres handle concurrent transactions?",
"What is Redis used for?",
"How does Kafka scale event streams?",
]
relevant_docs = [
[text_to_node["Postgres uses MVCC to handle concurrent transactions without read locks."]],
[text_to_node["Redis is an in-memory key-value store often used for caching and pub/sub."]],
[text_to_node["Kafka partitions logs to enable horizontal scaling of event streams."]],
]
multi_dataset = RetrieverEvalDataset(queries=queries, relevant_docs=relevant_docs)
multi_result = evaluator.evaluate_dataset(multi_dataset)
print(multi_result.metric_dict)
Expected output:
{"hit_rate": 1.0, "mrr": 1.0}
With three clean queries and top-k=2, the retriever nails every rank. In production you will see values between 0 and 1.
Per-query breakdown for debugging
Aggregated scores hide regressions. Capture each query’s result to find weak spots after a chunk-size change.
per_query = []
for q, rel in zip(queries, relevant_docs):
r = evaluator.evaluate_dataset(
RetrieverEvalDataset(queries=[q], relevant_docs=[rel])
)
per_query.append((q, r.metric_dict))
for q, m in per_query:
print(q, m)
This prints a tuple per query, letting you spot which phrasing breaks retrieval.
Why hit rate and MRR before LLM evaluation
Hit rate and MRR are cheap: they need no LLM calls, only embedding similarity and set membership. If your retriever scores poorly, any downstream RAG answer quality metric is irrelevant because the model never saw the right context. Run this llamaindex retrieval evaluation hit rate mrr tutorial step in CI on every embedding model swap or node parser tweak.
Caveats
Hit rate ignores whether the retrieved context is sufficient for the LLM to answer—only presence matters. MRR assumes a single relevant node per query; for multi-fact questions, add recall@k or nDCG. LlamaIndex registers more metric names in RetrieverEvaluator; inspect the source for the full list.
Label your evaluation set by hand for the first few hundred queries. Synthetic generation via DatasetGenerator is useful for smoke tests but tends to overfit to the embedding space and inflate scores.
That is the core loop: index, label, run RetrieverEvaluator, read metric_dict. Wire it into a pytest fixture and you get a retrieval gate that fails the build when hit rate drops below a threshold you set.