Building a Code Search Engine From Cross-File Dependencies
10 min read · updated August 11, 2026
A dependency graph improves code search in three distinct ways, and they need different edges. Building the graph is the easy half; deciding which of the three you are doing is what determines whether it helps.
What the edges actually are
“Dependency graph” covers at least four different graphs over the same repository, at different granularities and with different reliability.
- Module imports. File A imports module B. Cheap, almost always extractable from the parse tree without any type information, and coarse — a file that imports a utility module tells you very little.
- Symbol references. Chunk A names symbol
parse_config, which is defined in chunk B. Much more informative, and requires resolving the name to a definition. - Calls. Function A invokes function B. A subset of symbol references and the one people usually mean; extracting it is its own procedure with its own limits.
- Package dependencies. Declared in a manifest —
package.json,go.mod,pyproject.toml. Exact, trivially available, and the right graph for questions about third-party code rather than your own.
For search ranking, symbol references are the useful ones and module imports are the affordable approximation. Start with imports, because you can extract them from every file in the repository in one parsing pass with no cross-file state, and add symbol resolution only for the languages where it pays.
Resolution is where it breaks
Extracting the import statement is a query against a syntax tree and takes an afternoon. Turning the string in that statement into a file path in your repository is the part that consumes weeks, because every language resolves differently and most resolve dynamically.
A Python from . import helpers depends on where the file sits in the package tree. A TypeScript import x from "@app/util" depends on the paths map in tsconfig.json, which may be extended from another config, and in a monorepo there are many. Go’s import path is stable and absolute, which makes Go unusually pleasant here. Java’s package declaration must match the directory but often does not in generated sources. Dynamic imports — importlib.import_module(name), require(variable) — are unresolvable in principle.
The practical guidance is to make resolution failure a first-class outcome rather than an exception. Record every unresolved import with its raw string and the file it came from. Two things fall out: you get a measurable resolution rate, which tells you whether the graph is trustworthy at all before you build ranking on it, and the unresolved list clusters by cause, so fixing the top three patterns usually moves the rate from something like 70% to above 90%. A graph silently missing a third of its edges is worse than no graph, because it will confidently rank the wrong files.
Three ways ranking uses the graph
A centrality prior. Compute an importance score per file from in-degree — how many files import it — and use it as a mild tie-breaker. This is the same intuition as PageRank over the web, and it works for the same reason: a module that 400 files import is more likely to be the answer to a general question than a leaf script. Keep the weight small. Applied strongly it buries every specific answer under the same six utility modules, which is the classic failure of centrality on a graph where in-degree measures convenience rather than relevance.
Neighbourhood context at embedding time. Rather than ranking with the graph, use it to write better chunks. Prepend to each chunk the names of the symbols it references and the module it belongs to. A function body that reads return self._client.post(url, json=payload) is nearly contentless on its own; the same chunk annotated with the fact that it lives in the billing client and calls the Stripe wrapper is retrievable. This is often the highest-value use of the graph and it costs nothing at query time — though note it widens what a commit invalidates, as the index architecture page sets out.
Seeded expansion at query time. The one that changes what is retrievable at all, below.
Seeded expansion, worked
Consider the query “why does the invoice PDF come out with the wrong currency symbol”. Dense retrieval over chunks returns render_invoice_pdf because the words match. The actual bug is three hops away in a locale helper that never mentions invoices, PDFs, or currency symbols, and no embedding of the query will ever reach it — not because the model is weak, but because the function’s text genuinely does not contain the query’s subject.
1. dense retrieve -> seeds = top 5 chunks by cosine 2. expand: neighbours(seed, depth<=2) over the reference graph 3. candidate set = seeds + neighbours (~60 chunks) 4. rescore the whole candidate set against the query 5. return top 10 the graph supplies recall the embedding cannot; the rescoring supplies the precision the graph cannot.
The parameter that matters is depth. At depth 1 the expansion is usually too small to reach the answer. At depth 3 in a repository with any central utility module, the neighbourhood is most of the repository and you have replaced search with rescoring everything. Depth 2 with a cap on the out-degree of any single node — ignore edges into modules imported by more than, say, 200 files — is the shape that behaves. That cap is doing the same job the centrality prior does, from the other direction.
What the graph cannot tell you
The graph is a statement about references, not about behaviour. It does not know that two modules implement the same interface, that a config value routes execution down one of two paths, or that a dependency exists only in tests. It cannot see anything reached through a plugin registry, a dependency-injection container, a message bus or an HTTP call — which in a service-oriented codebase is most of the interesting coupling. A graph that stops at the repository boundary will show you two services as unrelated when they are two ends of one workflow.
It also decays differently from the vector index. Edges are exact, so a stale edge is a wrong answer rather than a slightly worse ranking, and the graph must be rebuilt for any file whose imports changed — including files that did not change themselves, when a renamed module breaks their resolution. Build it from the same commit sha the embeddings were built from and store that sha with it. Two stores that disagree about which commit they describe produce results that are individually plausible and jointly wrong, which is far harder to debug than an outright failure.