Ask your coding agent next Monday what it learned about you last Monday, and watch it blink. Most agents are goldfish: every session restarts from a blank context, and the standard workaround — replaying the whole transcript into the prompt — is a losing trade. You pay for every stale token on every call, retrieval quality decays as the window fills, and long sessions simply fall off a cliff when the transcript no longer fits. The context window is a working memory with terrible long-term hygiene, and no amount of prompt engineering fixes that.

Mem0, from mem0ai, is a bid to move that hygiene into infrastructure. It is the memory layer that sits beside your agent loop: after each turn you call add() with the conversation, before the next one you call search() with the query. The library uses the LLM once to distill the exchange into short facts, embeds those facts, and persists them to a vector store of your choice; retrieval then blends semantic similarity, BM25 keyword scores, and entity-graph boosts into a single ranked list. It is the plumbing that every framework we have toured leaves to you — the agent loop of LangChain, the indexing tricks of LlamaIndex, and the actor runtime of AutoGen all still need somewhere durable to remember users across sessions.

This post is a source tour of the OSS Python package on the main branch. We follow two paths end to end: the add path — one LLM extraction call wrapped in eight phases that end in a batched vector write plus a SQLite audit trail — and the search path, where over-fetched candidates get scored by three independent signals and optionally reranked. We also look at the maintenance surface, because memory that is never updated or deleted rots into noise. Every symbol below is cited with its file and line, verified in the clone.

Mem0 architecture overview: Memory.add extraction phases and Memory.search hybrid ranking across LLM, embeddings, pluggable vector stores and SQLite history

Mem0 overview: the add() extraction flow on the left, the search() ranking flow on the right, pluggable backends in the middle, and a SQLite ledger underneath both.

Reading the overview from left to right:

Mem0 detail architecture: Memory API surface, add phases 0-8, SQLite storage, hybrid retrieval scoring and provider factories with line-verified functions

The detail view: each phase of add(), the nine-step search scorer, the SQLite storage layer, and the factory-created provider backends, mapped to the functions that implement them.

The add path: eight phases behind one call

The interesting design decision is what the extraction call does not do. Earlier generations of Mem0 ran two LLM rounds: extract facts, then decide for each fact whether to ADD, UPDATE, or DELETE against similar existing memories. The current main branch collapsed that into a single additive call: the prompt receives the existing top-10 memories (as integer-indexed entries), the new messages, the last ten turns, and any custom instructions, and returns new facts only. Deduplication against what is already stored happens deterministically — the md5 hash check — rather than by asking the model to judge similarity. That trades a little recall (a rephrased duplicate will slip through as a new fact) for a large cut in latency, cost, and the failure modes of LLM-judged diffs. The legacy prompts still ship for compatibility: FACT_RETRIEVAL_PROMPT at L15 and the update-decision prompt at L176 are present in configs/prompts.py, but the live add path no longer routes through them.

Persistence is deliberately defensive. The batch insert falls back to per-record inserts when the bulk call fails, and only records the store actually accepted are journaled and returned as ADD results — the comment at L1052 is explicit that a rejected record must never be reported as success. Message saving (save_messages, L257) closes the loop: the raw turn is cached so the next extraction has running context. Telemetry fires once at the end (capture_event, L192), emitting mem0.add with hashed filter keys rather than raw user ids.

The search path: three signals, one score

_search_vector_store is a nine-step scorer. First, normalization: lemmatize_for_bm25, L22 flattens inflections, and extract_entities, L751 runs spaCy NER plus name/identifier heuristics over the query. Then the candidates: the dense search over-fetches at max(limit*4, 60) so the scorer has room to be picky, while keyword_search, L471 queries Qdrant’s sparse bm25 vector — this hook is optional in the VectorStoreBase, L72 contract, and Memory.__init__ logs a warning at L543 that hybrid scoring is disabled on stores that lack it.

Scoring is additive and honest about its arithmetic. get_bm25_params, L16 and normalize_bm25, L43 squeeze raw BM25 into 0-1; entity boosts come from searching the entity collection (similarity ≥0.5, capped at 8 entities) and are damped by how many memories each entity already links — a hub entity boosts less per memory. _compute_entity_boosts, L1747 caps the boost weight at 0.5 (ENTITY_BOOST_WEIGHT, L57). Crucially, the similarity threshold gates the semantic score before combining — keyword or entity signals alone cannot rescue an unrelated memory — and the divisor adapts to how many signals are active (1.0 to 2.5), so scores stay comparable across stores. Results are cast to MemoryItem, L16, and with explain=True you get score_details showing every component.

From Install to Your First Model

The README keeps this genuinely small. Install the library:

pip install mem0ai

For the hybrid scoring shown above (BM25 keyword matching plus entity extraction) add the NLP extras and a spaCy model:

pip install mem0ai[nlp]
python -m spacy download en_core_web_sm

Then instantiate and use — the README’s quickstart pattern, abridged:

from mem0 import Memory

memory = Memory()

relevant = memory.search(query=message, filters={"user_id": user_id}, top_k=3)
memory.add(messages, user_id=user_id)

Memory() reads a MemoryConfig, L29 that defaults to OpenAI gpt-5-mini for extraction and text-embedding-3-small for embeddings, with a local Qdrant path as the store. Swapping providers is a config change: LlmFactory, L35, EmbedderFactory, L152, and VectorStoreFactory, L180 map provider strings to classes, and register_provider() lets you add your own. The default Qdrant backend is covered in depth in our Qdrant source tour — worth reading to understand what query_points is doing underneath search.

Keeping memory from rotting: update, delete, history

The write path is only half the story; the maintenance surface is what keeps a memory base usable after months. update() at L1829 re-embeds the new text and rewrites both vector and payload — fresh md5, fresh updated_at — and when the text actually changed, _update_memory, L2052 unlinks the memory from its old entities and re-links the new ones. delete() at L1883 removes the vector and journals a DELETE row with is_deleted=1 (_delete_memory, L2116), also scrubbing entity links. delete_all loops in batches of 1000 with a repeated-batch guard against stores whose list() silently truncates. Two quieter features deserve note: expiration_date hides expired memories from search and get_all unless you ask (_payload_is_expired, L442), and history() gives you the full event log per memory — the debugging tool you will actually reach for when the agent starts remembering things you never said.

Honest Limits

  • Extraction quality is LLM quality. The facts are only as good as the model behind ADDITIVE_EXTRACTION_PROMPT; a weak LLM produces vague, duplicative, or contradictory memories, and Mem0 cannot compensate.
  • Adds cost real money. Every add() triggers at least one LLM call plus several embedding calls (conversation, facts, entities). High-traffic agents should batch turns rather than calling per message.
  • The additive flow only ever ADDs. Hash dedup drops exact repeats, but a rephrased fact — “moved to Berlin” after “lives in Munich” — lands as a second memory. Contradictions accumulate unless you call update()/delete() yourself or move to the managed platform.
  • Backend sprawl is real. mem0/configs/vector_stores/ ships roughly two dozen provider configs, and hybrid scoring quietly degrades to semantic-only on stores without keyword_search — the warning at Memory.__init__ is easy to miss. Pick a store that implements both hooks if you want the full scorer.
  • State is local and single-process. The history ledger and message cache live in one SQLite file (SQLiteManager, L11); fine for development, not for concurrent writers.
  • Telemetry is on by default. capture_event samples PostHog events (mem0.init, mem0.add, mem0.search) at a 0.1 rate unless you set MEM0_TELEMETRY=False (telemetry.py, L14).
Watch PyShine on YouTube

Contents