Memory Vector Recall
FFAI’s memory feature embeds completed Q+A turns and retrieves them by
semantic similarity. It is opt-in and disabled by default, matching
the rag.enabled convention.
This guide covers configuration, the embedding backend resolution ladder,
persistence semantics, the Memory class API, and troubleshooting. For
a hands-on walk-through, see Memory Vector Recall: Remember What Was Discussed.
How it works
When memory.enabled is true:
Each call to
generate_response()withstatus="success"submits an embed task to a single-worker background thread.The thread concatenates the prompt and response into a Q+A pair, embeds it, and appends it to an in-memory
TurnVectorStore.search()embeds the query and returns rankedTurnHitobjects.
The embed runs fire-and-forget — it does not block the main
generate_response() call. Failures are logged at WARNING and
dropped; they never propagate to the caller.
Configuration
All settings live under the memory: key in config/main.yaml:
memory:
enabled: false # opt-in; matches rag.enabled convention
embedding_model: null # null -> resolution ladder
store_backend: memory # reserved; Tier 1 uses in-memory store
store_config: {}
persist_dir: "./ffai_data/memory"
collection_name: "ffai_turns"
persist: false # ephemeral by default
Each field also has a constructor override on FFAI:
Keyword |
Effect |
Default |
|---|---|---|
|
Override |
|
|
Override |
|
|
Override |
|
|
Supply a pre-built |
|
Priority order: constructor kwargs > environment variables > YAML.
Environment variables
Pydantic settings reads nested fields via __ delimiter. The env var
for memory.enabled is:
MEMORY__ENABLED=true
There is no FFAI_ prefix (the Config class does not set
env_prefix).
Embedding backend resolution
When memory.enabled is true and embedding_model is null,
FFAI resolves a backend at construction time:
If
embedding_modelis set, try to constructEmbeddingswith it.ImportError→ log a warning and disable memory.Otherwise, try
local/all-MiniLM-L6-v2. Works iffastembedorsentence-transformersis importable.Otherwise, if
MISTRAL_API_KEYis set, usemistral/mistral-embed.Otherwise, if
OPENAI_API_KEYis set, useopenai/text-embedding-3-small.Otherwise: log a warning and disable memory (
ffai.history.memorybecomesNone, search returns[]).
Local embeddings
Install one of:
pip install "ffai[memory]" # fastembed only (~80 MB, no torch)
pip install "ffai[rag]" # fastembed + chromadb (heavier)
pip install sentence-transformers # alternative local backend
Local models are identified by a local/ prefix:
from ffai.core.embeddings import Embeddings
embeddings = Embeddings(model="local/all-MiniLM-L6-v2")
The first call downloads the model; subsequent calls use the cache.
API embeddings
Any LiteLLM-supported embedding provider works. Configure via
embedding_model:
memory:
enabled: true
embedding_model: "mistral/mistral-embed"
Common options:
Persistence
When persist: true:
Startup: FFAI loads
<persist_dir>/<collection_name>.parquetif it exists. The loadedTurnVectorStoreis wired into the activeMemoryinstance.Per embed: after each successful
index_turn_text, the store is rewritten to the same path.
The Parquet schema:
Writes are overwrite-on-persist, not append. Each write rewrites the
full file — acceptable for Tier 1 scale (<100K turns). For larger
workloads, future versions will support Chroma or Qdrant backends via
store_backend (currently reserved).
Warning
Writes are not atomic against process death mid-write. If the process crashes during a Parquet write, the file may be corrupted on restart. Acceptable for short-running workflows; long-running services should snapshot-then-rename or implement periodic backups.
Schema versioning
The _schema_version column lets future releases migrate the format.
Loaders reject unknown versions with a clear error rather than silently
corrupting data:
from ffai.core.memory import load_store
store = load_store("turns.parquet")
# ValueError: Unsupported memory persistence schema version(s): [2].
# This build supports version 1 only.
TurnHit reference
- class TurnHit(score, turn, turn_index, text, metadata)[source]
Bases:
objectA single ranked result returned by
TurnVectorStore.search.- Parameters:
- score
Cosine similarity in
[-1.0, 1.0]between the query embedding and the stored turn’s embedding. Sorted descending bysearch().- Type:
- turn
The raw turn dict stored at index time. Mirrors the
PermanentHistoryturn shape:{"role": str, "content": [{"type": "text", "text": str}], "timestamp": float, "metadata": dict}.
- turn_index
Position of the entry in
TurnVectorStoreat the time of the search. Stable within a singlesearch()call for a given store; may shift afterclear().- Type:
- text
Pre-extracted plain text (typically the Q+A pair) that was embedded. Equal to the string passed to
add(text=...).- Type:
- metadata
Caller-provided metadata attached at index time. Always present (possibly empty dict). Tier 2 will populate
user_id/session_id/agent_idhere.
Fields:
scoreCosine similarity in
[-1.0, 1.0]. Sorted descending bysearch().turnThe raw turn dict stored at index time. Mirrors the
PermanentHistoryturn shape:{"role": str, "content": [{"type": "text", "text": str}], "timestamp": float, "metadata": dict}.turn_indexPosition of the entry in the store at search time. Stable within a single search call; may shift after
clear().textThe plain text that was embedded. Equal to the Q+A pair (
f"{prompt}\\n{response}").metadataCaller-provided metadata. Always present (possibly empty dict). Currently carries
prompt_name; Tier 2 will adduser_id,session_id,agent_id.
Memory class API
The Memory facade is exposed via
ffai.history.memory (or None when disabled). You can also use it
directly without an FFAI wrapper:
from ffai.core.embeddings import Embeddings
from ffai.core.memory import Memory
embeddings = Embeddings(model="local/all-MiniLM-L6-v2")
memory = Memory(embeddings)
memory.index_turn_text(
text="user question\nassistant answer",
turn={"role": "assistant", "content": [...], "timestamp": ...},
metadata={"prompt_name": "demo"},
)
hits = memory.search("related question", top_k=3, threshold=0.3)
- class Memory(embeddings, store=None)[source]
Bases:
objectSemantic recall over completed conversation turns.
Wraps an
EmbeddingBackendand aTurnVectorStore. Provides two indexing entry points:index_turn()/aindex_turn()— extract text from a structuredturndict viaturn["content"][0]["text"].index_turn_text()/aindex_turn_text()— embed an arbitrary caller-supplied string while still storing the structuredturndict alongside. Used byHistoryRecorder(L4) to embed the Q+A pair (f"{prompt}\n{response}") rather than just the response.
- Parameters:
embeddings (EmbeddingBackend) – Embedding backend (LiteLLM API model or local
local/...model). Any object implementingEmbeddingBackendis accepted.store (TurnVectorStore | None) – Optional
TurnVectorStore. Defaults to a fresh instance. Public and settable so callers can swap in a store loaded from Parquet (L3).
- index_turn_text(text, turn, metadata=None)[source]
Embed an arbitrary
textand store the structuredturn.Use this when the embedded text differs from
turn["content"][0]["text"]— e.g., when embedding the Q+A pair (f"{prompt}\n{response}") while still storing the response as the canonical turn content.
- async aindex_turn(turn, metadata=None)[source]
Async variant of
index_turn().
- async aindex_turn_text(text, turn, metadata=None)[source]
Async variant of
index_turn_text().
- reindex(new_embeddings)[source]
Re-embed all stored texts with a new embedding model.
Reads all entries via
TurnVectorStore.iter_entries(), clears the store, and re-adds each entry with the new embedding. The turn dicts and metadata are preserved verbatim. After reindexing,self._embeddingsis updated so subsequentsearch()calls embed queries with the new model.- Parameters:
new_embeddings (EmbeddingBackend) – The new embedding backend to use.
- Return type:
None
- class TurnVectorStore[source]
Bases:
objectAppend-only in-memory store of embedded turns with cosine search.
Entries are stored in four parallel lists keyed by an integer index.
add()returns the index of the appended entry; that index is preserved on the correspondingTurnHitreturned bysearch().Search is O(N) over all stored entries. Adequate for Tier 1 scale; a vector-index backend (Chroma/Qdrant) is a future concern.
- add(text, embedding, turn, metadata=None)[source]
Append an entry and return its integer index.
- Parameters:
- Returns:
The integer index of the newly appended entry.
- Return type:
- search(query_embedding, top_k=5, threshold=None)[source]
Return up to
top_kturns ranked by cosine similarity.- Parameters:
- Returns:
Hits sorted by
scoredescending. Empty list if the store is empty or all hits fall belowthreshold.- Return type:
Persistence API
- persist_store(store, path)[source]
Write all entries in store to a Parquet file at path.
Overwrites any existing file. Empty stores produce a valid zero-row Parquet file.
- Parameters:
store (TurnVectorStore) – The
TurnVectorStoreto serialize.path (str) – Destination file path. Parent directories are created.
- Return type:
None
- load_store(path)[source]
Load a Parquet file written by
persist_store()into a fresh store.- Parameters:
path (str) – Source file path.
- Returns:
A new
TurnVectorStorewhose entries and ordering match what was persisted.- Raises:
FileNotFoundError – If path does not exist.
ValueError – If the file’s
_schema_versionis not1.
- Return type:
Lifecycle and resource cleanup
The background embed thread pool is owned by
HistoryRecorder. It is created when
a Memory is wired in and shut down by close():
ffai = FFAI(client, memory_enabled=True)
# ... use it ...
ffai.close() # shuts down the embed thread pool
close() is idempotent. Calling it at process teardown avoids leaking
daemon threads, which matters in long-running services.
If you forget to call close(), the daemon thread will not block
interpreter shutdown (it is a daemon thread by default).
Troubleshooting
Memory is None even though I set memory_enabled=True
The resolution ladder fell through to step 5 (no backend available). Check:
Is
fastembedorsentence-transformersimportable? (pip install "ffai[memory]")Is
MISTRAL_API_KEYorOPENAI_API_KEYset?Is
config.memory.embedding_modelpointing to a valid model?
A warning is logged at construction time when this happens:
Memory enabled but no embedding backend available.
Install ffai[memory] (or ffai[rag]) for local embeddings, or set
memory.embedding_model / MISTRAL_API_KEY / OPENAI_API_KEY.
Embeddings are slow
API embeddings add one network call per record(). Local embeddings
(local/all-MiniLM-L6-v2) avoid the network but use CPU. The embed
runs on a fire-and-forget background thread, so it does not block the
main call — but it can fall behind if record() is called faster than
embeds can complete.
Symptoms: ffai.history.memory.count() lags behind the number of
generate_response() calls.
Search returns nothing relevant
Lower
threshold(default is no floor)Increase
top_kVerify the embedding model matches the one used at index time. If you change
embedding_model, existing vectors become invalid; callreindex()with the new backend.
Parquet file is corrupted after a crash
Overwrite-on-persist is not atomic. If your service crashes mid-write, recover from a backup or rebuild the store by re-running the original workflow. Future versions may add atomic writes (write-temp-then-rename).
See also
Memory Vector Recall: Remember What Was Discussed — hands-on tutorial
History & Query API — the four other history stores (raw, clean, ordered, permanent)
Configuration — full configuration reference