Abstract: mem0 simplifies automatic writes with ADD-only extraction, moving conflict into retrieval.
Snapshot: Python OSS at ca2abca, package and latest release v2.0.13, inspected on 2026-07-23. Unless explicitly stated otherwise, this article describes the Python open-source engine, not Mem0 Platform or the TypeScript OSS implementation.
The older mem0 architecture made an LLM a database editor: extract facts, compare existing memories, then choose ADD, UPDATE, DELETE, or no-op. It asked one probabilistic component to perform two jobs:
- understand what the new conversation says;
- mutate the correct piece of historical state.
The April 2026 pipeline removed the second job from automatic ingestion. Its system prompt says:
“Your sole operation is ADD.”
That sentence is easy to overinterpret. mem0 did not become an immutable event store. update(), delete(), delete_all(), reset(), and history() still exist. The infer=True path of add() became additive.
This distinction is the key to understanding the current implementation. ADD-only is not a claim that history should never change. It is a write-path optimization that trades an LLM-driven diff for later ranking and explicit application governance.
Scope and write pipeline
At the application boundary, mem0 looks simple:
# After an interaction worth retaining
memory.add(messages, user_id="alice")
# Before a later model call
memory.search(
"Where is Alice living now?",
filters={"user_id": "alice"},
)
# When the application knows a record must change
memory.update(memory_id, text="Alice lives in Seattle")
memory.delete(memory_id)
Under that surface are four independently fallible projections:
main vector collection
memory text, embeddings, identity, metadata
entity vector collection
entity text → linked memory IDs
SQLite history
ADD / UPDATE / DELETE audit rows
SQLite recent messages
the last 10 messages per session scope
No transaction spans these stores; the Python process coordinates them in sequence. That matters more in production than prompt cleverness.
The default configuration also deserves precision. The default vector store is embedded Qdrant, but the default LLM and embedder providers are OpenAI. “Open source” and “self-hostable” do not mean the default Memory() path is offline.
An add() request, end to end.
The current Memory.add() path can be summarized as eight phases:
validate identity and input
→ gather recent and related context
→ one LLM extraction call
→ batch-embed extracted texts
→ exact hash deduplication
→ batch-persist memories and history
→ extract and link entities
→ save recent messages and return
The main engineering constraints sit at the boundaries between those phases.
Phase 0: identity is part of correctness.
An OSS write must include at least one of user_id, agent_id, or run_id. These identifiers are copied into metadata and later reused as vector-store filters. Search similarly requires an identity inside the filters object.
This is not just API ergonomics. In a shared memory service, filter construction is part of tenant isolation. A ranking improvement is worthless if it can retrieve another user's profile.
The OSS engine rejects timestamp: temporal reasoning is a Platform-only feature. It does accept an expiration_date, which is normalized to a calendar date and stored as metadata. Expiration is a read-time filter, not physical deletion.
There is also a procedural-memory side path for agent-scoped inputs. The normal v3 pipeline described below applies to infer=True.
Phase 1: a small context window.
mem0 retrieves two kinds of context before extraction:
- the last 10 messages for the current session scope from SQLite;
- the top 10 semantically related memories from the main vector store.
The related memories serve as deduplication and linking context for the model. They also supply the set of existing hashes used by deterministic deduplication later.
That second role creates an important limit: exact deduplication is scoped to the retrieved top 10, not enforced as a collection-wide uniqueness constraint. A byte-identical old memory outside that set can be inserted again.
Phase 2: one LLM call with two failure classes.
The engine embeds the parsed new messages as a search query, retrieves existing memories, and shortens their UUIDs:
existing_memories = []
uuid_mapping = {}
for idx, mem in enumerate(existing_results):
uuid_mapping[str(idx)] = mem.id
existing_memories.append(
{"id": str(idx), "text": mem.payload.get("data", "")}
)
Small numeric IDs are intended to reduce hallucination over long UUIDs. The model then receives:
- new messages;
- related existing memories;
- recent messages;
- current and observation dates;
- optional custom instructions.
Only one structured-output call is made. Transport or provider failures are re-raised as LLMError, so an upstream service can retry or fail over. Malformed JSON is treated differently: the exception is logged, recent messages are saved, and the method returns no extracted memories.
That distinction is operationally useful but easy to miss. “No memories returned” can mean “the conversation contained nothing durable” or “the model produced an unparsable answer.” Applications that care about ingestion completeness need telemetry around both.
Phases 3–5: deterministic work begins.
All extracted memory texts are sent to embed_batch(). If the batch call fails, the implementation retries each text individually; a text whose individual embedding also fails is omitted.
For every remaining text, mem0 computes:
- an MD5 content hash;
- a lemmatized representation for keyword search;
- a new UUID;
created_atandupdated_at;- identity and caller metadata;
- optional
attributed_to.
The hash is checked against the top-10 existing results and against memories already accepted in the current batch. MD5 here is a cheap exact-content fingerprint, not a security primitive. Semantic duplicates still depend on retrieval plus LLM judgment.
The extraction prompt's implicit contract.
The v3 prompt tells the model to emit linked_memory_ids when a new memory relates to an existing one. Its examples show explicit memory-to-memory links, and the code builds uuid_mapping to translate short IDs back to real UUIDs.
Python never performs that translation.
After parsing the model output, payload construction reads text and attributed_to; it does not read the model's linked_memory_ids. uuid_mapping is never consulted again. The same discrepancy is tracked in open Issue #4970, which reports that the field is ignored by both Python and TypeScript OSS.
It also reveals a naming trap: mem0 does have working linked_memory_ids, but they belong to a different data model.
Retrieval, transactions, and deletion
Phase 7 performs entity linking independently of the LLM-provided links.
When spaCy and en_core_web_sm are available, the Python engine extracts:
- named entities;
- proper-name spans;
- quoted text;
- technical identifiers;
- multiword topic phrases.
Entities are normalized and deduplicated across the batch, embedded in bulk, and looked up in a second collection named {collection}_entities. An exact text match wins; otherwise a semantic match must score at least 0.95 to be merged.
An entity payload resembles:
{
"data": "Shopify",
"entity_type": "PROPER",
"linked_memory_ids": ["memory-a", "memory-b"],
"user_id": "alice"
}
This structure is useful, but it is not a general knowledge graph. It has no entity-to-entity edges, relationship types, traversal contract, or exposed relations result field. External Neo4j, Memgraph, Kuzu, AGE, and Neptune integrations were removed in v2.0.0.
The precise description of current OSS “graph memory” is therefore:
a vector-backed entity inverted index that boosts memories sharing query entities.
That definition is less glamorous than “knowledge graph,” but it tells an engineer what the subsystem can and cannot answer.
Phase 6 and the transaction boundary.
The main vector write first attempts one batch insert. If it fails, the implementation retries every record individually:
try:
vector_store.insert(all_vectors, all_ids, all_payloads)
except Exception:
for record in records:
try:
vector_store.insert(record)
except Exception:
logger.error(...)
The subtle bug is what happens next. A record that fails both the batch and its individual retry remains in records. History creation, entity linking, and the method's return value all iterate that original list.
I reproduced this behavior with a temporary mock test against the pinned snapshot:
two extracted records
→ batch vector insert fails
→ record A retry succeeds
→ record B retry fails
→ two ADD records are returned
→ two ADD history rows are submitted
The reverse split is also possible. The vector batch can succeed while SQLite history ultimately fails; history failures are logged after their own per-row fallback, but the request still returns the records.
The pipeline therefore provides best-effort projection, not atomic commit. A production wrapper that needs reliable ingestion should maintain one or more of:
- an application-level idempotency key;
- read-after-write confirmation;
- an outbox or durable ingestion job;
- reconciliation between vector records and history;
- explicit partial-success responses;
- metrics for every fallback branch.
The most misleading memory failure is not a thrown exception. It is an API reporting that a fact was remembered when retrieval can never find it.
Search as candidate generation and feature fusion.
ADD-only makes contradictory records normal. Search must decide which ones deserve prompt space.
The path is:
lemmatize query and extract up to 8 entities
→ embed query
→ semantic search with max(top_k × 4, 60)
→ provider keyword_search()
→ normalize BM25
→ compute entity boosts
→ build candidates from semantic results only
→ fuse scores
→ optional reranker
The boldest implementation detail is that this is not three-way recall. Only semantic results become candidates. BM25 and entity scores are ranking features. A perfect exact-keyword match that never enters the semantic over-fetch cannot be rescued.
The migration guide states this explicitly: BM25 is a boost signal, not a recall expander.
Scoring formula.
score_and_rank() computes:
combined =
(semantic_score + normalized_bm25 + entity_boost)
/ max_possible
Three details affect interpretation:
- Semantic thresholding occurs before fusion. A candidate below
thresholdis discarded even if keyword or entity evidence is strong. - BM25 normalization depends on query length. Raw BM25 scores pass through a sigmoid whose midpoint and steepness change with the number of lemmatized query terms.
- The denominator is globally adaptive.
max_possibleis1.0,1.5,2.0, or2.5, depending on whether any BM25 and entity signals exist for the query.
That last rule means a candidate with no lexical or entity hit is penalized whenever some other candidate activates those signal families. The returned score is a ranking heuristic, not a calibrated probability.
This is why the v3 migration guide warns users to retune hard thresholds. An old cosine threshold cannot be carried across unchanged.
Entity-hub damping.
For each query entity, the engine searches up to 500 entity records. Matches below 0.5 similarity are ignored. A memory's boost is:
similarity × 0.5 ×
1 / (1 + 0.001 × (linked_count - 1)²)
If multiple query entities point to the same memory, the maximum boost wins; boosts are not summed.
The quadratic penalty prevents high-degree entities from boosting hundreds of records equally. “Google,” “project,” or a frequently mentioned person becomes less discriminative as its degree grows. It is a simple but sensible way to stop an entity hub from dominating the ranking.
Provider-dependent degradation.
mem0 supports many LLMs, embedders, vector stores, and optional rerankers. That flexibility also means the algorithm is not identical across installations.
If a vector-store adapter inherits the base keyword_search() no-op, initialization logs a warning and search loses BM25. Qdrant additionally needs fastembed and a collection created with the sparse-vector slot.
If spaCy is unavailable:
- entity extraction returns no entities;
- entity boosting disappears;
- lemmatization falls back to the original text.
The migration guide calls this “semantic-only.” The code permits a more nuanced outcome: a vector store with native text search may still run keyword search over un-lemmatized text. The exact fallback depends on the adapter and its dependencies.
Optional reranking is also off by default. When enabled, reranker failure logs a warning and preserves the pre-reranked order.
For production, “we use mem0 v2” is not a complete algorithm description. You need to record the LLM, embedder, vector adapter, collection schema, NLP extras, reranker, and thresholds.
Automatic ADD-only extraction and explicit governance.
When an application knows a record is wrong, update() performs a direct mutation:
- read the current vector record;
- preserve identity fields;
- re-embed changed text;
- update text, hash, lemma, and timestamps;
- write an
UPDATEhistory row; - remove old entity links and derive new ones.
Identity metadata is intentionally immutable during update. The v2.0.13 release tightened this behavior so user_id, agent_id, run_id, and actor_id cannot be overwritten or injected.
delete() removes the main vector record, appends a DELETE history row, and performs best-effort entity cleanup. Entity cleanup is non-fatal by design: a secondary-index failure must not prevent deletion from the primary memory collection.
That policy is reasonable, but its consequences need monitoring. Two open issues match code paths visible in the pinned snapshot:
- #4863: a fresh process may skip entity cleanup because
_remove_memory_from_entity_store()returns immediately when the lazily initialized_entity_storeis stillNone; - #4988: cleanup lists and scans at most 10,000 entity rows in Python, which can be slow and incomplete at scale.
Expiration is softer still. An expired memory is hidden by default from search() and get_all(), but remains in the vector collection, history, and entity index. show_expired=True makes it visible again.
Deletion semantics and audit history.
SQLite history stores old and new memory text, event type, timestamps, actor, and role. It does not store user_id, agent_id, or run_id.
delete_all(user_id="alice") lists Alice's vector memories and deletes them one by one. Every deletion appends another audit row. It does not physically remove Alice's previous history text.
Open Issue #6512, filed one day before this snapshot, frames the problem as a GDPR and PII-erasure gap. The request is for a Python disable_history option comparable to the Node implementation.
This illustrates a broader point: “delete” can mean at least three things:
- hide a record from normal retrieval;
- remove it from the primary vector collection;
- erase it from every audit log, derived index, backup, analytics stream, and managed copy.
The OSS API directly addresses parts of the first two. The third is a system-wide protocol that an embedding library cannot guarantee alone.
ADD-only avoids silently rewriting a user's past, but an append-only audit log can become its own form of over-retention. Neither behavior is automatically ethical.
Sync and async implementation drift.
Memory and AsyncMemory each contain a full copy of the eight-phase pipeline. The async version pushes blocking provider calls through asyncio.to_thread() and limits concurrent entity searches, but the control flow is intentionally similar.
Tests cover parity around identity immutability, entity embedding-count guards, entity-boost concurrency, history fields, and delete-all cleanup. Still, duplicating a large pipeline creates drift risk: a new metadata field or fallback fix can land in one version first.
The v3 migration PR describes “full parity” as a goal. Maintaining that property requires tests to remain structural, not merely happy-path.
Evaluation and deployment fit
The repository reports strong gains on LoCoMo, LongMemEval, and BEAM using a “production-representative model stack.” The separate open-source memory-benchmarks repository documents an ingest → search → answer → judge workflow, which is much better than publishing an unexplained score.
Those numbers are still project-reported results, not measurements of a default local Memory() instance.
Results depend on:
- extraction and answer models;
- embedding model;
- NLP and BM25 availability;
- vector-store behavior;
- top-k and threshold;
- optional reranking;
- dataset language and entity distribution.
Platform v3 also includes Temporal Reasoning and Memory Decay that Python OSS explicitly rejects. The 2025 Mem0 paper describes an earlier architecture and should not be treated as a line-by-line specification of the April 2026 ADD-only engine.
Teams should rerun evaluation on their own failure cases: short queries, renamed entities, contradictory single-slot facts, multilingual users, exact identifiers, stale preferences, and erasure requests. Memory quality is personal; an impressive average can conceal a system that is consistently wrong about one person.
Deployment fit.
mem0 is compelling for customer support, sales assistants, personal productivity, and SaaS personalization. It gives a small team a coherent extraction and retrieval layer without forcing the rest of the application into a particular agent framework.
Its main differentiator is replaceable infrastructure:
- choose the LLM;
- choose the embedder;
- choose the vector store;
- add a reranker if needed;
- keep the application-facing API stable.
OpenHuman spends more machinery preserving a path from hierarchical summaries back to provenance-bearing leaves; mem0 stores flat facts and optimizes retrieval. Hermes Agent embeds memory providers directly into agent lifecycle and prompt-prefix rules; mem0 stays independent of the loop.
The default pipeline should not serve unchanged as the authoritative current-state store for medical, legal, or financial decisions. Workloads requiring typed relationships, temporal validity intervals, or graph traversal need additional domain structures. Entity boosting is useful; it is not a substitute for a domain knowledge graph.
Production integration checklist.
Treating mem0 as durable infrastructure requires answers to these questions:
- Scope: Are identity filters applied and tested for every provider?
- Idempotency: Can the caller safely retry after an ambiguous timeout?
- Reconciliation: Can it find returned IDs that are not retrievable, vector records without history, and history rows without vectors?
- Lifecycle: Who decides when to update, expire, or delete a contradictory fact?
- Erasure: Which stores, logs, backups, and analytics receive a deletion request?
- Observability: Can operators distinguish no extraction from malformed model output?
- Configuration and evaluation: Are the full retrieval configuration and representative benchmarks versioned?
- User control: Can a person inspect and correct what the system believes about them?
The checklist grows when a convenient API becomes identity-bearing infrastructure.
Operational conclusion.
mem0 may turn persistent personalization into a commodity component. A weekend prototype can gain a longitudinal user model that once required a large data platform.
It will resonate with small product teams, SaaS companies using interaction history, and heavy AI users tired of starting from scratch.
The same accessibility changes the social contract of conversation. A message can stop being ephemeral communication and become an invisible profile write. “I do not feel like traveling lately” might be a one-month mood, a durable preference, or merely context for one answer. The user rarely sees which interpretation was stored.
Production adoption also requires memory observability and governance:
- provenance for extracted facts;
- conflict and supersession views;
- retrieval-use logs;
- retention and expiration policies;
- deletion propagation proofs;
- user-facing correction workflows.
Developers need these tools for debugging. Enterprises need them for compliance. Users need them to contest a machine's interpretation of their identity.
The best long-term memory should not offer only “remember” and “forget.” People change without needing to deny that an earlier version of themselves existed. A mature system should be able to represent:
This used to be true. It is no longer current. The old evidence may remain auditable, but it must not keep making decisions for me.
ADD-only is a useful step toward preserving that distinction. It is not the mechanism that completes it.
Open questions.
- Should contradictions in an ADD-only store be resolved by temporal ranking, explicit mutation, or user confirmation?
- What would a verifiable erasure protocol spanning vectors, entities, history, backups, and analytics look like?
- Should hybrid-retrieval weights remain fixed, or be calibrated per tenant, language, corpus, and query type?
Verification and sources
I ran 144 distinct focused checks against the pinned snapshot, all passing, plus 19 spaCy-dependent tests skipped because the NLP model was not installed. One passing check was a temporary consistency probe described above and removed after execution. I did not run live LLM extraction, real vector-store integration, a large-scale concurrency test, or an independent reproduction of the published benchmarks.
- Pinned mem0
v2.0.13source snapshot - PR #4805: the v3 additive and hybrid pipeline
- OSS v2 → v3 migration guide
- Issue #4970: unused extraction-time memory links
- Issue #4863: entity cleanup in a fresh process
- Issue #4988: entity cleanup's bounded full scan
- Issue #6512: history retention and PII erasure
- Open memory benchmark suite
- Mem0 paper
- Full evidence dossier and test commands