The previous note ended on a failure. Ask a retrieval system how many deadlines are shorter than three months and it returns three confident, irrelevant paragraphs, because no single paragraph holds that answer. Counting, comparing, “what depends on this”, “which is the newest” — these arrive phrased like search questions and are not search questions.
The alternative is not a better embedding. It is to stop sampling by resemblance and write the structure down explicitly, so it can be walked.
What a graph of your own code looks like
Graphify is one implementation of this idea, aimed at codebases: it parses a project into nodes and edges — files, functions, classes, and the relations between them — and hands you a graph you can traverse. Notably, it uses no embeddings and no vector store at all. Code is read with tree-sitter, an ordinary parser, which makes the result deterministic rather than probabilistic: the same input produces the same graph, every time.
Rather than describe it, I ran it over the source of this website.
What the 712 edges say
graphify update (the local, parser-only path) over this site's source — 105 files, ~62,900 words, on one laptop. Bar length is proportional to edge count. Two thirds of the graph is contains and imports: the skeleton of which file holds what and which file needs which. The 46 calls edges are the ones that carry behaviour.Two point two seconds, and nothing left the machine — no API key, no tokens, no model. That is the practical difference between parsing and inference: a parser does not need to be asked nicely, and it does not have an opinion.
Every edge says where it came from
The part worth borrowing, whatever tool you use, is the bookkeeping. Each edge is
tagged with its own provenance: EXTRACTED when it is written in the source,
INFERRED when the tool worked it out, AMBIGUOUS when it could not decide.
On this corpus, 710 of 712 edges were EXTRACTED and 2 were INFERRED — 0.28% guessed. Both guesses are in one file, and both are worth looking at, because they show what a careful inference looks like.
src/lib/tools/redact.ts. Only the positions are arranged for legibility. The nine functions are shown inside the file that contains them — that relation is real too, drawn as containment instead of nine more arrows. mergeHits and redactText have no call edges to the rest, which is correct: redactText receives its input as a parameter rather than fetching it.Both inferred edges come from the same construction. findStructured does not call
validIban; it passes it to another function, as an argument:
scan(text, IBAN, 'IBAN', out, validIban);
The tool recorded this as indirect_call, with the context argument and a
confidence of 0.5 — not as a call. That is the correct reading: the function will
be invoked, but not by this one. Meanwhile the line below it, where validLuhn is
called inside an inline function, was recorded as a plain calls edge. Both
readings are right, and they are distinguished for the right reason.
I also checked the other direction, for edges that should exist and do not. In this neighbourhood there were none: the absences are real absences.
This is the property the previous article was missing. A similarity score of
0.657 cannot tell you whether it is a good answer or a confident mistake — it is
one number with no account of itself. An edge tagged INFERRED · 0.5 · argument
tells you precisely what it is and how far to trust it. When the machine is going
to be wrong sometimes, being able to see where it guessed is worth more than
being wrong slightly less often.
What it is good at
The questions that defeated retrieval are the ones a graph answers by walking.
Asked for the most connected nodes in this codebase, it returns Base.astro (28
edges), Workspace.astro (24), NoteCover.astro (20), useTranslations() (18),
site.config (17). That is an accurate account of this site’s architecture: the
layout every page wraps, the enormous tools component, the cover art, the
translation helper. Nobody wrote that list down; it fell out of the structure.
Reversing the same edges answers “what breaks if I change this” — a question with no meaningful embedding, and a mechanical answer in a graph.
The token argument holds up too, with a caveat about when. Asked something local — how the redaction tool decides what to hide — the traversal came back at about half the size of simply opening the file. Asked something that spans files — where that tool is wired into the workspace UI — it returned 12,468 characters where opening the three relevant files would have been 138,206, since one of them is a 127 KB component. About 11× on this corpus. The advantage is not a constant; it scales with how much you would otherwise have had to read to find out where to look.
What it is not
A graph of your code knows structure, not meaning. It told me findStructured
reaches validIban; it has no idea what an IBAN checksum is for, or whether
redacting one is legally required. For that you still read the source, or ask a
model that has read it. The graph’s job is to tell you which twenty lines to
read out of thirteen thousand.
It also has to be kept honest about its own limits. The generated report rounds its provenance summary to “100% EXTRACTED · 0% INFERRED” on the same line where it states that 2 edges were inferred — true to one decimal place, and exactly the sort of rounding that becomes a habit of mind if nobody is watching. And a traversal that exceeds its token budget truncates: on the cross-file query it warned that the answer might be among the nodes it had cut. It happened to have cut none. It said so either way, which is the right behaviour.
The pairing
Retrieval and traversal are not competitors, and choosing between them is not the interesting decision. They answer different questions:
- “What does the document say about X?” — embed it, retrieve by similarity, and take the hallucination guards from the earlier note seriously.
- “What is connected to X, and what breaks without it?” — build the graph, walk the edges, and never guess when the structure is written down.
The mistake worth avoiding is reaching for similarity search because it is the thing everyone reaches for, and then wondering why it cannot count. Some of what you want to know is already explicit in your data. It does not need to be approximated — it needs to be read.