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 a 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: chat history takes a fixed ratio of one token budget and the
memory blocks contribute independently into the remainder. The
composition has a hole where the budget should be enforced.
atruncate is a contract no shipped block implements, the
default priority of 0 means never
truncate, and a block the orchestrator does truncate is dropped
whole, so with default blocks the budget holds only if chat history
absorbs the overrun.
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, Basic Memory, agentmemory and Magic Context all fuse lexical and semantic signals. CowAgent has both arms over its chunk index, and how they are combined is not traced in its report. OpenViking embeds each query once into dense and optional sparse vectors; its report does not call the sparse side lexical. Honcho keeps the arms apart: message search is ILIKE over the content or an embedding query, never both in one ranking.
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.
Cortexes adds a third caution:
a filter is only as strict as its weakest arm. Its
vector stream hands the --repo clause to Chroma, while BM25
and the wikilink graph evaluate it in Python, and until
cortex-vec 0.9.0 the nested $or was ignored by
one arm and never passed to the other. The repair keeps field names and
comparisons in one table that raises on anything unmodelled, and a test
fails when the clause producer emits a field that table lacks. Its
degradation is explicit in code where Holographic's is not — without a
key the vector stream is skipped and the weights renormalized over the
streams that answered — but not in the result: the cosine
score the agent reads is 0.0 alike for no key,
a failed backfill and a real zero. The same key gates writes to the
lexical index.
Few systems defend their weights. MetaClaw replays candidate policies
against past turns and promotes one only on non-regression across eight
metrics. Somnigraph sets a
separate RRF constant per arm — about eight for FTS5, about seven for
vectors — and a vector weight by a named tuning study, each constant
carrying its previous value in a comment. Uteke tunes its fusion weights on a
LongMemEval subset and writes the tuning set and plateau beside the
constant. Most others, Generative Agents with its
hand-tuned gw = [0.5, 3, 2] among them, ship constants
nobody has defended, most often RRF's k = 60.
Helm is a small correct instance —
both arms and the fusion are about 120 lines of JavaScript over rows
already in memory, with no FTS extension and no vector store — and it is
a clear place 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, confidence 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 a plain example of silent tier
degradation. The semantic arm is a cached MiniLM embedding if
the model is on disk, and TF-IDF cosine if it is not or if the import
throws, because the TF-IDF scores are computed first and an empty
catch {} keeps them; with no model and fewer than three
active facts the arm is all zeros. Three materially different retrieval
qualities sit behind one output shape, with 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". A system that persists
a dense index and embeds queries at read time holds 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. Qwen's loader catches the error, disables the dense arm and
logs the cause, so the mismatched vectors never rank anything; its
search_nodes result carries no field saying BM25 answered
alone, which leaves it in the degraded-channel case above.
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. On an instance that authenticates the predicate becomes
bot_name = $5 AND user_id = $1, and the no-embedding
fallback carries the same branch, because a fix applied to one search
function is inert on the path that takes the other.
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 on this page 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.
Graft fuses a title-embedding
vector list with BM25 over title and over body by reciprocal rank, and
its lexical arms are narrower than they look.
build_scoped_fts_query quotes each whitespace token and
joins them with a space, which FTS5 reads as AND, so a sentence-length
query matches only rows containing every word and the fusion quietly
becomes the vector list alone. Heimdall, which vendors the same C,
located this join as one cause of its measured recall.
nano-brain shows what a
relaxation step does to the predicates around it. Its
HybridSearch retries an empty AND lexical leg with a
separate OR query that keeps the workspace predicate and has no
time-range or tag parameters, so a time-filtered
memory_query can fuse out-of-window lexical hits with an
in-window vector leg. The same server's memory_search runs
the same fallback and refuses it whenever a time filter or tags are set,
with the reason in a comment. Build the fallback by changing only the
match clause, or test every relaxed path with a filter the first query
applied.
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.