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
| Backend | Persistence | recall() | recall_scored() | Async network | Fact store |
|---|---|---|---|---|---|
InMemoryMemory | none | substring match, then recency | BM25 only (no embedder) | n/a | InMemoryFactStore |
VectorMemory | none | cosine over every episode | BM25 + cosine + RRF | n/a | InMemoryFactStore |
SqliteMemory | single file | cosine, full table scan | BM25 + cosine + RRF | no | SqliteFactStore |
ChromaMemory | on disk, in process, or any Chroma client | Chroma’s HNSW | BM25 + cosine + RRF | depends on the client | ChromaFactStore |
PostgresMemory | Postgres + pgvector | pgvector HNSW | BM25 + cosine + RRF over an HNSW candidate pool | yes (asyncpg) | PostgresFactStore |
RedisMemory | Redis (optional RediSearch) | RediSearch HNSW, or brute force without it | BM25 + cosine + RRF | yes (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 hereOnce 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.