Why Synapse's knowledge graph lives in SQLite, not memory
- 5 min read
- synapse
- architecture
- sqlite
The cost of keeping a graph in a Python object
Synapse's knowledge graph used to be a NetworkX graph, held entirely in process memory. That's the obvious way to start: NetworkX has the API surface, the algorithms, and none of the ceremony of running a separate store. It also has one property that stops being cute once an agent is expected to run unattended for weeks at a time: peak RAM scales with graph size, and a process restart loses every uncommitted change unless a serialization step ran cleanly first.
My first fix wasn't a migration. It was tuning: trimming NetworkX's own memory footprint and calling the garbage collector manually after large graph operations. It shipped, ran for a while under real usage, and I reverted it once it was clear the RAM curve was still tracking graph size almost linearly. Tuning an object doesn't change what kind of object it is.
What replaced it
The graph now lives in the same SQLite store the rest of Synapse already uses for state, as two tables:
CREATE TABLE nodes (
name TEXT PRIMARY KEY,
type TEXT,
properties TEXT -- JSON
);
CREATE TABLE edges (
source TEXT,
target TEXT,
relation TEXT,
weight REAL,
evidence TEXT,
PRIMARY KEY (source, target, relation)
);
The connection is opened once, in WAL mode with synchronous=NORMAL, and
reused for the life of the process rather than reopened per query. That pair
of settings is a deliberate middle point: WAL lets reads and writes proceed
concurrently instead of hitting the exclusive-lock stalls SQLite's default
journal mode is known for, and synchronous=NORMAL skips an fsync on every
commit that full durability would cost, in exchange for a small, accepted
window of possible loss on an actual OS crash, not a Synapse crash, which
WAL already survives cleanly.
Keeping the old interface on purpose
The interesting decision here isn't "SQLite is smaller than a Python object
graph," which is unremarkable. It's that the replacement class deliberately
kept NetworkX's own surface: has_node(), neighbors(), a .graph property
that returns self, and a save_graph() that is now a no-op, because SQLite
persists every write as it happens instead of needing an explicit
flush.[1]
That's what made the migration tractable in one pass instead of a long-lived compatibility shim: every call site kept compiling and kept passing its own tests against the new class, because as far as those call sites could tell, nothing had changed. A one-time migration method handles the actual data move: it replays a gzipped node-link JSON export of the old graph into the two tables above, and only ever runs once, at migration time, never on every startup.
ADR-001
SQLite-backed knowledge graph over in-memory NetworkX
Context
I held Synapse's knowledge graph entirely in a NetworkX object in process memory. Peak RAM scaled directly with graph size, and a restart lost in-flight state unless a separate serialisation step ran first. My prior attempt tuned NetworkX's own memory footprint and added manual garbage collection after large graph operations. I reverted it once the RAM curve kept tracking graph size almost linearly.
Options considered
- Keep NetworkX and add periodic serialisation to disk.
- Move to a dedicated graph database such as Neo4j, with its own operational overhead.
- Move the graph into the existing SQLite store as a node/edge schema.
Decision
I moved the graph into the existing SQLite store as a node/edge schema, reusing the storage layer already in place for other state rather than adding a new dependency. The rewrite touched every call site the query layer had, and each one needed a test before I could trust the migration.
Consequences
- Graph state now survives restarts natively, with no separate serialisation step.
What I'd change
add an in-memory LRU cache in front of hot subgraphs if query latency becomes the bottleneck instead of memory.
What the new store still can't do well
Synapse's own limits documentation (docs/kg-limits.md) is specific about
the trade rather than quiet about it: single-hop lookups stay under 10ms at
100,000 triples, which
covers the large majority of what the agent core actually asks the graph.
Multi-hop traversal is the honest weak point: path search runs as a
recursive common table expression, it only searches source-to-target, and it
degrades past a depth of roughly two hops. The same documentation names the
eventual next step if that ever becomes the bottleneck: a graph-native store
such as KuzuDB or Neo4j, not another round of tuning SQLite.
A background maintenance worker (configured in source, not documented
separately the way the query limits above are) keeps the tables from
growing without bound: it prunes edges below a weight of 0.1 every ten
minutes and runs VACUUM every thirty, and it only runs at all when the
machine is on mains power and CPU load is under 20%, so maintenance never
competes with an actual agent request for the same resources.
What I still can't tell you
Every writeup of a migration like this wants a headline percentage, and I don't have one I trust yet. Synapse's own README makes a memory-reduction claim for this exact migration. I'm not repeating the figure here: citing my own README as proof of my own README's claim would be circular, and no test graph backing that claim appears anywhere in the repository. The migration itself predates this repository's git history: it was already complete at the first commit, so there's no before/after commit pair to diff, either.
A benchmark script, scripts/bench_kg_memory.py, exists specifically to
measure peak RAM for the old NetworkX object against the new SQLite-backed
schema, using tracemalloc. I've never run it against a committed dataset,
and I've never checked in its output. Until I do both, any number I gave you
here would be a claim with no artifact behind it, exactly the failure this
site exists to avoid.
What I can give you instead is the argument that motivated the move,
independent of the exact number. An in-memory object graph has no way to
page state to disk short of a full, explicit serialization step: memory
usage scales with graph size for as long as the process runs, and a restart
without a clean flush loses whatever hadn't been serialized yet. The SQLite
schema removes that failure mode by construction, not by shrinking a number
I can show you: every write lands in the same store the rest of Synapse
already uses for state, so durability stops depending on a serialization
step firing at the right moment. That structural argument doesn't need a
benchmark to be true. The percentage is still owed. It gets added back to
this page, with a linked artifact, the day bench_kg_memory.py runs against
a committed dataset and I check in the output, not before.
Reproducing what's here
- The graph class and its one-time migration method, described above, in the Synapse-OSS repository.
docs/kg-limits.md: the single-hop and multi-hop figures cited above, in the repository's own words.- The maintenance worker's pruning weight, run interval, and CPU gate, cited
above, also in the Synapse-OSS repository, same as the graph class; no
separate doc file covers it the way
docs/kg-limits.mdcovers the query limits. scripts/bench_kg_memory.py: written, not yet run against a committed dataset. Running it and committing the dated output is the one open item this page is waiting on.
The full case study, with the rest of Synapse's numbers, is at /work/synapse.
Append .md to this page's URL for the plain-text version. Canonical: https://upayanghosh-dev.vercel.app/writing/synapse-knowledge-graph-migration