Home » ACT-R Cognitive Architecture » How to Add Spreading Activation to Graph Search

How to Add Spreading Activation to Graph Search

Spreading activation lets your retrieval system find contextually related memories that text similarity alone would miss. By propagating activation through entity connections in a knowledge graph, a query about "authentication errors" can surface memories about "JWT signing keys" and "session expiry" even when the words do not overlap. This guide walks through building the graph, propagating activation, and integrating the scores with your existing retrieval pipeline.

What Spreading Activation Does

In ACT-R, spreading activation models how thinking about one concept makes related concepts more accessible. When you think about "coffee," words like "mug," "morning," and "caffeine" become easier to recall, not because you explicitly thought about them, but because activation spread through associative links in your memory. The same mechanism works in retrieval systems: when a query activates certain entities, memories connected to those entities through the knowledge graph receive an activation boost.

Without spreading activation, retrieval depends entirely on text similarity between the query and stored content. This means you only find memories that use similar words or phrases. With spreading activation, you find memories that are about related topics even when the vocabulary is different. This is especially valuable in technical domains where the same concept has multiple names, or where the user's question is phrased differently from the stored answer.

Step-by-Step Implementation

Step 1: Build the entity graph.
The entity graph is a bipartite structure with two types of nodes: entities and memories. An edge connects an entity to every memory that mentions it. When you store a new memory, extract its entities using an LLM or NER model and create edges from each entity to the memory. Over time, entities that appear across multiple memories become hubs that connect related content.
class EntityGraph: def __init__(self): self.entity_to_memories = {} # entity -> set of memory IDs self.memory_to_entities = {} # memory ID -> set of entities self.entity_neighbors = {} # entity -> set of related entities def add_memory(self, memory_id, entities): self.memory_to_entities[memory_id] = set(entities) for entity in entities: if entity not in self.entity_to_memories: self.entity_to_memories[entity] = set() self.entity_to_memories[entity].add(memory_id) # build entity-to-entity edges (co-occurrence) for e1 in entities: for e2 in entities: if e1 != e2: if e1 not in self.entity_neighbors: self.entity_neighbors[e1] = set() self.entity_neighbors[e1].add(e2)

Entity-to-entity edges form automatically through co-occurrence: when two entities appear in the same memory, they become neighbors in the graph. This captures relationships without needing an explicit ontology. The more memories that mention both "PostgreSQL" and "connection pooling" together, the stronger their implicit association becomes.

Step 2: Extract query entities.
When a retrieval query comes in, parse it for entity references. You can use the same entity extraction method you use for memories (LLM-based extraction), or maintain an entity index and do fast string matching against known entities. The extracted entities become the source nodes for activation propagation.
def extract_query_entities(query, known_entities): """Fast extraction: check query against known entity set.""" query_lower = query.lower() found = set() for entity in known_entities: if entity.lower() in query_lower: found.add(entity) return found
Step 3: Propagate activation at depth 1.
For each query entity, look up all memories connected to that entity in the graph. Each connected memory receives a spreading activation bonus. The bonus can be uniform (every connection gets the same weight) or weighted by the number of shared entities between the query and the memory.
def spread_depth_1(query_entities, entity_graph): """Returns dict of memory_id -> activation bonus from direct entity matches.""" bonuses = {} for entity in query_entities: connected = entity_graph.entity_to_memories.get(entity, set()) for mem_id in connected: bonuses[mem_id] = bonuses.get(mem_id, 0.0) + 1.0 return bonuses
Step 4: Propagate activation at depth 2.
For each query entity, find its neighboring entities in the graph (entities that co-occur with it in at least one memory). Then look up memories connected to those neighbor entities. These depth-2 memories receive a reduced bonus, typically half the weight of depth-1 connections. This captures indirect associations: if the query mentions "API rate limiting" and the graph connects "rate limiting" to "Redis," memories about Redis configuration receive a small boost.
def spread_depth_2(query_entities, entity_graph, depth_2_weight=0.5): """Returns dict of memory_id -> activation bonus from indirect connections.""" bonuses = {} for entity in query_entities: neighbors = entity_graph.entity_neighbors.get(entity, set()) for neighbor in neighbors: if neighbor in query_entities: continue # already counted at depth 1 connected = entity_graph.entity_to_memories.get(neighbor, set()) for mem_id in connected: bonuses[mem_id] = bonuses.get(mem_id, 0.0) + depth_2_weight return bonuses
Why stop at depth 2? Deeper propagation introduces noise. At depth 3, you are finding memories that are connected to entities that are connected to entities that are connected to your query entities. The associative signal becomes too weak to be useful, and the computation cost grows quadratically with each additional depth level. ACT-R research supports limiting activation spread to 2 levels for optimal signal-to-noise.
Step 5: Cap and normalize.
Memories connected to many query entities can accumulate very high spreading activation values. Cap the total spreading activation per memory to prevent runaway scores, then normalize to a 0-1 range for blending with other score components. A practical cap is 5.0, which means a memory connected to five or more query entities at depth 1 receives the maximum spreading activation bonus.
def compute_spreading_activation(query_entities, entity_graph, max_spread=5.0): d1 = spread_depth_1(query_entities, entity_graph) d2 = spread_depth_2(query_entities, entity_graph) combined = {} all_ids = set(d1.keys()) | set(d2.keys()) for mem_id in all_ids: raw = d1.get(mem_id, 0.0) + d2.get(mem_id, 0.0) capped = min(raw, max_spread) combined[mem_id] = capped / max_spread # normalize to 0-1 return combined
Step 6: Blend with other score components.
Add the normalized spreading activation value to your combined retrieval score. In the standard ACT-R scoring pipeline, spreading activation typically receives 20% weight alongside vector similarity (40%), base-level activation (30%), and confidence (10%). Adjust these weights based on how important contextual associations are in your domain. Systems with rich entity relationships (medical, legal, engineering) benefit from higher spreading activation weight.

Performance Optimization

Spreading activation requires graph lookups at query time, which adds latency. For small graphs (under 10,000 entities), lookups are fast enough to run inline. For larger graphs, consider these optimizations:

With these optimizations, spreading activation adds 5 to 15 milliseconds per query even on graphs with hundreds of thousands of entity connections.

Adaptive Recall builds the entity graph automatically and runs spreading activation on every retrieval. No graph database required.

Get Started Free