Advanced layers
Context graph
Link memories to each other, and search through the links.
With the context graph on, every memory becomes a node, and ContextKit links nodes as you add them: replies in the same conversation, the order things were said, memories from the same document, and memories on the same topic. Search then starts from the closest matches, walks those links, and re-ranks what it finds, so the context around the best match comes back with it.
The context graph is separate from graph memory: graph memory links entities in a graph database; the context graph links memories to each other, in process by default.
Turn it on
pip install networkx
from outhad_contextkit import Memory
memory = Memory.from_config({
"vector_store": {"provider": "qdrant", "config": {"path": "./memory", "on_disk": True}},
"context_graph": {"enabled": True, "persist_path": "./memory/context_graph.pkl"},
})
memory.add("I love pizza", user_id="u1", run_id="r1")
memory.add("Italian food is my favourite", user_id="u1", run_id="r1")
hits = memory.search("what should I eat?", user_id="u1")
for hit in hits["results"]:
print(round(hit["score"], 3), hit["memory"], hit.get("context_graph"))
print(hits.get("subgraph", []))
networkx is not installed by any extra. If it is missing, or the graph fails to start for another reason, ContextKit logs the error and searches without the graph.
Already have memories? Build the graph from them in one call. It is safe to run again; existing nodes and edges are skipped:
memory.backfill_context_graph(user_id="u1") # {"nodes": 42, "edges": 117}
backfill_context_graph reads up to 100,000 memories, in batches of batch_size (500), for the given user_id, agent_id, or run_id, or for everyone when you pass none.
How memories are linked
REPLY_TO, TEMPORAL_NEXT, DOCUMENT_LINK) and from how alike their embeddings are (TOPIC_SIMILAR, weighted by the cosine). Links only join memories of the same user and agent, so another user's memory is never one hop from yours.| Edge | Created when | Weight |
|---|---|---|
REPLY_TO |
The previous memory in the same run (or agent) for the same user was added within reply_to_window_seconds (30 minutes) |
1.0 |
TEMPORAL_NEXT |
Each memory follows the previous one in the same run (or agent) for the same user | 0.6 |
DOCUMENT_LINK |
Two memories share a doc_id, document_id, source_id, source_url, or url in metadata, or a URL in their text |
0.8 |
TOPIC_SIMILAR |
Embeddings are at least topic_min_similarity (0.75) alike; up to topic_top_k (5) per memory |
The cosine similarity |
UPDATED_FROM |
rollback writes a new version of a memory (see versioning) |
1.0 |
SUPPORTS, CONTRADICTS, REFINES, ELABORATES |
The LLM labels a pair of similar memories, when LLM edges are on | The LLM's confidence |
DOCUMENT_LINK and TOPIC_SIMILAR only join memories of the same user and agent, and of the same tenant when both have one. Turn individual edge types off under context_graph.edges, for example "edges": {"enable_topic_similar": False}.
LLM edges
LLM edges ask the configured LLM how pairs of topic-similar memories relate. They cost tokens, so they are off by default and capped by a daily budget per user:
memory = Memory.from_config({
"context_graph": {
"enabled": True,
"edges": {"llm": {"enabled": True, "max_pairs_per_insert": 3, "daily_token_budget": 100_000}},
},
})
Only pairs at least min_topic_similarity (0.80) alike are sent, and labels below min_confidence (0.60) are discarded.
How search ranks
When the context graph is on, search uses it by default. Pass use_context_graph=False to skip it for one call, or set retrieval.enabled to False to make that the default.
- Seed. The vector store's top
seed_top_k(10) matches become seeds. - Expand. From the seeds, follow edges up to
expansion_depth(2) hops, ignoring edges lighter thanedge_weight_floor(0.1), up tomax_candidates(50) nodes.algorithmisbfs(the default) orppr, personalised PageRank from the seeds. PageRank needsscipy; without it, search falls back to the seeds. - Re-rank. Every candidate gets a score:
score = (alpha_dense · dense + beta_bm25 · lexical + gamma_graph · graph) × relevance
dense is the vector similarity, lexical the share of query words the memory contains, and graph how strongly the candidate is connected to the seeds. relevance is the node's decaying relevance (below). The default weights are 0.55, 0.15, and 0.30, all under context_graph.retrieval. Personalized retrieval adds three more terms.
Each result's score is this final score, and it gains a context_graph key with the parts: dense, lexical, graph, frequency, personal, success, and was_seed. When any edges connect the results, the response has a subgraph list of {"src", "dst", "type", "weight"}; read it with hits.get("subgraph", []), since it is left out when empty.
Upgrade to 0.1.4 if one store serves several users
From 0.1.4, graph search stays within the user_id, agent_id, and run_id you pass: a memory reached through a link is returned only when it belongs to them. In 0.1.3 and earlier, topic links joined memories of different users and expansion did not check them, so a search for one user could return another user's memory. Upgrade with pip install -U outhad-contextkitai. A graph built before 0.1.4 can still hold links between users; search ignores them, and memory.reset_context_graph() followed by memory.backfill_context_graph() removes them.
Decay
Edges and nodes fade unless they are used, so old, unused context stops crowding search results.
Setting (context_graph.decay) |
Default | Meaning |
|---|---|---|
half_life_days |
30 | Edge weights and node relevance halve over this many days without use. |
min_edge_weight |
0.05 | Edges lighter than this are pruned. |
min_node_relevance |
0.02 | Nodes below this are archived and skipped in expansion. |
tick_on_read |
True |
Apply decay during search, at most once an hour. |
CONTRADICTS edges never decay. Run a round yourself with memory.tick_decay(), which returns {"decayed", "pruned", "archived"}, or steer one memory:
memory.mark_relevant(memory_id, delta=0.25) # boost, and keep it from decaying below the new level
memory.mark_irrelevant(memory_id, delta=0.25) # push it down
Pass include_archived=True to search to expand into archived nodes too.
Saving the graph
The default networkx backend keeps the graph in memory. With persist_path set, the graph is loaded from that file when the Memory starts, but it is not saved automatically. Save it when you are done, or on a timer:
persist_path is read at start and written only by snapshot(). The neo4j backend writes as the graph changes, into the database your graph_store already points at. green: what is stored or returnedmemory.context_graph.snapshot() # writes persist_path
print(memory.context_graph.stats()) # {"nodes": ..., "nodes_total": ..., "edges": ...}
For large graphs, or to share one graph between processes, use "backend": "neo4j", which stores nodes and edges in the Neo4j database configured as your graph store. From 0.1.4, "backend": "postgres" keeps the graph, its embeddings, and the change log in the Postgres database set as your state store instead.
memory.reset_context_graph() drops every node and edge, and leaves the vector store and history alone.
Change log, replay, and streaming
With log_changes on (the default), every change to the graph is appended to context_graph_changes.db, in the folder of persist_path, or ~/.outhad_contextkit/ when it is not set. With the postgres backend it goes to the ck_graph_changes table.
from datetime import datetime
memory.snapshot_context_graph("./graph-export.jsonl") # export nodes and edges
past = memory.replay_context_graph(until=datetime(2026, 10, 1)) # the graph as it was then
print(past.stats())
from_event_id lets a new one catch up from the log first. green: what is stored or returnedSubscribe to changes as they happen. Each event has event_type, target_id, timestamp, user_id, and payload:
def on_change(event):
print(event.event_type, event.target_id)
token = memory.subscribe_context_graph_changes(on_change, event_types=["node_added", "edge_added"])
memory.unsubscribe_context_graph_changes(token)
Pass from_event_id=0 to replay the whole log to a new subscriber first. Event types are node_added, node_updated, node_archived, node_deleted, node_relevance_bumped, node_accessed, edge_added, edge_decayed, edge_pruned, memory_feedback_recorded, and reset.
To forward changes to another system, pass a sink's send method as the callback:
from outhad_contextkit.memory.context_graph.sinks.webhook import WebhookSink
sink = WebhookSink("https://example.com/hooks/memory", secret="whsec_...")
memory.subscribe_context_graph_changes(sink.send)
| Sink | Import from outhad_contextkit.memory.context_graph.sinks |
Notes |
|---|---|---|
WebhookSink(url, secret=None) |
.webhook |
POSTs JSON with an X-CGL-Signature: sha256=… HMAC; check it with WebhookSink.verify_signature(...). Retries 3 times. |
SSESink() |
.sse |
async for chunk in sink.stream() yields server-sent event lines for a streaming HTTP response. |
KafkaSink(bootstrap_servers, topic) |
.kafka |
Needs pip install "outhad-contextkitai[context-graph-kafka]". Messages are keyed by target_id. |
Settings
All settings live under context_graph in the config. These are the ones you are most likely to change; every field is in the configuration reference.
| Setting | Default | What it does |
|---|---|---|
enabled |
False |
Turns the context graph on. |
backend |
"networkx" |
"networkx" (in process), "neo4j" (the graph store's database), or, from 0.1.4, "postgres" (the state store). |
persist_path |
None |
File the networkx graph is loaded from at start and written to by snapshot(). |
log_changes |
True |
Append every change to the change log. |
edges.enable_reply_to, enable_temporal_next, enable_document_link, enable_topic_similar |
True |
Turn each kind of edge off on its own. |
edges.reply_to_window_seconds |
1800 |
How close in time two memories must be for REPLY_TO. |
edges.topic_min_similarity, edges.topic_top_k |
0.75, 5 |
Cosine floor and per-memory cap for TOPIC_SIMILAR. |
edges.llm.enabled |
False |
LLM edges. |
retrieval.enabled |
True |
Use the graph in search unless use_context_graph=False. |
retrieval.algorithm |
"bfs" |
"bfs" or "ppr" (needs scipy). |
retrieval.seed_top_k, expansion_depth, max_candidates, edge_weight_floor |
10, 2, 50, 0.1 |
Seeds, hops, candidate cap, lightest edge followed. |
retrieval.alpha_dense, beta_bm25, gamma_graph |
0.55, 0.15, 0.30 |
Ranking weights; personalized retrieval adds three more. |
decay.half_life_days |
30 |
How fast edges and relevance fade. |
decay.min_edge_weight, min_node_relevance |
0.05, 0.02 |
Prune and archive thresholds. |
decay.tick_on_read |
True |
Run decay during search, at most once an hour. |