Knowledge Graph¶
anchor keeps a knowledge graph over memory and documents at the same time: entities and notes are nodes, relations are typed edges, and every node or edge is backed by the ContextItems that evidence it. You navigate (neighbors, backlinks, path, explain) instead of only searching, and the same RetrievalScope that governs retrieval governs the graph — a node without visible evidence does not exist for that scope, and is never crossed.
from anchor import KnowledgeGraph
graph = KnowledgeGraph() # in-memory store by default
graph.add_node("Alice", metadata={"type": "person"})
graph.link_item("Alice", "mem-001") # the item id is the currency
graph.add_edge("Alice", "works on", "Project X", evidence=["mem-001"],
fact="Alice leads Project X")
graph.neighbors("alice", max_depth=2) # ['project_x']
graph.related_items("Project X") # ['mem-001']
graph.explain("alice", "project x") # [GraphEdge(relation='works_on', ...)]
graph.query(["alice"], top_k=5) # [('mem-001', 0.58)] ← PPR scores on items
The model¶
| Piece | What it is |
|---|---|
GraphNode | id is a canonical key (normalize_key: NFKC + casefold + whitespace → _, so "Project X" and "project x" are one node), label the display name, aliases, metadata. |
GraphEdge | source → target with a free normalized relation ("Works On" → works_on; no closed enum), an optional fact, provenance (extracted / inferred / ambiguous), confidence, evidence (item ids) and bi-temporal validity: valid_from/valid_to (world time), created_at/invalidated_at (transaction time). |
| Evidence | The ContextItem ids that support a node or edge. For documents these are chunk ids; for memory the item id is MemoryEntry.id. |
An obsolete edge is invalidated, never deleted — invalidate_edge(), or valid_to from the text. Every read takes as_of and only walks edges live at that instant.
Scope: the node is the unit of exclusion¶
from anchor import RetrievalScope
no_secret = RetrievalScope(exclude=("/secret",))
graph.nodes(scope=no_secret) # the villain evidenced only by /secret is gone
graph.path("hero", "city", scope=no_secret) # None if the only route crossed the villain
Rules, identical on every backend:
- an item is visible when the scope matches its namespace;
- a node is visible when at least one of its items is; a node without any evidence (a hand-added one) lives at the root namespace
/— visible under an empty include, hidden byexclude=("/",)or a narrower include, exactly like memory; - an edge is visible when it is live at
as_of, both endpoints are visible and at least one evidence item is visible. An edge's evidence evidences both endpoints (the item mentions them), soRetrievalScope()is the identity: it shows exactly what an unscoped read shows.
A hidden node is a wall, not a bridge: path and explain refuse to cross it, backlinks and hubs never list it, query never scores its items. Because subagents inherit the parent's scope intersected with their own (Agent.with_scope, SubagentDefinition.scope), a child can never see more of the graph than its parent.
Building the graph¶
From documents — free, deterministic¶
GraphIndexer runs extractors over ContextItems and writes into the graph. The two default extractors cost nothing:
StructureExtractor— the chunk's note (its file name) becomes a node evidenced by the chunk; frontmatteraliasesbecome node aliases andtagsbecometaggededges.WikilinkExtractor— every[[target]](also|alias,#heading,#^block,![[embed]]) becomes alinks_toedge from the chunk's note, and the target is linked to the chunk as a mention. Resolution is by basename, case-insensitive, like Obsidian.
from anchor import DocumentIngester, GraphIndexer, KnowledgeGraph
items = DocumentIngester().ingest_directory("vault/")
graph = KnowledgeGraph()
GraphIndexer(graph).index(items) # add-only, idempotent: reindexing merges evidence
graph.unlink_item(items[0].id) # removal: edges left without evidence are invalidated
From memory — opt-in, one model call per fact¶
LLMGraphExtractor(llm) extracts entities (name, type, aliases) and typed relations (relation, fact, provenance, confidence) in a single call, with a suggested vocabulary (DEFAULT_RELATIONS) and no second "gleaning" pass. Give it to a MemoryManager and the persistent store is wrapped in a GraphIndexingEntryStore: whoever writes — the manager, the pipeline's consolidation step, the garbage collector — added entries are indexed, rewritten entries re-extracted, deleted or cleared entries take their evidence with them. Entries that already exist when the graph is attached are not backfilled: run indexer.index_entries(store.list_all()) once.
from anchor import GraphIndexer, KnowledgeGraph, LLMGraphExtractor, MemoryManager
graph = KnowledgeGraph()
manager = MemoryManager(
persistent_store=store,
graph=GraphIndexer(graph, extractors=[LLMGraphExtractor(agent.llm)]),
)
manager.add_fact("Ana Lima leads Team Payments and is on call for Billing.")
graph.explain("ana lima", "billing") # [on_call_for · extracted · 0.95 · evidence=('<entry id>',)]
The extractor is opt-in on purpose: on a 40-note wiki the wikilink graph did not beat hybrid BM25+dense retrieval (recall@5 0.94 for both, hybrid ahead on MRR — the numbers live in the plan doc), so the graph's value is navigation, scope and memory without links, not replacing your retriever.
Retrieval and fusion¶
GraphRetriever is a first-class Retriever: seeds are the nodes the query mentions (an alias-aware n-gram matcher, or your entity_extractor) plus, optionally, the nearest passages from a vector store; personalized PageRank over the entity–item graph scores every visible item. Items keep their canonical ids, so HybridRetriever fuses them with dense and sparse results by RRF.
from anchor import GraphRetriever, HybridRetriever
graph_ret = GraphRetriever(graph, context_store, vector_store=vectors, embeddings=emb)
fused = HybridRetriever([hybrid, graph_ret], weights=[1.0, 0.5])
graph_retrieval_step(graph, memory_store, entity_extractor) remains for the memory-only pipeline shape; it honours the published scope like every other step.
Communities and hubs¶
hubs() ranks visible nodes by degree. communities() partitions the visible subgraph — a deterministic Louvain in the core, Leiden through igraph when the [graph] extra is installed — and caches the result on the store's version, so derived data is never persisted and never stale.
Persistence and the CLI¶
SqliteGraphStore and PostgresGraphStore (async, drive it directly — KnowledgeGraph is synchronous) persist the graph next to the index, vault-bound at construction like every store. The CLI builds and navigates it:
anchor index vault/ --db anchor.db --graph --namespace /wiki
anchor graph path checkout ledger --db anchor.db
anchor graph explain checkout ledger --db anchor.db --exclude /incidents
anchor graph hubs --db anchor.db -k 10
anchor graph communities --db anchor.db
anchor graph backlinks payments --db anchor.db
anchor graph query "who is on call for checkout?" --db anchor.db
What's next¶
- Memory — the store the graph follows
- Retrieval — hybrid retrieval and RRF
- API: Knowledge Graph