Graphiti Temporal Fact Extraction

Phase 5 graphiti-service • port 8300 • Neo4j temporal graph

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:

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.