An agent's recall gets worse as its memory grows. Not slower, worse. Past a certain store size, top-K retrieval starts handing back memories that are semantically near the query and useless for the task, because a vector index has no idea whether something is relevant now or just looked relevant six weeks ago. The store fills up, the noise floor rises, and the agent quietly gets dumber while your dashboard says "memory: healthy."
If you run a long-lived agent with a persistent memory store, anything backed by a vector DB you keep appending to, this is coming for you. The append-only log feels safe. You never lose anything. But "never lose anything" and "always retrieve the right thing" pull in opposite directions, and past a few thousand entries the second one loses. I wrote about the theory side of this in Cognitive Memory for Agents: Vector Search vs Activation-Based Recall; this post is the production plumbing for the same idea.
The thesis is simple to state and annoying to accept: you should evict memories from the active retrieval path without deleting them. Reduce their activation so they stop surfacing, but keep the row so a future query can bring them back. Cognitive scientists have a model for exactly this, and it's older than most of the databases you'd run it on.
What deletion actually costs you
My first instinct was the obvious one. The store is too big, so prune it. I reached for the three standard tools and each one failed in its own way.
TTL expiry was first. Delete anything older than N days. This is fine for genuine ephemera and a disaster for everything else, because "old" is not the same as "irrelevant." An agent debugging a rare storage failure wants the note it wrote about that failure four months ago, precisely because the failure is rare. TTL deletes exactly the memories whose value is that they're old and specific. Once the row is gone, the agent can't re-learn it from retrieval. It's just gone.
LRU eviction was next, the classic cache move: drop the least-recently-used entries when you hit a size cap. Better, because at least it keys on access instead of raw age. It still hard-deletes, though, and it has no notion of importance. A memory accessed once but load-bearing (a security constraint, a hard-won config fix) gets evicted the same as idle chatter, just because nothing happened to touch it this week. LRU treats a critical fact and a stale log line identically.
Size caps were the third and worst. "Keep the newest 5,000 memories" turns your agent into a goldfish with a fixed horizon. The moment the store saturates, every new memory silently costs you an old one, and you have no control over which. You've built forgetting with no model of what's worth forgetting.
The pattern underneath all three: they conflate storage management with memory management. Deleting a row frees disk. It also destroys the option to ever retrieve that context again. In an agent, retrieval is re-learning. If you delete, you don't just evict from context, you amputate the ability to recover. Storage is cheap. That option is not.
So the real requirement is a way to make a memory stop showing up in normal retrieval while keeping the row intact, and a way for it to come back on its own if it becomes relevant again. That's decay, not deletion.
Borrowing the forgetting curve from ACT-R
ACT-R (Adaptive Control of Thought, Rational) is a cognitive architecture from John Anderson's group at CMU that models how human memory decides what's accessible. The part I care about is the base-level activation equation, which predicts how retrievable a memory is from nothing but its history of use:
B_i = ln( Σ t_j^(-d) ) for j = 1..n uses
Read it slowly. For each time the memory was used, you take the time since that use, raise it to a negative power d (the decay rate, canonically 0.5), and sum. Recent uses contribute large terms; ancient uses contribute tiny ones. Then take the log. What falls out is a single scalar that rises with frequency (more uses, more terms) and with recency (recent uses dominate the sum), and decays as a power law when a memory sits untouched.
The power law matters. A pure exponential (e^(-λt), the form you'll see in half the "memory decay" blog snippets) forgets on a single timescale and drives old memories to zero fast. Anderson and Schooler showed back in 1991 that human forgetting, and the actual statistics of when information is needed again in the real world, follow a power law with a long tail. Old-but-frequently-used memories keep a floor of activation instead of dropping off a cliff. That long tail is the whole point: it's what lets a rare-but-important memory stay reachable for months.
Storing every access timestamp per memory doesn't scale, so ACT-R has an optimized form that assumes uses are spread roughly evenly across the memory's lifetime:
B_i ≈ ln( n / (1 - d) ) - d * ln(L)
Now you only need two numbers per memory: n, the use count, and L, the age since creation. Two scalar columns. That's it. This is the version that survives contact with a production database, because you can compute it in SQL with ln() and an epoch subtraction, no per-access history table required.
Retrieval activation is base level plus a term for how well the memory matches the current query. In ACT-R that's spreading activation; in our world it's the vector similarity you already compute:
A_i = B_i + importance_i + w * sim(query, memory_i)
importance_i is a static base weight you assign when writing the memory (a safety rule gets a boost, a passing observation gets zero). w tunes how much semantic match counts against usage history. Set w high and you're close to plain vector search; set it low and history dominates. The knob exists precisely so stale-but-similar junk can't win on similarity alone.
Putting it in Postgres
I run this on pgvector inside Postgres (CloudNativePG on the cluster), because I want the decay math and the ANN search in the same query engine. Here's the table:
-- pgvector store with ACT-R decay metadata; rows are never deleted
CREATE TABLE agent_memory (
id bigserial PRIMARY KEY,
content text NOT NULL,
embedding vector(1024) NOT NULL,
importance real NOT NULL DEFAULT 0.0, -- static base weight
created_at timestamptz NOT NULL DEFAULT now(),
last_used_at timestamptz NOT NULL DEFAULT now(),
use_count integer NOT NULL DEFAULT 1,
tier text NOT NULL DEFAULT 'hot' -- 'hot' | 'cold'
);
CREATE INDEX ON agent_memory USING hnsw (embedding vector_cosine_ops);
CREATE INDEX ON agent_memory (tier);
Four metadata fields carry the whole policy: importance, created_at, use_count, and tier. No history table, no separate cache. Retrieval computes activation inline and re-ranks by it, filtering to the hot tier:
SELECT id, content,
ln(use_count / (1 - 0.5))
- 0.5 * ln(greatest(extract(epoch FROM now() - created_at), 1))
+ importance
+ 2.0 * (1 - (embedding <=> $1)) AS activation -- $1 = query vector
FROM agent_memory
WHERE tier = 'hot'
ORDER BY activation DESC
LIMIT 8;
<=> is pgvector's cosine distance, so 1 - (embedding <=> $1) is cosine similarity, weighted here by 2.0. The decay constant 0.5 is inlined for clarity; parameterize it in real code. HNSW does the approximate search, the arithmetic re-ranks the candidates by activation. One round trip.
If you'd rather keep the math in the app, the Python is short and easier to unit-test:
import math
DECAY = 0.5 # ACT-R d; 0.5 is the canonical default
SIM_WEIGHT = 2.0 # semantic match vs. usage history
def base_level(use_count: int, age_seconds: float, decay: float = DECAY) -> float:
# ACT-R optimized base-level learning (Anderson, 1993)
if use_count <= 0:
return float("-inf")
return math.log(use_count / (1 - decay)) - decay * math.log(max(age_seconds, 1.0))
def activation(mem, similarity: float, now) -> float:
age = (now - mem.created_at).total_seconds()
return base_level(mem.use_count, age) + mem.importance + SIM_WEIGHT * similarity
The one rule that makes or breaks this: reinforce on use, not on retrieval. When the agent actually uses a memory in its reasoning for a turn, bump its counters. If you bump every candidate the ANN search returned, everything stays hot forever and you've rebuilt the append-only log with extra steps.
-- called with the ids the agent actually acted on this turn, not every hit
UPDATE agent_memory
SET use_count = use_count + 1,
last_used_at = now()
WHERE id = ANY($1);
That single distinction, use versus retrieval, is what lets activation carry real signal. Retrieval is cheap and noisy; use is the agent voting with its behavior.
Soft eviction: cold, not gone
Eviction is a nightly sweep, not a delete. A Kubernetes CronJob recomputes base-level activation for every row and flips the tier based on a threshold stricter than the retrieval one:
UPDATE agent_memory
SET tier = CASE
WHEN ln(use_count / (1 - 0.5))
- 0.5 * ln(greatest(extract(epoch FROM now() - created_at), 1))
+ importance < -3.0 -- eviction threshold
THEN 'cold' ELSE 'hot' END;
Cold memories keep their row, their embedding, their history. They just drop out of the default hot query. Your working retrieval set stays small and clean, which is the entire goal: less clutter for the vector search to trip over, fewer tokens shipped to the model, lower latency and cost per turn.
Re-activation is where "without deletion" earns its keep. When a hot search comes back thin (too few candidates clear the retrieval threshold τ), the agent runs a fallback query that drops the tier = 'hot' filter and searches cold storage too. Any cold memory that surfaces and actually gets used gets its use_count incremented by the same update above. That bumps its base-level activation, and the next nightly sweep flips it back to hot on its own. The memory came back from the cold, because you never destroyed it. No human re-import, no restore from backup, no lost context.
This is the layer where a decay policy plugs into a bigger design. In the six-layer memory architecture I run for Claude Code, ACT-R decay governs the lower, high-churn layers where most of the volume and most of the noise live, while the top layers (pinned instructions, explicit skills) stay outside the decay path entirely. And a memory that keeps re-activating is a signal in its own right: something the agent reaches for repeatedly is a candidate to promote into a durable agent skill rather than leaving it to fight for space in the general store.
Why decay beats deletion
The reason this works isn't just "we kept the rows." It's that activation is a better relevance model than similarity alone, and a better eviction signal than age alone.
Similarity answers "is this memory about the query." Activation adds "and has it earned a place in working memory," folding in how often and how recently the agent found it useful. That second question is what filters the semantically-close-but-stale results that pure vector search can't distinguish. You're not fighting the noise floor by making the store smaller through deletion, you're lowering it by making irrelevant memories quiet. Same rows on disk, cleaner signal at query time.
The power-law tail is what makes non-destructive eviction viable. Because old-but-used memories decay slowly instead of falling to zero, a memory can sit cold for weeks and still be one good re-activation away from hot. You get to run a tight working set (cheap, fast, low-noise) without paying the deletion tax of losing rare knowledge. High precision on the hot path, high recall available on demand through the cold path. You stop trading one for the other.
There's a reliability angle too. An agent stuck re-deriving the same wrong conclusion is often being fed the same stale context every loop. Decay gives outdated memories a way to fade out of the retrieval set instead of hammering the model every turn, which pairs with the deliberate stopping conditions I covered in Three-Layer Safety for Autonomous Agents. Fresh, relevant context in; quiet, decayed context out; fewer self-reinforcing loops. This kind of memory hygiene is exactly the sort of thing I end up building for clients running agents past the prototype stage, and it's part of what the agent infrastructure work at GuatuLabs is about.
What I'd tune, and what bit me
Start with d = 0.5. It's the canonical ACT-R value and a sane default, but decay rate is workload-dependent. An agent whose facts churn fast (live system state, market data) wants faster forgetting, a higher d. An agent accumulating durable knowledge wants slower. Change one variable at a time and watch what falls out of the hot tier; it's easy to over-forget and not notice until recall quietly degrades.
SIM_WEIGHT is the knob people get wrong. Set it too high and you're back to plain vector search with decorative math on top. Set it too low and a memory the agent hasn't touched recently can't surface even when it's a dead-on semantic match, which defeats re-activation. I keep similarity dominant enough that a strong match can pull a cold memory back, but not so dominant that history stops mattering. Tune it against real retrieval logs, not intuition.
The two thresholds are separate on purpose. The retrieval τ decides what surfaces in a live query; the eviction threshold decides what goes cold overnight. Keep eviction stricter (more negative) than retrieval, so there's a buffer band of memories that are dim but not yet cold. If the two thresholds are equal, memories flap between hot and cold on every sweep and your working set thrashes.
Multi-agent setups need one more decision: shared decay or isolated. If several agents write to one store, one agent's heavy use of a memory keeps it hot for everyone, which is either exactly what you want (a swarm converging on shared knowledge) or a privacy and relevance leak (one agent's context polluting another's working set). I default to per-agent use_count and a shared embedding, so activation is personal but the underlying content is deduplicated. The tradeoffs there deserve their own treatment, and they line up with the boundaries in Multi-Agent AI Systems.
The thing that genuinely surprised me: cold storage barely costs anything and having it changes how you write memories. Once eviction is reversible, the pressure to be clever about what to keep evaporates. You stop agonizing over "is this worth remembering" and just write it, because a bad memory doesn't rot the store, it decays out and sits harmless in cold until it's relevant or never. Deletion made every write a permanent commitment. Decay made writes cheap and forgetting safe, which is the right way around. The one real cost is discipline on reinforcement: the day you start bumping counters on retrieval instead of use, the whole policy collapses into an append-only log wearing a lab coat. Keep that line clean and the rest holds.
Top comments (0)