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 shape | Pure vector | Pure BM25 | Hybrid (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” | medium | medium | strong (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.
| Parameter | Type | Default | Description |
|---|---|---|---|
query | str | required | The query text to match against episode inputs / outputs. |
kind | str | "episodic" | Episode kind filter (typically "episodic"). |
limit | int | 5 | Max matches returned. |
time_range | tuple[datetime, datetime] | None | None | Optional (start, end) UTC range. |
user_id | str | None | None | Multi-tenant partition. The anonymous bucket is the implicit default. Cross-tenant leaks are forbidden by the protocol. |
alpha | float | 0.5 | Weights 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| Field | Type | Description |
|---|---|---|
episode | Episode | The recalled episode (raw, unmodified). |
score | float | Final 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_score | float | None | Cosine-similarity component, in [-1, 1]. None when the backend has no embedder, or the episode didn’t score on the vector side. |
bm25_score | float | None | BM25 lexical-match component. None when the episode shared no terms with the query. |
rerank_score | float | None | Optional cross-encoder / LLM reranker score, computed after the initial fused ranking. None when no reranker was applied. |
from loomflow import EpisodeMatch # exported top-levelWhat each backend does
| Backend | Hybrid implementation |
|---|---|
InMemoryMemory | BM25 only. It has no embedder, so vector_score is always None. Lexically matching episodes rank ahead of unrelated recent ones. |
VectorMemory | BM25 + cosine + RRF over every stored episode. |
SqliteMemory | BM25 + cosine + RRF over the user’s episodes (full table scan). |
ChromaMemory | BM25 + cosine + RRF, scored in process over the user’s stored vectors. |
PostgresMemory | BM25 + cosine + RRF over a candidate pool fetched with the pgvector HNSW index (max(limit * 8, 40) nearest rows). |
RedisMemory | BM25 + cosine + RRF, scored in process over the user’s episodes. |
AutoExtractMemory | Pass-through. Preserves the inner backend’s score breakdown. |
LazyMemory | Pass-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_scoredA 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.