Notes · For developers

GraphRAG on SQLite, without a graph database

How the open-source LLM Wiki memory engine stores facts as nodes, writes typed edges, and walks the graph with one recursive CTE. No Neo4j required.

By Kurt VanDusen · · 4 min read

Many GraphRAG write-ups assume a graph database. For an agent memory that has to run on a phone, in a browser, inside a desktop app, or in a serverless function, that assumption is the problem. The open-source LLM Wiki memory engine (@equationalapplications/core-llm-wiki, MIT, source on GitHub) takes a different route: every fact is a row, every relationship is a row, and traversal is a single recursive common table expression in SQLite.

This note explains how that works and when it is the right tool.

Three retrieval modes, one database

The engine exposes three ways to read memory, each suited to a different shape of question:

Mode How it ranks Good for
Semantic cosine similarity over embeddings you supply "what do I know about X"
Keyword SQLite FTS5, or an in-memory MiniSearch index where FTS5 is unavailable exact names, identifiers, offline fallback
GraphRAG walks typed edges from a known anchor fact "what connects to X", "everything two hops from this"

The first two rank by relevance to a query string. The third does not rank by a query at all. It starts from a fact you already hold and returns the connected neighbourhood, which is dense and structural rather than a loose top-k list.

How facts become a graph

The librarian pass (the background job that turns raw events into durable facts) and document ingestion both write two things per fact when an ontology is enabled: an okf_type that classifies the fact, and typed edges into an llm_wiki_edges table pointing at other facts. Ontology can be strict, where you seed the allowed node and edge types per entity, emergent, where the model proposes types as it goes, or off.

You can also skip the model entirely. upsertGraph() writes nodes and edges directly under the same source-reference ownership rules as document ingestion, which is how you seed a graph from a CSV or a JSON export.

Facts carry a source_type. Facts that came from an ingested document are immutable: the librarian and the healer never rewrite them, and only forget() or a re-ingest removes them. Inferred and user-stated facts are mutable.

The traversal, in one query

traverseGraph() runs one recursive CTE. The anchor is the source fact, scoped to its entity and skipping soft-deleted rows. The recursive step joins the edges table in the requested direction (inbound, outbound, or both), filters by edge type and a minimum confidence, joins the neighbouring fact to make sure it is still live, and appends the neighbour id to a visited string column.

That visited accumulator is the cycle guard: the walk carries its own record of the path and refuses to step onto an id it has already seen. Results are ranked by minimum hop depth, with the most recently updated fact breaking ties, and capped by maxTraversalNodes.

Abridged, the shape is:

WITH RECURSIVE walk(node_id, depth, visited) AS (
  SELECT id, 0, ',' || id || ','
  FROM llm_wiki_entries
  WHERE id = ? AND entity_id = ? AND deleted_at IS NULL
  UNION
  SELECT
    CASE WHEN e.source_id = w.node_id THEN e.target_id ELSE e.source_id END,
    w.depth + 1,
    w.visited || (CASE WHEN e.source_id = w.node_id THEN e.target_id ELSE e.source_id END) || ','
  FROM walk w
  JOIN llm_wiki_edges e ON e.entity_id = ? AND (e.source_id = w.node_id OR e.target_id = w.node_id)
  JOIN llm_wiki_entries n ON n.id = (CASE WHEN e.source_id = w.node_id THEN e.target_id ELSE e.source_id END)
  WHERE w.depth < ? AND instr(w.visited, ',' || n.id || ',') = 0
)
SELECT node_id, MIN(depth) AS depth FROM walk GROUP BY node_id ORDER BY depth;

After the CTE returns (node_id, depth) pairs, the service hydrates them into full fact rows and drops any edge whose endpoint fell out of the result. formatGraphContext() then turns the subgraph into a dense text block you can put straight into a prompt, next to your vector results.

Temporal facts and the walk

Facts can carry a half-open validity window. supersede() records that one fact replaced another from a given instant, keeps the history, and lets read({ asOf }) answer "what did we believe was true at T". traverseGraph({ asOf }) respects the same windows: it only walks an edge when both of its facts are live at that instant.

When this is the right tool

Use it when:

  • memory has to live where the app lives, with no network dependency and no second service to operate;
  • you need many isolated "brains" in one database, which the engine supports by scoping facts and edges to an entity;
  • your questions are structural ("who reports to whom", "what depends on this component") rather than fuzzy.

Be aware of the limits:

  • One writer per database file. Job locks only work within one process, so you need to guarantee a single writer per database file across processes.
  • Serverless and request-scoped hosts. Background maintenance promises can be frozen or killed there. Set maintenance: 'deferred' and call runPendingMaintenance() yourself instead of relying on them.
  • Single-level type inheritance. A node type may declare one parent type so you can query "all CreativeWorks". Deeper chains are rejected at validation on purpose.
  • Embeddings are yours to supply. The engine never calls a model itself. You pass generateText and, optionally, embed. Without embed, reads fall back to keyword search automatically.

Try it

The root README has an end-to-end recipe that ingests one document, picks an anchor fact, walks two hops, and formats the result for a prompt. The same family of packages sits under Curated Thoughts on the desktop (via react-llm-wiki) and Curated Journal, which runs fully offline on a phone. The hosted SynapseTree service runs on the same open-source engine.