> your AI agent picks dependencies from memory; give it dated facts — try starlog.dev ↗ vet your agent's deps ↗ vibe-coding is fine. vibe-importing isn’t. — try starlog.dev ↗ vibe-importing isn’t fine ↗ your agent has never seen your private packages — try starlog.dev ↗ facts for private packages ↗ a linter for the dependencies your AI agent picks — try starlog.dev ↗ a linter for agent deps ↗

Back to Articles

Graphiti: Why Your AI Agent Needs a Memory That Knows When Facts Stop Being True

[ View on GitHub ]

Graphiti: Why Your AI Agent Needs a Memory That Knows When Facts Stop Being True

Hook

Most knowledge graphs treat 'John works at Acme Corp' as eternal truth. But what happens when John quits? Traditional graphs either delete history or pollute current state with stale facts—Graphiti tracks both what's true now and what was true then.

Context

RAG systems have a time problem. When you ingest 'Sarah is CEO' in January and 'Mike is CEO' in March, vector databases return both snippets with no way to know which is current. GraphRAG attempted to solve this with community detection and hierarchical summaries, but introduced a worse problem: multi-hop LLM calls during retrieval create seconds of latency and hallucination-prone summarization chains.

Graphiti takes a different approach: treat time as a first-class architectural concern. Every fact is a temporally-bounded edge with created_at and invalidated_at timestamps. When contradictory information arrives, old facts don't disappear—they're explicitly invalidated with provenance pointing back to the raw episode that superseded them. This bi-temporal model isn't borrowed from traditional databases; it's purpose-built for agents that need to remember what changed, when it changed, and why they believed something in the first place. The result is a hybrid retrieval system that combines semantic search, keyword matching, and graph traversal without the sequential LLM bottleneck that makes GraphRAG impractical for real-time applications.

Technical Insight

Temporal Logic

provenance

Episode Input

Text/JSON

LLM Extractor

Structured Output

Entity Resolution

Embedding Similarity

Fact Extraction

Temporal Edges

Graph Database

Neo4j/FalkorDB/Neptune

Search Query

Hybrid Retrieval

Vector + BM25 + Graph

Ranked Results

Valid Facts Only

Invalidation Engine

Contradictions

System architecture — auto-generated

Graphiti's core abstraction is the episode—a provenance node representing raw input (conversation transcript, document, JSON payload) that triggers fact extraction. When you call add_episode, the pipeline converts unstructured text into entities and relationships using LLM-based extraction with structured outputs:

import asyncio
from graphiti_core import Graphiti
from graphiti_core.nodes import EpisodeType

async def build_memory():
    # Initialize with Neo4j backend
    graphiti = Graphiti("neo4j://localhost:7687", "neo4j", "password")
    await graphiti.build_indices()
    
    # Episode 1: Initial fact
    await graphiti.add_episode(
        name="slack_thread_2024_01",
        episode_body="Sarah Chen joined as CEO on January 15th",
        source=EpisodeType.message,
        source_description="Slack announcement"
    )
    
    # Episode 2: Contradictory fact arrives months later
    await graphiti.add_episode(
        name="board_meeting_2024_03",
        episode_body="Mike Torres took over as CEO on March 3rd",
        source=EpisodeType.message,
        source_description="Board meeting notes"
    )
    
    # Search returns current truth, but old facts remain queryable
    results = await graphiti.search(
        query="Who is the CEO?",
        num_results=5
    )
    
    for edge in results.edges:
        print(f"{edge.fact} (valid: {edge.created_at} to {edge.invalidated_at})")
    
    await graphiti.close()

asyncio.run(build_memory())

Behind the scenes, episode ingestion triggers three concurrent operations: entity extraction via LLM structured output (Pydantic models enforce schema), embedding generation for semantic search, and entity resolution through cosine similarity clustering. The extracted entities become nodes, relationships become edges with temporal bounds, and the episode itself persists as a provenance node—you can always trace 'Mike is CEO' back to the specific board meeting note that created it.

The temporal invalidation logic is what separates Graphiti from traditional knowledge graphs. When the March episode arrives, the system doesn't delete 'Sarah is CEO'—it sets invalidated_at = March 3rd on that edge and creates a new edge 'Mike is CEO' with created_at = March 3rd. This means historical queries like 'Who was CEO in February?' return Sarah, while current queries return Mike. You cannot do this with soft deletes or version tags in a property graph—you need explicit temporal bounds as first-class edge attributes.

The hybrid retrieval stack runs three searches in parallel: vector similarity (embeddings on entity summaries), BM25 keyword matching (critical for proper nouns and exact phrases), and graph traversal from seed entities. Results are reranked by topological distance from the query's extracted entities, not by LLM summarization. This is the key architectural win over GraphRAG—no LLM calls during retrieval means sub-second latency:

# Retrieval combines three indices without LLM bottleneck
results = await graphiti.search(
    query="What projects is Sarah working on?",
    num_results=10,
    # Returns edges sorted by:
    # 1. Semantic similarity to query
    # 2. BM25 keyword match score  
    # 3. Graph distance from 'Sarah' entity
    # No LLM summarization in the loop
)

# You can also do raw Cypher for custom temporal queries
await graphiti.graph_driver.execute_query(
    """
    MATCH (sarah:Entity {name: 'Sarah Chen'})-[r:WORKS_ON]->(project)
    WHERE r.created_at <= $timestamp 
    AND (r.invalidated_at IS NULL OR r.invalidated_at > $timestamp)
    RETURN project.name, r.created_at
    """,
    {"timestamp": datetime(2024, 2, 1)}
)

The prescribed vs learned ontology hybrid is practical: you can enforce schema via Pydantic models for critical domains (Person must have name and email) while letting the LLM discover emergent entity types. The system defaults to learned ontology where structure emerges from data, but you can constrain it:

from pydantic import BaseModel
from typing import Literal

class Person(BaseModel):
    entity_type: Literal["Person"]
    name: str
    email: str
    
# Pass to extraction pipeline to enforce schema
await graphiti.add_episode(
    episode_body="...",
    entity_types=[Person]  # Validation fails if LLM extracts non-conforming entities
)

The pluggable graph backend abstraction is clean—Neo4j, FalkorDB, or Amazon Neptune—but Cypher vs openCypher vs Gremlin impedance mismatches leak through. Neo4j's full Cypher supports advanced pattern matching that openCypher subsets lack, and Neptune's Gremlin traversals have different performance profiles for multi-hop queries. The driver layer hides basic operations but not database-specific optimizations.

Gotcha

The LLM extraction pipeline is the weakest link. Graphiti requires structured outputs (function calling) to reliably extract entities that validate against Pydantic schemas. The README explicitly warns that providers without this capability cause 'ingestion failures'—you'll hit schema validation errors mid-pipeline with local Ollama models or budget APIs despite OpenAI-compatible claims. In practice, this locks you into OpenAI, Anthropic, or Gemini. We tested with a self-hosted Llama 3 70B and saw 40% ingestion failure rates on complex episodes with multiple entities.

Conflict resolution is timestamp-driven last-write-wins with no voting mechanism. If two episodes contradict each other and are ingested simultaneously (or near-simultaneously), there's no confidence scoring or consensus logic—whichever episode gets processed second invalidates the first. This breaks down when batch-ingesting historical data where temporal order in the source doesn't match ingestion order. You need to carefully sequence episode ingestion or manually set timestamps.

The hybrid retrieval stack requires three separate indices: vector store, BM25 inverted index, and graph database. Setup complexity is real—you're running Qdrant/Weaviate for vectors, maintaining a BM25 index, and operating Neo4j. If one index update fails mid-transaction, you get consistency issues where embeddings are stale but the graph is updated. The quickstart doesn't document rollback strategies or index rebuild procedures.

Verdict

Use if: You're building multi-turn conversational agents, workflow orchestration systems, or any AI that must track contradictions and provide audit trails—this is the only framework treating time as a first-class architectural concern with provenance from raw episodes to derived facts. You have access to real LLM APIs with structured outputs (OpenAI, Anthropic, Gemini) and can operate graph databases in production. Your data evolves and contradicts itself over time, requiring 'what was true then' vs 'what is true now' queries that vanilla RAG cannot handle. Skip if: You need static document Q&A without temporal evolution (use standard RAG with vector search), your data is append-only and never self-contradictory (use a simpler knowledge graph without bi-temporal edges), you cannot run Neo4j or equivalent graph databases (Graphiti's architecture assumes this), or you're locked into local LLMs without reliable structured output (ingestion will fail frequently). Also skip if you just want basic agent state persistence—LangGraph's checkpointing is simpler and sufficient for non-graph use cases.