Intent
Retrieve both conceptually related memory and exact facts, while enforcing scope and lifecycle constraints before results reach the agent.
The problem
Vector search is good at paraphrase and topical similarity but weak on exact names, identifiers, dates, paths, and negation. Lexical search catches exact strings but misses paraphrase. Either signal alone produces avoidable blind spots.
The size of that blind spot is measurable. On LIMIT, a benchmark built to expose single-vector retrieval, BM25 reaches Recall@20 of 0.9490 where Contriever reaches 0.0265 (Clavié et al., Latent Terms, arXiv:2605.29384, 28 May 2026). That paper also shows some of the gap is recoverable from the dense model — but recovering it is a second index, not a better read of the first. It trains a new sparse autoencoder on the retriever's frozen final-layer token activations and scores the resulting features with BM25 over an inverted index; the pooled embedding is never rescored. The recovery is partial (Contriever 0.0265 → 0.5100, still 0.44 short of BM25) and it does not hold everywhere — on a multi-vector model it loses ground on both LIMIT and BEIR. Read it as evidence that lexical structure exists in token-level activations and needs its own scoring machinery to be usable, which is an argument for keeping a lexical arm rather than for expecting an embedding to grow one.
The pattern
Apply hard filters first, retrieve candidates through independent channels, normalize or rank their outputs, then fuse and cap the final context:
Diagram source
%% caption: hard filters run before any candidate is scored, three arms then produce candidates that are fused, optionally reranked, and cut to a token budget
flowchart TD
Q["Query"] --> F["Hard scope, lifecycle,<br/>sensitivity filters"]
F --> V["Vector<br/>candidates"]
F --> L["BM25 / FTS<br/>candidates"]
F --> E["Entity or<br/>exact-key candidates"]
V --> R["Rank fusion"]
L --> R
E --> R
R --> X["Optional reranker"]
X --> B["Diversity +<br/>token budget"]Reciprocal-rank fusion is a useful baseline because it combines rankings without pretending incomparable scores share a calibrated scale — the property its authors claim for it, "without regard to the arbitrary scores returned by particular ranking methods" (Cormack, Clarke and Büttcher, SIGIR '09). Weighted score fusion can work when every component has measured, bounded behavior.
The constant is a default, not a measurement.
k = 60 was fixed in a pilot that fused thirty
configurations of a single search engine, over a curve flat from 30 to
100; the paper's own words are that 60 "was near-optimal, but that the
choice was not critical", and 80 scored fractionally higher in the table
it comes from.
What k controls is how much of an arm's rank ordering
survives fusion. Within one arm, k = 60 compresses the
whole top 60 into a factor of 1.967× — so a first-place hit is worth
barely more than a sixtieth-place one, and cross-arm agreement dominates
rank position. How much agreement is worth is not a fixed
factor: a document at rank a in one arm gains
1 + (k+a)/(k+b) by also appearing at rank b in
the other, which reaches 2× only when the two ranks coincide and falls
to 1.06× for a = 1, b = 1000. Two more things the
arithmetic alone will not tell you: how deep each arm's candidate list
runs, since a document missing from an arm's top-k has no
defined rank and is usually approximated over the union of the lists;
and how correlated the arms are, since agreement between two views of
the same corpus is much weaker evidence than agreement among many
independently built systems.
Fusing rank-only also discards the score distribution, and the literature has measured what that costs. Bruch, Gai and Ingber, An Analysis of Fusion Functions for Hybrid Retrieval, TOIS 2023 (arXiv:2210.11934) sweeps a separate constant per arm over 1–100 across nine datasets, and finds NDCG "swings wildly" with them. Their reframing is worth the detour: allow one constant per arm and RRF has m parameters against a convex combination's m−1, so the fusion usually chosen for being parameter-free has one parameter more than the one it is chosen over. Three results worth carrying:
- A convex combination of normalized scores, with one
tuned weight, beat RRF
(60,60)on NDCG on all nine datasets, in-domain and zero-shot, and needs only a small tuning sample. Normalization choice barely mattered. - Symmetrically lowering the constant transferred.
RRF
(5,5)matched or beat(60,60)on all nine. - Asymmetric tuning did not transfer.
(10,4), tuned in-domain, improved MS MARCO 0.425 → 0.451 and then cost HotpotQA 0.675 → 0.621 and FEVER 0.721 → 0.649. In-domain the tuning discounts the lexical arm; out-of-domain that reverses.
Akarsu et al.
(arXiv:2604.01733), 2 April 2026, agrees in one domain on two arms:
convex combination at α = 0.5 reaches Recall@5 0.726, RRF
k = 10 0.716, RRF k = 60 0.695.
So: keep 60 if you have nothing to tune against, and do not defend it as measured. If you can normalize scores and tune one weight on a small in-domain sample, that is the better-evidenced fusion. And if you tune per-arm constants, tune them on your own traffic and expect them not to survive a domain change.
Why it works
The channels fail differently. Semantic search recalls meaning; lexical search protects exactness; metadata enforces boundaries; entity or key lookup handles structured identity. A reranker can resolve close candidates after cheaper retrieval has narrowed the set.
Tradeoffs
- More channels add latency and operational complexity.
- Ad hoc score normalization makes rankings hard to explain.
- Heuristic bonuses can dominate if they are not bounded.
- Reranking without evaluation adds cost without known benefit.
- The final result and token limit must be enforced after fusion.
Do not call retrieval “hybrid” merely because two backends exist. The important behavior is intentional candidate fusion under one measured cutoff.
Cost to adopt
Build: a second retrieval arm, a fusion step, and a way to inspect which arm contributed each result. RRF is a dozen lines; the inspection is what makes it maintainable.
Forces elsewhere: each arm needs its own index kept in sync with writes, and a backend that silently lacks one of them degrades to single-arm search while still reporting itself as hybrid — a failure this atlas has found more than once.
Ongoing: fusion weights are tuning surface, and nothing tells you they have drifted except a benchmark you probably do not have.
Skip it if your corpus is small enough that exact search alone answers well. Boring FTS over a few thousand records outperforms an untuned hybrid stack.
Seen in the atlas
Gini is the most legible implementation, because it documents its channels and their provenance in a header comment: semantic cosine, BM25 over FTS5, graph spreading activation seeded from the top semantic hits with decay δ=0.5, and a temporal range match — fused with reciprocal rank fusion and reranked. The temporal arm only participates when the query contains a temporal expression, which is a small idea worth copying: a channel that cannot contribute should not dilute the fusion.
HippoRAG is the instructive alternative — it does not fuse at all. It seeds a personalization vector from query-linked entities plus a weak dense prior (passage nodes at 0.05) and reads relevance off a Personalized PageRank diffusion. Multi-hop association becomes a property of the diffusion rather than of a traversal policy or a weighted sum.
LlamaIndex composes instead of fusing: each memory block contributes independently under its own share of a token budget, and each truncates itself when over. The assembled context is easier to reason about than a single fused ranking, because every contributor's share is separately visible.
Hindsight runs four arms with task-specific fusion and cross-encoder reranking; MemPalace contributes the reusable rule that extracted indexes boost drawer ranking but never gate direct evidence retrieval; mem0, Honcho, Basic Memory, agentmemory, CowAgent, Magic Context, and OpenViking all fuse lexical and semantic signals.
Two cautions have strengthened with evidence. Naming is not
fusing — Claude-Mem's
ordinary text search selects semantic rather than combining it with FTS,
and A-MEM's "hybrid" path is
vector-only. And silent degradation is worse than no
fusion: Holographic
redistributes its weights to lexical-only when NumPy is missing while
is_available() still returns True, where Moltis makes the same situation
explicit with a keyword_only() constructor and a
has_embeddings() predicate callers can branch on.
Nobody has shown their weights are right. MetaClaw is the only system in the
atlas that could — it replays candidate policies against past turns and
promotes one only on non-regression across eight metrics. Everyone else,
including Generative
Agents with its hand-tuned gw = [0.5, 3, 2], ships
constants nobody has defended — starting with the fusion constant
itself, which no report in this atlas records a system sweeping.
Helm is the smallest correct
instance — both arms and the fusion are about sixty lines of JavaScript
over rows already in memory, with no FTS extension and no vector store —
and it is the clearest place in the atlas to see that fusion
quality is bounded by candidate generation, not by the fusion
rule. Its RRF is textbook (k=60, no score normalization, which
is the right refusal when you have no relevance data to calibrate
against), and its belief weight is applied as a multiplier rather than a
filter so a low-confidence row is penalized instead of excluded. Then
every arm runs over
SELECT … WHERE expired_at IS NULL ORDER BY updated DESC LIMIT 500
— a hard window ordered by recency. Past a few hundred
active facts, the memories that stop being candidates are the ones
nothing has touched lately, which is precisely the set of long-lived
preferences the store worked hardest to establish. A perfect ranker over
the wrong 500 rows is still wrong, and no amount of fusion tuning
recovers a candidate that was never scored.
Helm is also the atlas's plainest example of silent tier
degradation. The semantic arm is a cached MiniLM embedding if
the model is on disk, TF-IDF cosine if it is not, and nothing at all if
the import throws — three materially different retrieval qualities
behind one output shape, one catch {}, and no signal to the
caller. If a channel can degrade, the result should say which channel
ran.
Qwen MM Plugins
closes a failure mode this page has otherwise only described in its
degradation form: the two arms can also disagree about what the
vectors mean. Its dense arm reads an
embeddings.npz written at build time, its sparse arm is a
BM25 index built over the same nodes, and both are fused by RRF in the
usual way. What is uncommon is
check_dimension_compatibility, which compares the stored
matrix's width against the embedding backend now answering queries and
raises with a message naming the likely cause — a memory "built with a
different model than the current query backend". Every system on this
page persists an index and embeds queries at read time, and in every one
of them those are two artifacts that can drift apart with no exception,
no crash and no empty result: the dense arm keeps returning neighbours,
they are simply neighbours in a space the query never entered, and RRF
dutifully fuses that garbage with a sparse arm that is working
correctly. Fusion makes this harder to notice than a single-arm
retriever would, because the lexical hits keep the result list looking
plausible. A width comparison at load is a one-line check against the
highest-cost silent failure in the pattern.
The same system also shows why fusion earns its keep on content
nobody wrote prose for. Its two arms are searched over a video's
extracted events, and the lexical side reaches material the dense side
systematically blurs — a name, a score, a caption, a slide title,
captured verbatim into an OnScreenText record. The argument
for hybrid here is not that one arm is better tuned; it is that proper
nouns and short strings on screen have no useful embedding neighbourhood
at all.
Muninn contributes the placement
question this page has not asked: where does the access filter
go? Its fusion is one Postgres statement — an FTS CTE ranked by
ts_rank, a vector CTE ranked by
embedding <=> $3, each LIMIT 30, joined
FULL OUTER on id and scored
1.0/(60 + f.rank) + 1.0/(60 + v.rank) — and the scope
predicate
bot_name = $5 AND ((scope = 'personal' AND user_id = $1) OR scope = 'shared')
is written into both CTEs rather than wrapped around
the join.
The difference is not correctness, it is recall, and it falls on the caller with the fewest permissions. Filter after the fusion and each arm still spends its 30 candidate slots on whatever ranks highest globally, including rows the caller may not see; the survivors are whatever is left. Filter inside each arm and both budgets are spent within the boundary. A reader comparing two systems cannot see this in a benchmark — the post-filtered version returns fewer, worse results only for the narrowest-scoped users, which is exactly the population least likely to be in the golden set.
Muninn also shows the degradation worth having: the vector CTE
carries embedding IS NOT NULL, so a row saved when the
embedding model was unavailable is excluded from that arm and stays
reachable through FTS, with getMemoriesWithoutEmbeddings
available to backfill it. One arm failing costs one arm, not the
query.
repowise supplies the failure
this page's degradation advice exists to prevent, measured. Its vector
leg was bounded at a hardcoded 8-second timeout inside
contextlib.suppress, while a cold index costs
"6.3s + 13.4s ... where a warm query takes 0.19s" — so, in the
fix's own words, "the first query of every process expired, the leg
returned [], and search silently degraded to full-text with
nothing logged and embedder_degraded still false."
Three things compose into an unobservable outage, and each is worth
checking separately in any fused retriever. The budget was set from
warm-path timings, so it could never clear a cold start. The suppression
made the expiry indistinguishable from an empty result set — a fused
query cannot tell "the vector arm found nothing" from "the vector arm
never ran". And the health flag that exists to report exactly this
stayed false throughout, so the one field an operator would
query to detect the degradation reported health. The remedy is the shape
to copy as much as the number: one shared budget rather than a constant
at each call site, an override for slow disks, and an unusable override
that warns and keeps the default "rather than disabling the
leg".
Test that an arm's failure is distinguishable from an arm's empty result. No fused implementation in this atlas has that fixture, and it is the difference between a retriever that degrades and one that degrades silently.
Forgetful is the
counterexample to keep beside these. Its README, the four-stage
docstring on search in both of its repositories, and the
recall skill the agent loads all describe dense → sparse →
reciprocal rank fusion → cross-encoder, and the tree implements
dense → cross-encoder: no FTS table, no tsvector, no fusion
function. The identifiers the skill says are matched
"literally" are matched by cosine over a 384-dimension
embedding. A fusion that is documented and not built is worse than one
never mentioned, because the query-shaping advice — put the exact error
code and config key in the query — is written for the arm that is
missing.
Uteke tunes the fusion and then
leaves its scale. The default recall fuses two complete rankings —
vector at weight 1.7 and a vector-plus-FTS5 RRF at 1.0,
k = 60 — with the tuning set and plateau written in the
constant's comment, and the committed LongMemEval-S raw output
recomputes the published 98.4% recall_any@5. After fusion a memory's
score is the RRF sum, at most 2.7 / 61 ≈ 0.044; the default
path then adds 0.1 × salience + 0.1 × recency and applies
thresholds written for cosine, so --strict at 0.5, the
Hermes hook at 0.40 and the pi hook at 0.45 exclude every memory. RRF's
virtue of not pretending incomparable scores share a scale lasts only
until something downstream pretends again.
Claude
Self-Reflect is the case for fusion, made accidentally by a system
that does not fuse. Its FTS5 arm runs only when the best
semantic candidate scores below 0.5, and when it runs every hit enters
at a constant 0.45 — FTS5's own rank orders the SQL and is
then discarded, so BM25 never reaches the score. The project's own
pre-registered benchmark built a proper fusion arm for comparison and
measured all three: reciprocal-rank fusion over vector top-20 and FTS
top-20 scored 0.813 over 396 receipt-lookup queries, vector alone 0.697,
and FTS alone 0.162. Lexical exactness was the weakest single channel
and worth roughly twelve points in combination — the shape this page
predicts, published by a system whose shipped read path leaves those
points on the table.
GrayMatter fuses three signals and tests the claim that one of them is a sliding window. RRF over a vector cosine ranking, a TF-IDF keyword score with optional stemming, and recency. Two details are worth copying. Superseded facts are removed before anything is scored rather than filtered from the result, so a retired fact cannot occupy a slot a live one needed. And a decision record asserts that a sliding window is the special case of this ranking with all weight on recency, so the benchmark implements that window independently and asserts on every invocation that the recency-only weighting returns exactly the same fact set — a design claim turned into a check that fails when it stops holding. The published miss is equally instructive: the corpus's one failing query asked about "roll back" against a stored "Rollbacks", which is what motivated stemming, gated on a strict-subset property rather than a net win count.
Tests to require
- Exact identifiers, paraphrases, dates, negation, and typo cases.
- Scope leakage and lifecycle filtering before ranking.
- Ablations for each retrieval channel.
- A sweep of the fusion constant on your corpus and arm count, not the paper's — and a check that whatever you pick still holds on data you did not tune against, because per-arm tuning is the setting that has been measured failing to transfer.
- Hard assertions that
@kevaluates exactly the firstkresults. - Token-volume and latency reporting.
- Stable tie-breaking and bounded heuristic contributions.