This project has been created as part of the 42 curriculum by zakburak.
A Retrieval-Augmented Generation (RAG) system built on the vLLM codebase. Given a natural language question, the system retrieves the most relevant source chunks from the repository and generates a grounded answer using a local language model (Qwen/Qwen3-0.6B by default).
┌──────────────────────────────────────────────┐
│ INDEXING (offline) │
│ │
repo files ──▶│ Chunker ──▶ BM25 index (lexical) │
│ ──▶ Embeddings (semantic) │
│ ──▶ chunks_meta.json │
└──────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
Query ──────▶│ RETRIEVAL (online) │
│ │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ Query │ │ BM25 (original) │ │
│ │ expansion │───▶│ BM25 (expanded) │ │
│ │ (synonyms) │ └────────┬─────────┘ │
│ └──────────────┘ │ │
│ │ │
│ Semantic search ─────────────┤ │
│ (all embeddings) │ │
│ Weighted RRF fusion │
│ │ │
│ ▼ │
│ Top-k MinimalSource │
└──────────────────────────────┬───────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ GENERATION (Qwen3-0.6B) │
│ │
│ Top-k chunks ──▶ Context window ──▶ LLM │
│ │ │
│ ▼ │
│ Grounded answer │
└──────────────────────────────────────────────┘
The pipeline has three stages:
indexer.py) — repo files with an indexable suffix
(.py, .pyi, .md, .rst, .txt, .yaml, .yml, .json,
.toml, .sh, .bash) are chunked, then two parallel
indices are built:
bm25s) for lexical retrieval.sentence-transformers/all-MiniLM-L6-v2) for dense retrieval.
Each chunk's content is prefixed with its relative file path so BM25
can match module names mentioned in queries.
Per-chunk metadata (file_path, first_character_index,
last_character_index) is persisted to disk so re-indexing is not
needed across runs.retriever.py) — the query is optionally expanded with
a synonym table, then BM25 is run on both the original and expanded
queries. In parallel, a full semantic search over all corpus
embeddings is performed (one matrix multiply). The rankings are merged
with weighted Reciprocal Rank Fusion (RRF). Per-query results are
memoised via functools.lru_cache. For datasets, all queries are
batch-encoded in a single forward pass for efficiency.generator.py) — the top-k chunks are read from disk
and concatenated as context, then passed to Qwen/Qwen3-0.6B (GGUF,
served via llama-cpp-python) with a system prompt that forces a
self-contained, source-cited answer.Three strategies are implemented depending on file type (chunker.py).
Maximum chunk size is 2000 characters, configurable via the
--max_chunk_size flag on index. All strategies use a chunk overlap
of 100 characters to avoid losing context at boundaries.
chunk_pythonUses RecursiveCharacterTextSplitter.from_language(Language.PYTHON) from
langchain-text-splitters. The splitter tries separators in order:
\nclass , \ndef , \n\tdef , \n, , "".
This keeps class and function definitions together whenever they fit within
the chunk size limit, respecting Python's natural code boundaries.
chunk_markdownUses RecursiveCharacterTextSplitter.from_language(Language.MARKDOWN).
Separators prioritise headings (\n## , \n### , etc.) so each chunk
stays within a logical section of the documentation.
chunk_textUses the generic RecursiveCharacterTextSplitter with standard separators
(\n\n, \n, , ""), splitting on paragraph boundaries first.
max_context_length constraint is violated.The retriever is hybrid: it combines lexical BM25, full semantic search, and query expansion through a weighted RRF fusion.
Implementation: bm25s library with default parameters (k1 = 1.5,
b = 0.75).
BM25 ranks documents by term-frequency / inverse-document-frequency with length normalisation:
score(d, q) = Σ_t IDF(t) · tf(t,d)·(k1+1) / (tf(t,d) + k1·(1 − b + b·|d|/avgdl))
BM25 is the natural fit for code retrieval because identifiers
(function names, class names, command flags) are best matched by exact
keywords. The corpus prefixes each chunk with its relative file path
so module path tokens (vllm/attention/layer.py) are indexed and
searchable.
A MiniLM sentence-transformer (all-MiniLM-L6-v2, 384-dim,
normalised) embeds every chunk at index time. At query time the query
is encoded and a full dot-product search is performed over all
corpus embeddings (embeddings @ q_vec). This means semantically
similar chunks can surface even if they share no keywords with the
query — crucial for documentation questions that paraphrase the source.
For dataset queries, all embeddings are batch-computed in a single
encode_corpus call and the full score matrix (embeddings @ Q^T)
is computed in one BLAS call, making throughput efficient.
query_expander.py)A curated synonym table maps domain terms onto common alternatives:
k8s↔kubernetes, tp↔tensor parallel, kv-cache↔kvcache,
pagedattention↔paged attention, etc. When expansion produces a
different string, a second BM25 query is run and merged into the fusion.
The rankings (BM25 original, BM25 expanded, semantic) are merged with weighted RRF:
fused(d) = Σ_i w_i / (RRF_K + rank_i(d) + 1)
with RRF_K = 60, weights W_ORIGINAL = 5.0, W_EXPANDED = 1.0,
W_SEMANTIC = 2.0. The BM25 candidate pool is fixed at 60 documents;
the semantic search independently contributes its own top-60. Fusion
returns the top-k.
.npy) and chunk
metadata are written to data/processed/ and loaded once per process.functools.lru_cache(maxsize=256) memoises
(query, k) so repeated questions in a dataset are free.Default model: Qwen/Qwen3-0.6B-GGUF, served locally via
llama-cpp-python on CPU. Greedy decoding (temperature=0.0) is used
for deterministic, hallucination-resistant answers.
Context construction (generator.py):
--- separators and labelled with file
path and character range.The system prompt forces:
Source: <file_path> line,Results on the private datasets (100 questions each):
| Dataset | Recall@1 | Recall@3 | Recall@5 | Recall@10 |
|---|---|---|---|---|
| Docs | 63% | 78% | 82% | 88% |
| Code | 48% | 65% | 70% | 72% |
Pass thresholds:
Indexing time: ~151s (≤ 300s limit), 15050 chunks indexed. Retrieval throughput: 200 questions in ~15s (≤ 90s limit, batch encoding).
Key factors driving performance:
vllm.attention, vllm.engine, etc.).Path.as_posix() for file paths ensures cross-platform
compatibility with the moulinette path comparison.max_context_length validation limit.| Configuration | Docs R@5 | Code R@5 |
|---|---|---|
| BM25 only | ~76% | ~48% |
| BM25 + query expansion | ~78% | ~50% |
| BM25 + semantic rerank (top-100 only) | ~84% | ~52% |
| Full hybrid (BM25 + full semantic search + RRF) | 82% | 70% |
| Decision | Rationale |
|---|---|
| BM25 over pure TF-IDF | Better length normalisation; higher recall on code identifiers |
| Hybrid retrieval over single ranker | Semantic catches paraphrases; BM25 catches exact identifiers |
| Full semantic search (not just reranking) | Semantic can surface docs BM25 missed entirely — biggest recall gain |
| Weighted RRF (not score-sum) | Rank-based fusion is robust to score scale mismatches between rankers |
RecursiveCharacterTextSplitter.from_language | Language-aware separators keep functions/sections intact without AST parsing overhead |
| Chunk overlap = 100 chars | Prevents context loss at boundaries without inflating corpus too much |
| File path prefix in corpus | BM25 indexes module paths; questions that reference vllm/x/y.py benefit directly |
Batch encoding in search_dataset | One encode_corpus call for all queries + one BLAS matrix multiply — stays under 90s throughput limit |
Path.as_posix() in index | Ground-truth datasets use /; Windows \ would break matching |
| Chunk size = 2000 chars | Matches system max_context_length validation |
| Qwen3-0.6B (GGUF) via llama-cpp | CPU-only, no GPU required; small enough to run on any corrector machine |
Greedy decoding (temperature=0.0) | Deterministic, reproducible, hallucination-resistant |
| MiniLM-L6-v2 for embeddings | 384-dim, very fast on CPU, strong retrieval quality per parameter |
lru_cache on search | Free re-queries during dataset evaluation |
Trade-offs accepted:
| Feature | Points | Where |
|---|---|---|
| Query expansion (synonym table) | 1 pt | query_expander.py |
| Semantic embeddings (MiniLM, normalised) | 1 pt | embedder.py, indexer.py |
Caching (persisted index + lru_cache) | 1 pt | indexer.py, retriever.py |
| Hybrid retrieval (BM25 + semantic, RRF) | 2 pt | retriever.py |
Total: 5 bonus points (cap reached).
Note: vLLM-based LLM serving is not used. Generation is done via
llama-cpp-pythonwith the Qwen3-0.6B GGUF model so the system runs on a pure CPU machine without requiring GPU drivers or vLLM dependencies on the corrector's environment.
All commands are run from the repository root.
uv sync
uv run python -m student index data/raw/vllm-0.10.1
Optional flag: --max_chunk_size 2000.
This produces:
data/processed/
├── bm25_index/ # bm25s index
├── chunks/
│ ├── chunks_meta.json
│ └── corpus.json
└── embeddings.npy
uv run python -m student search "How to configure the OpenAI-compatible server?" --k 10
uv run python -m student search_dataset \
data/datasets/AnsweredQuestions/dataset_docs_public.json --k 10
Output is written to data/output/search_results/<dataset_name>.json as
a valid StudentSearchResults JSON.
uv run python -m student answer "What is PagedAttention?" --k 10
uv run python -m student answer_dataset \
--student_search_results_path data/output/search_results/dataset_docs_public.json \
--save_directory data/output/search_results_and_answer
uv run python -m student evaluate \
data/output/search_results/dataset_docs_public.json \
data/datasets/AnsweredQuestions/dataset_docs_public.json \
--k 10
make lint # flake8 + mypy
make lint-strict # flake8 + mypy --strict
.
├── student/ # main Python module (python -m student ...)
│ ├── __main__.py # Fire CLI entrypoint (RAGSystem)
│ ├── models.py # Pydantic models (MinimalSource, RagDataset, ...)
│ ├── chunker.py # language-aware chunking strategies
│ ├── indexer.py # builds BM25 + embeddings + meta on disk
│ ├── retriever.py # hybrid BM25 / semantic / RRF + batch search
│ ├── query_expander.py # synonym-table query expansion
│ ├── embedder.py # sentence-transformers wrapper
│ ├── generator.py # Qwen3-0.6B via llama-cpp-python
│ └── evaluator.py # Recall@k
├── data/ # datasets and persisted indices
├── stubs/ # type stubs for mypy
├── pyproject.toml
└── Makefile
2 commits
Python
85.2%
Cuda
8.5%
C++
4.7%
This project has been created as part of the 42 curriculum by zakburak.
A Retrieval-Augmented Generation (RAG) system built on the vLLM codebase. Given a natural language question, the system retrieves the most relevant source chunks from the repository and generates a grounded answer using a local language model (Qwen/Qwen3-0.6B by default).
┌──────────────────────────────────────────────┐
│ INDEXING (offline) │
│ │
repo files ──▶│ Chunker ──▶ BM25 index (lexical) │
│ ──▶ Embeddings (semantic) │
│ ──▶ chunks_meta.json │
└──────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
Query ──────▶│ RETRIEVAL (online) │
│ │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ Query │ │ BM25 (original) │ │
│ │ expansion │───▶│ BM25 (expanded) │ │
│ │ (synonyms) │ └────────┬─────────┘ │
│ └──────────────┘ │ │
│ │ │
│ Semantic search ─────────────┤ │
│ (all embeddings) │ │
│ Weighted RRF fusion │
│ │ │
│ ▼ │
│ Top-k MinimalSource │
└──────────────────────────────┬───────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ GENERATION (Qwen3-0.6B) │
│ │
│ Top-k chunks ──▶ Context window ──▶ LLM │
│ │ │
│ ▼ │
│ Grounded answer │
└──────────────────────────────────────────────┘
The pipeline has three stages:
indexer.py) — repo files with an indexable suffix
(.py, .pyi, .md, .rst, .txt, .yaml, .yml, .json,
.toml, .sh, .bash) are chunked, then two parallel
indices are built:
bm25s) for lexical retrieval.sentence-transformers/all-MiniLM-L6-v2) for dense retrieval.
Each chunk's content is prefixed with its relative file path so BM25
can match module names mentioned in queries.
Per-chunk metadata (file_path, first_character_index,
last_character_index) is persisted to disk so re-indexing is not
needed across runs.retriever.py) — the query is optionally expanded with
a synonym table, then BM25 is run on both the original and expanded
queries. In parallel, a full semantic search over all corpus
embeddings is performed (one matrix multiply). The rankings are merged
with weighted Reciprocal Rank Fusion (RRF). Per-query results are
memoised via functools.lru_cache. For datasets, all queries are
batch-encoded in a single forward pass for efficiency.generator.py) — the top-k chunks are read from disk
and concatenated as context, then passed to Qwen/Qwen3-0.6B (GGUF,
served via llama-cpp-python) with a system prompt that forces a
self-contained, source-cited answer.Three strategies are implemented depending on file type (chunker.py).
Maximum chunk size is 2000 characters, configurable via the
--max_chunk_size flag on index. All strategies use a chunk overlap
of 100 characters to avoid losing context at boundaries.
chunk_pythonUses RecursiveCharacterTextSplitter.from_language(Language.PYTHON) from
langchain-text-splitters. The splitter tries separators in order:
\nclass , \ndef , \n\tdef , \n, , "".
This keeps class and function definitions together whenever they fit within
the chunk size limit, respecting Python's natural code boundaries.
chunk_markdownUses RecursiveCharacterTextSplitter.from_language(Language.MARKDOWN).
Separators prioritise headings (\n## , \n### , etc.) so each chunk
stays within a logical section of the documentation.
chunk_textUses the generic RecursiveCharacterTextSplitter with standard separators
(\n\n, \n, , ""), splitting on paragraph boundaries first.
max_context_length constraint is violated.The retriever is hybrid: it combines lexical BM25, full semantic search, and query expansion through a weighted RRF fusion.
Implementation: bm25s library with default parameters (k1 = 1.5,
b = 0.75).
BM25 ranks documents by term-frequency / inverse-document-frequency with length normalisation:
score(d, q) = Σ_t IDF(t) · tf(t,d)·(k1+1) / (tf(t,d) + k1·(1 − b + b·|d|/avgdl))
BM25 is the natural fit for code retrieval because identifiers
(function names, class names, command flags) are best matched by exact
keywords. The corpus prefixes each chunk with its relative file path
so module path tokens (vllm/attention/layer.py) are indexed and
searchable.
A MiniLM sentence-transformer (all-MiniLM-L6-v2, 384-dim,
normalised) embeds every chunk at index time. At query time the query
is encoded and a full dot-product search is performed over all
corpus embeddings (embeddings @ q_vec). This means semantically
similar chunks can surface even if they share no keywords with the
query — crucial for documentation questions that paraphrase the source.
For dataset queries, all embeddings are batch-computed in a single
encode_corpus call and the full score matrix (embeddings @ Q^T)
is computed in one BLAS call, making throughput efficient.
query_expander.py)A curated synonym table maps domain terms onto common alternatives:
k8s↔kubernetes, tp↔tensor parallel, kv-cache↔kvcache,
pagedattention↔paged attention, etc. When expansion produces a
different string, a second BM25 query is run and merged into the fusion.
The rankings (BM25 original, BM25 expanded, semantic) are merged with weighted RRF:
fused(d) = Σ_i w_i / (RRF_K + rank_i(d) + 1)
with RRF_K = 60, weights W_ORIGINAL = 5.0, W_EXPANDED = 1.0,
W_SEMANTIC = 2.0. The BM25 candidate pool is fixed at 60 documents;
the semantic search independently contributes its own top-60. Fusion
returns the top-k.
.npy) and chunk
metadata are written to data/processed/ and loaded once per process.functools.lru_cache(maxsize=256) memoises
(query, k) so repeated questions in a dataset are free.Default model: Qwen/Qwen3-0.6B-GGUF, served locally via
llama-cpp-python on CPU. Greedy decoding (temperature=0.0) is used
for deterministic, hallucination-resistant answers.
Context construction (generator.py):
--- separators and labelled with file
path and character range.The system prompt forces:
Source: <file_path> line,Results on the private datasets (100 questions each):
| Dataset | Recall@1 | Recall@3 | Recall@5 | Recall@10 |
|---|---|---|---|---|
| Docs | 63% | 78% | 82% | 88% |
| Code | 48% | 65% | 70% | 72% |
Pass thresholds:
Indexing time: ~151s (≤ 300s limit), 15050 chunks indexed. Retrieval throughput: 200 questions in ~15s (≤ 90s limit, batch encoding).
Key factors driving performance:
vllm.attention, vllm.engine, etc.).Path.as_posix() for file paths ensures cross-platform
compatibility with the moulinette path comparison.max_context_length validation limit.| Configuration | Docs R@5 | Code R@5 |
|---|---|---|
| BM25 only | ~76% | ~48% |
| BM25 + query expansion | ~78% | ~50% |
| BM25 + semantic rerank (top-100 only) | ~84% | ~52% |
| Full hybrid (BM25 + full semantic search + RRF) | 82% | 70% |
| Decision | Rationale |
|---|---|
| BM25 over pure TF-IDF | Better length normalisation; higher recall on code identifiers |
| Hybrid retrieval over single ranker | Semantic catches paraphrases; BM25 catches exact identifiers |
| Full semantic search (not just reranking) | Semantic can surface docs BM25 missed entirely — biggest recall gain |
| Weighted RRF (not score-sum) | Rank-based fusion is robust to score scale mismatches between rankers |
RecursiveCharacterTextSplitter.from_language | Language-aware separators keep functions/sections intact without AST parsing overhead |
| Chunk overlap = 100 chars | Prevents context loss at boundaries without inflating corpus too much |
| File path prefix in corpus | BM25 indexes module paths; questions that reference vllm/x/y.py benefit directly |
Batch encoding in search_dataset | One encode_corpus call for all queries + one BLAS matrix multiply — stays under 90s throughput limit |
Path.as_posix() in index | Ground-truth datasets use /; Windows \ would break matching |
| Chunk size = 2000 chars | Matches system max_context_length validation |
| Qwen3-0.6B (GGUF) via llama-cpp | CPU-only, no GPU required; small enough to run on any corrector machine |
Greedy decoding (temperature=0.0) | Deterministic, reproducible, hallucination-resistant |
| MiniLM-L6-v2 for embeddings | 384-dim, very fast on CPU, strong retrieval quality per parameter |
lru_cache on search | Free re-queries during dataset evaluation |
Trade-offs accepted:
| Feature | Points | Where |
|---|---|---|
| Query expansion (synonym table) | 1 pt | query_expander.py |
| Semantic embeddings (MiniLM, normalised) | 1 pt | embedder.py, indexer.py |
Caching (persisted index + lru_cache) | 1 pt | indexer.py, retriever.py |
| Hybrid retrieval (BM25 + semantic, RRF) | 2 pt | retriever.py |
Total: 5 bonus points (cap reached).
Note: vLLM-based LLM serving is not used. Generation is done via
llama-cpp-pythonwith the Qwen3-0.6B GGUF model so the system runs on a pure CPU machine without requiring GPU drivers or vLLM dependencies on the corrector's environment.
All commands are run from the repository root.
uv sync
uv run python -m student index data/raw/vllm-0.10.1
Optional flag: --max_chunk_size 2000.
This produces:
data/processed/
├── bm25_index/ # bm25s index
├── chunks/
│ ├── chunks_meta.json
│ └── corpus.json
└── embeddings.npy
uv run python -m student search "How to configure the OpenAI-compatible server?" --k 10
uv run python -m student search_dataset \
data/datasets/AnsweredQuestions/dataset_docs_public.json --k 10
Output is written to data/output/search_results/<dataset_name>.json as
a valid StudentSearchResults JSON.
uv run python -m student answer "What is PagedAttention?" --k 10
uv run python -m student answer_dataset \
--student_search_results_path data/output/search_results/dataset_docs_public.json \
--save_directory data/output/search_results_and_answer
uv run python -m student evaluate \
data/output/search_results/dataset_docs_public.json \
data/datasets/AnsweredQuestions/dataset_docs_public.json \
--k 10
make lint # flake8 + mypy
make lint-strict # flake8 + mypy --strict
.
├── student/ # main Python module (python -m student ...)
│ ├── __main__.py # Fire CLI entrypoint (RAGSystem)
│ ├── models.py # Pydantic models (MinimalSource, RagDataset, ...)
│ ├── chunker.py # language-aware chunking strategies
│ ├── indexer.py # builds BM25 + embeddings + meta on disk
│ ├── retriever.py # hybrid BM25 / semantic / RRF + batch search
│ ├── query_expander.py # synonym-table query expansion
│ ├── embedder.py # sentence-transformers wrapper
│ ├── generator.py # Qwen3-0.6B via llama-cpp-python
│ └── evaluator.py # Recall@k
├── data/ # datasets and persisted indices
├── stubs/ # type stubs for mypy
├── pyproject.toml
└── Makefile
2 commits
Python
85.2%
Cuda
8.5%
C++
4.7%