## Homework: Vector Search
In this homework, we put what we learned in Module 2 into practice.
We'll first turn text into vectors, then search by similarity.
We'll also learn something new and see how to combine vector search with keyword search. We'll skip the RAG part and focus solely on search.
Like in homework 1, 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 the same 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
In this homework we won't use the same approach for embedding as in the
module. That is, we won't use the sentence-transformers library. Instead,
we'll use the lightweight embedding approach with the ONNX `Embedder`.
Both approaches produce identical vectors, but the ONNX runtime is far
lighter. It needs no PyTorch and no CUDA, which makes the installation about
30x smaller and lets it run anywhere, including a basic Codespace. We
skimmed through it in the lesson and said we'd cover it in the homework -
so here we are.
We prepare the environment the same way as in the module's
[ONNX Runtime](../../../02-vector-search/lessons/09-onnx-embedder.md)
lesson.
Create a fresh project and install the dependencies:
```bash
mkdir llm-zoomcamp-hw2 && cd llm-zoomcamp-hw2
uv init --no-workspace
uv add onnxruntime tokenizers numpy tqdm minsearch gitsource
uv add --dev huggingface-hub jupyter
```
We also need two helper scripts from the `embed/` directory of the course
repo:
- [`download.py`](https://github.com/DataTalksClub/llm-zoomcamp/blob/main/02-vector-search/embed/download.py)
(fetches an ONNX model from HuggingFace) and
- [`embedder.py`](https://github.com/DataTalksClub/llm-zoomcamp/blob/main/02-vector-search/embed/embedder.py) (the `Embedder` class with an `encode` interface)
Let's download them:
```bash
PREFIX=https://raw.githubusercontent.com/DataTalksClub/llm-zoomcamp/main/02-vector-search/embed
wget $PREFIX/download.py
wget $PREFIX/embedder.py
```
By default `download.py` fetches `Xenova/all-MiniLM-L6-v2`, the ONNX
version of the `all-MiniLM-L6-v2` model from the lessons:
```bash
uv run python download.py
```
Now we're ready to do the homework.
## Q1. Embedding a query
Embed the following query:
> How does approximate nearest neighbor search work?
The embedder returns a vector of 384 numbers. What's the first value
(`v[0]`)?
* -0.31
* -0.02
* 0.12
* 0.44
## Loading the data
We pull the lesson pages from the course repository, the same way as in
homework 1. We pin to commit `8c1834d` so everyone works with the same
data.
```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()]
```
Each document is a dictionary with a `filename` and `content`, and there
are 72 pages.
## Q2. Cosine similarity
The embedder returns normalized vectors, so the dot product between two
of them is their cosine similarity.
Take the page `02-vector-search/lessons/07-sqlitesearch-vector.md`, embed
its `content`, and compute the cosine similarity with the query vector
from Q1. What do you get?
* 0.07
* 0.37
* 0.68
* 0.92
## Q3. Chunking and search by hand
A full page covers several topics, which waters down its embedding.
We chunk the pages the same way as in homework 1:
```python
from gitsource import chunk_documents
chunks = chunk_documents(documents, size=2000, step=1000)
```
We embed every chunk's `content` with `encode_batch`, stack the vectors
into a matrix `X`, and score the Q1 query against all chunks:
```python
scores = X.dot(v)
```
Which file does the highest-scoring chunk belong to (its `filename`)?
* `02-vector-search/lessons/03-embeddings-dataset.md`
* `02-vector-search/lessons/06-rag-vector.md`
* `02-vector-search/lessons/07-sqlitesearch-vector.md`
* `02-vector-search/lessons/09-onnx-embedder.md`
## Q4. Vector search with minsearch
We've done vector search by hand, which is good for learning, but it's not
what we do in practice. In practice we use libraries.
Let's use `VectorSearch` from minsearch and run a search for the following
query:
> What metric do we use to evaluate a search engine?
Which file is the `filename` of the first result?
* `02-vector-search/lessons/04-vector-search.md`
* `04-evaluation/lessons/05-search-metrics.md`
* `04-evaluation/lessons/13-llm-as-judge.md`
* `05-monitoring/lessons/04-metrics.md`
## Q5. Text search vs vector search
Vector search matches by meaning, keyword search by exact words.
Let's compare them. Index
the same chunks with `Index` from minsearch. Use `content` as a
text field.
Run both searches for this query:
> How do I store vectors in PostgreSQL?
Take the top 5 results from each method. Which file shows up in the
vector results but not in the text results?
* `02-vector-search/lessons/01-intro.md`
* `02-vector-search/lessons/02-embeddings.md`
* `02-vector-search/lessons/08-pgvector.md`
* `03-orchestration/lessons/05-rag.md`
## Q6. Hybrid search
Both vector and text search have their strengths and weaknesses. Vector
search matches by meaning, so it finds relevant pages even when they use
words different from the query. But it can miss exact terms like names,
codes, or rare keywords. Text search is the opposite: it nails exact words
but misses paraphrases and synonyms.
We don't have to pick one or the other - we can use both and merge their
results. This approach is called "hybrid search".
Each search produces its own ranked list, so we need a way to combine them
into one. In this homework we use Reciprocal Rank Fusion (RRF). It ignores
the raw scores from each method, which live on different scales and aren't
directly comparable. Instead, it looks only at the position of each
document in each list.
Every document scores by its position (`rank`, starting at 0) in each
list, and we sum the scores across lists with a constant `k = 60`:
```text
RRF(d) = sum over lists of 1 / (k + rank(d))
```
"Sum over lists" means we go through every ranked list and, for each list
where the document appears, add its `1 / (k + rank)` contribution. A
document found by both searches collects a score from each list, while one
found by only a single search collects just one.
The constant `k` controls how much the exact rank matters. A larger `k`
flattens the gap between positions, so the difference between rank 0 and
rank 5 counts for less. A smaller `k` does the opposite: it sharpens that
gap, so being at the top of a list matters much more.
The value 60 comes from the original RRF paper and is the usual default.
You rarely need to tune it. Lower it when only the top results matter.
Raise it to reward documents that appear across many lists, even when they
never quite reach the top.
A document that ranks well in both lists ends up higher than one that's
only strong in a single list.
```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]]
```
Now run the query `"How do I give the model access to tools?"`
with vector and text search and fuse the results with `rrf`:
```python
results = rrf([vector_results, text_results])
```
Which file is ranked first after RRF?
* `01-agentic-rag/lessons/01-intro.md`
* `01-agentic-rag/lessons/13-function-calling.md`
* `01-agentic-rag/lessons/14-agentic-loop.md`
* `01-agentic-rag/lessons/16-other-frameworks.md`
Notice that this file isn't first in either search on its own - it wins
because it ranks high in both.
## Selecting the best approach
By now you can search several ways:
- vector search
- keyword search
- hybrid search
Which is the best way?
The right choice depends on your data, and the way to decide is to
measure.
We cover how to evaluate and compare search approaches in the
[evaluation module](../../../04-evaluation/lessons/04-search-evaluation.md),
and you'll do exactly that in the [evaluation homework](../04-evaluation/homework.md).
## 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 2 of LLM Zoomcamp by @DataTalksClub complete!
Just finished Module 2 - Vector Search. Learned how to:
✅ Turn text into embeddings with a lightweight ONNX model
✅ Build vector search from scratch with numpy
✅ Use minsearch for vector search, with chunking for long pages
✅ Compare keyword and vector search, and combine them with hybrid search (RRF)
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 2 of LLM Zoomcamp done!
- Embeddings with a lightweight ONNX model
- Vector search from scratch with numpy
- minsearch + chunking
- Keyword vs vector, and hybrid search with RRF
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/hw2
* It's possible your answers won't match exactly. If so, select the closest one.