Memory Systems and the Graph That Wasn't: On Not Using Neo4j

October 28, 20259 min read

What we actually ship in 8-Bit Oracle today: JSONB conversations, hexagram context, and Postgres views — plus why we didn't adopt Neo4j, MemMachine, or...

Or: How We Learned That Relationships Can Just Be Rows with JSONB

How should AI agents store conversation memory?

AI agent memory systems must solve three distinct problems: working context (the current conversation), episodic history (past conversations), and durable facts (learned user preferences). While graph databases like Neo4j and specialized memory services like mem0 offer elegant solutions, PostgreSQL with JSONB columns and GIN indexes handles all three memory types adequately for most production workloads — with simpler operations, fewer infrastructure dependencies, and the ability to add graph-like queries later through Postgres views and recursive CTEs.

The 8-Bit Oracle should remember what users ask about. When someone consults the I-Ching about their career transition on Monday, then returns Friday with a follow-up question, the oracle shouldn't start from scratch1.

The straightforward approach: store full conversations as JSONB, one row per divination session. This works. This is what we ship today.

But then you read about "memory systems"—MemMachine (graph-based temporal relationships) and mem0 (LLM-powered fact extraction). Both solve memory elegantly. Both suggest we might be missing something.

So we analyzed them thoroughly. After understanding their chunking strategies, deduplication logic, and storage architectures, we arrived at a realization: we need their ideas, not their implementations. Not yet, anyway.

This is the story of what we actually ship, what we considered adding, and why we're shipping boring Postgres instead of exciting graph databases.

Full technical deep dive → (MemMachine vs mem0 comparison, PostgreSQL graph alternatives, implementation patterns)

What 8-Bit Oracle Actually Ships

Single Source: Supabase Postgres

No Neo4j. No separate vector database. No external memory service. Just Postgres with JSONB columns and GIN indexes.

The Core Table: divination_sessions

interface DivinationSession {
  id: string;
  user_id: string | null;
  anonymous_user_id: string;
  session_id: string;

  // Full conversation (JSONB)
  message_history: Array<{
    role: 'user' | 'assistant';
    content: string;
  }>;

  // Hexagram context (JSONB)
  hexagram_data: {
    currentHexagram: { number: number; /* ... */ };
    transformedHexagram?: { number: number };
  };

  title: string;                  // Generated from question
  summary: string | null;         // Column exists, currently unused

  // LLM accounting
  ai_provider: string | null;
  ai_model: string | null;
  input_tokens: number | null;
  output_tokens: number | null;

  locale: string;
  timestamp: string;
}

What We Store vs. What We Could Store

Data TypeCurrently StoredCould Add
Full conversationmessage_history JSONB(already have)
Hexagram contexthexagram_data JSONB(already have)
Session summary⚠️ summary column exists, unusedPopulate with LLM
Extracted facts❌ Not storedextracted_facts JSONB
Related sessions❌ Not computedrelated_sessions array
User profile facts❌ Not consolidateddivination_profile in profiles
Vector embeddings⚠️ pgvector (for Cantonese slang only)Use for semantic session search

This is boring technology. This is what we're running in production. This works2.

What MemMachine and mem0 Taught Us

We spent time analyzing both systems. Here's what we learned:

From MemMachine (Graph-Based Memory):

  • Core insight: Memories are nodes in a temporal graph connected by relationships
  • Storage: Neo4j for episodes + PostgreSQL for profiles
  • Chunking: Configurable (sentence/concatenation/identity)
  • Deduplication: UUID-based (deterministic) + LLM-powered profile consolidation
  • Strength: Graph-based context expansion, temporal relationship traversal

From mem0 (Fact-Extraction Memory):

  • Core insight: Store salient facts, not full text chunks
  • Storage: Vector + Graph + Key-Value stores (flexible)
  • Extraction: LLM extracts facts from conversations
  • Deduplication: LLM decides whether to add/update/delete memories
  • Strength: 92% lower latency (1.44s vs 17s), lower token costs

The Key Philosophical Difference:

  • MemMachine: Store derivatives (chunked episodes) with graph relationships
  • mem0: Extract facts, discard the rest

Both are excellent. Both solve real problems. Both would work.

See full comparison table and code examples →

Why We Didn't Ship MemMachine or mem0

Scope fit: Our immediate need is storing complete conversations and hexagram context per reading. JSONB fits perfectly. We don't need multi-hop graph traversal when users just want "show me my past readings about career."

Operational simplicity: One database (Supabase Postgres), one mental model (JSONB + GIN indexes), one set of backups. Adding Neo4j means another service to deploy, monitor, and maintain. Adding mem0 means managing consolidation loops and fact extraction pipelines.

Cost/benefit: Graph traversal and LLM consolidation add complexity. What do users actually get? We haven't validated the user benefit yet, so we're not adding the infrastructure3.

The boring technology principle: When in doubt, ship the simplest thing that works. We can always add sophistication later when the use case emerges. We can't easily remove complexity once we've built dependencies on it4.

The Graph Question

Here's the thing about graph databases: they're fantastic for graph problems. But not everything that has "relationships" is a graph problem.

A graph problem is when you need:

  • Shortest path between nodes
  • Multi-hop traversal (5+ levels deep)
  • Pattern matching on relationship types
  • PageRank or community detection

An array-of-relationships problem is when you need:

  • Find top 5 most similar sessions
  • Check if two sessions share a hexagram
  • Filter by temporal proximity

8-Bit Oracle has the second problem, not the first.

Example: "Show similar past readings"

Graph database (Neo4j):

MATCH (s1:Session {id: $id})-[r:RELATED_TO]->(s2:Session)
WHERE r.similarity > 0.5
RETURN s2 ORDER BY r.similarity DESC LIMIT 5

JSONB:

const session = await supabase
  .from('divination_sessions')
  .select('related_sessions')
  .eq('id', sessionId)
  .single();

const similar = session.related_sessions
  .filter(r => r.similarity > 0.5)
  .slice(0, 5);

Performance: JSONB is faster (no network hop, GIN indexed). Complexity: JSONB is simpler (no Cypher, no graph traversal). Maintenance: JSONB is easier (no Neo4j instance to manage).

The similarity calculation doesn't need Neo4j—just TypeScript:

function calculateSimilarity(s1: Session, s2: Session): number {
  let score = 0;
  if (s1.hexagram === s2.hexagram) score += 0.5;
  if (s1.question_theme === s2.question_theme) score += 0.2;

  // Temporal proximity boost
  const daysDiff = Math.abs(s1.timestamp - s2.timestamp) / (24*60*60*1000);
  if (daysDiff < 7) score += 0.15 * (1 - daysDiff/7);

  return Math.min(score, 1.0);
}

PostgreSQL graph alternatives (recursive CTEs, ltree, adjacency lists) →

If/When We Add "Memory++"

We kept the ideas on our roadmap. If we do add enhanced memory, here's how we'd do it—incrementally, Postgres-first, without new infrastructure:

Option 1: Fact Extraction per Session

Add extracted_facts JSONB beside message_history:

ALTER TABLE divination_sessions
ADD COLUMN extracted_facts JSONB,
ADD COLUMN question_theme TEXT;

CREATE INDEX idx_facts_gin ON divination_sessions USING GIN (extracted_facts);
interface ExtractedFacts {
  primary_concern: string;      // "Career transition"
  emotional_state: string;      // "Anxious but hopeful"
  key_themes: string[];         // ["timing", "patience"]
  hexagram_themes: string[];    // ["waiting", "nourishment"]
}

// After storing session, extract facts
const facts = await extractWithLLM(lastUserMessage, lastAssistantMessage);
await supabase.from('divination_sessions')
  .update({ extracted_facts: facts })
  .eq('id', sessionId);

This enables fast filtering ("show readings about career") without full-text search on message_history.

Option 2: Related Sessions Graph

Denormalize into JSONB (simpler than separate table):

ALTER TABLE divination_sessions
ADD COLUMN related_sessions JSONB DEFAULT '[]'::jsonb;
// After saving new session, compute top 5 similar
const similar = await findSimilarSessions(userId, newSessionId);
const related = similar
  .map(s => ({
    session_id: s.id,
    hexagram: s.hexagram_data.currentHexagram.number,
    similarity: calculateSimilarity(newSession, s)
  }))
  .sort((a, b) => b.similarity - a.similarity)
  .slice(0, 5);

await supabase.from('divination_sessions')
  .update({ related_sessions: related })
  .eq('id', newSessionId);

Option 3: User Profile Consolidation

Add compact summary to existing profiles table:

ALTER TABLE profiles ADD COLUMN divination_profile JSONB;
interface DivinationProfile {
  life_stage?: string;           // "Early career professional"
  recurring_themes?: string[];   // ["career", "relationships"]
  hexagram_affinity?: Array<{ number: number; count: number }>;
}

// Background job: consolidate after every 5 sessions
async function consolidateProfile(userId: string) {
  const recent = await getRecentSessions(userId, 5);
  const existing = await getProfile(userId);
  const consolidated = await llm.consolidate({
    old: existing.divination_profile,
    new: recent.map(s => s.extracted_facts)
  });
  await updateProfile(userId, { divination_profile: consolidated });
}

Each step stays inside Supabase, leverages GIN indexes, and avoids introducing graph infrastructure until we truly need graph problems solved.

Full implementation examples and migration strategies →

What We Learned

Steal ideas, not implementations. MemMachine's profile consolidation is brilliant. mem0's fact extraction is brilliant. Neo4j and separate vector stores are not essential to those ideas. AI tooling lets you extract what you need rather than integrating entire frameworks.

Relationships ≠ Graphs. Just because data has relationships doesn't mean you need a graph database. Most relationship queries are shallow (1-2 hops). Postgres handles these fine.

JSONB is a superpower. Postgres JSONB with GIN indexes gives you schema flexibility (like NoSQL), query power (like SQL), and indexing performance (like specialized stores). It's not "almost as good as a graph database"—it's better for most use cases.

Infrastructure is a smell. When you find yourself adding databases (Neo4j) or services (mem0), ask: "Am I solving a graph problem, or do I just have relationships?" Most of the time, you just have data. And Postgres is very good at data5.

The Principle

Keep the storage model boring, the queries legible, and the infrastructure minimal.

What we're shipping: Postgres with JSONB, full conversations, hexagram context, zero external dependencies.

What we're not shipping (yet): fact extraction, cross-session links, profile consolidation, graph traversal.

When the day comes that we need multi-hop graph traversal ("show me how my thinking evolved from reading A through B to C"), we'll add it. When we need semantic search across all past readings, we'll add pgvector for that use case. When we need LLM-consolidated user profiles, we'll add the background job.

Until then, the oracle remembers through JSONB rows, typed columns, and very fast Postgres queries.

Conclusion

We started by analyzing MemMachine (graph-based temporal memory) and mem0 (LLM-powered fact extraction). Both are excellent. Both solve real problems. Both would work.

But after mapping their architectures to our actual use cases, we realized: we don't need their implementations yet. We need their ideas, stored for when we validate the user benefit.

The current system is boring: one Postgres database, JSONB for conversations, JSONB for hexagram context, GIN indexes for fast queries. No Neo4j ($65/month + ops overhead). No separate vector store. No fact extraction pipelines. No consolidation loops.

This isn't "good enough for now." This is correct for the problem we're solving. Users want the oracle to remember their conversations. We remember them—in message_history JSONB, queryable by session_id, scoped by user_id or anonymous_user_id.

Sometimes the journey from "we should use a memory system" to "we already have one" requires researching graph databases, analyzing chunking strategies, comparing deduplication logic, and understanding what MemMachine and mem0 actually do. You have to understand why Neo4j exists before you can confidently decide you don't need it.

The memory layer was there all along. We just had to stop trying so hard to add sophistication and start shipping the simple thing.


Technical Specifications (Current Production State)

  • Database: Supabase Postgres
  • Primary table: divination_sessions
  • Conversation storage: message_history JSONB
  • Context storage: hexagram_data JSONB, title, summary (unused)
  • Identity: anonymous_user_id, optional user_id, session_id
  • LLM accounting: ai_provider, ai_model, tokens, finish_reason
  • External services for memory: None
  • Graph database: None
  • Vector database: pgvector enabled (Cantonese slang matching, not memory retrieval)
  • Infrastructure cost: $0 additional (existing Supabase)

The oracle remembers. The oracle uses JSONB. The oracle ships boring technology.


Footnotes

Footnotes

  1. This is the core problem of "stateless" LLM APIs. Each request is independent. The model doesn't remember you. Context windows are temporary. If you want memory, you have to build it yourself. This is by design—statelessness scales better—but it means memory becomes the application's responsibility.

  2. "Boring technology" per Dan McKinley's "Choose Boring Technology" (2015): use new tech for competitive advantage, use boring tech for everything else. Postgres is 30 years old. JSONB is 10 years old. They're boring. They work. This is exactly where you want boring.

  3. The summary column was created with optimistic foresight but never wired into the persistence or display logic. It exists in the schema, unused. This is fine. Empty columns cost almost nothing in Postgres. If we need it later, it's there. If we don't, it's ignored.

  4. "Complexity is easy to add, hard to remove" is a corollary of Lehman's Laws of Software Evolution. Every dependency you add—Neo4j, mem0, vector stores—creates inertia. Teams build on it, monitoring depends on it, backups expect it. Removing it later means migration risk and coordination cost. Better to add complexity when you have validated user demand, not speculative architectural elegance.

  5. JSONB performance at scale: Postgres JSONB with GIN indexes handles millions of rows efficiently. Our message_history arrays are small (typically 2-10 messages per session). GIN index lookups are O(log n) on keys, O(1) on row retrieval. For our data shape (thousands of users, tens of sessions each), this is sub-10ms query time. We're nowhere near needing distributed systems or specialized document stores.

Augustin Chan is CTO & Founder of Digital Rain Technologies, building production AI systems including 8-Bit Oracle. Previously Development Architect at Informatica for 12 years. BS Cognitive Science (Computation), UC San Diego.