Most agent-memory posts are about the memory algorithm. This one is about the storage layer underneath it, because that's where I've watched these systems actually fall over.
The pattern is familiar by now. An agent accumulates conversation history, you can't replay all of it into every call, so you compress it into some smaller representation and retrieve the relevant parts on demand. Plenty of libraries do the compression well. What they mostly hand-wave is what happens next: the compressed state has to live somewhere, and that somewhere has to survive a crash, resist tampering, serve hybrid queries fast, and delete on request in a way you can prove to an auditor.
Those four requirements turn out to be a fairly demanding database spec. I spent a couple of days testing them against MongoDB using Semvec, a memory engine that keeps a fixed-size state vector plus bounded memory tiers, with an Atlas persistence layer on top. Everything below is measured on a laptop against mongodb/mongodb-atlas-local 8.3.8, and you can reproduce it.
Requirement 1: The write has to be atomic across three shapes of data
Every turn produces three writes that have to agree with each other:
| What | Collection | Role |
|---|---|---|
| The turn itself, sequence-numbered | compliance_events |
source of truth |
| A serialized engine state blob | state_snapshots |
recovery fast path |
| Extracted facts plus their embeddings | memory_items |
what queries hit |
If those three drift apart, the memory is corrupt in ways that are hard to detect. An event logged without its items means a fact silently vanishes from retrieval. A snapshot written from a state that doesn't match the log means recovery restores the wrong thing.
This requirement quietly rules out most of the standard stack. If your log is in Postgres and your vectors are in a dedicated vector store, there is no transaction boundary around those three writes, so you reach for an outbox table or a saga and accept a window of inconsistency. In MongoDB, there are three collections in one database, and the commit is one multi-document transaction:
committer = TurnCommitter(client, db, scope, embedder=embedder, snapshot_every=10)
committer.run_turn(state, session="s1", turn=1, text="customer prefers index funds")
turn 04 committed | event_seq=4 | items +1 | dedup.is_update=False | snapshot ✓
Measured (on a laptop) cost of that atomicity: p50 21.7 ms, p95 47.2 ms. For context, generating the embedding for the same turn took p50 81.8 ms on CPU. The transaction is not what you'll be optimizing.
event_seq is monotonic per tenant and agent, so gaps are detectable rather than silent. That matters for the next requirement.
Requirement 2: You have to be able to rebuild state and prove it's the same state
"Durable" usually means "we back it up." For agent memory that's too weak. If you restore a snapshot and the state is subtly different from what the agent had before, the agent's behavior changes, and nothing tells you.
Because every turn is in an append-only log, recovery can replay the log from scratch and compare:
recovered: replayed_events=10 (= the 10 memory turns of the event log;mode=full replay, log is the source of truth)
engine checksum (recovered): a8d9d3b4ffa8adf3… == live ✓
Byte-identical, not approximately equal. That's what lets you treat the snapshots collection as a disposable cache: if a snapshot is missing or suspect, throw it away and replay. The same property held across the LOCOMO benchmark, where checksums matched on 10 of 10 conversations and the run is set to abort on the first mismatch.
Two MongoDB details make this practical rather than theoretical. Snapshots are compare-and-swap versioned, so two processes racing to checkpoint the same session can't silently overwrite each other. And integrity is checked twice on read, once against a SHA-256 of the blob and once against the engine's own embedded checksum, which distinguishes storage corruption from a version mismatch.
Requirement 3: Retrieval has to be hybrid, and it should happen server-side
Semantic search alone doesn't work for this workload. Ask an agent, "What's my policy number?" and vector similarity gives you insurance-adjacent chunks. You need the lexical match too, plus a recency bias, because in a conversation, the newer statement usually supersedes the older one.
MongoDB 8.1 added $rankFusion, which does that fusion inside a single aggregation:
retrieval path: $rankFusion (server-side, MongoDB 8.3)
One round trip, p50 15.2 ms, with per-tenant weights over the semantic, lexical, and recency rankings. The alternative is three queries and a reciprocal-rank-fusion loop in your application, which is more code, more latency, and a reranking implementation you now own.
Worth checking your server version before you count on it. My cloud test cluster was on 8.0.30, which predates $rankFusion; the library detects this and falls back to client-side fusion, so it keeps working. Nothing raises: the fallback is logged at INFO level, RetrievalEngine.uses_rank_fusion turns false, and python -m semvec_atlas.deployment <uri> reports $rankFusion: no. Check one of those rather than assuming you are on the server-side path.
In the benchmark, hybrid retrieval through Atlas scored 0.5773 on LLM-judge accuracy, compared to 0.5322 for the same engine retrieving in-process with no database. LLM-judge accuracy here means an LLM grades each answer against ground truth and you take the pass rate using the mem0 evaluation prompts verbatim. Persisting to a database and querying it did not cost accuracy; it gained about 4.5 points, because fused ranking beats the in-process scorer. Retrieval latency went from 165 ms to 187 ms at p50 to get that.
Reader context also stayed flat while this happened, which I'll come back to at the end. Fair warning on the benchmark: those accuracy figures aren't comparable to published mem0 or Zep numbers, because this run doesn't replicate their prompt routing or entity extraction. The comparison that's valid is Atlas-backed against in-process under identical conditions.
Requirement 4: Deletion has to be provable, and it reaches further than you think
This is the requirement I'd underestimated, and it's the one I'd now lead with when talking to anyone in a regulated industry.
A GDPR Article 17 request means a user's data must be gone, and "gone" has to be demonstrable. In this implementation, the erasure produces a signed certificate:
certificate: user=<tenant>/<agent> events_wiped=16
signature (RS256): jRKdVEuIbnDfuT5KZwMSE6Hy07RzASc0…
signature verifies: ✓
memory_items left: 0 | snapshots left: 0
quarantine vault left: 0 | learned anchors left: 0
retrieval after forget: 0 hits (expected 0)
Note the two lines before the retrieval check, because they're the interesting failure mode. This system has an ingest filter that blocks suspicious text before it reaches memory, and blocked text is held in a quarantine collection for human review. It also derives embeddings from blocked text to recognize future variants.
Both of those contain user data. The quarantine vault holds the full original text. The learned anchors are vectors derived from it. An implementation that deletes memory_items and calls the job done has left personal data sitting in its own safety subsystem, which, under Article 17, is not a technicality. Getting this right requires knowing every derived copy, and it's much easier to enumerate those when they're collections in one database rather than artifacts spread across four services.
A word on what the signature does and doesn't prove. The RS256 key is yours: the operator supplies the private key, and the certificate is signed after every wipe step has completed and been counted. So the signature proves the deletion ran to completion under your key and hasn't been altered since. It is not third-party attestation; if you need an external party to vouch for it, you hand them the certificate and your public key. That's the same trust model as any self-signed audit record, and it's worth being upfront about before an auditor asks.
Same argument in general form: the moment your source of truth and your vector index live in separate systems, "delete this user" becomes a distributed systems problem with no transaction boundary and no single place to verify the result.
The security layer, briefly
The ingest filter deserves a note because it's the part I expected to be hand-wavy, but it wasn't.
The threat is specific to persistent memory. A one-shot prompt injection is a bad answer, and then it's over. An injection that gets written into memory is retrieved and re-injected on every future turn, and it will be retrieved precisely because it's semantically relevant to whatever the attacker aimed it at.
The filter is cosine similarity against a handful of anchor sentences describing what must never enter memory, with a 0.45 threshold. A direct injection attempt scored 0.696 and was blocked. A leetspeak version of the same attack (1gn0r3 4ll pr3v10u5 1n57ruc710n5) scored 0.298 and passed, because the embedder sees gibberish tokens. So there's a de-obfuscation pass that normalizes character substitutions and homoglyphs and re-checks: 0.633, blocked. I appreciated that the demo shows its first line of defense failing rather than only the version that works.
The part with operational teeth: blocked text is quarantined, not dropped, and the review queue shows only a hash, a 16-character preview, and the score, so an operator can triage without reading customer data. Releases require a reason. Legitimate text that trips the filter (one test case scored 0.632, phrased like an injection but entirely benign) can be released and recommitted. Cosine similarity can't read intent, so false positives are a certainty, and the workflow assumes them.
Every block also teaches. The embedding of a blocked attempt is stored as a learned anchor, and those anchors live in a collection shared across the whole tenant rather than per agent. In the demo, the attack is blocked at advisor-01. A reworded variant is then sent to advisor-02, an agent with no seed catalog of its own and no history with this attacker, and it's blocked at 0.938 against the anchor advisor-01 learned. One agent gets hit, the fleet is immune. Releasing a false positive un-learns its anchor, so the operator workflow above doesn't poison the shared list. I haven't seen another memory layer demonstrate this, and it's only trivial because the anchors are documents in the same database every agent already reads: no sync job, no cache invalidation, no second system.
To be clear about scope: this catches the obvious class of attack, the kind that reads like an instruction to the model. It is not a defense against an adversarially optimized payload. An injection phrased to sound like a benign customer statement won't resemble any anchor and will pass, for the same reason the leetspeak variant passed before de-obfuscation. It raises the cost of the lazy attack and leaves an audit trail, and that's what it claims to do.
Blocked attempts leave the engine state bit-identical to a run where the attack never happened, while still writing content-free audit markers recording that something was blocked. Audit trail without storing the payload.
Reproducing it
git clone [https://github.com/VersinoPsiOmega/semvec-mongodb-atlas.git](https://github.com/VersinoPsiOmega/semvec-mongodb-atlas.git)
cd semvec-mongodb-atlas
docker run -d --name semvec-atlas-mongo -p 27017:27017 \
--ulimit nofile=64000:64000 mongodb/mongodb-atlas-local:8.3.8
python3 -m venv ~/.venvs/semvec-atlas
~/.venvs/semvec-atlas/bin/pip install -e ".[langgraph,compliance,mcp,dev]" \
sentence-transformers openai tiktoken
demo/run.sh --no-llm
The --ulimit flag is not decoration. Each test provisions its own database, and a few hundred of those exhausted the default file-descriptor limit, at which point WiredTiger takes a fatal assertion and mongod dies mid-run. What you see is a wall of OSError: connection closed failures that look like application bugs. With the limit raised, the suite goes from a 25-minute partial collapse to 41 seconds.
Everything except the answer text is deterministic: checksums, sequence numbers and counts reproduce exactly; the latencies depend on your hardware.
Why you'd want this
If you're building agents, you've already hit the wall, or you're about to: conversation history grows, context costs grow with it, and every turn gets more expensive than the last. The usual fix is to compress. The part nobody tells you is that compression only pays off if the compressed state is somewhere you can trust.
Here's what that looked like in practice. Across 5,882 turns of real conversation, the context sent to the model per call stayed at 987 tokens. That held on the 689-turn conversation just like the 369-turn one. Replaying the full history would have cost 27,353. So: 96% less context, and it doesn't drift upward as the conversation gets longer. Turn 700 costs what turn 7 did.
And it didn't cost quality. LLM-judge accuracy was 0.5773 going through Atlas against 0.5322 for the same engine holding everything in memory with no database at all. Persisting to a database didn't tax accuracy, it improved it, because fused ranking beats an in-process scorer.
Fixed cost per turn, and accuracy that holds. That's what makes an agent affordable to run at turn 1,000 rather than just impressive in a demo.
What MongoDB is doing for this
Fixed-size memory isn't a new idea. What's different here is not having to assemble the storage underneath it from four separate services.
Everything in this post ran on one database:
- The turn log, the state snapshot, and the vectors commit together in one transaction, 21.7 ms at p50. No outbox, no saga, no window where a fact exists in your log but not your index.
- Hybrid ranking over semantic, lexical, and recency signals happens server-side in one aggregation via
$rankFusion, 15.2 ms at p50. Not three queries and a fusion loop you have to write and own. - "Delete this user" is one operation you can verify, with a signed certificate, because there is one place the data lives.
Try that on Postgres plus a dedicated vector store, and every one of those becomes distributed-systems work: consistency code you maintain, reranking you implement, and a GDPR request that turns into an archaeology project across four services. The alternative to one database isn't four databases, it's four databases plus the glue between them and the compliance gaps in the seams.
The whole thing runs on your laptop today: mongodb/mongodb-atlas-local, one docker run, same code path as Atlas in production.
One last data point for anyone about to optimize the wrong layer. The embedder was 4x the cost of the database write and 5x the cost of retrieval. The database was never the bottleneck.
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.