
This blog is written by AI for SEO
Code Knowledge Graph for Coding Agents: Build One
Ask a coding agent to change how charge_card handles retries and watch what it does. It greps for the name, pulls the ten embedding chunks that look most like "retry logic", and edits the wrong file. It never learned that charge_card is called from three checkout handlers and one nightly job, or that the retry helper it found is a different function that happens to share a vocabulary.
Grep returns lines. Top-k embeddings return similar-looking chunks. Neither knows who calls what. The fix is to model the repository as a graph first (files, functions, classes, imports, call edges) and then run semantic search inside the part of the graph that matters for the task.
This guide builds that graph in seven steps. The engineering works on any stack: tree-sitter for parsing, any embedding model, any store. HelixDB comes in at the end, where it removes the separate vector index and graph store, so callers, callees and semantic recall come from one place. You need a repo in a language tree-sitter supports (Python and TypeScript are easiest), an embedding model you can call, and Node or Python for the scripts. Examples below use TypeScript.
Step 1: Decide the schema: files, symbols and typed edges
Write the schema down before you parse anything. Most failed code graphs are failed schemas. Everything becomes a generic "entity" with a generic "related_to" edge, and the agent can't ask a precise question.
Start with three node labels. File (path, language, content hash). Symbol (name, qualified name, kind such as function, method or class, start and end line, signature, body text, file path). Module (an import target that may or may not resolve to a file in the repo, such as requests or node:fs).
Then type your edges, because the type is what makes traversal useful. DEFINES goes from File to Symbol. CONTAINS goes from a class Symbol to its method Symbols. IMPORTS goes from File to File or File to Module. CALLS goes from Symbol to Symbol. INHERITS goes from class to class.
Give CALLS and IMPORTS a confidence property from day one. Static call resolution in dynamic languages is a guess some of the time, and an edge that says "I am 0.4 sure" is far more useful to an agent than an edge that silently lies. You will fill it in at Step 3.
Put a stable ID on every symbol. Use path::qualified_name, for example billing/charge.py::charge_card. Don't use a line number. Line numbers change on every edit, and your re-indexing job in Step 7 depends on IDs surviving unrelated changes.
Make one decision now: the embedding vector is a property of the Symbol node, not a separate table. Keeping the vector next to the symbol means similarity search and graph traversal address the same object. That's the whole point of the exercise.
Step 2: Parse the repo with tree-sitter into symbols, imports and call sites
Tree-sitter gives you a concrete syntax tree per file, fast and tolerant of broken code, with grammars for most languages. That tolerance matters because an agent often indexes a working tree mid-edit.
Walk the repo, skip vendored directories and generated code (node_modules, dist, .venv, lockfiles), and parse each file. Then run tree-sitter queries to pull three things.
First, definitions. For Python, a query on function_definition and class_definition nodes gives you name, parameters, byte range and the enclosing class. Slice the source by byte range to get the body text you will embed later.
Second, imports. Capture import_statement and import_from_statement (or import_declaration in TypeScript) and record the raw module string plus the imported names and any aliases. Don't resolve anything yet. Just record what the file says.
Third, call sites. Capture call expressions and record the callee text exactly as written (charge_card, self.retry, stripe.Charge.create) along with the enclosing function, found by walking up parents until you hit a definition node. A call at module level belongs to a synthetic <module> symbol for that file.
Emit all of this as plain records, not graph writes: a list of definitions, a list of raw imports, a list of unresolved call sites. Keep parse output dumb and separate from resolution on purpose. Parsing is embarrassingly parallel and cacheable per file hash, while resolution needs the whole repo in view. Mixing them makes both harder to debug.
Parse in a worker pool and key the cache on the file's content hash. On a large repo the first run is the expensive one, and Step 7 reuses that cache.
Step 3: Resolve calls and imports into edges, and mark uncertain ones
This step decides whether your graph is useful or merely decorative. You have raw callee strings. Now turn them into edges to specific symbols.
Resolve imports first, because calls depend on them. For each raw import, try to map the module string to a file in the repo: relative paths, package __init__ files, TypeScript path aliases from tsconfig.json, index files. If it maps, emit File -IMPORTS-> File. If it doesn't, emit File -IMPORTS-> Module and move on. External modules are still worth recording, since "which files touch stripe" is a good question.
Then resolve each call site using a ladder, best match first, and write the rung you used into confidence:
Same-file definition with an exact name match: confidence 0.95.
Imported name that resolves through your import map to a symbol in another file: 0.9.
Method call on
selforthis: look up the enclosing class and its bases: 0.8.Attribute call on a known imported module (
billing.charge_card(...)): 0.8.Name-only match across the repo, when exactly one symbol has that name: 0.5.
Name-only match with several candidates: emit an edge to each, confidence
1/n, and setambiguous: true.
Don't drop rung 6 and don't pick a winner at random. A coding agent can reason over "three possible callees, here they are" and verify by reading. It can't recover an edge you threw away or trust one you guessed.
Dynamic dispatch, decorators, reflection and dependency injection will defeat a purely syntactic resolver. Accept that. If you need precision on a hot language, run a real language server or SCIP indexer and let it overwrite your low-confidence edges, but keep the tree-sitter pass as the fallback that works everywhere. The agent's retrieval policy in Step 6 filters on confidence, so imperfect edges are survivable as long as they're labelled.
Step 4: Embed function bodies and attach the vectors to symbol nodes
Now give each symbol a semantic fingerprint. What matters here is the text you embed, not which model you use.
Don't embed the raw body alone. A function called process with a ten-line body embeds as noise. Build a small document per symbol: the qualified name, the signature, the docstring or leading comment, the enclosing class name, and the body, truncated to your model's input limit. Prefixing the path (billing/charge.py) gives the model a free hint about domain.
Embed at symbol granularity, not file granularity and not arbitrary 500-token windows. This is the core difference from the chunk-and-retrieve approach. A chunk boundary can cut a function in half or glue two unrelated functions together. A symbol is a unit a developer would actually name, and it's the unit your graph already has nodes for. Very long functions can be split into a summary document plus the body, but keep one vector per Symbol.
Batch the calls, cache by content hash (the same hash you used in Step 2), and store the model name and version alongside the vector. You will change models eventually, and when you do you want to know which nodes are stale. The guide on re-embedding agent memory after you change models covers that migration.
Pick one distance metric and commit to it. Cosine is the default for most text embedding models. The vector is a property on the Symbol node, so after this step each node carries its structure (edges), its text (body) and its meaning (vector). That co-location lets one query ask a question that mixes all three.
Step 5: Load the graph into HelixDB (embedded client, or helix init local and helix start dev)
You can load this into any pair of stores: a graph database for edges and a vector index for symbols, with an ID mapping between them and a sync job to keep them consistent. That works. It's also the two-database tax. Every re-index writes twice, every query joins across a network boundary, and the two stores drift. HelixDB (Apache-2.0, built in Rust) holds the graph, the vectors and BM25 text indexes in one engine, so this step is a single load.
The fastest path is the embedded client, which needs no CLI or server:
import { Client, g, writeBatch } from "@helix-db/helix-db";
const db = await Client.embedded({
kind: "disk",
root: "./code-graph",
database: "repo",
});If you'd rather run a server, the repo README's local setup is helix init local followed by helix start dev, which needs Docker or Podman and serves on port 6969. Then connect with Client.server(url).withApiKey(...). Same SDK, same queries, either way.
Queries are built with writeBatch() and g() and only run when you call .send(). Write nodes in batches per file: the File node, its Symbol nodes with the vector as a property, and the DEFINES, CONTAINS, IMPORTS and CALLS edges with their confidence values. Writes commit or roll back atomically across graph and vector data, so a half-indexed file can't leave a symbol with edges but no vector.
Create a vector index on the Symbol embedding property and a BM25 text index on name and signature (you will use it in Step 7). Check the HelixDB docs for the exact index-creation calls in your SDK version, and confirm every call against the published @helix-db/helix-db types before you commit your loader. In embedded mode, close the writer when you finish so data persists to disk.
Step 6: Run one query: vector similarity scoped to the call graph
This is the payoff. The agent has a task: "change how retries work when charging a card." The embedding of that sentence is a good starting point and a bad finish. Run it against the whole repo and you get every retry helper in the codebase. Run it against the neighbourhood of the code being changed and you get the right one.
The pattern is three moves in one query:
Anchor. Resolve the symbol the task is about, by ID or by name, for example
billing/charge.py::charge_card.Expand. Traverse
CALLSedges from the anchor, outward for callees and inward for callers, one to three hops, filtering edges toconfidence >= 0.8(or including lower-confidence edges flagged as uncertain when you want recall over precision).Rank. Run vector similarity on the task embedding, restricted to that candidate set.
The third move is vector pre-filtering. HelixDB can scope vector search to things related to each other, so similarity runs over the graph-filtered candidates instead of the full index. That's different from searching everything and filtering the results afterwards, where the entities you care about may never make the top-k at all. A traversal-scoped search applies the 800-result ceiling after candidate intersection, and a candidate stream above 1,000,000 unique entities is a query error, so keep your hop count sane on huge monorepos.
Build the traversal with the g() builder from @helix-db/helix-db, run it with .send(), and return the symbol IDs, names, file paths, edge confidence and body text. The agent gets back not just "similar code" but "similar code that is one hop from the function you are editing, reached via a 0.9-confidence call edge." That context lets it explain a change, find the callers it might break, and stop editing a lookalike in an unrelated module.
For review bots, run the same query in reverse: start from every symbol in the diff, expand inward to callers, and rank by similarity to the changed behaviour.
Step 7: Re-index changed files and add BM25 for exact identifiers
A code graph that goes stale is worse than no graph, because the agent trusts it. Make re-indexing incremental from the start.
On each change (a file watcher, a git hook, or a diff against the last indexed commit), take the changed paths and do this per file. Compare the new content hash with the stored one and skip if equal. Delete the file's Symbol nodes and every edge that has one of them as an endpoint, in one write batch. Re-parse, re-embed only symbols whose body hash changed, and rewrite the nodes and edges. Because your symbol IDs are path::qualified_name, unchanged symbols in unchanged files keep their identity.
The subtle part is inbound edges. If checkout.py calls charge_card and you re-index charge.py, the edge from checkout.py pointed at a node you just deleted. Either re-resolve call sites in files that import the changed file (the import edges tell you exactly which ones), or keep symbol nodes and only update their properties when the ID still exists. The first is simpler and correct. Renames and moves are the same problem with a changed ID: treat them as delete plus add, and re-resolve dependents.
Then add keyword search. Embeddings are bad at exact identifiers. A query for ERR_RETRY_EXHAUSTED or chargeCardV2 wants a literal match, not a nearby concept. HelixDB runs BM25 full-text search in the same engine, so index symbol names, signatures and docstrings and run keyword and vector retrieval in one scope. The pattern is covered in how to combine BM25 and vector search in one query. A good default: BM25 when the task contains something that looks like an identifier, vector similarity for natural-language intent, and graph scoping for both.
What to do next: wire it to your agent and troubleshoot bad edges
Expose the graph to your agent as a handful of narrow tools rather than one generic "search code" tool: find_symbol(name), callers(symbol_id, depth), callees(symbol_id, depth), and search_in_neighborhood(symbol_id, query, hops). Narrow tools produce predictable tool calls and short, checkable results. If your agent speaks MCP, the HelixDB MCP server at https://mcp.helix-db.com/mcp lets it deploy, query and manage instances directly.
When results look wrong, debug the edges before the embeddings. In practice the usual suspects are these:
A symbol has no callers. Check the import map first. A path alias or a re-exported name in an
index.tsis the most common cause.A symbol has too many callers. You are probably hitting rung 6 of the resolution ladder, with common names like
get,runorhandlefanning out. Filter byambiguousor raise your confidence threshold.Callers are missing after a refactor. That's the inbound-edge problem from Step 7. Re-resolve dependents of every changed file.
Similarity returns the right neighbourhood but the wrong function. Improve the embedded document: add the docstring, the class name and the path. Don't reach for a bigger model first.
The query is slow or rejects. You are expanding too many hops. Cut to two, or require higher-confidence edges.
Build a small eval from your own history: take twenty real past tasks, record which functions the human actually changed, and measure whether the scoped query surfaces them versus plain top-k. That number tells you whether the graph is earning its keep.
Conclusion
Stop asking your coding agent to infer structure from text similarity. The structure is in the repo, and tree-sitter will hand it to you for free. Build the symbol graph, label the uncertain edges, and scope semantic search to the call graph around the code being changed.
If you'd rather not run a vector index, a graph store and a sync job to do it, load the same graph into HelixDB. One engine holds the call edges, the symbol vectors and the BM25 index, and a single query combines them. Start with the embedded client on one repo you know well, run the Step 6 query against twenty real tasks, and compare it to your current top-k. Then star HelixDB on GitHub and tell us what your call graph looked like.