Code Repository RAG: Semantic Search Your Codebase (2026)
The short answer
Semantic search over a codebase needs language-aware parsing instead of fixed chunking: Tree-sitter splits code into functions, classes, and modules preserving semantic boundaries. Voyage voyage-code embeddings score 15 to 20% higher on code retrieval than general-purpose embeddings. Qdrant stores the vectors; Claude Code queries them with repo-aware prompts. This stack turns a 100,000-line codebase into a queryable knowledge base in under an hour of indexing.
Grep finds exact strings. IDE search finds symbols. Neither handles “where do we validate email addresses” or “find the function that handles refund logic” when the code uses different naming or hides the logic in a middleware chain. Code RAG fills that gap: ask the codebase a question in plain English and get a specific file-and-line answer.
This guide builds a code repository RAG system in Python. It covers parsing with Tree-sitter, chunking on function and class boundaries, embedding with voyage-code-2, retrieval that respects the repo structure, and the agentic layer that answers questions by reading code the way a senior engineer would.
Why code RAG is different from text RAG
Three structural differences change the whole pipeline:
- Code has strict syntactic boundaries. A function, class, or module is a semantic unit. Chunking mid-function destroys meaning in a way chunking mid-paragraph does not.
- Code references other code. A function calls others, imports others, inherits from others. Isolated chunks lose this graph structure.
- Natural language queries vs code tokens. “Where do we handle authentication” needs to match code where
authenticate_userorcheck_credentialsorverify_tokenlive. Standard embeddings struggle with this cross-domain gap.
Code-tuned embedding models and function-boundary chunking address the first two. For the third, agentic retrieval (where the model can decide what to read next) beats static top-k retrieval by a wide margin.
Parse with Tree-sitter
Tree-sitter is the standard syntactic parser for 60+ languages. The py-tree-sitter package gives Python access to the parse tree, so you can extract functions, classes, and imports without regex gymnastics.
from tree_sitter_languages import get_parser
py_parser = get_parser("python")
def extract_python_definitions(source_code, file_path):
tree = py_parser.parse(bytes(source_code, "utf-8"))
root = tree.root_node
results = []
def walk(node, class_context=None):
if node.type == "function_definition":
name_node = node.child_by_field_name("name")
name = source_code[name_node.start_byte:name_node.end_byte]
text = source_code[node.start_byte:node.end_byte]
results.append({
"kind": "function",
"name": name,
"class_context": class_context,
"text": text,
"start_line": node.start_point[0] + 1,
"end_line": node.end_point[0] + 1,
"file_path": file_path,
})
elif node.type == "class_definition":
name_node = node.child_by_field_name("name")
class_name = source_code[name_node.start_byte:name_node.end_byte]
text = source_code[node.start_byte:node.end_byte]
results.append({
"kind": "class",
"name": class_name,
"text": text,
"start_line": node.start_point[0] + 1,
"end_line": node.end_point[0] + 1,
"file_path": file_path,
})
for child in node.children:
walk(child, class_context=class_name)
return
for child in node.children:
walk(child, class_context=class_context)
walk(root)
return resultsThe same pattern works for JavaScript, TypeScript, Go, Rust, Java, and anything else with a Tree-sitter grammar. Each extracted definition has a name, a kind, start and end line, and the full source text. These are your chunks.
Enrich chunks with surrounding context
A raw function body loses meaning. Add three pieces of context before indexing:
- Imports. Prepend the file’s import block so the chunk knows what dependencies are in scope.
- Class signature. For methods, prepend the class declaration (one line) so the method knows what it belongs to.
- Docstring and nearby comments. Already part of the function text in most languages; preserve them.
def enrich_chunk(definition, imports, file_path):
header = f"# File: {file_path}\n"
if imports:
header += f"# Imports: {imports[:300]}\n"
if definition.get("class_context"):
header += f"# Inside class: {definition['class_context']}\n"
return header + "\n" + definition["text"]The header is cheap context the embedding sees. Retrieval improves because similar functions in different files now share the “File:” and “Class:” tokens, and the model that reads retrieved chunks does not have to infer the containing class from indentation.
Embed with voyage-code-2
Voyage voyage-code-2 is trained on code plus natural-language queries about code. It outperforms general-purpose embeddings by 5-12% on code retrieval tasks. For code RAG, it is the default pick.
import voyageai
vo = voyageai.Client()
def embed_code(text, is_query=False):
result = vo.embed(
[text],
model="voyage-code-2",
input_type="query" if is_query else "document",
)
return result.embeddings[0]Alternative options: Jina embeddings v2 code, bge-code-v1, nomic-embed-text-v1.5. All competitive. If data must stay local, bge-code-v1 runs cleanly on a modern GPU.
Index with repo-aware metadata
Attach language, file path, directory, kind (function/class/module), and names referenced in the function to every chunk.
def index_definition(d, imports, file_path, vector_store):
text = enrich_chunk(d, imports, file_path)
emb = embed_code(text)
metadata = {
"repo": "my-app",
"file_path": file_path,
"directory": "/".join(file_path.split("/")[:-1]),
"language": "python",
"kind": d["kind"],
"name": d["name"],
"class_context": d.get("class_context"),
"start_line": d["start_line"],
"end_line": d["end_line"],
"text": text,
}
vector_store.upsert(
vectors=[{"id": f"{file_path}:{d['start_line']}", "values": emb, "metadata": metadata}]
)The directory field is especially useful. A user asking “how does the frontend handle auth” can filter to directory LIKE 'frontend/%' before retrieval, which cuts noise and improves top-k precision.
Agentic retrieval: let the model walk the code
Static top-k retrieval misses one big use case: a function that references another function the retriever did not surface. Agentic retrieval gives the model tools to navigate the code graph.
tools = [
{
"name": "search_code",
"description": "Semantic search across the code repository. Returns relevant functions and classes.",
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string"},
"directory_filter": {"type": "string"},
},
"required": ["query"],
},
},
{
"name": "read_file",
"description": "Read a specific file to see its full content.",
"input_schema": {
"type": "object",
"properties": {"file_path": {"type": "string"}},
"required": ["file_path"],
},
},
{
"name": "find_callers",
"description": "Find functions that call a given function by name.",
"input_schema": {
"type": "object",
"properties": {"function_name": {"type": "string"}},
"required": ["function_name"],
},
},
]
def agentic_code_qa(question):
messages = [{"role": "user", "content": question}]
while True:
response = claude.messages.create(
model="claude-opus-5-5",
max_tokens=1024,
tools=tools,
system="You answer questions about a code repository by using the provided tools to inspect code. Cite file paths and line numbers for every claim.",
messages=messages,
)
if response.stop_reason == "end_turn":
return response.content[0].text
for block in response.content:
if block.type == "tool_use":
result = handle_tool(block.name, block.input)
messages.append({"role": "assistant", "content": response.content})
messages.append({
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": block.id, "content": result}],
})
breakThe agent starts with the user’s question, calls search_code to find candidate functions, calls read_file on promising matches, calls find_callers to trace usage. Three to five tool calls usually resolve a question that top-k retrieval alone would botch.
Keep the index fresh: hook into git
Code changes constantly. Rebuilding the whole index on every commit is wasteful. Incremental updates driven by git are the right pattern.
import subprocess
def changed_files_since(ref):
out = subprocess.check_output(
["git", "diff", "--name-only", ref, "HEAD"]
).decode()
return [p for p in out.splitlines() if p.endswith(".py")]
def incremental_update(last_indexed_ref):
changed = changed_files_since(last_indexed_ref)
for path in changed:
# Delete old chunks for this file
vector_store.delete(filter={"file_path": path})
# Re-index if the file still exists
if Path(path).exists():
source = Path(path).read_text()
defs = extract_python_definitions(source, path)
imports = extract_imports(source)
for d in defs:
index_definition(d, imports, path, vector_store)Hook this into a post-receive git hook or a CI step on main-branch pushes. The index stays within minutes of the current state of the codebase without burning compute on unchanged files.
Query interface patterns
Code RAG answers four flavors of question well:
- Where does X happen? “Where does the system validate email addresses?” Retrieval finds the function.
- How does X work? “How does our rate limiter work?” Retrieval returns the function; the model summarizes.
- Where is X called from? “Who calls process_refund?” Needs the
find_callerstool. - Comparative questions. “What’s the difference between validate_input and sanitize_input?” Retrieval returns both; the model compares.
Weak at: generative tasks (“Write a new auth middleware”). Code RAG surfaces context; LLM-based code generation is a related but different system.
Scaling considerations
A typical mid-size codebase has 50,000-500,000 function definitions. That is a 1-5M vector index. Manageable on any of the major vector databases.
Three real cost drivers:
- Initial embed: at voyage-code-2 pricing, a 500K-chunk repo costs around $50-200 to embed once.
- Incremental embed: a commit changing 50 files touches maybe 200 definitions. Pennies.
- Query cost: embedding the query (cheap) plus a Claude generation (dominant). Agentic retrieval multiplies this by the number of tool calls, so 3-5x the single-shot cost.
For a team of 50 engineers using the system actively, expect $500-2,000/mo in total. For teams working on a single large monorepo, closer to the top of that range.
Frequently asked questions
Official documentation
How do I chunk source code for RAG?
Chunk by function or class boundary, not by fixed token size. Use Tree-sitter or a language-specific parser to extract definitions. Enrich each chunk with the file path, import block, and class context header. Mid-function cuts destroy meaning and ruin retrieval.
What embedding model is best for code?
Voyage voyage-code-2 is the top-performing code embedding model in 2026, trained specifically on code plus natural-language queries. For self-hosted, bge-code-v1 is the strongest open-source option. General-purpose embeddings work but trail by 5-12% on code retrieval.
Should I use agentic retrieval for code RAG?
For non-trivial code questions, yes. Top-k retrieval misses functions that reference each other. Agentic retrieval, where the model has tools to search, read files, and find callers, handles multi-hop questions correctly. The cost is 3-5x the per-query cost but the quality gap on real engineering questions is large.
How do I keep a code RAG index fresh?
Hook into git. On every push to main, run an incremental update that deletes chunks for changed files and re-indexes them. A commit touching 50 files triggers re-embedding of maybe 200 chunks, which is a few cents. The index stays within minutes of the current codebase.
Can code RAG replace an IDE’s symbol search?
No, it complements it. IDE symbol search is exact and instant for “find definition of foo”. Code RAG shines on natural-language questions where the user does not know the exact name of what they are looking for. Teams use both: IDE for known symbols, RAG for exploratory questions and onboarding.
Does code RAG work on monorepos?
Yes, and it is often most useful there. The directory metadata lets users scope queries to specific projects inside the monorepo. The repo or service metadata field makes it easy to filter to a single service in a microservices architecture. Scale-wise, a monorepo with a million functions is still comfortably within what any major vector DB handles.
Code RAG pays off big on exploratory questions, onboarding, and architecture inspection. Build on Tree-sitter, embed with voyage-code-2, add an agentic layer for multi-hop queries. The incremental update loop driven by git keeps the whole thing self-maintaining.
