The plan
Working name of the library: hmem (hybrid memory). One Python package, a handful of small modules, SQLite underneath, any OpenAI-compatible LLM on top.
1. Goal
Build the memory layer from the class notes (memory/notes.md) as real,
runnable, modular code, borrowing the best ideas from Mem0 (the code in
mem0/) and HydraDB (append-only temporal graph), and measure it on the
three public benchmarks: LoCoMo, LongMemEval, BEAM.
Success, in order:
- We understand memory systems well enough to explain every design choice
in one sentence (see
knowledge/). hmemruns end to end on a laptop with no Docker and no Ollama, only an API key (OpenAI-compatible) and SQLite.- Benchmark scores that beat the open-source numbers people actually reproduce, with a smaller token budget than Mem0's 7K per question.
- A scale story (sharding, quantization, async writes) that we can defend with the numbers in the notes (1M users, 200M memories, ~664 GB raw).
Non-goals for v1: document RAG, multi-agent shared memory, a hosted API, a UI. Graph databases (Neo4j etc.) are out: the "graph" lives as two SQLite tables.
2. What the research says (short)
| Source | What it does | Numbers | What we steal |
|---|---|---|---|
| Class notes | extract -> ADD/UPDATE/DELETE/NOOP -> vector store; score = relevance + recency + importance; forget episodic after 90 days; user_id filter is the privacy wall | 75-token read budget, <50 ms read overhead, ~$312/day for 200k DAU | categories, scoring, forgetting policy, privacy, async write, eval layers |
| Mem0 paper (Apr 2025, arXiv 2504.19413) | extractor sees summary + last 10 msgs; per fact, top-10 similar memories, one tool call picks ADD/UPDATE/DELETE/NOOP; Mem0g adds entity/relation triples | LoCoMo judge score 66.9 (Mem0), 68.4 (Mem0g), full-context 72.9; 7K tokens vs 26K; p95 1.44 s vs 17 s | the "show similar memories to the LLM" trick |
Mem0 v3 code (Apr 2026, mem0/mem0/memory/main.py) |
ADD-only single LLM call, 15-80 word contextual memories, dates resolved against observation date, facts from assistant too, md5 hash dedup, spaCy entities linked to memories, retrieval = dense + BM25 + entity boost, additive score / max_possible | LoCoMo 92.5, LongMemEval 94.4, BEAM-1M 64.1 (platform, top_200, ~7K tokens) | ADD-only extraction, rich memories, multi-signal retrieval, entity boost, linked_memory_ids |
| HydraDB (blog) | Git-style append-only temporal graph + vector index + B-trees; bitemporal (system time + valid time); provenance on every assertion; resolve pronouns/entities at write time ("sliding window inference"); hybrid recall with graph expansion and reranking tiers | LongMemEval-S 90.79 (Gemini 3 Pro), sub-200 ms recall | never overwrite: supersede with timestamps; provenance; write-time reference resolution |
| Zep / Graphiti (arXiv 2501.13956) | episodes -> entities -> communities; edges carry t_valid, t_invalid, t_created, t_expired; edge invalidation instead of delete; RRF/MMR/cross-encoder reranking | LongMemEval up to +18.5 pts, 90% lower latency vs full-context | the four-timestamp edge |
| LongMemEval paper (arXiv 2410.10813) | 500 Q, 6 types + abstention; S = ~115K tokens/40 sessions, M = 500 sessions; GPT-4o judge | round-level granularity beats session-level; fact-augmented keys +9.4% recall@k, +5.4% acc; time-aware query expansion +6.8-11.3% temporal recall; Chain-of-Note + JSON reading up to +10 pts | store facts AND the raw round they came from; expand time expressions in queries; structured reading prompt |
| BEAM / LIGHT (arXiv 2510.27246, ICLR 2026) | 100 conversations, 128K to 10M tokens, 2,000 questions, 10 abilities; LIGHT = episodic vector memory + working memory + scratchpad of salient facts | baselines ~0.3; LIGHT +3.5 to +12.7 | three-layer read: recent turns + retrieved facts + a running profile |
| TurboQuant (arXiv 2504.19874) | random rotation -> per-coordinate optimal scalar quantizer -> 1-bit QJL residual for unbiased inner products; data-oblivious, no training | 3.5 bits/coord quality-neutral, 2.5 bits marginal loss; beats PQ recall with ~zero index time | the vector compression path for turbovec, later |
Two lessons dominate everything else:
- Recall is the whole game. Every leaderboard system retrieves a lot (Mem0 uses top-200) and lets a strong answer model sort it out. Precision matters for cost, recall matters for score.
- Never destroy information. Mem0 v3 dropped UPDATE/DELETE and gained 20 points. HydraDB and Zep keep every version with timestamps. Overwriting is how "where did I use to live?" becomes unanswerable.
3. The hybrid: what goes where
class notes Mem0 v3 HydraDB / Zep
write path ------ categories, + ADD-only rich + supersede-with-time,
importance, memories, dedup, provenance, resolve
privacy policy entity links references at write time
read path ------ recency + + dense + BM25 + + time filter on
importance entity boost valid_from / valid_to,
scoring graph hop over links
store ------ one collection, + SQLite history + append-only, versions
user_id filter table never overwritten
forgetting ------ episodic decay, + (none) + (roadmap "decay engine")
dedup at write
Concretely, our memory row is a bitemporal fact:
id uuid
user_id maya privacy wall: every query filters on it
text "Maya moved from London to Paris in March 2026 for a payments job"
category semantic | episodic | procedural
importance 1..10 (LLM-rated at write time)
event_date 2026-03-01 when it happened / started (valid time)
valid_from 2026-03-02T09:14 when the system started believing it
valid_to NULL set when a newer fact supersedes it; never deleted
superseded_by uuid or NULL the newer fact
source_ids ["s18241:t3"] provenance: session + turn ids the fact came from
hash md5(text) exact dedup
embedding float32[d] blob; int8 / turbovec later
last_accessed 2026-06-09T18:02 recency term
Plus three small side tables: episodes (the raw turns, round-level, with
timestamps, so retrieval can hand the answer model the original words),
entities (name -> ids of memories that mention it), and SQLite FTS5 over
text for BM25. That is the whole "graph": memories linked by shared
entities and by superseded_by.
4. Architecture
flowchart TD
subgraph read["READ PATH (sync, must be fast)"]
Q["user question + user_id"] --> X["query prep<br/>time expressions -> date range<br/>entities from query"]
X --> D["dense search<br/>cosine over user's vectors"]
X --> K["keyword search<br/>SQLite FTS5 (BM25)"]
X --> E["entity match<br/>entities table"]
D --> F["fuse<br/>weighted sum, then<br/>recency + importance + time filter"]
K --> F
E --> F
F --> H["hop: pull superseded_by chain<br/>and source episodes for top hits"]
H --> P["prompt block<br/>dated facts, newest first"]
end
subgraph write["WRITE PATH (async, seconds are fine)"]
M["session messages"] --> S["similar existing memories (top 10)<br/>+ profile summary + last k turns"]
S --> L[["extractor LLM, one call<br/>strict JSON"]]
L --> C["candidates: text, category, importance,<br/>event_date, links, supersedes"]
C --> DD["dedup: hash, then cosine > 0.95"]
DD --> W["insert memory + embedding + entities<br/>stamp valid_to on superseded rows<br/>save raw episodes"]
end
W --> DB[("SQLite: memories, episodes,<br/>entities, memories_fts")]
DB --> D
DB --> K
DB --> E
Write path, in pseudocode
add(messages, user_id, observed_at):
save_episodes(messages, user_id, observed_at) # raw turns, round-level
similar = dense_search(embed(join(messages)), user_id, k=10)
profile = latest_profile(user_id) # optional running summary
cands = llm.json(EXTRACT_PROMPT, {similar, profile, last_k, messages, observed_at})
for c in cands:
if md5(c.text) in known_hashes: continue # exact dup
v = embed(c.text)
if max_cos(v, similar) > 0.95: continue # near dup
mid = insert_memory(c, v, user_id)
link_entities(mid, c.entities or ner(c.text))
for old in c.supersedes: mark_superseded(old, by=mid, at=observed_at)
One LLM call per add, one batch embed call. No per-fact update calls.
That is the whole reason Mem0 v3 is 2x faster than Mem0 v1.
Read path, in pseudocode
search(query, user_id, k=10, as_of=None):
q = embed(query)
dates = parse_time(query) # "last summer" -> range
dense = top(cos(q, vectors[user_id]), 4k) # over-fetch
bm25 = fts5(query, user_id, 4k)
ents = entity_hits(query, user_id)
score = 0.6*dense + 0.3*bm25 + 0.1*ents # each normalized to [0,1]
+ w_rec * recency(last_accessed)
+ w_imp * importance/10
if dates: keep rows whose event_date in range (soft boost if none match)
if as_of: keep rows with valid_from <= as_of < valid_to
hits = top(score, k); touch(last_accessed)
return hits + superseded chain + source episodes (opt-in)
The prompt block is the class notes' injection format, plus dates:
Known facts about the user (newest first, with dates):
- Maya lives in Paris. (since 2026-03; replaced "lives in London", 2025-01 to 2026-03)
- Maya is vegetarian. (since 2026-01)
5. Code layout (KISS)
hmem/
__init__.py exports Memory
config.py one dataclass: model names, base_url, db path, weights
llm.py LLM protocol + OpenAI-compatible impl (+ MockLLM for tests)
embed.py Embedder protocol + OpenAI-compatible impl + local (fastembed) + Mock
store.py SQLite schema, insert/search/list/delete, FTS5, vector blobs
extract.py write path: prompt -> candidates -> dedup -> store
retrieve.py read path: dense + bm25 + entity -> fuse -> rerank
prompts.py EXTRACT, ANSWER (chain-of-note JSON), JUDGE
timeparse.py "last week", "in 2023", "yesterday" -> (start, end) given a reference date
memory.py the facade: add / search / get_all / forget / delete_user
bench/
common.py answer + judge loop, caching, per-category report
locomo.py 10 conversations, ~1,540 Q (drop adversarial), categories 1-4
longmemeval.py 500 Q, S variant first
beam.py 128K conversations first, then 1M
tests/
test_store.py, test_extract.py, test_retrieve.py, test_memory.py (mock LLM/embedder, no network)
Rules: every module under ~250 lines, no module imports from a sibling
except through the protocols in llm.py / embed.py / store.py, no
framework. numpy for the dot products, stdlib sqlite3 for everything
else, openai for the API. Optional extras: fastembed (local
embeddings), spacy (better entities; regex proper-noun fallback without it).
6. Benchmark plan
Order: LoCoMo (smallest, 10 conversations) -> LongMemEval-S -> BEAM-128K -> BEAM-1M. Same loop every time: ingest sessions with their timestamps -> search per question -> answer with a fixed prompt -> judge with a fixed prompt and model -> report per category.
| Benchmark | Size | Reproduced OSS baseline | Our v1 target | Stretch |
|---|---|---|---|---|
| LoCoMo (judge acc.) | 1,540 Q | Mem0 paper 66.9; third-party reproductions of Zep ~75 | 75 | 85 with <5K tokens/Q |
| LongMemEval-S | 500 Q | Zep 71.2; paper's best pipeline ~75 | 75 | 85, abstention included |
| BEAM-128K then 1M | 2,000 Q | baselines ~0.30-0.36; LIGHT +3.5 to +12.7 | beat LIGHT at 128K | 0.6 at 1M |
Rules we hold ourselves to: same answer model and judge model across all systems we compare; publish token count per question next to every score; never tune on the test questions (hold out 2 LoCoMo conversations for development).
Where the points come from, by question type:
- single-hop / information extraction: recall, so hybrid search + over-fetch
- multi-hop / multi-session: entity links + one hop over
linkedmemories - temporal: dates on every memory, time-aware query expansion, event_date filter
- knowledge update / contradiction:
superseded_bychain shown in the prompt - abstention: answer prompt that must cite a memory id or say "unknown"
- preference / instruction following: procedural category always injected
7. Scale plan (after the benchmarks work)
- Async write:
addreturns immediately; a worker pulls from a queue (stdlibqueuefirst, Redis later). Notes: 4.6 writes/s average, 14/s peak. - Sharding: one SQLite file per user-hash bucket (or Postgres +
pgvector behind the same
Storeprotocol). The user filter is built into the shard key, so a cross-user leak is structurally impossible. - Vector budget: 200M memories x 3,072 B = 614 GB of float32. int8 gets
4x (154 GB).
turbovecwith TurboQuant at 3.5 bits/coordinate gets ~9x (~67 GB) with near-zero recall loss and no training step, which is why it beats product quantization for a store that changes every second. - Forgetting: nightly job: expire episodic memories 90 days after
last_accessed, never semantic or procedural; superseded rows stay but drop out of default search. - Read budget: the injected block is capped in tokens, not rows, so cost is flat no matter how much is stored.
8. Milestones
- M0
uv init, deps (numpy, openai, pytest),hmem/config.py, mocks - M1
store.pywith tests: insert, user-filtered dense search, FTS5, supersede, delete_user - M2
extract.py+prompts.py: ADD-only extraction with links/supersedes, dedup; tests with MockLLM - M3
retrieve.py+timeparse.py: fusion, recency/importance, time filter; tests - M4
memory.pyfacade + a 30-line demo script reproducing the class experiments - M5
bench/locomo.pyend to end on 2 dev conversations, then all 10; per-category report - M6 error triage by category, prompt/weights iteration (never on held-out)
- M7
bench/longmemeval.py(S), abstention handling - M8
bench/beam.pyat 128K, then 1M; profile the ingest cost - M9 async worker, int8 vectors,
turbovecspike with TurboQuant - M10 write-up: scores, tokens, latency, what moved the needle
9. Decisions made
- ADD-only extraction with explicit
supersedes. The LLM never deletes. It may say "this replaces memory 3", and we stampvalid_to. Old truth stays queryable. (Mem0 v3 + HydraDB.) - Facts and raw rounds both indexed. LongMemEval showed fact-augmented keys and round granularity each add several points. Facts are the keys, rounds are the values the answer model may read.
- SQLite, not a vector DB, for v1. 200 memories per user is a numpy dot
product. FTS5 gives BM25 for free. The
Storeprotocol is the seam for Postgres/Qdrant later. - Weighted-sum fusion, not RRF, by default. Mem0's additive score is
easy to explain and to
explain=True. RRF stays as a one-line option. - No spaCy dependency in the core. Entities come from the extractor LLM (it already knows the names) with a regex proper-noun fallback.
10. Open questions (answer with data, not opinion)
- Does a running per-user profile summary (LIGHT's scratchpad) help the extractor enough to pay for itself?
- Weighted sum vs RRF vs cross-encoder rerank: measure on LoCoMo dev split.
- How large should the default read budget be in tokens: 500, 2K, 7K?
- Is event_date extraction reliable enough from a small model, or do we need a second pass only for temporal questions?
11. Environment notes
- Machine has Python 3.12 and
uv; no Ollama, no Docker, no API keys in the shell. SetHMEM_BASE_URL/HMEM_API_KEY/HMEM_LLM/HMEM_EMBED(any OpenAI-compatible endpoint: OpenAI, OpenRouter, Groq, a local vLLM). - Tests run fully offline with
MockLLMandMockEmbedder. - Benchmarks need a real key. Budget estimate for LoCoMo end to end with a small extractor and gpt-4o-class answerer/judge: a few dollars per run.