Recall runs four searches at once and merges them into one ranked list. The merge, it turns out, matters more than most of the searches.
Four ways of being relevant
A memory can be worth returning for quite different reasons, and no single index captures all of them.
Semantic — embedding similarity. Finds "the deploy took ages" when you
asked about "slow migrations". Blind to exact identifiers: it has no special
respect for v2.3.
Keyword — Postgres full-text search over a stored tsvector. The opposite
temperament. It will find v2.3 and GC-4156 precisely, and miss every
paraphrase. Deliberately faithful to Hindsight here: tokens are OR-joined with no
:* prefix matching, so run does not silently match running.
Graph — traversal of the entity graph built during retain. Finds memories
that never mention your query terms but are about an entity that does. This is
the arm that connects "Maria" to "Lisbon" without either sentence containing both.
Temporal — time-proximity. Matters for "what else was happening that week?". Worth knowing: on our benchmark corpus this arm fires on 29 of 4,785 candidates. It is a specialist, not a workhorse, and any tuning that appears to learn something about it is almost certainly learning noise.
Each arm returns its own ranked list with its own scores, on its own scale.
The merge is the interesting part
Four lists have to become one. The obvious approach — the one Foresight shipped
originally and Hindsight still uses — is Reciprocal Rank Fusion: ignore the
scores, use only positions, sum 1/(k + rank) across arms.
RRF is robust precisely because it discards the scores. It cannot be fooled by one arm's scale.
It can, however, be blinded by it. Consider semantic returning:
1. 0.95 "the payments migration slipped to Q3"
2. 0.31 "payments team standup notes"To RRF these are simply first and second. The chasm between 0.95 and 0.31 — information the embedder computed and handed over — is thrown away.
Normalizing instead keeps it: rescale each arm's native scores to a common range, then add. We measured both over 134 queries carrying graded relevance labels, paired per query, with a 2,000-sample bootstrap:
| mode | nDCG@10 | MRR | vs :rrf | 95% CI |
|---|---|---|---|---|
:rrf | 0.7670 | 0.7760 | — | — |
:normalized / min_max | 0.8273 | 0.8508 | +0.0603 | [0.0322, 0.0906] |
:normalized / zscore | 0.8679 | 0.9027 | +0.1009 | [0.0597, 0.1476] |
zscore beats min_max because a single outlier squashes a min-max range,
while a standard-deviation scale is unmoved by it.
:normalized with :zscore is the default. The number to watch is MRR
0.776 → 0.903: that is the right memory sitting at rank 1 far more often, and
rank 1 is what reflect reads first.
What that result does not say
It is one corpus, and the relevance labels were produced by a model rather than a person. A ranking improvement is not an answer-quality improvement — the same change did not produce a significant lead in end-to-end accuracy. See Evaluation.
Weights, and a negative result worth reading
:normalized and :hybrid accept per-arm weights:
config :foresight,
recall_fusion: [
mode: :normalized,
normalization: :zscore,
weights: %{"semantic" => 1.5, "keyword" => 1.0, "graph" => 0.5, "temporal" => 1.0}
]Weights are ignored under :rrf, which ranks on position alone.
We tried to learn these weights per bank — coordinate ascent on a training split, scored once on held-out queries. Across 12 query-level splits it was worth +0.0153 nDCG@10 (SD 0.0275), and negative in 3 of them. Not significant, and not shipped.
The honest lesson is that picking the right merge strategy was worth ~7× more than tuning weights within it, and cost nothing at runtime. If you are tempted to tune weights, measure first — and measure on held-out queries, not the ones you tuned on.
After the merge
The fused list is truncated to a rerank window, optionally passed to a
cross-encoder reranker, boosted, and trimmed. The default reranker is
Passthrough — it preserves fusion order — so out of the box your ranking is
the fusion result. Configure Rerankers.Bumblebee or
Rerankers.SentenceTransformers for real reranking, at the cost of the ML stack.
Seeing what happened
Pass trace: true and recall explains itself: per-arm entry points and native
scores, the fused ordering, what each stage pruned, and per-stage timings_ms.
{:ok, %{"trace" => trace}} = Foresight.recall(ctx, %{"query" => "...", "trace" => true})
trace["fusion"] #=> %{"algorithm" => "normalized", "normalization" => "zscore", "k" => 60}That "algorithm" field is derived from the configuration actually in force. It
used to be hardcoded to "rrf", which meant that after the default changed it
confidently reported the wrong algorithm on every single request — the one field
an operator would consult to find out what really ran.