Skip to main content

Graph Database Integration Guide

Info

Last Updated: 2026-01-09 | Version: v0.29.0+

Comprehensive guide for integrating a graph database with Cortex for advanced relationship queries.

Overview​

This guide covers everything you need to add graph database capabilities to Cortex: from deciding if you need it, to choosing the right database, to setup and integration.

What you'll learn:

  • When to add a graph database (vs using built-in Graph-Lite)
  • Which graph database to choose (Neo4j vs Memgraph vs others)
  • How to set up with Docker
  • How to integrate with TypeScript
  • Sync patterns and workers
  • Advanced query patterns

Prerequisites:

  • Cortex SDK installed and working
  • Convex deployment running
  • Basic understanding of graph databases
  • Docker (recommended for local development)

Section 1: Should You Add a Graph Database?​

Graph-Lite vs Native Graph​

Graph-Lite (built-in) is sufficient for:

  • Context hierarchies (depth 1-5)
  • Agent collaboration patterns (1-3 hops)
  • Audit trails with known paths
  • User data relationships

See Graph Capabilities for built-in graph features without external databases.

Add a native graph database when you need:

  • Deep traversals (6+ hops)
  • Complex pattern matching
  • Graph algorithms (PageRank, shortest path, centrality)
  • Sub-100ms query latency for multi-hop
  • Dense relationship networks

Performance Comparison​

DepthLatencyNotes
5 hops~500-800msMultiple sequential queries
10 hops2000ms+Impractical for real-time

Decision Matrix​

Your NeedGraph-LiteNative Graph DB
Context hierarchies (depth <5)PerfectOverkill
Audit trails (known paths)PerfectOverkill
Agent collaboration (1-3 hops)GoodBetter
Deep traversals (6+ hops)Too slowRequired
Pattern matchingVery hardRequired
Graph algorithmsNot feasibleRequired
Dense relationship networksSlowBest

Section 2: Choosing a Graph Database​

Quick Recommendation​

  • Development: Memgraph (fast, easy Docker setup)
  • Production: Neo4j Community (proven, mature, large community)
  • Enterprise: Cortex Graph-Premium (fully managed, zero DevOps)

Comparison Matrix​

DatabaseLicenseLocal DeployTypeScript SupportQuery LanguagePerformanceCommunityRecommendation
**Neo4j Community**GPL v3Docker/InstallExcellentCypher (native)ExcellentLargeBest overall
**Memgraph**BSLDockerExcellentCypher (compatible)ExcellentGrowingBest performance
**Kùzu**MITEmbeddedLimitedCypher (compatible)GoodSmallExperimental

Neo4j Community Edition​

Overview: Industry-standard graph database with Cypher query language.

Pros:

  • Most popular graph DB (large community)
  • Excellent documentation and tooling
  • Production-ready and battle-tested
  • Great TypeScript support (neo4j-driver)
  • Scales to billions of nodes

Cons:

  • GPL license (copyleft, may require legal review)
  • Heavier than alternatives
  • Enterprise features require commercial license

Best For: Production deployments, long-term projects

Memgraph​

Overview: High-performance in-memory graph database, Neo4j-compatible.

Pros:

  • Very fast (in-memory architecture)
  • Neo4j compatible (same Bolt protocol, Cypher language)
  • Works with neo4j-driver (same code as Neo4j!)
  • Modern and actively developed

Cons:

  • BSL license (source-available, not OSI open-source)
  • Requires sufficient RAM
  • Smaller community than Neo4j

Best For: High-performance requirements, development

License Considerations​

GPL v3 (Neo4j Community):

  • Free to use
  • Must open-source your application if you distribute it
  • SaaS usage: Generally okay if not competing with Neo4j
  • For Cortex integration: Likely fine (user runs Neo4j)

BSL (Memgraph):

  • Source-available, free for development
  • Commercial use allowed for most cases
  • Can't offer Memgraph "as a service"
  • After 4 years, converts to Apache 2.0

Our Recommendation: Start with Memgraph for development (fast, easy), use Neo4j for production (mature, proven).


Project-owned setup and runtime configuration​

SDK init/install and independently versioned template init call one shared optional graph setup service. Both Neo4j and Memgraph are supported; graph off does not require Docker.

cortex init agent --template basic --local --yes --graph neo4j --graph-mode local
cortex install ./existing-project --no-deploy --yes --graph memgraph --graph-mode local
cortex templates init demo --template vercel-ai-quickstart --template-version 1.2.0 --catalog-version 1.1.0 --yes --graph neo4j --graph-mode local
cortex graph setup . --graph neo4j --graph-mode local --yes --dry-run
cortex graph setup . --graph neo4j --graph-mode local --yes
cortex graph status .
cortex graph stop .
cortex graph start .

Local setup uses pinned images, loopback ports, private credential/env files and validated project-owned Compose resources. Stop preserves volumes. No global container names, port/PID guessing or unrelated resource deletion is performed. --graph-bolt-port and --graph-http-port select SDK/setup local ports; template init uses --bolt-port and --http-port. Repeated setup preserves existing credentials and data. --skip-graph conflicts with an explicitly enabled graph. Dry-run performs no configuration, dependency, credential, container or metadata writes.

An existing service uses --graph-mode existing --graph-uri bolt://localhost:7687 with a credential-free URI and that provider's environment tuple, or --graph-credentials for the SDK/setup private 0600 credential file. It is probed without graph data writes; external start/stop is not owned by the CLI.

CORTEX_GRAPH_SYNC=true
NEO4J_URI=bolt://localhost:7687
NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=your-private-password

For Memgraph use MEMGRAPH_URI, MEMGRAPH_USERNAME, MEMGRAPH_PASSWORD instead. The shared TypeScript SDK/provider resolver and its Python counterpart never mix the providers' credentials. Explicit off prevents driver work; explicit provider/connection selects one tuple; missing/ambiguous enabled configuration or failed connection reports an error.

import { Cortex, resolveGraphConnection } from "@cortexmemory/sdk";

const connection = resolveGraphConnection({ enabled: true });
const cortex = await Cortex.create({
convexUrl: process.env.CONVEX_URL!,
graphEnabled: true,
});
try {
// Use ordinary namespaced APIs with verified application scope.
} finally {
cortex.close();
await cortex.shutdown();
}

new Cortex() remains manual configuration; Cortex.create() performs asynchronous environment-based graph connection. Manual adapters are borrowed; disconnect them in their owner's finally block. Provider auto setup uses createCortexMemoryAsync with enableGraphMemory:true and awaited factory close(). Native deployment credentials are server-only transport initialization, distinct from application session verification.

Current managed synchronization uses a disjoint version 2 namespace with physical source identities, exact tenant/user/space scope, SourceState and queued jobs. Legacy/manual graph data is preserved rather than reinterpreted as managed ownership. Managed labels are CortexV2Memory, CortexV2Fact, CortexV2Conversation and the other CortexV2* kinds, keyed by captured physical source and exact scope. Unprefixed low-level Cypher examples below illustrate manual/legacy graphs; they do not select the current managed namespace or provide tenant authorization. Use the scoped SDK operations for managed data. Rebuild/erasure maintenance requires the matching fixed administrator transports (createGraphRebuildTransport, createGraphErasureTransport) and an explicit domain; an ordinary application AuthContext does not grant this capability. User/space cascade requires its separate erasure transport and matching graph participant. Readiness/reference migrations and unknown/partial receipts must be inspected before another mutation; a successful primary SDK deletion alone does not prove graph erasure.

Section 4: TypeScript Integration​

Install Dependencies​

npm install neo4j-driver
npm install --save-dev @types/neo4j-driver

Basic Connection​

import neo4j from "neo4j-driver";

// Create driver
const driver = neo4j.driver(
process.env.GRAPH_DB_URI || "bolt://localhost:7687",
neo4j.auth.basic(
process.env.GRAPH_DB_USER || "neo4j",
process.env.GRAPH_DB_PASSWORD || "password",
),
);

// Test connection
async function testConnection() {
const session = driver.session();

try {
const result = await session.run("RETURN 1 as test");
console.log("Graph DB connected:", result.records[0].get("test"));
} finally {
await session.close();
}
}

await testConnection();

Initialize Graph Schema​

async function initializeGraphSchema() {
const session = driver.session();

try {
// Create constraints (unique IDs)
await session.run(`
CREATE CONSTRAINT memory_space_id IF NOT EXISTS
FOR (ms:MemorySpace) REQUIRE ms.memorySpaceId IS UNIQUE
`);

await session.run(`
CREATE CONSTRAINT context_id IF NOT EXISTS
FOR (c:Context) REQUIRE c.contextId IS UNIQUE
`);

await session.run(`
CREATE CONSTRAINT fact_id IF NOT EXISTS
FOR (f:Fact) REQUIRE f.factId IS UNIQUE
`);

// Create indexes for common queries
await session.run(`
CREATE INDEX context_status IF NOT EXISTS
FOR (c:Context) ON (c.status)
`);

await session.run(`
CREATE INDEX fact_category IF NOT EXISTS
FOR (f:Fact) ON (f.factType)
`);

console.log("Graph schema initialized");
} finally {
await session.close();
}
}

await initializeGraphSchema();

Section 5: Syncing Cortex to Graph​

Use Cortex's built-in graph sync:

import { Cortex } from "@cortexmemory/sdk";
import { CypherGraphAdapter } from "@cortexmemory/sdk";

// Setup graph adapter
const graphAdapter = new CypherGraphAdapter();
await graphAdapter.connect({
uri: "bolt://localhost:7687",
username: "neo4j",
password: "cortex-dev-password",
});

// Initialize Cortex with graph
const cortex = new Cortex({
convexUrl: process.env.CONVEX_URL!,
graph: {
adapter: graphAdapter,
autoSync: true, // Auto-start sync worker
},
});

// Use Cortex normally - graph syncs automatically!
await cortex.memory.remember({
memorySpaceId: "agent-1",
conversationId: "conv-123",
userMessage: "I work at Acme Corp",
agentResponse: "Noted!",
userId: "user-123",
userName: "User",
});
// Automatically synced to graph!

Strategy 2: Manual Sync Functions​

For fine-grained control:

import {
syncContextToGraph,
syncMemoryToGraph,
syncFactToGraph,
} from "@cortexmemory/sdk";

// Sync a context
const context = await cortex.contexts.get("ctx-001");
const nodeId = await syncContextToGraph(context, graphAdapter);

// Sync a memory
const memory = await cortex.vector.get("agent-1", "mem-123");
await syncMemoryToGraph(memory, graphAdapter);

// Sync a fact
const fact = await cortex.facts.get("agent-1", "fact-456");
await syncFactToGraph(fact, graphAdapter);

Real-Time Sync Worker​

The auto-sync worker uses Convex reactive queries:

// Worker starts automatically with autoSync: true
const cortex = new Cortex({
convexUrl: process.env.CONVEX_URL!,
graph: {
adapter: graphAdapter,
autoSync: true,
syncWorkerOptions: {
batchSize: 100,
retryAttempts: 3,
verbose: true,
},
},
});

// Monitor worker health
const worker = cortex.getGraphSyncWorker();
if (worker) {
const metrics = worker.getMetrics();
console.log("Worker metrics:", metrics);
// { isRunning: true, totalProcessed: 150, queueSize: 3, ... }
}

Sync Context Example​

async function syncContextToGraph(context: Context) {
const session = driver.session();

try {
// Create context node
await session.run(
`
MERGE (c:Context {contextId: $contextId})
SET c.purpose = $purpose,
c.status = $status,
c.depth = $depth,
c.createdAt = $createdAt
`,
{
contextId: context.contextId,
purpose: context.purpose,
status: context.status,
depth: context.depth,
createdAt: context.createdAt,
},
);

// Create parent-child relationship
if (context.parentId) {
await session.run(
`
MATCH (child:Context {contextId: $childId})
MATCH (parent:Context {contextId: $parentId})
MERGE (child)-[:CHILD_OF]->(parent)
`,
{
childId: context.contextId,
parentId: context.parentId,
},
);
}
} finally {
await session.close();
}
}

Sync Facts with Entities​

async function syncFactToGraph(fact: FactRecord) {
const session = driver.session();

try {
// Create fact node
await session.run(
`
MERGE (f:Fact {factId: $factId})
SET f.fact = $fact,
f.factType = $factType,
f.confidence = $confidence
`,
{
factId: fact.factId,
fact: fact.fact,
factType: fact.factType,
confidence: fact.confidence,
},
);

// Create entity nodes and relationships
if (fact.entities) {
for (const entity of fact.entities) {
await session.run(
`
MERGE (e:Entity {name: $name, type: $type})
WITH e
MATCH (f:Fact {factId: $factId})
MERGE (f)-[:MENTIONS]->(e)
`,
{
name: entity.name,
type: entity.type,
factId: fact.factId,
},
);
}
}
} finally {
await session.close();
}
}

Section 6: Querying the Graph​

Basic Queries​

Find all contexts for a memory space:

async function getMemorySpaceContexts(memorySpaceId: string) {
const session = driver.session();

try {
const result = await session.run(
`
MATCH (c:Context)
WHERE c.memorySpaceId = $memorySpaceId
RETURN c
ORDER BY c.createdAt DESC
`,
{ memorySpaceId },
);

return result.records.map((r) => r.get("c").properties);
} finally {
await session.close();
}
}

Find context hierarchy:

async function getContextHierarchy(rootContextId: string) {
const session = driver.session();

try {
const result = await session.run(
`
MATCH (root:Context {contextId: $rootId})
MATCH path = (root)<-[:CHILD_OF*0..10]-(descendants:Context)
RETURN descendants
ORDER BY descendants.depth
`,
{ rootId: rootContextId },
);

return result.records.map((r) => r.get("descendants").properties);
} finally {
await session.close();
}
}

Find related facts via entities:

async function findRelatedFacts(factId: string) {
const session = driver.session();

try {
const result = await session.run(
`
MATCH (f:Fact {factId: $factId})
MATCH (f)-[:MENTIONS]->(e:Entity)<-[:MENTIONS]-(related:Fact)
WHERE related.factId <> $factId
RETURN related.factId as id,
related.fact as fact,
collect(DISTINCT e.name) as sharedEntities
ORDER BY size(sharedEntities) DESC
LIMIT 10
`,
{ factId },
);

return result.records.map((r) => ({
id: r.get("id"),
fact: r.get("fact"),
sharedEntities: r.get("sharedEntities"),
}));
} finally {
await session.close();
}
}

Advanced Queries​

Shortest path between user and agent:

async function findPathToMemorySpace(userId: string, memorySpaceId: string) {
const session = driver.session();

try {
const result = await session.run(
`
MATCH (u:User {userId: $userId})
MATCH (ms:MemorySpace {memorySpaceId: $memorySpaceId})
MATCH path = shortestPath((u)-[*..10]-(ms))
RETURN path
`,
{ userId, memorySpaceId },
);

if (result.records.length === 0) return null;

const path = result.records[0].get("path");
return {
length: path.length,
nodes: path.segments.map((s) => s.start.properties),
relationships: path.segments.map((s) => s.relationship.type),
};
} finally {
await session.close();
}
}

Combining Graph with Convex​

Best practice: Use Convex for primary data, graph for relationships:

async function hybridContextQuery(userId: string) {
// Step 1: Get user's contexts from Convex (authoritative)
const contexts = await cortex.contexts.list({
userId,
status: "active",
limit: 50,
});

// Step 2: For each context, get related contexts via graph
const enriched = await Promise.all(
contexts.map(async (context) => {
const session = driver.session();

try {
const related = await session.run(
`
MATCH (c:Context {contextId: $contextId})
MATCH (c)-[:PARENT_OF|CHILD_OF*1..3]-(r:Context)
RETURN DISTINCT r.contextId as contextId
`,
{ contextId: context.contextId },
);

return {
...context,
relatedContextIds: related.records.map((r) => r.get("contextId")),
};
} finally {
await session.close();
}
}),
);

return enriched;
}

Section 7: Performance & Best Practices​

Indexing Strategy​

-- Context queries
CREATE INDEX context_status FOR (c:Context) ON (c.status);
CREATE INDEX context_depth FOR (c:Context) ON (c.depth);

-- Fact queries
CREATE INDEX fact_category FOR (f:Fact) ON (f.factType);
CREATE INDEX fact_confidence FOR (f:Fact) ON (f.confidence);

-- Entity queries
CREATE INDEX entity_name FOR (e:Entity) ON (e.name);

Query Optimization​

// Good: Use parameters (query plan cached)
await session.run(
`
MATCH (c:Context {contextId: $contextId})
RETURN c
`,
{ contextId },
);

// Avoid: String concatenation (new plan each time)
await session.run(`
MATCH (c:Context {contextId: '${contextId}'})
RETURN c
`);

// Good: Limit results
MATCH (c:Context)-[:CHILD_OF*1..3]-(related)
RETURN related
LIMIT 100

// Good: Use DISTINCT
MATCH (c:Context)-[:CHILD_OF*1..3]-(related:Context)
RETURN DISTINCT related

Best Practices​

1. Convex is Source of Truth​

// Recommended: Always write to Convex first
await cortex.contexts.create({ ... });
await syncContextToGraph(context); // Then sync

// Avoid: Writing to graph first
await graphAdapter.createNode({ ... }); // Graph could succeed but Convex fail

2. Handle Sync Failures Gracefully​

// Good: App continues working if graph sync fails
try {
await syncToGraph(entity);
} catch (error) {
console.error("Graph sync failed (non-critical):", error);
// App continues, will retry later
}

3. Graph is Rebuildable​

// Graph should be rebuildable from Convex at any time
async function rebuildGraphFromConvex() {
console.log("Rebuilding graph from Convex...");

// Clear graph
const session = driver.session();
await session.run("MATCH (n) DETACH DELETE n");
await session.close();

// Re-sync all data
await initialGraphSync();

console.log("Graph rebuilt successfully");
}

4. Use Graph for Discovery, Not Storage​

// Good: Query graph for relationships
const related = await graphAdapter.query(`
MATCH (m:Memory)-[:REFERENCES]->(c:Conversation)
RETURN c.conversationId
`);

// Good: Fetch full data from Convex
for (const record of related.records) {
const conversation = await cortex.conversations.get(record.conversationId);
// Full data from authoritative source
}

Section 8: Troubleshooting​

Graph Out of Sync​

Problem: Graph returns stale data

Solution:

// Force re-sync
async function forceSyncContext(contextId: string) {
const context = await cortex.contexts.get(contextId);
await syncContextToGraph(context);
console.log(`Context ${contextId} re-synced to graph`);
}

// Verify sync
async function verifySync(contextId: string) {
const convexContext = await cortex.contexts.get(contextId);

const session = driver.session();
const graphContext = await session.run(
`
MATCH (c:Context {contextId: $contextId})
RETURN c
`,
{ contextId },
);
await session.close();

if (graphContext.records.length === 0) {
console.warn("Context missing from graph!");
await syncContextToGraph(convexContext);
}
}

Slow Graph Queries​

Solutions:

  1. Add indexes:
CREATE INDEX IF NOT EXISTS FOR (c:Context) ON (c.contextId);
  1. Limit depth:
-- Slow: Unlimited depth
MATCH (a)-[*]-(b) RETURN b

-- Fast: Limited depth
MATCH (a)-[*1..5]-(b) RETURN b
  1. Profile queries:
PROFILE
MATCH (c:Context {contextId: 'ctx-123'})-[:CHILD_OF*1..3]-(related)
RETURN related
-- Shows query plan and performance

Connection Issues​

// Test connection with better error handling
async function testGraphConnection() {
try {
const driver = neo4j.driver(
process.env.GRAPH_DB_URI!,
neo4j.auth.basic(
process.env.GRAPH_DB_USER!,
process.env.GRAPH_DB_PASSWORD!,
),
);

const session = driver.session();
await session.run("RETURN 1");
await session.close();

console.log("Graph DB connected successfully");
return true;
} catch (error) {
console.error("Graph DB connection failed:", error.message);
console.error("Check: URI, username, password, network access");
return false;
}
}

Section 9: Complete Integration Example​

Here's a full working example:

// app.ts
import { Cortex } from "@cortexmemory/sdk";
import {
CypherGraphAdapter,
initializeGraphSchema,
} from "@cortexmemory/sdk";

// Initialize graph adapter
const graphAdapter = new CypherGraphAdapter();
await graphAdapter.connect({
uri: "bolt://localhost:7687",
username: "neo4j",
password: "cortex-dev-password",
});

// Initialize schema
await initializeGraphSchema(graphAdapter);

// Initialize Cortex with graph
const cortex = new Cortex({
convexUrl: process.env.CONVEX_URL!,
graph: {
adapter: graphAdapter,
autoSync: true,
},
});

// Use Cortex normally
await cortex.memory.remember({
memorySpaceId: "agent-1",
conversationId: "conv-123",
userMessage: "I work at Acme Corp with Alice",
agentResponse: "Got it!",
userId: "user-123",
userName: "User",
extractFacts: async () => [
{
fact: "User works at Acme Corp",
factType: "relationship",
subject: "user-123",
predicate: "worksAt",
object: "Acme Corp",
confidence: 95,
},
],
});

// Graph query (after sync)
await new Promise((r) => setTimeout(r, 1000)); // Wait for sync

const session = graphAdapter.session();
const result = await session.run(`
MATCH (f:Fact)-[:MENTIONS]->(e:Entity {name: 'Acme Corp'})
RETURN f.fact as fact
`);
await session.close();

console.log(
"Facts about Acme Corp:",
result.records.map((r) => r.get("fact")),
);

Next Steps​


Questions? Ask in GitHub Discussions.