Sable Memory
Sable Memory gives an agent knowledge bases it can write to and search. You
create a collection, add documents, and search it semantically. The text is
chunked and embedded, and both the text and its embedding vector are stored
AES-GCM sealed at rest. What that covers, what it does not, and the one
condition on it for vectors stored before sealing shipped are spelled out under
Privacy and limits. The chunks a search returns are meant
to drop straight into the sable_context
field on your next chat call, so the receipt attests exactly what context the
model was given.
It is metered like any other work: adding a document is billed as embedding
usage on the same key and the same prepaid balance as inference, and both
metered calls return a signed receipt — in the response body
and in the x-sable-receipt headers — carrying a sha256 prefix of your text,
never the text. A collection uses sable-embed-3-small by default.
Quickstart
Memory is key-authed: send a sk-sable_ API key as
Authorization: Bearer.
# 1. Create a collection
curl https://api.buildsable.com/v1/memory/collections \
-H "authorization: Bearer $SABLE_API_KEY" \
-H 'content-type: application/json' \
-d '{"name": "handbook"}'
# 2. Add a document (chunked, embedded, stored sealed)
curl https://api.buildsable.com/v1/memory/collections/$COLLECTION_ID/documents \
-H "authorization: Bearer $SABLE_API_KEY" \
-H 'content-type: application/json' \
-d '{"text": "Refunds are processed within 5 business days.", "label": "refunds"}'
# 3. Search it
curl https://api.buildsable.com/v1/memory/collections/$COLLECTION_ID/search \
-H "authorization: Bearer $SABLE_API_KEY" \
-H 'content-type: application/json' \
-d '{"query": "how long do refunds take?", "k": 3}'A search returns the top matches, each with its similarity score, its label, and the chunk text — plus how much of the collection the scan actually covered and the signed receipt for the call:
{
"results": [
{
"id": "mk_9f21…",
"score": 0.83,
"label": "refunds",
"text": "Refunds are processed within 5 business days."
}
],
"chunks_considered": 20000,
"chunk_count": 34120,
"truncated": true,
"vector_cache": "hit",
"receipt": {
"receipt": "eyJ2IjoxL…",
"signature": "0x…",
"signer": "0x…",
"payload": { "kind": "memory", "operation": "search", "…": "…" }
}
}
How much of a collection a search reads
Search is exact cosine similarity over a scan, not an index: a sealed vector cannot be indexed by the database, so every candidate chunk's vector is scored. One search scans at most the 20,000 most recently added chunks, while a collection may hold up to 50,000. Chunk text is opened only for the results a search returns, never for the chunks it merely scored.
Past 20,000 chunks a search therefore sees part of the collection, not all of
it. It is not hidden: chunks_considered is how many chunks were scored,
chunk_count is how many the collection holds, and truncated is true
whenever the second is larger than the first. The same three numbers are signed
into the receipt, so the proof of a search states its own bounds. If your
collection is larger than the scan window, split it across collections along
whatever boundary your data already has and search the one you need.
How fast a search is, and the vector cache
Scoring a sealed vector means decrypting it first. To keep repeat searches cheap, each gateway process keeps a bounded cache of decrypted vectors and chunk ids per collection, in memory only. It never holds chunk text, and it is never written to disk or to a log.
vector_cache on a search response says how that search got its vectors:
| Value | Meaning |
|---|---|
hit | MeaningScored from vectors this gateway process already held decrypted. |
miss | MeaningRead and decrypted from the sealed column, then cached for the next search. |
bypass | MeaningRead and decrypted from the sealed column and not cached: the cache is switched off on this deployment, or the collection is larger than the cache allows. |
The field never changes the results. Every path scores the same values with the same function in the same order, so the ranking is identical whichever one ran.
What it does change is latency, in both directions, and you should plan for it:
- The first search of a collection is slower than it was before vectors were sealed, because every scanned vector is decrypted. So is the first search after any write to the collection (a write drops the cached copy), and the first after the collection has sat unsearched past the cache's idle timeout (10 minutes by default).
- A collection larger than the cache is slower on every search, not only the
first. The cache has one total size for all collections on a gateway process
(64 MiB by default, set by the operator). A collection whose vectors alone
exceed it is never cached: it is decrypted on each query and reported as
bypass. At 1,536 dimensions the default holds roughly 5,400 chunks in total. Collections that do fit share that space, and when it is full the least recently searched one is dropped to make room, so its next search is amissagain. - A search that hits the cache is faster than search used to be. It decrypts nothing and fetches only the text of its results.
We are not quoting timings here. The numbers we have were measured on a
development machine and would not be a fair promise about production. If search
latency matters to you, keep collections small enough to cache and watch
vector_cache on your own traffic.
The cache is per gateway process, and there is more than one. A copy is used only if it was built at the collection's current write generation, which every search reads before it reads any chunk, so a search never ranks from vectors older than the last write, on any machine.
Composing with a chat call
The point of a search result is to become the context for a completion. Take
the chunks you got back and declare them on the next chat call as
sable_context. The gateway fingerprints
each item in-frame and stamps a content-free context block on the receipt, so
anyone holding the receipt can prove which documents the answer was grounded in
without ever seeing them.
curl https://api.buildsable.com/v1/chat/completions \
-H "authorization: Bearer $SABLE_API_KEY" \
-H 'content-type: application/json' \
-d '{
"model": "sable",
"messages": [{"role": "user", "content": "How long do refunds take?"}],
"sable_context": [
{"text": "Refunds are processed within 5 business days.", "label": "refunds"}
]
}'Endpoints
All key-authed (Authorization: Bearer sk-sable_…).
| Method | Path | What it does |
|---|---|---|
| POST | Path/v1/memory/collections | What it doesCreate a collection. Body {name, embed_model?}. |
| GET | Path/v1/memory/collections | What it doesList your collections with dimension and chunk count, plus plaintext_vectors and vectors_sealed (see Privacy and limits). |
| DELETE | Path/v1/memory/collections/{id} | What it doesDelete a collection and its sealed chunks. |
| POST | Path/v1/memory/collections/{id}/documents | What it doesChunk, embed, and store a document. Body {text, label?}. Billed as embedding usage; returns a signed receipt. |
| POST | Path/v1/memory/collections/{id}/search | What it doesSemantic search. Body {query, k?} (k up to 50). Returns the top matching chunks, the scan bounds, vector_cache, and a signed receipt. |
Privacy and limits
- Sealed at rest: the text and the vector. Document text and the embedding vector made from it are both stored AES-GCM sealed under the gateway's master key. Each sealed vector is bound to its account, collection and chunk, so a ciphertext cannot be moved onto another row. The embedding dimension, the chunk count and any label you set are stored in the clear.
- Why the vector is sealed too. An embedding is a lossy but real encoding
of the text it came from. Until this change the vector sat unsealed beside
its sealed text, and because Sable also serves
/v1/embeddings, anyone holding a copy of the database and any API key could embed a guess and confirm it against a stored vector by cosine similarity. Sealing the vector closes that. - Vectors stored before sealing shipped are sealed by a background job, and
"every vector is sealed" is only true once that job has finished. New writes
go only to the sealed column. Older rows are sealed in batches by a backfill
that clears the plaintext as it goes. You can check both levels yourself:
GET /v1/statusreportsmemory_vectors.plaintext_remainingfor the whole deployment (nullmeans it has not been counted yet since the last restart, which is not the same as zero;all_sealedis true only once it has been counted at 0), andGET /v1/memory/collectionsshowsplaintext_vectorsandvectors_sealedfor each of your collections. Until your collection readsplaintext_vectors: 0, some of its older vectors are still stored unsealed. Even after the count reaches 0, vectors that were once stored unsealed can remain in two places: in the database's own superseded row versions until routine maintenance reclaims them, and in backups taken before the backfill, for as long as those backups are kept. - Decrypted vectors live in gateway memory while a collection is cached. This is the vector cache: vectors and chunk ids only, never text, never on disk, never logged, dropped on any write to the collection and after an idle timeout, and zeroed when dropped. While a collection is cached, a memory dump of that gateway process contains its vectors. That is the same place the master key lives.
- Not end-to-end, and not a zero-knowledge store. Sealing at rest is custody of ciphertext under a key Sable holds. It protects against someone reading a copy of the database without that key. It does not hide your content from the gateway, which opens text to embed it and to return a search hit.
- Metered, not free. Adding a document embeds it and is billed as embedding usage; search embeds the query and scores stored vectors. Both run on your prepaid balance and both return a signed, content-free receipt.
- Partial search past 20,000 chunks. A collection holds up to 50,000 chunks
but one search scores the newest 20,000 of them. Every response and every
receipt reports
chunks_considered,chunk_countandtruncated. - This is storage you opted into. Memory is the deliberate case where you ask Sable to hold your text so it can search it later. That is different from the privacy contract for prompts and completions, which are never persisted. Deleting a collection destroys its sealed chunks.
- The default embedding model is
sable-embed-3-small; passembed_modelto pick another from the embeddings catalog.