How Local Code Knowledge Graphs Work — Inside the Microsecond BEAM Engine

Most code search tools built for LLMs do the same thing: slice files into 500-token chunks, embed them, and stuff the results into a vector database. It works okay for "find me something about authentication." It falls apart completely when you need to know who calls this function.

We built Synapse MCP because we got tired of watching AI agents burn tokens grepping through files they'd already read. The approach is straightforward: parse the AST, build a graph of every function, every call, every import, store it in ETS, and let agents query it in microseconds. We wrote it in Elixir. This is how it actually works.

Why not just use vector search?

Vector embeddings are good at finding code that looks similar. Search for "authentication" and vectors will find the auth module. But agents don't need semantic similarity — they need exact structural dependencies. They need to know that Accounts.User.get_by_id/1 is called from AuthController, PaymentWorker, and AdminAPI, including the one that imports it unqualified.

Text search misses that last one every time. grep "Accounts.User.get_by_id" won't find import Accounts.User; get_by_id(1). The agent thinks it found all the callers, edits two files, declares success, and leaves three broken. We call this silent breakage, and it's the single biggest reason AI-assisted refactors go wrong.

There's also the latency problem. Cloud-based code intelligence tools send your query to a remote vector database, wait 800ms–2,500ms for a response, and send it back. That's per turn. An agent doing 9 lookups burns 20+ seconds just waiting for network.

ETS, not a database

The first design decision was: the graph lives in memory, in ETS, not in a database. We have 12 named tables:

ETS Table Type What's in it
:synapse_chunkssetchunk_id → chunk struct
:synapse_indexbag{repo_id, file_path} → chunk_ids
:synapse_symbolssetsymbol → chunk_id
:synapse_edgesbagfrom_chunk_id → edge
:synapse_edge_targetsbagto_chunk_id → edge
:synapse_behaviour_factssetbehaviour/module facts
:synapse_file_indexsetfile_path → metadata
:synapse_worktree_deltassetgit worktree delta overlays
:synapse_repo_statussetrepo_id → indexing status

All reads are direct :ets calls on public named tables — no GenServer round-trip, no message passing. A single Store.ETS GenServer serialises writes. This is why graph queries resolve in microseconds: there's nothing between the caller and the data.

The :persistent_term crash

We originally put the IDF table in :persistent_term because it's supposed to be the fastest read path in the VM. And it was — until we indexed a large repo and the IDF map hit 50–100MB. The BEAM hard-crashed with literal_alloc: Cannot allocate N bytes of memory (of type "literal"). Turns out :persistent_term uses the literal allocator, the same region that stores compiled BEAM constants. It has a hard limit. ETS uses the heap allocator, which scales to arbitrary size. We moved everything to ETS with read_concurrency: true and write_concurrency: true and never looked back. If you're storing anything bigger than a small map in :persistent_term, don't.

One GenServer per repo

Each registered repo gets its own Indexer GenServer, started under a DynamicSupervisor and registered via {:via, Registry}. The supervision tree looks like this:

SynapseCore.Supervisor (one_for_one)
├── SynapseCore.Repo (Ecto/SQLite with SQLCipher)
├── SynapseCore.RepoHeat (ranks repos by query frequency)
├── SynapseCore.Embedder.Native.IdfServer (GenServer + 4 ETS tables)
├── SynapseCore.Embedder.Pipeline (GenStage producer/consumer)
├── Task.Supervisor (SynapseCore.IndexerTaskSupervisor)
├── SynapseCore.Store.SQLiteWriter (single durable write lane)
├── SynapseCore.Indexer.Admission (global concurrency bounder)
├── SynapseCore.Store.ETS (owns 12 named ETS tables)
├── SynapseCore.ScratchpadStore (Agent)
├── SynapseCore.Learn (GenServer)
├── SynapseCore.RouteLearn (GenServer)
├── Registry (unique, SynapseCore.IndexerRegistry)
└── DynamicSupervisor (SynapseCore.IndexerSupervisor)
    └── one Indexer GenServer per registered repo

The Admission GenServer is the interesting bit. It bounds the total number of file-index workers across all repos. If you register 30 repos and they all start indexing at once, something has to throttle that or you'll eat every CPU on the box. Manual work — triggered by a user query — preempts background work. Grants rotate fairly across repos so one big repo can't starve the writer. Permits are reference-tracked, so if an indexer crashes mid-work, the permit gets released. Late releases are harmless.

Embeddings without a Python process

We didn't want to ship a Python runtime or call an external API for embeddings. So we wrote BM25F + random projection in pure Elixir. It runs in-process with zero network I/O and zero NIFs.

The pipeline is simple: a code-aware tokeniser splits on camelCase, snake_case, module dots, and operators. BM25F weights three fields — symbol name (3×), summary (2×), raw_source (1×). IDF weights come from IdfServer, which rebuilds from the live corpus after each full index pass and supports incremental updates when individual files change. The sparse weighted token map projects into 256 dimensions via a compile-time projection matrix, using :erlang.phash2/2 as the hash trick. L2 normalisation means cosine similarity is just a dot product at query time.

A single chunk embeds in 10–50µs. 1,000 chunks in 10–50ms. All float-list arithmetic, no tensor allocations, no Nx, no external model. Is it as good as a 1536-dim OpenAI embedding? No. Does it need to be? Also no — we're doing code structure lookup, not natural language understanding. BM25F with code-aware tokenisation is the right tool for this job.

Shadow Graph — catch broken callers before disk

When an agent wants to change a file, ShadowGraph.simulate/2 builds an ephemeral graph from the proposed contents. It chunks the new source with the same Chunker, pulls the baseline chunks from ETS, and diffs them. It checks every caller of every modified symbol, looks for arity mismatches, changed signatures, and broken contracts. If anything would break, the write never touches disk.

The agent gets a verdict, not just data. "Safe to write" or "3 callers would break — here they are."

This is all in-memory, all in Elixir. The Shadow Graph doesn't persist — it's built, consulted, and discarded per edit. The baseline ETS graph is the source of truth.

Cross-repo edges

If you've got a monorepo or a set of related repos, functions in repo A call functions in repo B. Individual indexers can't resolve those — they only see their own repo. The CrossRepoLinker runs after individual indexing completes. It scans every unresolved edge across all registered repos, looks up the target symbol in a global ETS index, and resolves it. Language-match and same-repo preferences break ties when multiple chunks define the same symbol. The whole pass runs in ETS — no file I/O, no GenServer calls, no database writes. Already-resolved edges are skipped, so it's safe to run repeatedly.

SmartCrusher

Every JSON response goes through SmartCrusher before it hits the wire. It relativises paths, strips null/empty fields, and shortens structural keys — file_path becomes fp, start_line becomes sl, chunk_id becomes cid. It cuts payload size by 30–60% with zero information loss. The agent still gets everything it needs, just in a more compact form.

There's also an outline format that strips function bodies entirely — useful when an agent is scanning 20 files to find the right one. It gets signatures and structure without paying for implementation details it doesn't need yet.

Does it actually save money?

We ran the same security audit task on our own 500k LOC Elixir codebase, same model, with and without Synapse:

Metric grep-based agent Synapse agent Delta
Tokens6.2M2.5M−60%
Cost$12.63$5.05−60%
Time16m 56s7m 36s2.2× faster
Vulnerabilities3 critical3 criticalidentical

Same bugs found. 60% fewer tokens. The agent spent its budget on the actual audit instead of re-reading user.ex for the fourth time.

Why Elixir was the right call

We didn't pick Elixir because we're Elixir developers. We picked it because the problem maps cleanly onto the BEAM's strengths. Per-repo indexers are naturally actor-model — each one is a GenServer with its own state, supervised, restartable. ETS gives us lock-free reads on the hot path with serialised writes through a single process. OTP supervision means an indexer crash doesn't take down the graph. Pure Elixir arithmetic for embeddings means no Python process to manage, no NIF overhead, no external service that can go down.

The :persistent_term crash was a painful education. The Shadow Graph proved that in-memory simulation is fast enough to validate edits before they reach disk — you don't need a compiler pass, you need a graph diff. And the benchmark proved that microsecond queries don't just save time, they save real money.

Synapse MCP is an Elixir umbrella app — three OTP apps (synapse_core, synapse_mcp, synapse_pro) packaged as a standalone Burrito binary for macOS, Linux, and Windows. Ecto + SQLite with SQLCipher for durable storage. Tree-sitter via NIF for AST parsing across 50+ languages. Everything else is Elixir.

Deploy the Microsecond Graph Engine

Get 100% local, zero-cloud AST knowledge graphs for Cursor, Windsurf, Warp, Claude Code, and Antigravity.

Download Now — for FREE →