Skip to Content
MemoryHybrid recall

Hybrid recall

recall_scored() searches an agent’s past episodes using keyword matching and vector similarity together, and tells you why each result ranked where it did. Use it when you want to inspect, filter or rerank what memory returns, instead of taking plain recall() results as they come.

Every Memory backend exposes it. Each result pairs an episode with its score components: BM25 lexical match, vector cosine, and (when you add a reranker) a reranker score.

matches = await agent.memory.recall_scored( "postgres replication failure", user_id="alice", alpha=0.5, # lower favours BM25, higher favours vector; 0.5 = balanced ) for m in matches: print(m.episode.input, m.score, m.bm25_score, m.vector_score)

For the conceptual model behind episodes vs facts see Episode vs Fact.


Why hybrid

Plain vector recall (cosine over embeddings) misses queries with rare keywords: acronyms, IDs, proper nouns and error codes, which embeddings smooth out. Plain BM25 misses synonyms and paraphrases that share no surface tokens.

Hybrid retrieval runs both rankers and fuses the results. Loomflow uses Reciprocal Rank Fusion (RRF), the standard algorithm that scores by rank position only and ignores raw score magnitudes. That matters because cosine (in [-1, 1]) and BM25 (in [0, ∞)) aren’t on the same scale.

Query shapePure vectorPure BM25Hybrid (RRF)
“How do I improve database speed?“strong (paraphrase match)weak (no shared terms)strong
”INV-2026-0042”weak (embedding smooths IDs)strong (exact token match)strong
”Tell me about replicating postgres”mediummediumstrong (both signals reinforce)

Memory.recall_scored(...)

async def recall_scored( self, query: str, *, kind: str = "episodic", limit: int = 5, time_range: tuple[datetime, datetime] | None = None, user_id: str | None = None, alpha: float = 0.5, ) -> list[EpisodeMatch]: ...

Same filtering semantics as recall() (user_id partition, time_range, kind), but returns scored matches instead of bare episodes.

ParameterTypeDefaultDescription
querystrrequiredThe query text to match against episode inputs / outputs.
kindstr"episodic"Episode kind filter (typically "episodic").
limitint5Max matches returned.
time_rangetuple[datetime, datetime] | NoneNoneOptional (start, end) UTC range.
user_idstr | NoneNoneMulti-tenant partition. The anonymous bucket is the implicit default. Cross-tenant leaks are forbidden by the protocol.
alphafloat0.5Weights the lexical vs vector mix: lower values give the BM25 ranking more votes in the fusion, higher values give the vector ranking more. 0.0 is BM25 alone and 1.0 vector alone, as in the vector stores (before 0.17 the other ranking kept one vote). Backends without an embedder (InMemoryMemory) rank by BM25 alone; at alpha=1.0 that leaves nothing to rank, so they return the most recent episodes with score=0.0.

Returns list[EpisodeMatch], sorted by score descending. An empty query returns the most recent episodes with score=1.0; a query that matches nothing returns the most recent episodes with score=0.0, so you always get something back.


EpisodeMatch

class EpisodeMatch(BaseModel): episode: Episode score: float vector_score: float | None = None bm25_score: float | None = None rerank_score: float | None = None
FieldTypeDescription
episodeEpisodeThe recalled episode (raw, unmodified).
scorefloatFinal fused score the backend used for ranking. Higher is better. With RRF this is rank-based and small (around 1/60 per vote), so compare it between results rather than against a fixed threshold.
vector_scorefloat | NoneCosine-similarity component, in [-1, 1]. None when the backend has no embedder, or the episode didn’t score on the vector side.
bm25_scorefloat | NoneBM25 lexical-match component. None when the episode shared no terms with the query.
rerank_scorefloat | NoneOptional cross-encoder / LLM reranker score, computed after the initial fused ranking. None when no reranker was applied.
from loomflow import EpisodeMatch # exported top-level

What each backend does

BackendHybrid implementation
InMemoryMemoryBM25 only. It has no embedder, so vector_score is always None. Lexically matching episodes rank ahead of unrelated recent ones.
VectorMemoryBM25 + cosine + RRF over every stored episode.
SqliteMemoryBM25 + cosine + RRF over the user’s episodes (full table scan).
ChromaMemoryBM25 + cosine + RRF, scored in process over the user’s stored vectors.
PostgresMemoryBM25 + cosine + RRF over a candidate pool fetched with the pgvector HNSW index (max(limit * 8, 40) nearest rows).
RedisMemoryBM25 + cosine + RRF, scored in process over the user’s episodes.
AutoExtractMemoryPass-through. Preserves the inner backend’s score breakdown.
LazyMemoryPass-through. Preserves the inner backend’s score breakdown.

The ranking code is shared, so alpha and the score fields mean the same thing on every backend.


default_recall_scored helper

from loomflow.memory import default_recall_scored

A synchronous helper that wraps a list of Episode rows in EpisodeMatch objects with a neutral score (default 1.0). The wrapper backends use it as a fallback when the inner memory has no recall_scored of its own.

def default_recall_scored( episodes: Iterable[Episode], *, score: float = 1.0, ) -> list[EpisodeMatch]: ...

If you’re writing a custom Memory backend, the easiest path is to run your existing recall() and wrap its results:

from loomflow.memory import default_recall_scored class MyMemory: async def recall(self, query, *, kind="episodic", limit=5, time_range=None, user_id=None): ... async def recall_scored(self, query, *, alpha=0.5, **kwargs): episodes = await self.recall(query, **kwargs) return default_recall_scored(episodes)

That gives you a working recall_scored while you decide whether to plumb your backend’s native distance / score into vector_score.


Worked example: InMemoryMemory native BM25

from datetime import UTC, datetime from loomflow import Episode, InMemoryMemory memory = InMemoryMemory() # Seed three episodes for alice for text in [ "Postgres replication is configured asynchronously.", "Today's lunch was great.", "We migrated to logical replication on the primary.", ]: await memory.remember( Episode( session_id="notes", user_id="alice", input="Remember this.", output=text, occurred_at=datetime.now(UTC), ) ) matches = await memory.recall_scored( "postgres replication", user_id="alice", limit=3, ) for m in matches: print(f"{m.score:.3f} bm25={m.bm25_score:.3f} → {m.episode.output[:50]}") # 0.016 bm25=1.537 → Postgres replication is configured asynchronously. # 0.016 bm25=0.422 → We migrated to logical replication on the primary.

Only the two episodes that share terms with the query come back, best match first; the unrelated “lunch” episode is left out. score is the fused RRF value, while bm25_score carries the raw BM25 strength.


Composing rerankers / MMR / score thresholds

The EpisodeMatch shape is what makes downstream layers straightforward. A threshold filter works best on a component score, since the fused score is rank-based:

matches = await memory.recall_scored("...", user_id="alice", limit=20) strong = [m for m in matches if (m.vector_score or 0.0) >= 0.6]

A cross-encoder reranker (using whatever scoring library you like):

from loomflow import EpisodeMatch from sentence_transformers import CrossEncoder ranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2") matches = await memory.recall_scored("...", user_id="alice", limit=50) texts = [m.episode.output for m in matches] rerank_scores = ranker.predict([("...", t) for t in texts]) reranked = sorted( [ EpisodeMatch( episode=m.episode, score=float(rerank_scores[i]), vector_score=m.vector_score, bm25_score=m.bm25_score, rerank_score=float(rerank_scores[i]), ) for i, m in enumerate(matches) ], key=lambda m: m.score, reverse=True, )[:5]

What hybrid recall gives you. Every backend ranks with BM25 and (when it has an embedder) vector cosine, fused with RRF. The EpisodeMatch shape exposes the BM25, vector, and reranker components so you can layer a cross-encoder reranker, MMR diversifier, or score threshold on top. It composes with bi-temporal facts, auto-extract, and multi-tenant partitioning.

Last updated on