## Homework: Evaluation
In homework 2 we built keyword, vector, and hybrid search over the course
lessons, and ended with an open question: which one is best? The way to answer
that is to measure, and that's what we do here.
In this homework we generate a ground truth dataset and use it to evaluate
search, the same way we did in the module. There we only evaluated keyword
search. Here we also evaluate vector and hybrid search, so we can finally
compare them on numbers instead of intuition.
Like in homework 1 and 2, our knowledge base is the course lessons themselves.
Each module has a `lessons/` folder of numbered markdown pages, and we pull
them from GitHub. We use commit `8c1834d`, so everyone works with the exact
same 72 pages.
> It's possible your answers won't match exactly. If so, select the closest one.
## Setup
This homework continues directly from homework 2. Keep working in the same
project directory: we reuse its chunks, search functions, `embedder.py`, and
downloaded ONNX model. Before starting, make sure the project still contains:
- `embedder.py`
- `download.py`
- `models/Xenova/all-MiniLM-L6-v2/model.onnx`
If any of these are missing, repeat the
[homework 2 setup](../02-vector-search/homework.md#setup) before continuing.
We need a few more libraries for generating questions with an LLM:
```bash
uv add openai pydantic python-dotenv pandas
```
For the LLM, we recommend OpenAI with `gpt-5.4-mini`, but you can use any model
and provider you like - just adapt the client accordingly. Put your key in a
`.env` file as in the earlier modules.
Load the data exactly as in homework 2:
```python
from gitsource import GithubRepositoryDataReader
reader = GithubRepositoryDataReader(
repo_owner="DataTalksClub",
repo_name="llm-zoomcamp",
commit_id="8c1834d",
allowed_extensions={"md"},
filename_filter=lambda path: "/lessons/" in path,
)
documents = [file.parse() for file in reader.read()]
```
This gives 72 pages.
## Generating ground truth
To evaluate search, we need a dataset of questions where we know which document
is the correct answer. This is the ground truth.
We generate it the same way as in the module. For each lesson page, we ask an
LLM to write 5 questions that are answered by that page. Each question is then
labeled with the page it came from.
We use the same structured-output approach as in the module - the same
`Questions` model and the `llm_structured` helper from `evaluation_utils.py`.
Download `evaluation_utils.py` and the `rag_helper.py` it depends on:
```bash
PREFIX=https://raw.githubusercontent.com/DataTalksClub/llm-zoomcamp/main
wget ${PREFIX}/01-agentic-rag/code/rag_helper.py
wget ${PREFIX}/04-evaluation/code/evaluation_utils.py
```
The module's instructions generate questions from a FAQ record, so we adapt
them for a lesson page:
```python
data_gen_instructions = """
You emulate a student who is taking our LLM course.
You are given one lesson page from the course.
Formulate 5 questions this student might ask that are answered by this page.
Rules:
- The page should contain the answer to each question.
- Make the questions complete and not too short.
- Use as few words as possible from the page; don't copy its phrasing.
- The questions should resemble how people actually ask things online:
not too formal, not too short, not too long.
- Ask about the content of the lesson, not about its formatting or filename.
""".strip()
```
We ask for different wording from the page on purpose. Real users don't phrase
their questions the way the lesson does, and copying the text would make the
evaluation too easy.
For each page, build a JSON user prompt from its `filename` and `content`, then
call `llm_structured` with the `Questions` model. Turn each returned question
into a record labeled with the page's `filename`. The call also returns the
token usage, the same as in the lessons.
## Q1. Generating questions
Generating questions for all 72 pages costs money and takes time, so let's
start small and generate questions for just the first 3 pages:
- `01-agentic-rag/lessons/01-intro.md`
- `01-agentic-rag/lessons/02-environment.md`
- `01-agentic-rag/lessons/03-rag.md`
Each call returns the token usage, which most LLM APIs report on the response
object (e.g. `response.usage.input_tokens` / `prompt_tokens`).
What's the average number of input tokens across these 3 calls?
* 140
* 1400
* 14000
* 140000
> These numbers vary between runs, even with the same model, so pick the closest
> option. A different provider or model may land further apart, but the input
> tokens stay in the same order of magnitude - the prompt we send is the same.
## The full ground truth
You don't need to generate the data for the rest of the homework. We already
did it for all 72 pages, using the same approach as in the lessons, and saved
the 360 questions to a file.
Download it:
```bash
PREFIX=https://raw.githubusercontent.com/DataTalksClub/llm-zoomcamp/main
wget ${PREFIX}/cohorts/2026/04-evaluation/ground-truth.csv
```
Load it with pandas into a dataframe of records called `ground_truth`. Each record
has a `question` and the `filename` of the page that should answer it.
## Searching the chunks
We search over the same chunks as in homework 2.
Create them with `chunk_documents`:
```python
from gitsource import chunk_documents
chunks = chunk_documents(documents, size=2000, step=1000)
```
This gives 295 chunks.
Now rebuild the search from homework 2 over these chunks. Build a text index
(`Index`) and a vector index (`VectorSearch`), both keyed on `filename`. Wrap
each one in a function, `text_search` and `vector_search`, that takes a query
and the number of results to return (5 by default).
For hybrid search, reuse the `rrf` function from homework 2:
```python
def rrf(result_lists, k=60, num_results=5):
scores = {}
docs = {}
for results in result_lists:
for rank, doc in enumerate(results):
key = (doc["filename"], doc["start"])
scores[key] = scores.get(key, 0) + 1 / (k + rank)
docs[key] = doc
ranked = sorted(scores, key=scores.get, reverse=True)
return [docs[key] for key in ranked[:num_results]]
```
Then define `hybrid_search` on top of it:
```python
def hybrid_search(query, k=60):
text_results = text_search(query, num_results=10)
vector_results = vector_search(query, num_results=10)
return rrf([text_results, vector_results], k=k)
```
## Q2. First result with text search
Take the first question from the ground truth:
```python
q = ground_truth[0]["question"]
```
After running `text_search` for it, what's the `filename` of the first result?
* `01-agentic-rag/lessons/01-intro.md`
* `01-agentic-rag/lessons/03-rag.md`
* `01-agentic-rag/lessons/13-function-calling.md`
* `01-agentic-rag/lessons/10-rag-next-steps.md`
## Q3. First result with vector search
After running `vector_search` for the same question, what's the `filename` of
the first result?
* `01-agentic-rag/lessons/01-intro.md`
* `01-agentic-rag/lessons/03-rag.md`
* `04-evaluation/lessons/11-evaluation-intro.md`
* `04-evaluation/lessons/12-rag-answers.md`
This question was generated from `01-agentic-rag/lessons/01-intro.md`. Notice
that one method finds the right page at the top and the other doesn't. That's
exactly why we measure across the whole dataset instead of trusting one query.
## Evaluation metrics
We evaluate search exactly as in the module, reusing the same functions from the
lecture. We change only the label. Our ground truth uses `filename`, so a result
counts as a hit when a returned chunk's `filename` matches the question's
`filename`, not a document `id`.
The lesson evaluates whole FAQ records, while this homework evaluates chunks.
Adjust the relevance logic to use the fields available on the chunk results and
homework ground truth instead of copying the lesson's record-based comparison
unchanged.
As a reminder, these functions do the following:
- `compute_relevance` runs search for a question and returns a list of 0s and 1s
- `hit_rate` is the fraction of questions where the correct page appears in the results
- `mrr` (Mean Reciprocal Rank) also rewards finding the page near the top
- `evaluate` runs a search function over the whole ground truth and returns both metrics
## Q4. Evaluating text search
Evaluate `text_search` on the ground truth data.
What's the Hit Rate?
* 0.55
* 0.66
* 0.76
* 0.88
## Q5. Evaluating vector search
Now evaluate `vector_search` - the part we left for the homework, since the
module only evaluated keyword search.
What's the MRR?
* 0.35
* 0.45
* 0.55
* 0.65
## Q6. Tuning hybrid search
The `k` constant in RRF controls how much the top ranks matter. A smaller `k`
sharpens the gap between positions, so being at the top of a list counts for
more. The RRF paper uses 60 as a default, but the best value depends on the data
- so let's measure it.
Evaluate `hybrid_search` over the full ground truth dataset for `k` values 1,
50, 100, and 200. Compare the MRR values for these runs.
Which `k` gives the best MRR?
* 1
* 50
* 100
* 200
> Several values of `k` may give the same MRR. If there's a tie, pick the
> smallest `k`.
## Using this framework
You now have an `evaluate` function that takes any search function and returns
Hit Rate and MRR.
Use it to measure any change you make to search:
- tune the field boosts in keyword search
- try a different embedding model for vector search
- change `k` in the RRF formula for hybrid search
- change the number of results you return
Change a setting, re-run `evaluate`, and see whether the metric moves. The
ground truth stays fixed, so the comparison is fair. That's how you replace
guessing with measuring.
## Learning in Public
We encourage everyone to share what they learned. This is called "learning in public".
Read more about the benefits [here](https://alexeyondata.substack.com/p/benefits-of-learning-in-public-and) and in the [course's learning in public guide](https://datatalks.club/docs/courses/zoomcamp-logistics/learning-in-public/).
### Example post for LinkedIn
Tag [@Alexey Grigorev](https://www.linkedin.com/in/agrigorev/) and [@DataTalksClub](https://www.linkedin.com/company/datatalks-club/) in your post - we'll like and comment to give your post more reach.
```
🚀 Module 4 of LLM Zoomcamp by @DataTalksClub complete!
Just finished Module 4 - Evaluation. Learned how to:
✅ Generate ground truth questions with an LLM and structured output
✅ Measure retrieval quality with Hit Rate and MRR
✅ Evaluate keyword, vector, and hybrid search on the same dataset
✅ Replace gut-feeling with numbers when comparing search methods
Here's my homework solution:
Following along with this amazing free course by @Alexey Grigorev - who else is learning to build with LLMs?
You can sign up here: https://github.com/DataTalksClub/llm-zoomcamp/
```
### Example post for X
```
📏 Module 4 of LLM Zoomcamp done!
- Ground truth generation with structured output
- Hit Rate and MRR
- Evaluating keyword vs vector vs hybrid search
- Measuring instead of guessing
My solution:
Free course by @Al_Grigor & @DataTalksClub: https://github.com/DataTalksClub/llm-zoomcamp/
```
## Submit the results
* Submit your results here: https://courses.datatalks.club/llm-zoomcamp-2026/homework/hw4
* It's possible your answers won't match exactly. If so, select the closest one.