Building a Codebase Q&A Pipeline With a PViz Bundle
How to apply retrieval-augmented generation to a PViz dependency bundle for open-ended codebase questions. Covers chunking strategy, embedding, retrieval quality, and generation prompt design.
The workflows in the earlier posts in this series all share a common assumption: you have a specific module in mind as a starting point. You know what you are changing, or you know roughly where to look. The dependency graph gives you structure around that starting point.
But sometimes you do not have a starting point.
You are new to a codebase, or you have inherited a system, and your question is open-ended: "How does authentication work here?" or "Where does error handling happen, and what are the failure modes?" or "What is the architectural boundary between the API layer and the data layer?"
For those questions, graph traversal from a known node is not the right tool. You need a way to find the relevant part of the graph without already knowing where it is.
This post describes how to build that: a retrieval-augmented generation pipeline over a PViz bundle that can answer open-ended structural questions about a codebase.
The architecture
The pattern is retrieval-augmented generation applied to a dependency bundle. The pipeline has four stages:
- Chunk the bundle into semantically coherent pieces
- Embed each chunk
- Retrieve the chunks most relevant to the query
- Generate an answer grounded in the retrieved chunks
None of this is novel — it is standard RAG. What is specific to a dependency bundle is the chunking strategy, which is where most codebase Q&A pipelines go wrong.
Why file-level chunking fails
The naive approach is to chunk by source file: one chunk per file, containing the file's content or a summary of it.
This works poorly for structural Q&A for two reasons.
First, individual files are often too small to carry semantic meaning on their own. A utility module that exports three helper functions does not tell you much unless you know what depends on it and what it depends on.
Second, the semantic unit you care about when asking structural questions is usually a cluster of related modules, not an individual file. Authentication does not live in one file. It lives in a middleware file, an auth service, a token validator, a session manager, and several configuration modules that wire them together. A question about authentication needs to surface that cluster, not any single file within it.
The right chunking unit: the local subgraph
A dependency bundle gives you a better chunking unit: the local subgraph around each node.
Each chunk should represent a module plus its direct dependencies and direct dependents — the first-hop neighborhood of that node in the dependency graph.
Here is why this works: a module's meaning is partly defined by its relationships. scrapy/core/engine.py means something different in isolation than it does alongside the knowledge that it is imported by scrapy/crawler.py and imports from scrapy/core/downloader.py and scrapy/core/scheduler.py. The local subgraph carries that relational context into the embedding.
When a question about scheduling comes in, the embedding for the engine chunk — which includes its relationship to the scheduler — is more likely to surface as a relevant result than the embedding for the engine file content alone.
Building the chunks
The chunk builder walks every node in the bundle, pulls its immediate imports and importers, and serializes that neighborhood into a text representation for embedding.
def build_chunks(bundle):
chunks = []
# Index nodes by ID for fast lookup
node_map = {node["id"]: node for node in bundle["nodes"]}
# Build reverse edge index: for each node, which nodes import it?
importers = {}
for edge in bundle["edges"]:
target = edge["dst"] # dst = the module being imported
if target not in importers:
importers[target] = []
importers[target].append(edge["src"])
for node in bundle["nodes"]:
node_id = node["id"]
# Direct imports: what this module depends on
direct_imports = [
node_map[dep]
for dep in node.get("imports", [])
if dep in node_map
]
# Direct importers: what depends on this module
direct_importers = [
node_map[imp]
for imp in importers.get(node_id, [])
if imp in node_map
]
# Serialize exports as a readable list
exports = node.get("exports", [])
export_text = ", ".join(exports) if exports else "none listed"
# Build the chunk text
import_lines = "\n".join(
f" - {n['id']} (exports: {', '.join(n.get('exports', [])) or 'none listed'})"
for n in direct_imports
) or " none"
importer_lines = "\n".join(
f" - {n['id']}"
for n in direct_importers
) or " none"
chunk_text = f"""Module: {node_id}
Exports: {export_text}
Transitive dependents: {node.get("importers_count", 0)}
SCC size: {node.get("scc_size", 1)}
Imports from:
{import_lines}
Imported by:
{importer_lines}
"""
chunks.append({
"id": node_id,
"text": chunk_text,
"node": node,
})
return chunks
A few notes on the field names used here. PViz bundles use src and dst on edges, where src is the importing module and dst is the module being imported — the direction is dependency, not data flow. The imports field on a node lists the IDs of modules that node depends on. importers_count and scc_size are structural metrics available in standard PViz output. If your bundle schema differs, adjust the field names accordingly; the structure of the chunk text is what matters, not the exact key names.
The scc_size field is worth including in the chunk text even though it will not affect most queries. For questions about cyclic dependencies or tightly coupled clusters, it gives the retrieval step a signal it would otherwise miss entirely.
Embedding and indexing
Once you have chunks, embedding and indexing are straightforward. The text representation of each chunk is embedded using your model of choice and stored with the chunk ID as metadata.
# Illustrative — substitute your embedding client and vector store
def index_chunks(chunks, embedding_client, vector_store):
for chunk in chunks:
vector = embedding_client.embed(chunk["text"])
vector_store.upsert(
id=chunk["id"],
vector=vector,
metadata={
"node_id": chunk["id"],
"text": chunk["text"],
},
)
def retrieve(query, embedding_client, vector_store, top_k=5):
query_vector = embedding_client.embed(query)
return vector_store.query(query_vector, top_k=top_k)
Infrastructure choice does not matter much at codebase scale. A few hundred to a few thousand nodes is a trivially small dataset for any vector store. An in-memory solution like FAISS works fine. A managed service like Pinecone or Weaviate works fine. The retrieval quality difference between them at this scale is negligible — do not over-engineer the storage layer.
What does matter is the embedding model. Models with stronger code and technical vocabulary understanding will produce better chunk embeddings for structural questions. OpenAI's text-embedding-3-small and text-embedding-3-large both work well. If you are running locally, nomic-embed-text is a reasonable option.
Improving retrieval quality
Embedding similarity is the weakest link in this pipeline. If the wrong chunks come back, the answer will be wrong regardless of how well the generation step is written. Two techniques improve retrieval quality without adding much complexity.
Always include high-centrality nodes
Modules with high importer counts tend to be architecturally significant. A question about almost any system behavior is likely to involve at least one of these central nodes — a core service, a shared utility, a framework integration point, or a primary data model.
A cheap way to improve coverage is to always include your top two or three highest-centrality chunks as fixed context, regardless of retrieval scores. These nodes appear in enough dependency paths that they are almost always relevant to structural questions, and their presence in the context helps the generation step connect retrieved chunks to the broader system picture.
def get_top_centrality_chunks(chunks, n=3):
return sorted(
chunks,
key=lambda c: c["node"].get("importers_count", 0),
reverse=True,
)[:n]
Pre-filter by subdirectory
For large codebases, embedding-based retrieval alone can surface chunks from unrelated parts of the codebase that happen to share vocabulary with the query. A question about authentication might return chunks from a logging module that happens to mention tokens, or from a test fixture that imports an auth helper for unrelated reasons.
A cheap pre-filter is to ask a fast model to identify which top-level subdirectories are likely relevant to the query before running the vector search, then restrict retrieval to chunks from those directories.
def identify_relevant_directories(query, folder_index, fast_model):
prompt = f"""Given this question about a codebase:
"{query}"
And this folder structure:
{folder_index}
Which top-level directories are most likely to contain relevant code?
Return a JSON array of directory prefixes, e.g. ["src/auth", "src/middleware"].
Return only the JSON array."""
response = fast_model.complete(prompt)
return parse_json_array(response)
def retrieve_filtered(query, relevant_dirs, chunks, embedding_client, vector_store, top_k=5):
eligible_ids = {
chunk["id"]
for chunk in chunks
if any(chunk["id"].startswith(d) for d in relevant_dirs)
}
query_vector = embedding_client.embed(query)
return vector_store.query(query_vector, top_k=top_k, filter={"node_id": {"$in": list(eligible_ids)}})
The folder index from the PViz bundle is well-suited to this step — it gives the model a lightweight structural map without requiring it to read the full bundle to orient itself.
The two techniques compound. Fixed high-centrality chunks ensure that architecturally significant nodes are always present. Directory pre-filtering reduces noise in the variable retrieval slots. Together they make the retrieved context more consistently useful than either approach alone.
Generating the answer
Once you have your retrieved chunks — fixed high-centrality chunks plus the top-k retrieved results — assemble them into a generation prompt:
You are a software engineer helping answer questions about a codebase.
You will receive a set of dependency graph chunks. Each chunk describes a module,
what it exports, what it imports, and what imports it.
Use these chunks to answer the question. Be specific. Cite module IDs exactly as they
appear in the bundle. If the chunks do not contain enough information to answer the
question with confidence, say so rather than speculating.
<retrieved_chunks>
[assembled chunk texts]
</retrieved_chunks>
Question: [user question]
The citation instruction is important. Without it, the model will synthesize a plausible-sounding answer that is hard to verify. With it, you get answers like:
Authentication is handled primarily in
app/auth/middleware.py, which exportsAuthMiddlewareandrequire_auth. It is imported byapp/routing/router.py, which applies it at the routing layer. The token validation logic lives inapp/auth/tokens.py, imported by the middleware. Session state is managed inapp/auth/session.py, which is imported by both the middleware andapp/api/handlers.py.
That answer is specific, traceable, and checkable. A developer can open those files directly and verify whether the description matches the code.
The instruction to say "I do not know" when the chunks are insufficient is equally important. A Q&A pipeline that invents answers to questions outside its retrieved context is worse than useless for structural investigation. The model should decline confidently when the evidence is not there.
What this pipeline can and cannot answer
This pipeline answers structural questions well. Questions about what imports what, which modules are central to a given subsystem, how a feature area is decomposed across files, what the dependency footprint of a given module is, and which parts of the codebase are architecturally coupled — all of these are well-suited to this approach.
It answers behavioral questions partially. If a question requires understanding what a function does at the implementation level — its branching logic, its error handling, its exact output — the chunks will not contain that. They describe structure, not behavior. The pipeline can identify where behavior lives, but not what that behavior is without source retrieval.
For questions that need both, the right pattern is to use this pipeline to locate the relevant modules, then hand those module IDs to an agent with source access for the behavioral part of the question. The RAG pipeline narrows the search space; the agent with source access fills in the behavioral detail.
That combination — structural retrieval to locate, source access to verify — is the same principle that runs through the change impact analysis pattern. The dependency graph is a routing layer, not a replacement for source truth.
When to use this pattern versus graph traversal
The two patterns cover different question types.
Graph traversal from a known node is the right choice when you have a specific starting point: a module you are changing, a function you are tracing, a file you are investigating. The traversal gives you the structural neighborhood of that node with precision.
RAG over the bundle is the right choice when you do not have a starting point: a subsystem you want to understand, a feature area you want to locate, a cross-cutting concern you want to map. The retrieval gives you a way into the graph without already knowing where to enter.
In practice, the two patterns compose. An open-ended Q&A question can identify the right starting node for a targeted graph traversal. A targeted traversal can surface questions that are better answered by a broader retrieval pass. Neither pattern is a complete solution on its own; they are complementary tools over the same underlying data.
Keeping the index current
A vector index built from a bundle is only as current as the bundle it came from.
For a repository under active development, modules are added, renamed, split, and removed regularly. An index built weeks ago will miss new modules entirely, retain stale chunks for modules that no longer exist, and produce answers that describe an earlier state of the codebase.
The right cadence depends on how quickly the repository changes and how much accuracy matters for your use case. A reasonable baseline is to regenerate the bundle and rebuild the index on each merge to the default branch. That keeps the index within one merge of current, which is accurate enough for most structural Q&A purposes.
If regeneration cost is a concern, a lighter approach is to regenerate only the chunks for files that changed since the last bundle run, update those vectors in place, and leave unchanged chunks as-is. This requires the bundle to support incremental output, but avoids a full re-index on every merge.
The important thing is to avoid treating the index as a one-time artifact. A stale structural index produces confidently wrong answers, which is worse than no index at all.
What comes next
This post covered how to answer open-ended structural questions about a codebase using RAG over a dependency bundle.
The final post in this series brings everything together: using the full bundle to bootstrap an agent's working knowledge of an unfamiliar repository before it starts making changes. That is the onboarding problem — approached structurally rather than through comprehensive source reading.
Try PViz on your own codebase
Get dependency graphs, coupling signals, and a compressed bundle ready for your LLM — for any GitHub repository, in minutes.