MemorySync

Inside the Write Path: How a Sentence Becomes Facts

What happens between add() and a searchable memory: extraction, key canonicalization, replacement, dedup and embedding, shown with calls we ran against production.

Published on 5 min read

Inside the Write Path: How a Sentence Becomes Facts
On this page

Every write to MemorySync goes through extraction, and only the facts it extracts are stored. Knowing what happens between the request and a searchable memory explains the usual surprises: a write that stored nothing, a fact that replaced another, or one sentence that became four memories.

We ran each example below against production for one end user, sam, and trimmed the responses to the fields that matter.

The stages in order

  1. Extraction. OpenAI gpt-4o-mini, at temperature 0, reads the input and proposes candidates. Each candidate has a type, a key, a value, a short canonical sentence and a confidence.
  2. Key canonicalization. The candidate's key is compared, by embedding similarity, with the key labels this user already has for that type. A close match takes the existing label, so one attribute keeps one key when the wording drifts.
  3. Replacement. If the type and key form a one-value slot, such as a city or a UI theme, and a live memory holds a different value, the old memory is superseded.
  4. Deduplication. Otherwise the candidate is compared with existing memories of the same user, project, environment, type and key. A cosine similarity of 0.9 or more skips it.
  5. Insert. A surviving candidate is stored as a memory with its own id.
  6. Embedding. The memory is embedded with OpenAI text-embedding-3-small.
  7. Searchable. Once the embedding is written, search can return the memory. status(memory_id) reports whether it is searchable yet.

Memory text is sent to OpenAI through its API, which does not train on API data, and MemorySync does not train models on your memories.

Extraction into candidates

The plainest case is one sentence and one fact. "I live in Berlin." was stored as:

JSON
{
"id": 66425,
"text": "Lives in Berlin",
"metadata": {
"extraction": {
"type": "fact",
"key": "city",
"value": "Berlin",
"action": "create",
"confidence": 0.9
}
}
}

The stored text is the canonical sentence from extraction. A longer sentence becomes several memories, one per fact. "I am a backend engineer at a logistics startup, I write mostly Go, and I am training for a half marathon in March." produced four:

Stored textTypeKeyValue
Works as backend engineerfactoccupationbackend engineer
Works in logistics industryfactindustrylogistics
Writes mostly in Gofactprogramming_languageGo
Training for half marathon in Marchintenttraining_eventhalf marathon

add() answered with one of them, "Works as backend engineer", and stored the other three alongside it. When a sentence can hold several facts, read them back with a query or a list call instead of relying on the single memory in the response.

Input with nothing durable in it stores nothing. ok thanks! came back as:

JSON
{
"status": "skipped",
"reason": "no_valid_candidates",
"memory_ids": [],
"candidates_extracted": 0,
"candidates_stored": 0,
"candidates_updated": 0,
"candidates_deduped": 0,
"candidates_rejected": 0,
"deduplicated_against": []
}

In the Python SDK that is an AddSkippedResponse with reason set to no_valid_candidates.

Keys and one-value facts

Replacement and deduplication both work within one type and key, which is why stable keys matter. If a city arrived under a slightly different label each time, the old value could survive next to the new one. Canonicalization maps a new key onto a label the user already has when the two are close, and it never invents a label.

Then the one-value rule. After Berlin, Sam said "I moved to Lisbon last month." The response:

JSON
{
"id": 66426,
"text": "Lives in Lisbon",
"metadata": {
"extraction": {
"type": "fact",
"key": "city",
"value": "Lisbon",
"action": "create",
"action_auto_upgraded": "create->update",
"supersedes": 66425
}
}
}

The model labelled the candidate create. Because city holds one value and the value differed, the write path upgraded it to an update and superseded memory 66425. The same sentence produced two side facts, "Is adjusting to new city" and "Moved to Lisbon (last month)".

A query for "Where does the user live?" then returned only "Lives in Lisbon", with the context block User Facts:\n- city: Lisbon.

Keys that hold collections behave differently. Multi-value keys, such as food preferences, keep every member, because liking ramen does not stop anyone liking tacos.

Duplicates within one slot

A candidate that replaced nothing is checked for duplicates before it is inserted. The check is cosine similarity between the candidate's canonical sentence and existing memories with the same user, project, environment, type and key, and 0.9 or more skips it. It never crosses users, projects or environments, so two users who say the same thing both keep it.

The check runs per candidate, not per input, so a sentence that repeats one known fact and adds a new one stores the new one. Exact repeats do not create copies either: we sent "I am allergic to peanuts." twice, and both calls returned memory 66437.

The limit is the scope of the comparison. A paraphrase that extraction files under a different key is compared with a different set of memories, and both can survive. For retries, send a client_ref, as described in Safe Retries: Idempotent Memory Writes.

When each write route answers

  • add() (POST /memory/add) runs extraction before it returns. You get the stored memory, or an AddSkippedResponse that says why nothing was stored.
  • add_turn (POST /v1/memory/add_turn) returns immediately with status: "processing" and a request_id, and the facts appear shortly after. The turn text itself is not stored, so memory_id is None.

This is the add_turn receipt for a user turn from Sam:

JSON
{
"memory_id": null,
"status": "processing",
"processing_status": "distilling",
"embed_mode": "none",
"already_exists": false,
"processing_error": null,
"request_id": "106a13b4c78c49f0808924f25c106ecc"
}

Either way, keep writes off the response path. Reply to the user first, then write from a background task or a queue worker. Extraction is a model call, and the person waiting for an answer should not wait for it as well.

What this means for your writes

  • Send user turns, not assistant turns. An assistant turn sent to add_turn stores nothing and comes back as skipped_non_user_turn. add() takes no role at all, so pass it what the user said, not the assistant's reply.
  • Do not pre-summarize. A summary your model writes, such as "User discussed their training plans", drops the values that become keys and values. Send the person's own words and let extraction split them, as it did with the four-fact sentence.
  • Read the skipped reasons. no_valid_candidates means nothing durable was found, and already_ingested means a client_ref you already sent. Log both. A sudden rise in skips can mean your app is sending the wrong text, such as interface chatter or assistant replies.
  • Write one fact per call when you need its id. add() returns one memory even when a sentence yields several.

Next step

Send a few of your users' real sentences through add() with a development key and compare the stored text with the input. Then read how adding a memory works in the docs.

Share article

Blog

Try it on your own workload

The free tier stores real memories and returns real rankings. One write and one retrieval to start.

Start free. No credit card required.