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.
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.
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:
Each memory activates linked memories. Retrieval is a path through a graph, not a ranked list.
Can AI memory systems work this way?
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);
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);
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;
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;
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;
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 }));
}
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;
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.
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);
}
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
...
)
Query: "Tell me about my first day"
Process:
// 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);
Goal: Find surprising connections between seemingly unrelated concepts
Approach:
-- 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."
Query: "What do I remember about Lance?"
Context: Agent is in negative emotional state (valence = -0.4)
Process:
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).
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)
-- Periodically remove links below threshold DELETE FROM memory_links WHERE strength < 0.05 AND co_activation_count < 3;
-- Cap traversal depth to prevent expensive queries max_depth = 3 -- typically sufficient
-- 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();
-- 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');
My current setup (PostgreSQL + pgvector):
-- 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;
-- 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;
-- Run nightly SELECT decay_links(); SELECT prune_weak_links(); SELECT create_semantic_links(); -- link semantically similar memories
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.