Memory for AI Agents
Article 10 of 11

What are we building?

3 min read · The hybrid, one fact walked end to end, and the code layout.

In this blog, we will learn the design of our hybrid memory layer, called hmem. We will see where each piece comes from, walk one fact through the whole system, and look at the code layout. The goal is a system you can explain on a whiteboard in five minutes.

The one-line version

A card file where every card has a date, a source, and a pointer to the card it replaced; a librarian who searches by meaning, words, and names; and a secretary who writes cards but is not allowed to tear any up.

Where the parts come from

                      class notes        Mem0 v3            HydraDB / Zep
  secretary (write)   categories,        ADD-only, rich     supersede with
                      importance,        sentences, dedup,  time stamps,
                      privacy rules      entity links       provenance
  librarian (read)    recency +          dense + BM25 +     time filter,
                      importance         entity boost       one-hop expansion
  card file (store)   one collection,    history table      append-only,
                      user_id filter                        two clocks
  forgetting          episodic decay,    (none)             (roadmap)
                      write-time dedup

One card

id            7f3a...
user_id       maya
text          "Maya moved from London to Paris in March 2026 for a payments startup job"
category      semantic
importance    7
event_date    2026-03-01
valid_from    2026-03-02T09:14Z
valid_to      NULL
superseded_by NULL
source_ids    ["s18241:t1"]
hash          md5(text)
embedding     768 x float32
last_accessed 2026-06-09T18:02Z

Beside the cards: episodes (the raw turns, so the answer model can read the original words), entities (name to card ids), and an FTS5 index over text for BM25. All in one SQLite file.

The whole system

flowchart LR
    subgraph write["WRITE (async)"]
        M["session messages"] --> S["10 similar memories<br/>+ last k turns"]
        S --> L[["extractor LLM<br/>one call, JSON"]]
        L --> D["dedup: hash, cosine>0.95"]
        D --> W["insert + embed + link entities<br/>close superseded rows<br/>save episodes"]
    end
    subgraph read["READ (sync)"]
        Q["question"] --> P["time parse + entities"]
        P --> V["dense"] & B["BM25"] & E["entity"]
        V & B & E --> F["fuse + recency + importance<br/>+ date filter"]
        F --> H["hop: superseded chain,<br/>source turns"]
        H --> O["prompt block, dated"]
    end
    W --> DB[("SQLite")] --> V
    DB --> B
    DB --> E

Walk one fact through

Session, March 2: Maya says "I just moved from London to Paris for a new role at a payments startup."

Write path. The messages are embedded; the ten closest existing memories come back, including "Maya lives in London" (id 3). The extractor sees both and returns:

{"text": "Maya moved from London to Paris in March 2026 for a job at a payments startup",
 "category": "semantic", "importance": 7, "event_date": "2026-03-01",
 "entities": ["Maya", "London", "Paris"], "supersedes": ["3"]}

Hash is new, cosine to id 3 is 0.84 (not a duplicate). Insert as id 7. Link the three entities. Stamp id 3 with valid_to = 2026-03-02, superseded_by = 7. Save the raw turn as an episode. One LLM call, one embed call.

Read path, June: "Any good bakeries near me?" The time parser finds nothing. Dense search ranks id 7 high (Paris, near). BM25 finds nothing ("bakeries" is not in any memory). Entity search finds none. Fusion keeps id 7; recency and importance keep it there. Id 3 is closed, so it is not in the default result. The prompt block reads:

- Maya lives in Paris, moved from London in March 2026. (since 2026-03)

Read path, later: "Where did I live before Paris?" Dense search finds id 7. The hop pulls its superseded chain: id 3, London, 2025-01 to 2026-03. Both go into the prompt, with dates. The answer model has everything.

The code

hmem/
  config.py     one dataclass: models, endpoint, db path, fusion weights
  llm.py        LLM protocol, OpenAI-compatible client, MockLLM
  embed.py      Embedder protocol, OpenAI-compatible, local, MockEmbedder
  store.py      SQLite: memories, episodes, entities, FTS5, vector blobs
  extract.py    write path
  retrieve.py   read path
  timeparse.py  "last summer" -> (start, end)
  prompts.py    EXTRACT, ANSWER, JUDGE
  memory.py     Memory.add / search / get_all / forget / delete_user
bench/          locomo.py, longmemeval.py, beam.py, common.py
tests/          offline, with mocks

Rules: every file small enough to read in one sitting, dependencies only on numpy, sqlite3, and the OpenAI client, mocks for every network call so tests run offline, and one seam (the Store protocol) for Postgres or a vector database later.

Why SQLite and numpy are enough for v1

The class notes size a user at about 200 memories. A dot product over 200 vectors of 768 floats is microseconds. Even a benchmark conversation with 50,000 memories is a 150 MB matrix and a 10 ms dot product. FTS5 gives BM25 without a search server. A single file is easy to copy, inspect, and delete. When it stops being enough, that is the scale blog.

What we expect to score, and why

  • single-hop and extraction: hybrid recall with over-fetch
  • multi-hop and multi-session: entity links plus one hop
  • temporal: dates on every row plus time-aware queries
  • knowledge update: superseded chain in the prompt
  • abstention: the answer prompt must cite a memory id or say unknown
  • preferences and instructions: procedural rows always injected

Targets: 75 on LoCoMo and LongMemEval-S with a small extractor and a GPT-4o-class answerer, then push toward 85 with a token budget under 5K per question. Reported next to tokens and latency, every time.

Closing

A secretary who never tears pages out, a card file with two clocks, and a librarian with three senses. That is the whole system. Everything else is tuning, and tuning is what the benchmarks are for.

← All articles