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?” |
End-to-end
Section titled “End-to-end”-
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(); -
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.
-
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 -
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?',});
Traversal options
Section titled “Traversal options”| 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 |
Seed a graph without an LLM
Section titled “Seed a graph without an LLM”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
sourceRefthrowsWikiSourceRefHashCollision. - 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. ingestDocumentapplies 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 withupsertGraph.- 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.
How the query works
Section titled “How the query works”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 entrySELECT node_id, MIN(depth) AS depthFROM walkGROUP BY node_idORDER BY depth ASC, (SELECT updated_at FROM llm_wiki_entries WHERE id = node_id) DESCLIMIT ? -- opts.maxTraversalNodesIn React
Section titled “In React”import { useWikiTraversal, formatGraphContext } from '@equationalapplications/react-llm-wiki';
const { nodes, edges, isPending } = useWikiTraversal('user-123', { sourceId, maxDepth: 2 });const context = formatGraphContext({ nodes, edges });