How to Add Spreading Activation to Graph Search
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
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.
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 foundFor 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 bonusesFor 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 bonusesMemories 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 combinedAdd 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:
- Cache the entity neighbor sets in memory rather than querying a database for each lookup.
- Precompute the depth-2 neighbor expansion for frequently queried entities.
- Limit depth-2 propagation to entities with fewer than 100 connections to avoid hub entities (like "API" or "database") that connect to everything.
- Only compute spreading activation for memory candidates already returned by vector search, not for the entire memory store.
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