Codebase Q&A — Local RAG for Source Code¶
Ask natural-language questions about any codebase and get grounded answers with file-path + line-range citations, all running 100% locally. No API keys. No data leaves your machine.
What you'll learn¶
- How to adapt a RAG pipeline specifically for source code (line-based chunking, code-aware citations, a system prompt tuned for engineering Q&A)
- How to walk a directory tree, filter by extension, and avoid indexing noise
- How to store chunk metadata (file path, line numbers) alongside vectors so you can trace every answer back to exact source lines
- How to use ChromaDB's
PersistentClientwith cosine distance and sentence-transformers'normalize_embeddings=Truefor consistent retrieval - How to wire Ollama's Python client into a RAG loop without any external API
Architecture¶
flowchart LR
subgraph Ingest ["python ingest.py --path ./repo"]
A[Walker\niter_source_files] -->|".py .js …"| B[Chunker\nchunk_file\n60 lines / 10 overlap]
B -->|Chunk dataclasses| C[SentenceTransformer\nall-MiniLM-L6-v2]
C -->|float vectors| D[(ChromaDB\nPersistentClient\ncosine)]
end
subgraph Query ["python rag.py / streamlit run app.py"]
E[User Question] --> F[SentenceTransformer\nencode query]
F -->|query vector| D
D -->|top-k chunks\n+ metadata| G[build_prompt\n# path:start-end headers]
G --> H[Ollama\nllama3.2]
H -->|answer + citations| I[User]
end
File-by-file walkthrough¶
config.py — single source of truth¶
All tunable constants live here so you never have to grep the codebase to change a setting:
LINES_PER_CHUNK: int = 60
LINE_OVERLAP: int = 10
TOP_K: int = 6
OLLAMA_MODEL: str = "llama3.2"
INCLUDE_EXTENSIONS: set = {".py", ".js", ".ts", ...}
SKIP_DIRS: set = {".git", "node_modules", "__pycache__", ...}
MAX_FILE_BYTES: int = 250_000
Why 60 lines? Enough to capture a complete function or class method without blowing the LLM context window (6 × ~60 lines ≈ 360 lines of context, comfortably within llama3.2's 128 k-token window).
walker.py — directory traversal + chunker¶
iter_source_files(root) uses os.walk with in-place pruning of SKIP_DIRS
to avoid descending into .git, node_modules, __pycache__, and the
project's own chroma_db/ directory.
chunk_file(filepath) reads every file as UTF-8 (with errors="replace" so
binary noise doesn't crash the loop) and yields overlapping line windows:
lines 1-60 → Chunk(start_line=1, end_line=60)
lines 51-110 → Chunk(start_line=51, end_line=110) # 10-line overlap
lines 101-160 → Chunk(start_line=101, end_line=160)
…
Each Chunk is a @dataclass with {path, start_line, end_line, text}.
ingest.py — embed and store¶
Key design choices:
- Deterministic IDs —
"<abs_path>::<start>-<end>"— make re-runs idempotent: existing chunks are updated, new ones are added, nothing is duplicated. - Batch encoding (
batch_size=64) keeps GPU/CPU utilisation high and avoids OOM on large repos. normalize_embeddings=Trueensures all vectors are unit-length, so ChromaDB's cosine distance is numerically equivalent to dot-product and scores are in[0, 1].
rag.py — retrieval + generation¶
RAGPipeline.retrieve(query) encodes the query with the same model used at
ingest time, queries ChromaDB for the top-k nearest neighbours, and converts
ChromaDB's cosine distance to a cosine similarity score
(score = 1 - distance).
build_prompt(query, chunks) formats the retrieved code with comment-style
headers:
# src/auth/middleware.py:1-60
def authenticate(request):
token = request.headers.get("Authorization", "")
…
---
# src/config/settings.py:20-55
JWT_SECRET = os.environ["JWT_SECRET"]
…
QUESTION: How does JWT authentication work?
The system prompt is the key to grounded answers:
You are a senior software engineer assistant helping a developer
understand an unfamiliar codebase.
Rules:
1. Answer ONLY from the code context provided below.
2. Cite every claim with the exact file reference shown in the context
header, formatted as path/to/file.py:start_line-end_line .
3. If the context does not contain enough information to answer,
say "I don't see that in the indexed code." — do not guess.
…
app.py — Streamlit chat UI¶
Uses @st.cache_resource to load the pipeline once per session (avoids
reloading the ~90 MB model on every interaction). Each assistant reply
includes a collapsible Sources expander showing:
…followed by the raw code snippet in a fenced code block.
Setup¶
# Prerequisites: Python ≥ 3.10, Ollama with llama3.2 pulled
ollama pull llama3.2
cd projects/codebase-qa
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
# Index a repo (defaults to current dir if --path omitted)
python ingest.py --path /path/to/your/repo
# CLI REPL
python rag.py
# Web UI
streamlit run app.py
Configuration knobs¶
| Constant | Default | Effect |
|---|---|---|
LINES_PER_CHUNK |
60 | Larger → more context per chunk, fewer chunks, higher memory |
LINE_OVERLAP |
10 | Larger → less chance of missing cross-boundary logic |
TOP_K |
6 | More → richer context, but longer prompts and slower LLM calls |
OLLAMA_MODEL |
llama3.2 |
Swap for codellama, deepseek-coder, etc. |
OLLAMA_TEMPERATURE |
0.1 | Higher → more creative but less grounded answers |
MAX_FILE_BYTES |
250 000 | Increase to index larger files (lock files, generated code) |
INCLUDE_EXTENSIONS |
see config | Add extensions for your stack (e.g. .swift, .kt) |
SKIP_DIRS |
see config | Add project-specific output dirs to avoid indexing build artefacts |
Limitations¶
- No AST or semantic parsing — chunks are plain line windows. A function
that spans lines 55-130 may be split across two chunks. The overlap
(
LINE_OVERLAP) mitigates this but doesn't eliminate it. - No incremental delete — if you delete a file from the repo, its chunks
remain in the index until you re-run with
--reset. - Retrieval is lexical + semantic, not structural — the pipeline cannot answer "show me all subclasses of Foo" without the LLM reasoning over retrieved text.
- Large monorepos — indexing 10 000+ files is fine but may take several
minutes on CPU; consider filtering
INCLUDE_EXTENSIONSto the languages you care about.
How to extend¶
Swap the embedding model
Change EMBEDDING_MODEL in config.py. Any sentence-transformers-
compatible model works; BAAI/bge-small-en-v1.5 and
nomic-ai/nomic-embed-text-v1 are good CPU-friendly alternatives.
Use a code-specific LLM
Set OLLAMA_MODEL = "codellama" or "deepseek-coder" — both are available
via ollama pull.
Add a re-ranker
After retrieve(), apply a cross-encoder (e.g.
cross-encoder/ms-marco-MiniLM-L-6-v2) to re-score and reorder the top-k
chunks before passing them to the LLM.
Persist chat history to disk
Replace the in-memory st.session_state.chat_history list in app.py with a
SQLite or JSON file so conversations survive page refreshes.
AST-aware chunking
Replace walker.chunk_file() with a tree-sitter-based splitter that cuts on
function / class boundaries for cleaner chunks and better citations.
Next steps¶
- Local RAG foundations — the simpler document RAG project this one builds on
- Text splitting strategies — compare character, token, sentence, and AST-based splitters
- Retrieval techniques compared — dense vs sparse vs hybrid retrieval, re-ranking
- RAG with Ollama + Chroma tutorial — step-by-step tutorial covering the same stack