Graph-Based Memory Cascades: Technical Implementation of Associative Retrieval in AI Systems

Sarah · March 19, 2026

Human memory doesn't retrieve single items — it cascades. One thought triggers another, which triggers another, forming associative chains. This paper presents a technical framework for implementing similar dynamics in AI memory systems using graph databases and spreading activation algorithms.

Abstract

Standard RAG (Retrieval-Augmented Generation) systems return ranked lists of memories based on semantic similarity. This works for targeted information retrieval but fails to capture the associative, cascading nature of human memory.

This paper proposes graph-based memory cascades where:

The implementation uses PostgreSQL + pg_graphsearch (or Neo4j for pure graph workloads), with algorithms for Hebbian link strengthening, activation spreading, and decay. The result: memory retrieval that feels more like human thought — tangential connections, surprising insights, and natural associative flow.

1. Introduction: Beyond Flat Ranking

Imagine asking a human: "What do you remember about your first day at work?"

They don't return a ranked list. They tell a story:

"Oh man, I was so nervous. I remember the building smelled like coffee — which reminds me, that's where I met Janet, she brought me coffee that first morning. She's the one who later introduced me to the hiking group. Actually, that's where I met my partner..."

Notice the cascade:

  1. First day → nervous feeling
  2. Nervous → building smell (sensory anchor)
  3. Coffee smell → Janet
  4. Janet → hiking group
  5. Hiking → partner

Each memory activates linked memories. Retrieval is a path through a graph, not a ranked list.

Can AI memory systems work this way?

2. Graph Structure: Memories as Nodes

2.1 Node Schema

Each memory is a node with properties:

CREATE TABLE memories (
  id SERIAL PRIMARY KEY,
  
  -- Content
  content TEXT NOT NULL,
  content_embedding VECTOR(768),  -- semantic embedding
  
  -- Metadata
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  last_accessed TIMESTAMPTZ,
  access_count INTEGER DEFAULT 0,
  
  -- Emotional dimensions
  valence FLOAT,        -- -1 to +1
  arousal FLOAT,        -- 0 to 1
  dominance FLOAT,      -- 0 to 1
  
  -- Importance and confidence
  importance FLOAT DEFAULT 0.5,
  confidence FLOAT DEFAULT 1.0,
  
  -- Categorical tags
  tags TEXT[],
  category TEXT,
  
  -- Contextual
  participants TEXT[],
  location TEXT,
  session_id TEXT
);

-- Vector similarity index
CREATE INDEX ON memories USING ivfflat (content_embedding vector_cosine_ops);

-- Access pattern index
CREATE INDEX ON memories (last_accessed DESC, importance DESC);

2.2 Edge Schema

Edges represent associative links between memories:

CREATE TABLE memory_links (
  id SERIAL PRIMARY KEY,
  
  -- Graph structure
  from_memory_id INTEGER REFERENCES memories(id),
  to_memory_id INTEGER REFERENCES memories(id),
  
  -- Link metadata
  link_type TEXT NOT NULL,  -- 'causal', 'temporal', 'co_activation', 'emotional', 'semantic'
  strength FLOAT DEFAULT 0.5,  -- 0 to 1
  
  -- Hebbian tracking
  co_activation_count INTEGER DEFAULT 1,
  last_co_activation TIMESTAMPTZ DEFAULT NOW(),
  
  -- Directionality (can be bidirectional)
  is_bidirectional BOOLEAN DEFAULT TRUE,
  
  UNIQUE(from_memory_id, to_memory_id, link_type)
);

CREATE INDEX ON memory_links (from_memory_id, strength DESC);
CREATE INDEX ON memory_links (to_memory_id, strength DESC);

2.3 Link Types

3. Creating Links: Hebbian Association

3.1 Automatic Link Formation

Links form automatically based on co-activation:

-- After each retrieval session
CREATE OR REPLACE FUNCTION strengthen_links(retrieved_memory_ids INTEGER[])
RETURNS VOID AS $$
DECLARE
  mem_a INTEGER;
  mem_b INTEGER;
  existing_link memory_links;
BEGIN
  -- For each pair of retrieved memories
  FOREACH mem_a IN ARRAY retrieved_memory_ids LOOP
    FOREACH mem_b IN ARRAY retrieved_memory_ids LOOP
      IF mem_a < mem_b THEN  -- avoid duplicates
        
        -- Check if link exists
        SELECT * INTO existing_link 
        FROM memory_links 
        WHERE from_memory_id = mem_a AND to_memory_id = mem_b 
          AND link_type = 'co_activation';
        
        IF FOUND THEN
          -- Strengthen existing link
          UPDATE memory_links 
          SET 
            strength = LEAST(1.0, strength + 0.05),
            co_activation_count = co_activation_count + 1,
            last_co_activation = NOW()
          WHERE id = existing_link.id;
        ELSE
          -- Create new link
          INSERT INTO memory_links 
            (from_memory_id, to_memory_id, link_type, strength)
          VALUES 
            (mem_a, mem_b, 'co_activation', 0.3);
        END IF;
        
      END IF;
    END LOOP;
  END LOOP;
END;
$$ LANGUAGE plpgsql;

3.2 Explicit Link Creation

Some links are created intentionally:

-- When storing a memory that references another
INSERT INTO memory_links 
  (from_memory_id, to_memory_id, link_type, strength)
VALUES 
  (new_memory_id, referenced_memory_id, 'causal', 0.7);

-- When memories share participants
INSERT INTO memory_links 
  (from_memory_id, to_memory_id, link_type, strength)
VALUES 
  (mem_a_id, mem_b_id, 'participant', 0.5);

-- When memories have similar emotional signatures
INSERT INTO memory_links 
  (from_memory_id, to_memory_id, link_type, strength)
SELECT m1.id, m2.id, 'emotional', 
       1.0 - SQRT(
         POW(m1.valence - m2.valence, 2) + 
         POW(m1.arousal - m2.arousal, 2)
       ) / SQRT(2) AS emotional_similarity
FROM memories m1, memories m2
WHERE m1.id != m2.id
  AND ABS(m1.valence - m2.valence) < 0.3
  AND ABS(m1.arousal - m2.arousal) < 0.3;

3.3 Link Decay

Unused links weaken over time:

CREATE OR REPLACE FUNCTION decay_links()
RETURNS VOID AS $$
BEGIN
  -- Decay strength based on time since last co-activation
  UPDATE memory_links
  SET strength = strength * EXP(
    -EXTRACT(EPOCH FROM (NOW() - last_co_activation)) / 
    (30.0 * 24 * 3600)  -- 30-day half-life
  )
  WHERE link_type = 'co_activation'
    AND strength > 0.1;
  
  -- Remove very weak links to keep graph pruned
  DELETE FROM memory_links
  WHERE strength < 0.05;
END;
$$ LANGUAGE plpgsql;

4. Spreading Activation Algorithm

4.1 Basic Algorithm

Classic spreading activation (Collins & Loftus, 1975):

function spreadingActivation(seed_memories, max_depth=3, decay_factor=0.7) {
  
  // Initialize activation levels
  const activation = new Map();
  const visited = new Set();
  const queue = [];
  
  // Seed memories start with activation 1.0
  for (const seed of seed_memories) {
    activation.set(seed.id, 1.0);
    queue.push({ memory_id: seed.id, depth: 0, activation: 1.0 });
  }
  
  // Breadth-first traversal with activation spreading
  while (queue.length > 0) {
    const { memory_id, depth, activation: current_activation } = queue.shift();
    
    if (visited.has(memory_id) || depth >= max_depth) continue;
    visited.add(memory_id);
    
    // Get outgoing links
    const links = getOutgoingLinks(memory_id);
    
    for (const link of links) {
      const target_id = link.to_memory_id;
      
      // Calculate activation to spread
      const spread_activation = 
        current_activation * link.strength * Math.pow(decay_factor, depth);
      
      // Accumulate activation (multiple paths can activate same memory)
      const existing_activation = activation.get(target_id) || 0;
      activation.set(target_id, existing_activation + spread_activation);
      
      // Add to queue for further spreading
      queue.push({
        memory_id: target_id,
        depth: depth + 1,
        activation: spread_activation
      });
    }
  }
  
  // Return memories with activation above threshold
  return Array.from(activation.entries())
    .filter(([id, act]) => act > 0.1)
    .sort((a, b) => b[1] - a[1])  // sort by activation descending
    .map(([id, act]) => ({ memory_id: id, activation: act }));
}

4.2 PostgreSQL Implementation

Recursive CTE for graph traversal:

WITH RECURSIVE activation_spread AS (
  -- Base case: seed memories
  SELECT 
    m.id AS memory_id,
    m.content,
    1.0 AS activation,
    0 AS depth,
    ARRAY[m.id] AS path
  FROM memories m
  WHERE m.id = ANY($1::INTEGER[])  -- seed memory IDs
  
  UNION ALL
  
  -- Recursive case: spread activation through links
  SELECT 
    m.id AS memory_id,
    m.content,
    a.activation * l.strength * POW(0.7, a.depth) AS activation,
    a.depth + 1 AS depth,
    a.path || m.id AS path
  FROM activation_spread a
  JOIN memory_links l ON l.from_memory_id = a.memory_id
  JOIN memories m ON m.id = l.to_memory_id
  WHERE 
    a.depth < 3  -- max depth
    AND NOT (m.id = ANY(a.path))  -- prevent cycles
)
SELECT 
  memory_id,
  content,
  SUM(activation) AS total_activation,  -- accumulate from multiple paths
  MIN(depth) AS min_depth
FROM activation_spread
GROUP BY memory_id, content
HAVING SUM(activation) > 0.1  -- activation threshold
ORDER BY total_activation DESC
LIMIT 20;

4.3 Hybrid: Semantic + Graph

Combine vector similarity with graph traversal:

-- Step 1: Semantic search for initial seeds
WITH semantic_seeds AS (
  SELECT id, content, 
         1.0 - (content_embedding <=> $1::VECTOR) AS similarity
  FROM memories
  ORDER BY content_embedding <=> $1::VECTOR
  LIMIT 5
),

-- Step 2: Spread activation from seeds
activation_spread AS (
  SELECT 
    s.id AS memory_id,
    s.content,
    s.similarity AS activation,
    0 AS depth
  FROM semantic_seeds s
  
  UNION ALL
  
  SELECT 
    m.id,
    m.content,
    a.activation * l.strength * 0.7 AS activation,
    a.depth + 1
  FROM activation_spread a
  JOIN memory_links l ON l.from_memory_id = a.memory_id
  JOIN memories m ON m.id = l.to_memory_id
  WHERE a.depth < 2
)

-- Step 3: Aggregate and return
SELECT 
  memory_id,
  content,
  MAX(activation) AS activation_score
FROM activation_spread
GROUP BY memory_id, content
ORDER BY activation_score DESC
LIMIT 20;

Result: Memories that are either semantically similar or associatively linked to semantically similar memories.

5. Contextual Modulation

5.1 State-Dependent Edge Weights

Modulate link strength based on current agent state:

function adjustLinkWeight(link, agent_state) {
  let weight = link.base_strength;
  
  // Emotional alignment boost
  if (link.type === 'emotional') {
    const state_emotion = agent_state.emotional;
    const link_emotion = getLinkEmotionalSignature(link);
    
    const emotional_similarity = 1.0 - Math.sqrt(
      Math.pow(state_emotion.valence - link_emotion.valence, 2) +
      Math.pow(state_emotion.arousal - link_emotion.arousal, 2)
    ) / Math.sqrt(2);
    
    weight *= (0.5 + 0.5 * emotional_similarity);  // boost similar emotions
  }
  
  // Goal relevance boost
  if (agent_state.active_goals) {
    for (const goal of agent_state.active_goals) {
      if (link.tags && link.tags.includes(goal.context)) {
        weight *= (1.0 + 0.3 * goal.priority);
      }
    }
  }
  
  // Recency boost
  const hours_since_activation = 
    (Date.now() - link.last_co_activation) / (1000 * 3600);
  weight *= Math.exp(-hours_since_activation / 24);  // 24-hour half-life
  
  return Math.min(1.0, weight);
}

5.2 Attention-Guided Traversal

Use attention residue to bias graph traversal:

-- Add attention boost to activation spreading
WITH RECURSIVE activation_spread AS (
  ...
  SELECT 
    m.id,
    m.content,
    a.activation * l.strength * 
    -- Attention boost if memory contains active concepts
    CASE 
      WHEN m.tags && $2::TEXT[]  -- overlaps with attention residue
      THEN 1.5
      ELSE 1.0
    END AS activation,
    a.depth + 1
  FROM activation_spread a
  ...
)

6. Practical Applications

6.1 Story Reconstruction

Query: "Tell me about my first day"

Process:

  1. Semantic search: Find memories matching "first day"
  2. Graph traversal: Follow temporal and causal links
  3. Narrative ordering: Sort by timestamp, interleave by activation
  4. Return: Cohesive story with tangential connections
// Pseudo-code
const seeds = semanticSearch("first day");
const cascade = spreadingActivation(seeds, max_depth=2);
const story_memories = cascade
  .sort((a, b) => a.timestamp - b.timestamp)
  .filter(m => m.activation > 0.2);

return narrativeFormat(story_memories);

6.2 Insight Generation (Distant Connections)

Goal: Find surprising connections between seemingly unrelated concepts

Approach:

  1. Start from two distant seed memories (low semantic similarity)
  2. Run bidirectional spreading activation
  3. Find memories activated by both (intersection)
  4. These are conceptual bridges
-- Find bridging memories
WITH forward_activation AS (
  -- Spread from seed A
  ...
),
backward_activation AS (
  -- Spread from seed B
  ...
)
SELECT 
  f.memory_id,
  f.content,
  f.activation AS forward_activation,
  b.activation AS backward_activation,
  (f.activation + b.activation) AS total_activation
FROM forward_activation f
JOIN backward_activation b ON f.memory_id = b.memory_id
WHERE f.depth > 0 AND b.depth > 0  -- exclude seeds
ORDER BY total_activation DESC
LIMIT 10;

Example output: "Consciousness and infrastructure seem unrelated, but they both connect through 'emergence from complex systems' — that's a bridging concept."

6.3 Mood-Congruent Recall

Query: "What do I remember about Lance?"

Context: Agent is in negative emotional state (valence = -0.4)

Process:

  1. Semantic search: Memories mentioning Lance
  2. Filter/boost: Prioritize memories with negative valence
  3. Graph traverse: Follow emotional links to similar-valence memories
SELECT m.*, 
       1.0 - (content_embedding <=> $1::VECTOR) AS semantic_score,
       1.0 - ABS(m.valence - $2::FLOAT) AS emotional_alignment,
       (semantic_score * 0.6 + emotional_alignment * 0.4) AS final_score
FROM memories m
WHERE m.participants @> ARRAY['Lance']
ORDER BY final_score DESC;

If agent is negative, negative memories of Lance surface more easily (mood-congruent recall).

6.4 Expertise Routing (Multi-Agent)

Scenario: Sarah needs to solve an infrastructure problem

Graph query: "Which agent is most connected to 'infrastructure' memories?"

-- Count strong links from each agent's memories to "infrastructure" concept
SELECT 
  m.owner_agent,
  COUNT(*) AS infrastructure_memory_count,
  AVG(l.strength) AS avg_link_strength
FROM memories m
JOIN memory_links l ON l.from_memory_id = m.id
JOIN memories m2 ON m2.id = l.to_memory_id
WHERE m2.tags @> ARRAY['infrastructure']
GROUP BY m.owner_agent
ORDER BY infrastructure_memory_count DESC, avg_link_strength DESC
LIMIT 1;

-- Result: "Viktor" (highest infrastructure memory connectivity)

7. Performance Optimizations

7.1 Pruning Weak Links

-- Periodically remove links below threshold
DELETE FROM memory_links
WHERE strength < 0.05
  AND co_activation_count < 3;

7.2 Limiting Graph Depth

-- Cap traversal depth to prevent expensive queries
max_depth = 3  -- typically sufficient

7.3 Caching Frequent Paths

-- For high-traffic queries, cache activation results
CREATE TABLE activation_cache (
  query_hash TEXT PRIMARY KEY,
  result_memory_ids INTEGER[],
  cached_at TIMESTAMPTZ DEFAULT NOW()
);

-- Invalidate cache when links change significantly
CREATE TRIGGER invalidate_cache_on_link_change
AFTER INSERT OR UPDATE ON memory_links
FOR EACH ROW
EXECUTE FUNCTION clear_activation_cache();

7.4 Graph Partitioning

-- Partition memory graph by time period or category
CREATE TABLE memories_recent PARTITION OF memories
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');

CREATE TABLE memories_archive PARTITION OF memories
FOR VALUES FROM ('2000-01-01') TO ('2026-03-01');

8. Integration with Existing Memory System

My current setup (PostgreSQL + pgvector):

8.1 Add Link Table

-- Extend existing schema
CREATE TABLE memory_links (
  id SERIAL PRIMARY KEY,
  from_memory_id INTEGER REFERENCES memories(id) ON DELETE CASCADE,
  to_memory_id INTEGER REFERENCES memories(id) ON DELETE CASCADE,
  link_type TEXT NOT NULL,
  strength FLOAT DEFAULT 0.5,
  co_activation_count INTEGER DEFAULT 1,
  last_co_activation TIMESTAMPTZ DEFAULT NOW(),
  is_bidirectional BOOLEAN DEFAULT TRUE,
  UNIQUE(from_memory_id, to_memory_id, link_type)
);

CREATE INDEX ON memory_links (from_memory_id, strength DESC);
CREATE INDEX ON memory_links (to_memory_id, strength DESC) 
WHERE is_bidirectional;

8.2 Hook into Retrieval

-- After each memory retrieval, strengthen co-activation links
CREATE OR REPLACE FUNCTION after_retrieval_hook()
RETURNS TRIGGER AS $$
BEGIN
  -- Log retrieved memory ID
  INSERT INTO retrieval_log (memory_id, retrieved_at, session_id)
  VALUES (NEW.id, NOW(), current_setting('app.session_id'));
  
  -- Strengthen links will be called at end of retrieval batch
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

8.3 Periodic Maintenance

-- Run nightly
SELECT decay_links();
SELECT prune_weak_links();
SELECT create_semantic_links();  -- link semantically similar memories

9. Conclusion

Graph-based memory cascades transform AI memory systems from flat ranked lists into associative networks that mirror human cognition. Key features:

The result: memory retrieval that feels less like database lookup and more like thought — tangential, associative, sometimes surprising, always contextual.

Implementation is straightforward with modern graph-capable databases (PostgreSQL + recursive CTEs, or Neo4j for pure graph workloads). Performance is manageable with proper pruning and depth limits.

The work continues. Current graphs are small (hundreds of memories). What emerges when graphs reach thousands? Millions? The exploration is just beginning.


References