This project has been created as part of the 42 curriculum by joflorid.
RAG against the machine is a Retrieval-Augmented Generation (RAG) system that answers questions about the vLLM code base (version 0.10.1): both its Python source code and its documentation.
The goal is to answer a question with information that actually exists in the corpus instead of what a language model remembers. To do that, the system:
Everything runs locally, from a command-line interface or a small HTTP API.
Bonus features:
all-MiniLM-L6-v2)
through Reciprocal Rank Fusion.mps), then CUDA, then CPU.git clone <repository-url> rag_against_the_machine
cd rag_against_the_machine
make install # same as: uv sync --all-groups
The corpus is not stored in the repository. Place the vLLM 0.10.1 source tree
in data/raw/, so that the files end up under data/raw/vllm-0.10.1/. The
reference datasets are already in data/datasets/.
The first run also downloads the NLTK English stopword list, so it needs network access once.
Every command is run as a module. Python Fire turns the function arguments into command-line flags.
| Command | What it does |
|---|---|
index | Chunk the corpus and build the BM25 index (and the embeddings with --bonus y) |
search | Print the top-k chunks for one question |
search_dataset | Retrieve the top-k chunks for every question in a dataset and save them as JSON |
evaluate | Compute recall@1…recall@k of a search-results file against a dataset |
answer | Answer one question with the LLM |
answer_dataset | Answer every question in a search-results file |
serve | Start the HTTP API |
The index must be built once before any other command:
uv run python -m src index --max_chunk_size 800 --bonus n
The Makefile wraps each command in an interactive target that asks for its
parameters: make index, make search, make search_dataset,
make evaluate, make answer, make answer_dataset and make server.
make lint runs flake8 and mypy.
The pipeline has an offline part (indexing) and an online part (retrieval and
generation). The two communicate only through the files in data/processed/.
OFFLINE (index)
data/raw/ ──► Indexer ──► ChunkerCode (.py, tree-sitter) ──┐
└─► ChunkOther (docs, text) ────────┤
▼
Tokenizer (stopwords + stemming)
▼
BM25Okapi ──► data/processed/bm25_index.pkl
chunks ──► data/processed/chunks.json
(bonus) Embeddings ──► data/processed/embeddings.npy
ONLINE (search / answer)
question ──► Retrieval ──► BM25 scores × type booster
└─► (bonus) cosine scores ─► RRF fusion
▼
top-k chunks (file + offsets)
▼
PromptBuild ──► system prompt + numbered chunks + question
▼
Model (Qwen3-0.6B) ──► answer
| Component | File | Role |
|---|---|---|
Indexer | src/indexer/indexer.py | Walks the corpus, runs both chunkers, builds and saves the index |
ChunkerCode / ChunkOther | src/chunker/ | Split Python files and documentation into chunks |
Tokenizer | src/indexer/tokenizer.py | Turns code and text into BM25 terms |
Embeddings | src/indexer/embeddings.py | Dense vectors for the hybrid search (bonus) |
Retrieval | src/retriever/retrieval.py | Ranks the chunks for a question |
PromptBuild, Model, Generator | src/generator/ | Build the prompt, load the LLM, generate answers |
Evaluate | src/evaluator/evaluate.py | Recall@k against a reference dataset |
CacheHandler | src/cacher/cacher.py | JSON caches for search results and answers (bonus) |
| FastAPI app | src/server/api.py | HTTP API over Retrieval and Generator (bonus) |
All data exchanged between steps (datasets, chunks, search results, answers)
is validated with Pydantic models defined in src/entities/data_model.py. A
chunk is identified by its file path and its start and end offsets, not by
its text. The text is read back from the source file when building a prompt.
Python files and documentation are chunked differently, because their natural
boundaries are different. Chunks are limited to max_chunk_size (800 by
default and 2000 as maximum).
Python files (ChunkerCode) are parsed with
tree-sitter, and chunks follow
the top-level nodes of the syntax tree:
import statements of a file go into a single chunk, so they do not
produce dozens of tiny, nearly identical chunks.Other text files (ChunkOther), mainly Markdown, use a sliding window:
At tokenization time, chunks with fewer than 10 tokens are dropped: they are mostly noise (a closing bracket, a lone heading) and only take up room in the top-k results. With the default settings the corpus gives 42,480 chunks.
The ranking is BM25 (Okapi
variant, from rank_bm25), with k1 = 2.0 and b = 0.3.
Tokenization. Code chunks and text chunks go through different tokenizers:
camelCase and PascalCase names are split into words
(getHTTPResponse → get, http, response), while snake_case names
are kept whole (max_tokens).prefix_caching.md even if the chunk itself does not say it.A question can contain identifiers as well as plain words, so it goes through both tokenizers, and their tokens are merged without duplicates.
Type booster. About 78% of the chunks come from Python files. If one file
type makes up less than 30% of the index, the BM25 scores of its chunks are
multiplied by 1.2. On this corpus that boosts the .md, .txt and .rst
chunks, which would otherwise be outnumbered by code in the results.
The k chunks with the highest boosted score are returned, best first.
With --bonus y, each chunk also has a 384-dimension embedding from
all-MiniLM-L6-v2:
The question is embedded the same way, and the two rankings are fused with Reciprocal Rank Fusion:
score(chunk) = 1 / (60 + rank_BM25(chunk)) + 1 / (60 + rank_cosine(chunk))
RRF only uses ranks, so it does not need the BM25 and cosine scores to be on the same scale.
Generator takes the top 3 chunks of each question and sends them to
Qwen3-0.6B in this order:
Qwen3's thinking mode is disabled, and an answer is at most 200 new tokens.
For each question of a dataset, recall@k is the share of its reference sources found among the first k retrieved chunks. A reference source counts as found when a retrieved chunk comes from the same file and overlaps it with an intersection over union (IoU) above 0.05. The reported value is the average over all questions.
These were measured on the two public datasets in
data/datasets/AnsweredQuestions/, with max_chunk_size = 800, on an Apple
M1 Pro:
| k | Docs: BM25 | Docs: hybrid | Code: BM25 | Code: hybrid |
|---|---|---|---|---|
| 1 | 69.0% | 49.0% | 30.3% | 32.3% |
| 3 | 84.0% | 69.0% | 46.5% | 48.5% |
| 5 | 90.0% | 77.0% | 62.6% | 59.6% |
| 10 | 94.0% | 86.0% | 73.7% | 68.7% |
The docs dataset has 100 questions and the code dataset 99.
Documentation questions reach 90% recall@5 with BM25. They tend to use the same words as the documentation that answers them, which is the case where BM25 does best.
Code questions are harder: 62.6% at k=5 and 73.7% at k=10. A question is written in natural language ("What activation formats does the fused batched MoE layer return?"), while the answer is a piece of code whose vocabulary is identifiers. Many chunks of the same module share those identifiers, so the right one is often ranked just below the cut.
The hybrid search did not improve recall. It is clearly worse on the docs
dataset, and only better on the code dataset for k ≤ 3. With equal weights,
RRF gives the semantic ranking as much say as BM25, and all-MiniLM-L6-v2,
which was trained on general English text, is a weaker signal here than exact
term matching. That is why BM25 stays the default and the hybrid search is an
option. Giving BM25 a larger weight in the fusion would be the next thing to
try.
| Step | Time |
|---|---|
| Searching a whole dataset (~100 questions, BM25), including loading the index | 13–19 s |
| Searching a whole dataset with the hybrid search | ~10 s, plus loading the embeddings model |
Computing the embeddings of the 42,480 chunks (index --bonus y) | ~2 min |
| Loading Qwen3-0.6B | ~10 s |
| Generating one answer | 1–2 s |
With the cache enabled, a question that was already searched or answered is returned straight from disk, without running the search or the model again.
file + start + end, which
is the format the evaluator expects, and it keeps the index small. The text
is read from the source file only when it is needed.index deletes both caches, because their entries
point to chunks of the old index.k1 and b;tqdm progress bar when the embeddings model was loaded during
the search loop. It is now loaded before the bar starts.uv run python -m src index --max_chunk_size 800 # BM25 only
uv run python -m src index --max_chunk_size 800 --bonus y # also embeddings
uv run python -m src search "How do I enable prefix caching in vLLM?" --k 3
data/raw/vllm-0.10.1/docs/features/automatic_prefix_caching.md [0:698]
data/raw/vllm-0.10.1/examples/offline_inference/automatic_prefix_caching.py [107:722]
data/raw/vllm-0.10.1/docs/design/prefix_caching.md [0:748]
uv run python -m src search_dataset \
data/datasets/AnsweredQuestions/dataset_docs_public.json \
--k 10 --save_directory data/output/search_results
uv run python -m src evaluate \
data/output/search_results/dataset_docs_public.json \
data/datasets/AnsweredQuestions/dataset_docs_public.json --k 10
Questions evaluated: 100
Recall@1: 0.69 (69.00%)
...
Recall@5: 0.90 (90.00%)
...
Recall@10: 0.94 (94.00%)
Add --bonus y to search or search_dataset to use the hybrid search and
the cache.
uv run python -m src answer "How do I enable prefix caching in vLLM?" --k 3
Automatic Prefix Caching (APC) allows the vLLM engine to reuse cached KV cache
blocks from previous prompts when a new query shares the same prefix. This
reduces redundant computation and improves inference speed.
uv run python -m src answer_dataset \
data/output/search_results/dataset_docs_public.json \
data/output/search_results_and_answer
uv run python -m src serve --port 8000 --bonus n
The interactive documentation is then at http://127.0.0.1:8000/docs.
curl -X POST http://127.0.0.1:8000/search \
-H "Content-Type: application/json" \
-d '{"query": "How do I enable prefix caching in vLLM?", "k": 3}'
curl -X POST http://127.0.0.1:8000/answer \
-H "Content-Type: application/json" \
-d '{"query": "What does the --tensor-parallel-size option do?", "k": 3}'
| Endpoint | Method | Input |
|---|---|---|
/health | GET | none |
/search | POST | JSON body {"query": str, "k": 1–10} |
/answer | POST | JSON body {"query": str, "k": 1–10} |
/search_dataset | POST | Query parameters dataset_path, k, save_directory |
/answer_dataset | POST | Query parameters student_search_results_path, save_directory |
rank_bm25: https://github.com/dorianbrown/rank_bm25AI assistants were used as a support tool. The design, the implementation and the tuning of the pipeline were done by hand. AI was used for:
src/. While doing this, the assistant
pointed out mismatches between the code and its documentation, and a few
bugs, which were then reviewed and fixed by hand.This project has been created as part of the 42 curriculum by joflorid.
RAG against the machine is a Retrieval-Augmented Generation (RAG) system that answers questions about the vLLM code base (version 0.10.1): both its Python source code and its documentation.
The goal is to answer a question with information that actually exists in the corpus instead of what a language model remembers. To do that, the system:
Everything runs locally, from a command-line interface or a small HTTP API.
Bonus features:
all-MiniLM-L6-v2)
through Reciprocal Rank Fusion.mps), then CUDA, then CPU.git clone <repository-url> rag_against_the_machine
cd rag_against_the_machine
make install # same as: uv sync --all-groups
The corpus is not stored in the repository. Place the vLLM 0.10.1 source tree
in data/raw/, so that the files end up under data/raw/vllm-0.10.1/. The
reference datasets are already in data/datasets/.
The first run also downloads the NLTK English stopword list, so it needs network access once.
Every command is run as a module. Python Fire turns the function arguments into command-line flags.
| Command | What it does |
|---|---|
index | Chunk the corpus and build the BM25 index (and the embeddings with --bonus y) |
search | Print the top-k chunks for one question |
search_dataset | Retrieve the top-k chunks for every question in a dataset and save them as JSON |
evaluate | Compute recall@1…recall@k of a search-results file against a dataset |
answer | Answer one question with the LLM |
answer_dataset | Answer every question in a search-results file |
serve | Start the HTTP API |
The index must be built once before any other command:
uv run python -m src index --max_chunk_size 800 --bonus n
The Makefile wraps each command in an interactive target that asks for its
parameters: make index, make search, make search_dataset,
make evaluate, make answer, make answer_dataset and make server.
make lint runs flake8 and mypy.
The pipeline has an offline part (indexing) and an online part (retrieval and
generation). The two communicate only through the files in data/processed/.
OFFLINE (index)
data/raw/ ──► Indexer ──► ChunkerCode (.py, tree-sitter) ──┐
└─► ChunkOther (docs, text) ────────┤
▼
Tokenizer (stopwords + stemming)
▼
BM25Okapi ──► data/processed/bm25_index.pkl
chunks ──► data/processed/chunks.json
(bonus) Embeddings ──► data/processed/embeddings.npy
ONLINE (search / answer)
question ──► Retrieval ──► BM25 scores × type booster
└─► (bonus) cosine scores ─► RRF fusion
▼
top-k chunks (file + offsets)
▼
PromptBuild ──► system prompt + numbered chunks + question
▼
Model (Qwen3-0.6B) ──► answer
| Component | File | Role |
|---|---|---|
Indexer | src/indexer/indexer.py | Walks the corpus, runs both chunkers, builds and saves the index |
ChunkerCode / ChunkOther | src/chunker/ | Split Python files and documentation into chunks |
Tokenizer | src/indexer/tokenizer.py | Turns code and text into BM25 terms |
Embeddings | src/indexer/embeddings.py | Dense vectors for the hybrid search (bonus) |
Retrieval | src/retriever/retrieval.py | Ranks the chunks for a question |
PromptBuild, Model, Generator | src/generator/ | Build the prompt, load the LLM, generate answers |
Evaluate | src/evaluator/evaluate.py | Recall@k against a reference dataset |
CacheHandler | src/cacher/cacher.py | JSON caches for search results and answers (bonus) |
| FastAPI app | src/server/api.py | HTTP API over Retrieval and Generator (bonus) |
All data exchanged between steps (datasets, chunks, search results, answers)
is validated with Pydantic models defined in src/entities/data_model.py. A
chunk is identified by its file path and its start and end offsets, not by
its text. The text is read back from the source file when building a prompt.
Python files and documentation are chunked differently, because their natural
boundaries are different. Chunks are limited to max_chunk_size (800 by
default and 2000 as maximum).
Python files (ChunkerCode) are parsed with
tree-sitter, and chunks follow
the top-level nodes of the syntax tree:
import statements of a file go into a single chunk, so they do not
produce dozens of tiny, nearly identical chunks.Other text files (ChunkOther), mainly Markdown, use a sliding window:
At tokenization time, chunks with fewer than 10 tokens are dropped: they are mostly noise (a closing bracket, a lone heading) and only take up room in the top-k results. With the default settings the corpus gives 42,480 chunks.
The ranking is BM25 (Okapi
variant, from rank_bm25), with k1 = 2.0 and b = 0.3.
Tokenization. Code chunks and text chunks go through different tokenizers:
camelCase and PascalCase names are split into words
(getHTTPResponse → get, http, response), while snake_case names
are kept whole (max_tokens).prefix_caching.md even if the chunk itself does not say it.A question can contain identifiers as well as plain words, so it goes through both tokenizers, and their tokens are merged without duplicates.
Type booster. About 78% of the chunks come from Python files. If one file
type makes up less than 30% of the index, the BM25 scores of its chunks are
multiplied by 1.2. On this corpus that boosts the .md, .txt and .rst
chunks, which would otherwise be outnumbered by code in the results.
The k chunks with the highest boosted score are returned, best first.
With --bonus y, each chunk also has a 384-dimension embedding from
all-MiniLM-L6-v2:
The question is embedded the same way, and the two rankings are fused with Reciprocal Rank Fusion:
score(chunk) = 1 / (60 + rank_BM25(chunk)) + 1 / (60 + rank_cosine(chunk))
RRF only uses ranks, so it does not need the BM25 and cosine scores to be on the same scale.
Generator takes the top 3 chunks of each question and sends them to
Qwen3-0.6B in this order:
Qwen3's thinking mode is disabled, and an answer is at most 200 new tokens.
For each question of a dataset, recall@k is the share of its reference sources found among the first k retrieved chunks. A reference source counts as found when a retrieved chunk comes from the same file and overlaps it with an intersection over union (IoU) above 0.05. The reported value is the average over all questions.
These were measured on the two public datasets in
data/datasets/AnsweredQuestions/, with max_chunk_size = 800, on an Apple
M1 Pro:
| k | Docs: BM25 | Docs: hybrid | Code: BM25 | Code: hybrid |
|---|---|---|---|---|
| 1 | 69.0% | 49.0% | 30.3% | 32.3% |
| 3 | 84.0% | 69.0% | 46.5% | 48.5% |
| 5 | 90.0% | 77.0% | 62.6% | 59.6% |
| 10 | 94.0% | 86.0% | 73.7% | 68.7% |
The docs dataset has 100 questions and the code dataset 99.
Documentation questions reach 90% recall@5 with BM25. They tend to use the same words as the documentation that answers them, which is the case where BM25 does best.
Code questions are harder: 62.6% at k=5 and 73.7% at k=10. A question is written in natural language ("What activation formats does the fused batched MoE layer return?"), while the answer is a piece of code whose vocabulary is identifiers. Many chunks of the same module share those identifiers, so the right one is often ranked just below the cut.
The hybrid search did not improve recall. It is clearly worse on the docs
dataset, and only better on the code dataset for k ≤ 3. With equal weights,
RRF gives the semantic ranking as much say as BM25, and all-MiniLM-L6-v2,
which was trained on general English text, is a weaker signal here than exact
term matching. That is why BM25 stays the default and the hybrid search is an
option. Giving BM25 a larger weight in the fusion would be the next thing to
try.
| Step | Time |
|---|---|
| Searching a whole dataset (~100 questions, BM25), including loading the index | 13–19 s |
| Searching a whole dataset with the hybrid search | ~10 s, plus loading the embeddings model |
Computing the embeddings of the 42,480 chunks (index --bonus y) | ~2 min |
| Loading Qwen3-0.6B | ~10 s |
| Generating one answer | 1–2 s |
With the cache enabled, a question that was already searched or answered is returned straight from disk, without running the search or the model again.
file + start + end, which
is the format the evaluator expects, and it keeps the index small. The text
is read from the source file only when it is needed.index deletes both caches, because their entries
point to chunks of the old index.k1 and b;tqdm progress bar when the embeddings model was loaded during
the search loop. It is now loaded before the bar starts.uv run python -m src index --max_chunk_size 800 # BM25 only
uv run python -m src index --max_chunk_size 800 --bonus y # also embeddings
uv run python -m src search "How do I enable prefix caching in vLLM?" --k 3
data/raw/vllm-0.10.1/docs/features/automatic_prefix_caching.md [0:698]
data/raw/vllm-0.10.1/examples/offline_inference/automatic_prefix_caching.py [107:722]
data/raw/vllm-0.10.1/docs/design/prefix_caching.md [0:748]
uv run python -m src search_dataset \
data/datasets/AnsweredQuestions/dataset_docs_public.json \
--k 10 --save_directory data/output/search_results
uv run python -m src evaluate \
data/output/search_results/dataset_docs_public.json \
data/datasets/AnsweredQuestions/dataset_docs_public.json --k 10
Questions evaluated: 100
Recall@1: 0.69 (69.00%)
...
Recall@5: 0.90 (90.00%)
...
Recall@10: 0.94 (94.00%)
Add --bonus y to search or search_dataset to use the hybrid search and
the cache.
uv run python -m src answer "How do I enable prefix caching in vLLM?" --k 3
Automatic Prefix Caching (APC) allows the vLLM engine to reuse cached KV cache
blocks from previous prompts when a new query shares the same prefix. This
reduces redundant computation and improves inference speed.
uv run python -m src answer_dataset \
data/output/search_results/dataset_docs_public.json \
data/output/search_results_and_answer
uv run python -m src serve --port 8000 --bonus n
The interactive documentation is then at http://127.0.0.1:8000/docs.
curl -X POST http://127.0.0.1:8000/search \
-H "Content-Type: application/json" \
-d '{"query": "How do I enable prefix caching in vLLM?", "k": 3}'
curl -X POST http://127.0.0.1:8000/answer \
-H "Content-Type: application/json" \
-d '{"query": "What does the --tensor-parallel-size option do?", "k": 3}'
| Endpoint | Method | Input |
|---|---|---|
/health | GET | none |
/search | POST | JSON body {"query": str, "k": 1–10} |
/answer | POST | JSON body {"query": str, "k": 1–10} |
/search_dataset | POST | Query parameters dataset_path, k, save_directory |
/answer_dataset | POST | Query parameters student_search_results_path, save_directory |
rank_bm25: https://github.com/dorianbrown/rank_bm25AI assistants were used as a support tool. The design, the implementation and the tuning of the pipeline were done by hand. AI was used for:
src/. While doing this, the assistant
pointed out mismatches between the code and its documentation, and a few
bugs, which were then reviewed and fixed by hand.