Stores: where memories live¶
Every store holds every kind of memory, with the same behaviour. Pick one by URL:
| Store | connect() / kioko.intelligent() target |
Install | Holds | Good for |
|---|---|---|---|---|
| In-process | memory:// |
nothing | memories, history, events (in RAM) | tests, notebooks, demos |
| SQLite | sqlite:///memory.db or memory.db |
nothing (standard library) | memories, history, events | one machine, a service with moderate traffic |
| Postgres + pgvector | postgresql://user:password@host:5432/db |
pip install "kioko[pgvector]", or pip install "psycopg[binary]>=3.2" next to a wheel |
memories, history, events | production: many processes, large memories, SQL operations |
| Qdrant | qdrant://host:6333/collection (qdrant+https:// for TLS) |
nothing (Kioko speaks Qdrant's REST API) | memories (pair it with a history store) | large vector workloads, an existing Qdrant cluster |
| A Kioko service | http(s)://memory-service:8765 |
nothing | whatever the service uses | sharing one memory across processes, languages and MCP hosts |
| Your own | MemoryLayer(MyStore()) |
— | what you implement | any database |
import kioko
from kioko import MemoryLayer
memory = kioko.connect("sqlite:///memory.db")
memory = kioko.connect("postgresql://kioko:<password>@db:5432/kioko")
memory = kioko.connect("qdrant://qdrant:6333/kioko_memories")
memory = kioko.intelligent("postgresql://kioko:<password>@db:5432/kioko") # + self-management
memory = MemoryLayer.from_config({"store": {"type": "pgvector", "dsn": "postgresql://..."}})
kioko serve --store postgresql://kioko:<password>@db:5432/kioko --intelligence
kioko ui --store qdrant://localhost:6333/kioko_memories
Keep passwords out of code and command lines that end up in logs. PgVectorStore() reads KIOKO_PG_DSN (or
DATABASE_URL) when no DSN is given, QdrantStore() reads QDRANT_API_KEY, and describe() and the
console show DSNs with the password redacted.
Local databases with Docker¶
docker-compose.stores.yml starts Postgres with pgvector (pgvector/pgvector:0.8.7-pg17), Qdrant
(qdrant/qdrant:v1.20.0) and a Kioko service on top of Postgres, with intelligence on:
export KIOKO_PG_PASSWORD='<choose one>' KIOKO_API_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
docker compose -f docker-compose.stores.yml up -d
export KIOKO_PG_DSN="postgresql://kioko:$KIOKO_PG_PASSWORD@localhost:5432/kioko"
export KIOKO_QDRANT_URL=http://localhost:6333
python examples/cookbook/17_stores.py # the same code on SQLite, pgvector and Qdrant
The ports are bound to 127.0.0.1. For anything beyond a laptop, put the databases on a private network,
set QDRANT_API_KEY, and keep passwords in a secret store.
SQLite¶
SQLiteStore(path, table_prefix="kioko_") keeps memories, session history and the event log in one file. It
uses WAL mode and one serialized connection per process, which is fine for a service plus the console.
Without a vector index, search scores every memory in the user's scope: a few milliseconds for hundreds of
memories, about 150 ms for a thousand. Past that, use pgvector or Qdrant.
Postgres + pgvector¶
from kioko.stores import PgVectorStore
store = PgVectorStore("postgresql://kioko:<password>@db:5432/kioko", # or KIOKO_PG_DSN / DATABASE_URL
table_prefix="kioko_", # several memories can share a database
ef_search=100, # HNSW search breadth (pgvector's hnsw.ef_search)
iterative_scan="relaxed_order")
memory = MemoryLayer(store) # history and events live in Postgres too
How it works:
<prefix>memoriesholds every memory. The embedding is avectorcolumn plus its length (dims). Kioko creates an HNSW cosine index per embedding size, as a partial expression index ((embedding::vector(d)) vector_cosine_ops ... WHERE dims = d). Changing embedders therefore never breaks the index. Above 2,000 dimensions it indexeshalfvec, which pgvector supports up to 4,000.- Filters on user, agent, session, kind and key run in SQL next to the vector search. With pgvector 0.8+,
iterative index scans (
hnsw.iterative_scan) keep filtered searches from returning too few rows. <prefix>messagesand<prefix>eventshold session history and the activity log, which the console reads.CREATE EXTENSION vectoris attempted on first use. If your database user may not create extensions, an administrator runs it once.- Reuse your own connection with
connection=(a psycopg 3 connection) orpool=(apsycopg_pool.ConnectionPool).
Qdrant¶
from kioko import MemoryLayer, SQLiteStore
from kioko.stores import QdrantStore
store = QdrantStore("http://qdrant:6333", collection="kioko_memories", api_key=None) # or QDRANT_API_KEY
memory = MemoryLayer(store, history_store=SQLiteStore("history.db"))
- Every memory is a point. The embedding is the vector (cosine), and the fields are the payload. The point id is the memory id as a UUID, and the original id is kept in the payload.
- Payload indexes on user, agent, session, kind, key and timestamps are created with the collection, so filtered vector searches stay fast. The collection is created on first use, once the embedding size is known.
- Memories stored without an embedding (working memory) get a placeholder vector and
has_vector=false, so vector searches never return them. - Qdrant holds memories only. Pass a
history_store(SQLite or Postgres) for session history and the activity log. Withconnect("qdrant://...")they stay in process memory. - It needs Qdrant server 1.10 or newer (the Query API). There is no client library to install. The API key is
sent as the
api-keyheader and is never logged.
Changing the embedder¶
After you switch embedders, for example from the offline HashingEmbedder to OpenAIEmbedder, run
memory.reembed() once (per user with user_id=, if you prefer). It re-computes every vector that was made by
another embedder and returns how many changed. SQLite and the in-process store would also re-embed old
memories lazily the first time a search reads them. pgvector searches with its own index and only finds
vectors of the current size: it creates the index for the new size by itself, but the old memories need
reembed(). A Qdrant collection has one vector size, so use a new collection= per embedder.
Vectors do not record which embedder made them, so reembed() only notices a switch when the vector size
changes. The offline embedder makes 1024-dimensional vectors, the same size as bge-large, mxbai-embed-large and
e5-large. After switching to an embedder of the same size, run memory.reembed(force=True) to recompute every
vector.
Writing your own store¶
A store implements kioko.MemoryStore: insert, update, get, delete and query (filter + newest first
+ limit/offset). It can optionally implement:
vector_search(embedding, filter, limit), to let the database rank by similarity;find_by_key,count,clear,touchandstats, which have defaults on the base class;requires_dimensions/set_dimensions(n), if the database must know the vector size before the first write (as Qdrant does).
examples/04_custom_components.py has a JSON-file store in about 30 lines. Register it by name to use it from
config and the CLI:
or ship it as a plug-in with an entry point:
The contract tests in tests/test_layer.py (LayerContract) and tests/test_kinds.py (KindsContract) are
reusable. Subclass them with a make() that builds a layer on your store, and every behaviour Kioko relies on
is tested against it. The pgvector and Qdrant stores are tested exactly this way: offline against faithful
fakes on every run, and against real servers in CI (KIOKO_TEST_PG_DSN, KIOKO_TEST_QDRANT_URL).