Skip to Content
MemoryBackends

Memory backends

A memory backend is where an agent’s episodes, working blocks and facts are stored. Pick one based on whether memory must survive a restart and how many processes share it; the agent behaves the same on all of them.

There are six backend classes, all behind the same Memory protocol and all partitioned by user_id. Five can be selected with a memory= string; VectorMemory is constructed directly. Every class is exported, so you can also construct any of them yourself.

Feature matrix

BackendPersistencerecall()recall_scored()Async networkFact store
InMemoryMemorynonesubstring match, then recencyBM25 only (no embedder)n/aInMemoryFactStore
VectorMemorynonecosine over every episodeBM25 + cosine + RRFn/aInMemoryFactStore
SqliteMemorysingle filecosine, full table scanBM25 + cosine + RRFnoSqliteFactStore
ChromaMemoryon disk, in process, or any Chroma clientChroma’s HNSWBM25 + cosine + RRFdepends on the clientChromaFactStore
PostgresMemoryPostgres + pgvectorpgvector HNSWBM25 + cosine + RRF over an HNSW candidate poolyes (asyncpg)PostgresFactStore
RedisMemoryRedis (optional RediSearch)RediSearch HNSW, or brute force without itBM25 + cosine + RRFyes (redis.asyncio)RedisFactStore

recall_scored() returns each episode with its BM25 and vector score components (EpisodeMatch.bm25_score / vector_score). See Hybrid recall.

InMemoryMemory: default

Zero dependencies, zero setup. Lost on process exit.

from loomflow import Agent, InMemoryMemory memory = InMemoryMemory() agent = Agent("...", model="claude-opus-4-7", memory=memory) # Or just by URL: agent = Agent("...", model="claude-opus-4-7", memory="inmemory")

Bounded by default to 100k users with a 24h idle TTL. See Bounded in-process state.

VectorMemory: in-process semantic recall

Like InMemoryMemory, but it embeds every episode and recalls by cosine similarity. Nothing persists. Recall scans every episode, so it suits up to a few thousand episodes; past that, use Postgres or Chroma. It has no URL form:

from loomflow import Agent from loomflow.memory import VectorMemory from loomflow.memory.embedder import OpenAIEmbedder memory = VectorMemory(embedder=OpenAIEmbedder()) agent = Agent("...", model="claude-opus-4-7", memory=memory)

SqliteMemory: persistent, no server

A single file and no infrastructure. A good fit for prototypes, single-instance bots, and demos.

from loomflow import Agent agent = Agent("...", model="claude-opus-4-7", memory="sqlite:./bot.db") # Or explicit: from loomflow.memory.embedder import OpenAIEmbedder from loomflow.memory.sqlite import SqliteMemory memory = SqliteMemory("./bot.db", embedder=OpenAIEmbedder(), with_facts=True) agent = Agent("...", model="claude-opus-4-7", memory=memory)

The constructor is synchronous and creates the tables (and any missing parent directories) on the spot. SQLite has no vector type, so recall ranks episodes by brute-force cosine similarity in Python. That is fine for tens of thousands of episodes; for more, move to Postgres or Chroma. SQLite also serialises writes, so use Postgres if several processes write to the same memory.

ChromaMemory: ephemeral or persistent

chromadb is imported lazily. Give a path to persist on disk; omit it for an ephemeral, in-process collection.

from loomflow import Agent agent = Agent("...", model="claude-opus-4-7", memory="chroma") # ephemeral agent = Agent("...", model="claude-opus-4-7", memory="chroma:./chroma-db") # persistent # Explicit: from loomflow.memory.chroma import ChromaMemory from loomflow.memory.embedder import OpenAIEmbedder memory = ChromaMemory.local( "./chroma-db", with_facts=True, embedder=OpenAIEmbedder(), )

ChromaMemory.ephemeral(...) builds the in-process variant, and ChromaMemory(client, ...) accepts any Chroma client you have already configured (for example a remote chromadb.HttpClient).

New episode and fact collections are created in cosine space, as on the other backends. Chroma can’t change the space of an existing collection, so one created by a release before 0.17 keeps Chroma’s default L2 space: opening it emits a UserWarning, and its rankings can differ from the other backends’. To fix it, move the data into new collections (collection_name= / facts_collection_name=).

Install: pip install 'loomflow[chroma]'.

PostgresMemory: production durable

Postgres + pgvector, async via asyncpg.

from loomflow import Agent agent = Agent("...", model="claude-opus-4-7", memory="postgres://user:pw@host/db") # Explicit: from loomflow.memory.embedder import OpenAIEmbedder from loomflow.memory.postgres import PostgresMemory pg_memory = await PostgresMemory.connect( dsn="postgres://user:pw@host/db", embedder=OpenAIEmbedder("text-embedding-3-small"), with_facts=True, init_schema=True, # create the tables if missing; safe on every start ) agent = Agent("...", model="claude-opus-4-7", memory=pg_memory)

Install: pip install 'loomflow[postgres]'.

connect() opens the pool and creates nothing unless you pass init_schema=True. (You can call await pg_memory.init_schema() yourself instead, but only once the vector extension is installed: the pool’s setup needs the type.) The schema setup creates the vector extension and the tables, and every statement in it is IF NOT EXISTS, so it is safe to run on every start. The memory="postgres://..." URL form does this on first connect. Creating the extension needs a role allowed to do so; if yours isn’t, install vector beforehand.

The schema is memory_blocks, episodes (with an HNSW cosine index on the embedding), episode_tool_transcripts, and facts (when with_facts=True). The episode and block tables carry a user_id column with indexes for the multi-tenant queries. The embedding column’s dimension is fixed when the table is created, so switching to an embedder with a different dimension means migrating the table.

Recall filters by user_id inside the HNSW index scan. pgvector’s scan returns at most hnsw.ef_search rows (40 by default), so for each query loomflow raises ef_search to the number of rows it needs (up to pgvector’s limit of 1000) and, on pgvector 0.8+, turns on iterative index scans, which keep scanning until the filter has enough rows. On older pgvector the scan stops at ef_search rows and filters those, so a user with few episodes among many tenants can get fewer matches than they have; use pgvector 0.8+ for multi-tenant recall.

RedisMemory: fast multi-instance

Redis, with an optional RediSearch HNSW vector index. Async via redis.asyncio.

from loomflow import Agent agent = Agent("...", model="claude-opus-4-7", memory="redis://localhost:6379/0") # Explicit: from loomflow.memory.embedder import OpenAIEmbedder from loomflow.memory.redis import RedisMemory memory = await RedisMemory.connect( url="redis://localhost:6379/0", embedder=OpenAIEmbedder(), with_facts=True, use_vector_index=True, # the default: use RediSearch HNSW when available )

Install: pip install 'loomflow[redis]'.

With RediSearch available (Redis Stack), recall uses the HNSW index. On plain Redis, or with use_vector_index=False, the backend falls back to scanning every episode in process, which is fine for small corpora.

LazyMemory: what memory="postgres://..." actually returns

Agent(...) is synchronous, but PostgresMemory.connect() and RedisMemory.connect() are async. The resolver returns a LazyMemory proxy that opens the connection on the first agent.run:

from loomflow import Agent agent = Agent("...", model="claude-opus-4-7", memory="postgres://...") # no connection yet await agent.run("hi", user_id="alice") # connection opens here

Once the connection is open, LazyMemory forwards every Memory call to the real backend, so you can treat it like any other memory.

Pick a backend by durability and scale. Single instance with ephemeral data: InMemory (or Vector for semantic recall). Single instance, durable: SQLite. Several instances or large vector recall: Postgres or Redis. Hosted vector ANN with metadata filters: Chroma. The agent behaves the same on every backend.

Last updated on