Memory evolves. Facts that were true go stale, because the world keeps moving. Managing memory means moving it forward.
One mainstream solution is to let the model manage its own memory (we will visit that in the letta-code deep dive). The model decides what to keep, when to update. That puts the policy in the model's judgment.
Graphiti takes a different approach. The update policy is engineered. The LLM is a helper instead of a driver. The LLM finds out what entities are involved, what facts, whether any is a duplicate. The mechanism makes the decisions. When two facts contradict, Graphiti compares their world dates. The newer fact wins. The older fact gets stamped closed, not deleted. Every fact carries a world clock (when was this true?) and a system clock (when did we believe it?). So when you later ask a question, the fact that is currently in effect surfaces. The older fact stays underneath, dated, still available as data for the caller to reason over.
How it works
The building blocks
Graphiti uses 5 types of building blocks to represent the world. One running example throughout: in 2015, the winemaker Mira directs Château Nord and works with Gamay; in 2023 she leaves for Château Sud, trading Gamay for Cabernet Franc.
-
Episode
The raw record of a new piece of information (e.g. Mira leaves Château Nord for Château Sud, trading Gamay for Cabernet Franc), content is kept verbatim by default alongside system-added fields. Each episode carries a caller-supplied reference time: the world date the events happened, which may be years before the system ingests it. Episodes are not chained to each other by default; ordering lives in the timestamps.
-
Entity
A thing the graph can point at: Mira, Château Nord, Gamay. Entities are extracted from episodes by LLM and deduplicated with a lot of effort (more on that below). Notably, entity nodes are not bi-temporal, only their fact edges are.
-
Edges
There are three different kinds.
A fact edge joins two entities with an actual sentence (e.g. "Mira works at Château Nord."). At the database level every fact edge wears the same generic label,
RELATES_TO; the relation name (WORKS_AT,DIRECTS) is a property on the edge, generated by the LLM. The relation name is keyword-searchable. Additionally, the edge also carries an embedding of the fact sentence plus four timestamps:valid_at/invalid_at(the world clock) andcreated_at/expired_at(the system clock).A mention edge (
MENTIONS) points from an episode to each entity it spoke of, it provides provenance for the entity nodes.And
NEXT_EPISODE/HAS_EPISODEedges exist only inside sagas (more on sagas later). -
Community
An optional clustering layer, built only when the caller explicitly asks for a rebuild. Label propagation is similar to a rumor spreading: every node starts with its own label, and in each round it adopts its neighbor node's label if the label has at least two fact edges backing it. Rounds repeat until equilibrium is achieved. The nodes with the same label become a community.
Each community gets a name and summary. The summary is built by merging member summaries in a bracket tournament, and the one-sentence paraphrase of the final summary becomes the community's name. The name is the only part search can see; the full summary underneath is neither indexed nor embedded.
-
Saga
A named thread of episodes. At ingestion the caller can declare "this belongs to the saga Mira's career," and every episode so marked is linked into a sequence, ordered by event time, not by arrival time.
Ingestion: eight steps
Before anything runs, a pre-flight validates custom entity types and resolves the group (tenant) ID. Then:
-
Context fetch. Pull the previous episodes (up to 10) as background for extraction.
-
Episode object. Build the episode row with the raw content and the reference time.
-
Entity extraction. The new episode or a list of new episodes plus background context gets sent to the LLM. Entity extraction is restricted to new episodes only. The LLM returns entity names, types, and an index of which new episode actually mentioned which entity.
-
Entity resolution. The step that decides create-vs-reuse, built as a funnel from cheap to expensive. Each extracted name is embedded and cosine-searched for candidates. If nothing comes back, a new node gets minted on the spot, no LLM consulted. Otherwise the cheap tiers run first: an exact normalized-name match resolves immediately, but only if exactly one candidate bears that name. A name still unresolved but information-rich enough (a Shannon-entropy gate: long enough) goes through MinHash, a fingerprint for near-identical strings, to handle small spelling variations. Whatever is still ambiguous goes to a single batched LLM call.
-
Fact extraction and resolution. A second LLM call extracts relationships. It is constrained to a closed vocabulary: it may only assert facts between entities that survived resolution, so it can't invent endpoints. I guess that is why extraction gets split into two parts.
Each extracted fact is then resolved against the graph with two searches: one scoped to edges between the same two endpoints (duplicate candidates) and one across the whole group (contradiction candidates). A cheap, small-model LLM call rules on duplicate vs. contradiction; typed edges get one more small-model LLM call to fill their custom attributes, and a timestamp backfill call fires only if the fact's dates are still empty.
-
The date fight. This is where bi-temporality shines. When two facts contradict ("Mira works at Château Nord" vs. "Mira works at Château Sud"), the fight compares world dates, never arrival order. Strictly newer
valid_atwins. The loser is stampedinvalid_at(world clock: when it stopped being true) andexpired_at(system clock: when we stopped believing it), and is kept, not removed. That's why "Where did Mira work in 2019?" is answerable. The dated fact is preserved. To clarify, no query answers it directly. The caller has to read the dates from the data and reason from there.Because the comparison uses world time, it is safe to backfill. A fact from 10 years ago ingested into the graph can never overwrite the present. The date fight will stamp its
invalid_atupon arrival. -
Enrichment. Each new fact's sentence is appended verbatim to the summaries of both endpoint entities. If a summary would exceed 2,000 characters, an LLM condenses it instead.
-
Commit. Everything above happened in memory. Now one transaction writes the episode, its mention edges, the entities, and the facts with all their stamps. If any earlier step failed, nothing was ever persisted. (Two honest caveats: this atomicity is real on Neo4j; on FalkorDB the "transaction" is a passthrough. And saga links are written after, outside the transaction.)
Search
There are two ways in:
- The simple
search(): BM25 over fact text plus cosine over fact embeddings, fused by rank. - The configurable
search_(). It fans out across all four scopes: facts, entities, episodes, and communities. Not everything is embedded. Fact sentences, entity names, and community names have embeddings. Episode content does not, so episodes are keyword-only. The query is embedded once and reused everywhere.
The interesting arm is the graph walk, on fact and entity search. It follows edges up to three hops from what BM25 and cosine just found. However, it's off by default. Only the cross-encoder recipes turn it on.
How do you compare a BM25 score to a cosine score?
Good news: you don't. Rank fusion (RRF, Reciprocal Rank Fusion) is the default. It throws the scores away and combines positions. Each arm votes 1/(rank+1). A fact near the top of the two arms beats a fact that topped one. I like how the solution sidesteps the problem that BM25 scores and cosine scores are not comparable.
Deletion
Ingestion never deletes anything. Duplicates are re-pointed, contradictions are stamped. Actual removal is one explicit call, remove_episode, and it cascades by two questions with different answers:
- Facts: a fact dies only if the removed episode was its first author. What first author means: position zero in the fact's episode list. Conversely, a fact first authored by a different episode survives.
- Entities: an entity dies only if the removed episode was its only mentioner.
Then the episode itself is deleted. Worth highlighting: there are three separate writes here, but no transaction. It took me a bit to get the concept: deletion and invalidation are entirely different mechanisms. Deleting removes rows; it does not set expired_at. And a fact that was invalidated by an episode keeps its stamps even if that episode is later deleted. In short, delete the first-author episode, the fact dies; delete the invalidator, the fact survives but stays closed.
Discussion
Do we actually need Neo4j?
Personally, I don't think so.
The core design maps cleanly onto a relational database like Postgres. Nodes become a table, bi-temporal edges become a table with four timestamp columns. Embeddings go to pgvector. And Graphiti barely uses what a graph engine is for: the deepest traversal in the entire codebase is that three-hop walk (off by default); the one genuinely graph-shaped algorithm, community detection, does not run in the graph engine. Instead, it runs in Python on the client after pulling the edge list out. Moreover, there are zero vector indexes. Every cosine search is a full scan the engine can't accelerate, pgvector would beat it, or really, anything with a vector index. Please don't get me wrong: Neo4j has its own HNSW index. Graphiti just does not use it.
It seems to me that language is what ties Graphiti to Neo4j instead of capability: every query in the codebase is written in Cypher, already forked across four Cypher-speaking backends. Postgres does not use Cypher. Adding Postgres as the backend would mean rewriting all the queries.
Is Graphiti enterprise-ready?
A mild hot take: no. Four reasons.
Auditability. The bi-temporal stamps are real. Every fact records when we learned it and when we stopped believing it. However, there is no way to use them for recovery: no as-of query ("what did the graph believe last Tuesday?") and no rollback. If a bad episode gets ingested, its pollution spreads. It lands in entity summaries, resolution decisions, and communities. Unfortunately, remove_episode won't save you: it's heuristic, non-transactional, and never repairs the summaries or communities the bad data already touched.
Access control. Tenancy is a group_id filter that callers cooperatively apply. Nothing enforces it at search time. Forget the filter and you search across every tenant.
Concurrency and idempotency. There are no uniqueness constraints in the schema. Two near-simultaneous ingestions of the same content can each conclude "no candidate exists" and mint nearly identical nodes and edges, and nothing prevents or repairs it.
Correctness at the edges. Maintaining this many search surfaces has produced real inconsistencies. One reranker, MMR, L2-normalizes its candidates but accepts the query raw, silently shifting its own tradeoff parameter. The embedding dimension defaults disagree across the library's surfaces (1024 in core, 1536 in the MCP server). And because no vector index exists, there is no schema contract on dimensionality. As a result, a mismatched vector can be accepted at write time and explode at some arbitrary later read, far from the cause.
Zep, the company behind Graphiti, presumably solves some of this in their hosted product. However, that's closed source, so the secret sauce is out of scope here. I still very much appreciate that the bi-temporal idea is open source. The path to enterprise hardening would take serious engineering thought. It is the effort that makes things worthwhile, isn't it?