Graphiti Temporal Fact Extraction
What is Graphiti?
graphiti-service is a FastAPI wrapper around graphiti-core that runs as a Docker container on port 8300. It extracts temporal facts from conversation content and stores them in the Neo4j temporal knowledge graph.
Every memory upsert to memory-api triggers an async call to graphiti-service
(fail-soft — if graphiti-service is down or slow, the memory write to
Qdrant still succeeds immediately). The ingestion call is dispatched via
asyncio.create_task() so it never blocks the upsert response.
Why Temporal Facts?
Standard knowledge graphs store static assertions: "Alice works at Excalibur." Graphiti stores facts with temporal validity: "Alice joined Excalibur on 2024-01-15, became Tech Lead on 2025-06-01."
This temporal dimension lets you query your team's history with precision:
- "Who was the tech lead in Q3 2025?"
- "What was our fundraising target before the pivot in November?"
- "Which decisions were made by Alice before she moved to a new role?"
- "What did we know about competitor X six months ago?"
Without temporal tracking, every new fact overwrites the old one. With graphiti-core, both facts coexist as time-bounded edges in Neo4j — the full history is queryable.
Architecture
The ingestion pipeline is fully asynchronous and fail-soft. The primary write path (memory-api → Qdrant) completes in milliseconds; graphiti-service processes the content in the background:
Data flow — memory upsert to temporal graphmemory-api (POST /v1/memory/upsert)
│
├──► Qdrant (primary write — synchronous, <50ms)
│ returns 201 to caller immediately
│
└──► asyncio.create_task (fail-soft, non-blocking)
│
▼
graphiti-service (port 8300)
POST /v1/ingest
│
▼
graphiti-core (Python library)
- Extract entities (people, projects, concepts, dates)
- Extract relationships (LEADS, WORKS_ON, DECIDED, FUNDED)
- Detect temporal markers (since, until, from, before)
- Resolve contradictions via temporal edges
│
▼
Neo4j (temporal graph)
- Episode nodes (conversation chunks, source-tagged)
- Entity nodes (Alice, Excalibur, Q2-fundraising)
- Temporal edges (WORKED_AT, IS_TECH_LEAD, DECIDED)
each edge carries: valid_from, valid_until, confidence
Fail-soft design
If graphiti-service is unavailable (container restarting, Neo4j unreachable, rate limit hit), the memory upsert still returns 201. The graphiti call is logged as a failed background task. You lose temporal graph coverage for that chunk, but the semantic memory in Qdrant is never affected. This is intentional — graphiti-service is enrichment, not the primary write path.
Service API
Health Check
The health endpoint verifies that graphiti-service is alive and that graphiti-core has initialized successfully (including the Neo4j connection check):
bash — health checkcurl http://graphiti-service:8300/v1/healthz
# Response (200 OK):
{
"status": "ok",
"graphiti": true
}
Ingest Endpoint
The primary ingestion endpoint. Accepts content, a group identifier (team_scope), and a source tag. Returns 202 Accepted immediately — processing is asynchronous:
bash — ingest conversation contentcurl -X POST http://graphiti-service:8300/v1/ingest \
-H "Content-Type: application/json" \
-d '{
"content": "Alice confirmed the Q2 fundraising target is 2M€. Bob will lead the investor outreach starting next week.",
"group_id": "excalibur",
"source": "memory-api"
}'
# Response (202 Accepted):
{
"status": "queued",
"group_id": "excalibur"
}
Request body fields:
| Field | Type | Required | Description |
|---|---|---|---|
content |
string | Yes | The text to extract facts from (conversation chunk, document excerpt) |
group_id |
string | Yes | Team scope identifier — maps to Neo4j graph partition |
source |
string | Yes | Origin of the content (memory-api, drive-sync, etc.) |
Configuration
| Environment Variable | Required | Description |
|---|---|---|
NEO4J_URI |
Yes | bolt://neo4j:7687 (Docker internal DNS) |
NEO4J_USER |
Yes | neo4j (default) |
NEO4J_PASSWORD |
Yes | Set in .env — must match Neo4j container config |
OPENAI_API_KEY |
Yes | Required for embeddings (text-embedding-3-small). See warning below. |
ANTHROPIC_API_KEY |
Yes | LLM for entity extraction (Claude Haiku — fast and cheap) |
SEMAPHORE_LIMIT |
No | Max concurrent graphiti-core requests (default: 3 — for Anthropic Tier 1 rate limit) |
GRAPHITI_SERVICE_URL |
No | URL memory-api uses to reach graphiti-service (default: http://graphiti-service:8300) |
OPENAI_API_KEY is required even if you use Anthropic
graphiti-core uses OpenAI's text-embedding-3-small model for
embeddings. This is a hard dependency of graphiti-core — Anthropic embeddings
are not yet supported by the library. Even if you configure Claude Haiku as the LLM
for entity extraction, you must provide a valid OPENAI_API_KEY for
graphiti-service to function.
Embedding costs are minimal for typical usage: ~$0.02 per million tokens with
text-embedding-3-small.
Contradiction Detection
graphiti-core automatically detects when new facts contradict existing ones. Rather than overwriting, it creates temporal edges that capture the full history:
Example — temporal contradiction resolution in Neo4jEpisode 1 (2025-01-15):
"Alice is the Tech Lead of the engineering team."
Episode 2 (2025-06-01):
"Bob is now the Tech Lead. Alice moved to a product role."
→ graphiti-core detects the contradiction and creates temporal edges:
Alice -[WAS_TECH_LEAD: valid_from=2025-01-15, valid_until=2025-05-31]→ Excalibur.Engineering
Bob -[IS_TECH_LEAD: valid_from=2025-06-01, valid_until=null]→ Excalibur.Engineering
Both facts coexist. Querying for "tech lead in Q1 2025" returns Alice.
Querying for "current tech lead" returns Bob.
This contradiction resolution happens automatically — no manual curation required. graphiti-core uses the LLM (Claude Haiku) to understand whether two facts are contradictory, and what the temporal relationship is between them.
Querying the Graph
Query temporal graph data through memory-api (not directly from Neo4j).
The GET /v1/graph/neighbors endpoint returns entities and their
relationships for a given entity name within a team scope:
bash — query graph neighbors via memory-apicurl "https://api.grooveos.app/v1/graph/neighbors?entity=Alice&team_scope=excalibur" \
-H "Authorization: Bearer $JWT" \
-H "X-Team-Scope: excalibur"
# Response:
{
"entity": "Alice",
"team_scope": "excalibur",
"neighbors": [
{
"name": "Excalibur.Engineering",
"relationship": "WAS_TECH_LEAD",
"valid_from": "2025-01-15",
"valid_until": "2025-05-31"
},
{
"name": "Q2-Fundraising",
"relationship": "CONTRIBUTED_TO",
"valid_from": "2025-03-01",
"valid_until": null
}
]
}
Implementation Notes
graphiti_client lifespan() initialization
graphiti_client is initialized inside the FastAPI lifespan()
context manager — not at module load time. This is a requirement of
graphiti-core: the client must be created within an active asyncio event loop.
Initializing it at import time causes event loop conflicts that are difficult to debug.
The SEMAPHORE_LIMIT variable controls how many concurrent graphiti-core
calls are permitted. Anthropic Tier 1 accounts are limited to 5 requests/minute for
Haiku — set SEMAPHORE_LIMIT=3 to stay safely under the limit.