Skip to content

GraphRAG

GraphRAG answers structural questions that vector search handles poorly: “who reports to Bob?”, “what’s two hops from this project?”. In Core LLM Wiki it runs entirely on SQLite.

Vector / keyword read() Graph traverseGraph()
Starts from A query string A known anchor fact
Returns Loose top-K matches A connected subgraph (nodes and edges)
Good for “What do I know about X?” “What’s connected to X?”
  1. Turn on an ontology so the model emits edges

    import { createWiki } from '@equationalapplications/core-llm-wiki';
    import { schemaOrgWarmAgentManifest } from '@equationalapplications/schema-org-llm-wiki';
    const wiki = createWiki(adapter, {
    llmProvider,
    config: {
    ontology: {
    mode: 'strict',
    seedManifests: { 'user-123': { mode: 'strict', manifest: schemaOrgWarmAgentManifest } },
    },
    },
    });
    await wiki.setup();
  2. Ingest or write as usual

    await wiki.ingestDocument('user-123', {
    sourceRef: 'onboarding.md',
    sourceHash,
    documentChunk: 'Alice joined the data team in March and reports to Bob.',
    });

    In strict mode the model returns typed facts with inline edges, and the engine resolves and stores them.

  3. Find an anchor and walk the graph

    const { facts } = await wiki.read('user-123', 'Alice');
    const graph = await wiki.traverseGraph('user-123', {
    sourceId: facts[0].id,
    maxDepth: 2,
    direction: 'both',
    });
    // graph.nodes — anchor first, then neighbours by hop depth
    // graph.edges — only edges whose two endpoints are in nodes
  4. Format it for the prompt

    import { formatGraphContext } from '@equationalapplications/core-llm-wiki';
    const answer = await generateText({
    systemPrompt: `Answer using this memory graph:\n\n${formatGraphContext(graph)}`,
    userPrompt: 'Who does Alice report to?',
    });
Option Default Notes
sourceId required The anchor fact ID
maxDepth — Clamped to the range 1–3
direction config.traversalDirection 'inbound', 'outbound', or 'both'
edgeTypes all [] matches nothing. Leave it out to match everything.
maxTraversalNodes config.maxTraversalNodes Cap on the number of nodes returned
minTraversalConfidence config.minTraversalConfidence Skip nodes below tentative, inferred, or certain
excludeSourceTypes config.excludeSourceTypes For example ['immutable_document']
excludeDrafts config.excludeDrafts Drafts are dead ends. The anchor is always returned.
asOf now Only walk edges whose two facts were both live at that time
tokenBudget — Keep nodes in breadth-first order until the budget runs out

Have structured data already (a code index, a CSV, an API response)? upsertGraph() writes nodes and edges directly, with the same (sourceRef, sourceHash) ownership as ingest:

await db.withTransactionAsync((tx) =>
wiki.upsertGraph('repo:app', {
sourceRef: 'src_main.ts',
sourceHash: sha256(source),
nodes: [
{ id: 'fn_processData', type: 'function', title: 'processData', body: 'Processes user data' },
{ id: 'class_UserService', type: 'class', title: 'UserService' },
],
edges: [{ type: 'calls', sourceId: 'fn_processData', targetId: 'class_UserService' }],
}, tx),
);
await wiki.syncSearchIndex('repo:app'); // AFTER commit, so keyword reads see the new nodes
  • It joins your transaction (you pass tx). It never begins or commits one itself.
  • Re-writing an identical (sourceRef, sourceHash) is a zero-count no-op.
  • The same hash under a different live sourceRef throws WikiSourceRefHashCollision.
  • A node ID owned by another entity throws WikiGraphNodeOwnershipConflict, and nothing is written. Entity-qualified IDs (repo:app/fn_processData) avoid accidental collisions.
  • Entry IDs share one namespace per database. An ID belongs to its entity as long as the row exists, including after a soft delete. The error deliberately doesn’t say who owns the ID.
  • An edge ID that already exists with a different entity, endpoints or type is caught later and throws a plain Error (the existing edge is never overwritten). Let it propagate so your transaction rolls back. Catching it and committing isn’t atomic.
  • ingestDocument applies the same ownership rule. It generates fresh IDs, so a collision there is unexpected. If one happens, ingestDocument’s own transaction rolls back. There’s no zero-write preflight check as with upsertGraph.
  • Authenticating callers and choosing the right entity ID is still your app’s job.
  • Embeddings aren’t computed here. They come from the next maintenance or reembed sweep.

traverseGraph() is a single recursive CTE. It walks edges in the requested direction, keeps a visited string to break cycles, filters by confidence and source type, and returns nodes ranked by MIN(depth) and then recency. After the CTE returns (node_id, depth) pairs, the engine loads the full facts and drops edges whose endpoints fell out.

The query below is abridged from EdgeRepository.ts. Bind parameters are shown as ?, and the dynamic edgeTypes and excludeSourceTypes clauses are expanded inline. CI checks that its depth guard and ranking clause match the source.

WITH RECURSIVE walk(node_id, depth, visited) AS (
-- 1. Anchor: the source fact row, scoped to entity, soft-delete-aware
SELECT id, 0, ',' || id || ','
FROM llm_wiki_entries
WHERE id = ? AND entity_id = ? AND deleted_at IS NULL
UNION
-- 2. Recursive step: walk edges in the requested direction(s), join the
-- neighbour entry, gate by min-confidence and excluded source_type,
-- and append the neighbour id to the `visited` accumulator to break
-- cycles before recursing further.
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 (
(? != 'inbound' AND e.source_id = w.node_id) OR
(? != 'outbound' AND e.target_id = w.node_id)
)
AND e.edge_type IN (?, ?) -- expanded from opts.edgeTypes[]
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)
AND n.entity_id = ?
AND n.deleted_at IS NULL
AND (CASE n.confidence WHEN 'tentative' THEN 0 WHEN 'inferred' THEN 1 WHEN 'certain' THEN 2 ELSE -1 END) >= ?
AND n.source_type NOT IN (?, ?) -- expanded from opts.excludeSourceTypes[]
WHERE w.depth < ? -- opts.maxDepth (clamped to [1, 3])
AND instr(w.visited, ',' || (CASE WHEN e.source_id = w.node_id THEN e.target_id ELSE e.source_id END) || ',') = 0
)
-- 3. Final ranking: shallowest hop first, then most-recently-updated entry
SELECT node_id, MIN(depth) AS depth
FROM walk
GROUP BY node_id
ORDER BY depth ASC, (SELECT updated_at FROM llm_wiki_entries WHERE id = node_id) DESC
LIMIT ? -- opts.maxTraversalNodes
import { useWikiTraversal, formatGraphContext } from '@equationalapplications/react-llm-wiki';
const { nodes, edges, isPending } = useWikiTraversal('user-123', { sourceId, maxDepth: 2 });
const context = formatGraphContext({ nodes, edges });