Skip to content

Replace min-max BM25 fusion with RRF on hybrid recall path - #112

Closed
Mxneeb wants to merge 2 commits into
Lyellr88:MARM-mainfrom
Mxneeb:feature/rrf-hybrid-fusion
Closed

Replace min-max BM25 fusion with RRF on hybrid recall path#112
Mxneeb wants to merge 2 commits into
Lyellr88:MARM-mainfrom
Mxneeb:feature/rrf-hybrid-fusion

Conversation

@Mxneeb

@Mxneeb Mxneeb commented Jul 24, 2026

Copy link
Copy Markdown

Summary
Replace min-max BM25 + weighted cosine fusion on the hybrid filter→rerank path with Reciprocal Rank Fusion (Cormack et al., k=60). Exact/lexical lanes and semantic-fallback-only scoring are unchanged. _normalize_bm25 and its FTS callers are untouched.

Math
RRF(d) = sum of 1/(k + rank_r(d)) across the BM25-ranked and cosine-ranked lists, scaled by (k+1)/2, then blended with temporal decay exactly as before: final = (1-w)s + wt

Rank-based fusion avoids mixing an absolute cosine score with a per-query relative BM25 stretch, which previously let tight BM25 clusters amplify noise or one outlier crush mid-tier lexical signal.

Implemented in _rrf_scores (memory_scoring.py) and the hybrid fusion block in _recall_similar (memory_recall.py). The obsolete HYBRID_SEARCH_TEXT_WEIGHT setting has been removed.

Client note
Hybrid similarity magnitudes are now RRF-scaled, not a cosine/BM25 convex mix. Don't compare them to old thresholds without recalibration.

Known follow-up (tracked separately)
#113find_semantic_duplicate() checks results[0]["similarity"] >= CONSOLIDATION_THRESHOLD (default 0.92), but hybrid-path similarity is RRF-scaled, not cosine. exact_mode="semantic" does not fix this: it still runs FTS-candidate + RRF fusion whenever FTS returns scoreable candidates (memory_recall.py ~208–286). Proposed fix: preserve raw cosine (e.g. cosine_similarity) alongside the RRF ranking score so consolidation can gate on the correct quantity. Scope: consolidation only, gated behind CONSOLIDATION_ENABLED (default off).

What I'd do next (other candidates considered)

  • FTS-as-hard-gate: _recall_similar only scores the semantic corpus when FTS returns zero candidates. When FTS returns any candidates, semantically close paraphrases that share no/few tokens never enter the pool. Higher impact than the fusion fix but riskier, since it changes candidate generation itself, not just ranking.
  • Chunk-pooling bias: _score_chunk_aware collapses multi-chunk memories via max-over-chunks, which is statistically biased toward memories with more chunks and discards the parent/summary embedding entirely. A length-corrected max or logsumexp pooling, plus including the parent embedding as a competitor, would fix this cheaply.

I picked the RRF fusion fix because it had the best effort-to-impact ratio: most surgical of the three, has a clean established mathematical justification rather than an ad-hoc formula, and carries the lowest risk since it doesn't touch candidate generation or lane semantics.

Summary by CodeRabbit

  • Improvements
    • Hybrid memory search now combines semantic and keyword rankings using reciprocal rank fusion.
    • Improves relevance stability when semantic similarity and keyword matches disagree; semantic-only fallback behavior remains unchanged.
  • Tests
    • Added unit coverage for reciprocal-rank-fusion scoring, hybrid ranking behavior under reversed keyword orders, and empty-result edge cases.
  • Documentation
    • Removed HYBRID_SEARCH_TEXT_WEIGHT from configuration/environment variable references and related changelog notes.

Min-max BM25 + weighted cosine mixed an absolute geometry score with a
per-query relative stretch, amplifying BM25 noise and compressing mid-tier
hits. Fuse BM25 and cosine by Reciprocal Rank Fusion (k=60) instead, scale
by (k+1)/2, then keep the existing temporal blend. Exact/lexical lanes and
_normalize_bm25 callers are unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The hybrid recall path now uses Reciprocal Rank Fusion over FTS candidate and cosine similarity rankings, then applies temporal blending. A reusable RRF helper, updated configuration parsing, documentation changes, and tests for fusion ordering and score calculations were added.

Changes

Hybrid recall fusion

Layer / File(s) Summary
RRF scoring helper and validation
marm-mcp-server/marm_mcp_server/core/memory_scoring.py, marm-mcp-server/tests/test_hybrid_search.py
Adds scaled RRF scoring for ranked memory IDs, including empty-input handling and direct mathematical tests.
Hybrid recall integration
marm-mcp-server/marm_mcp_server/core/memory_recall.py, marm-mcp-server/tests/test_hybrid_search.py
Replaces weighted BM25/cosine blending with FTS and cosine rank fusion while retaining temporal blending; tests verify ordering and tie behavior under reversed FTS rankings.
Configuration and documentation alignment
marm-mcp-server/marm_mcp_server/config/settings.py, README.md, marm-mcp-server/README.md, marm-mcp-server/marm-docs/README.md, CHANGELOG.md
Removes HYBRID_SEARCH_TEXT_WEIGHT parsing and documentation, and updates the hybrid recall changelog description.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
marm-mcp-server/marm_mcp_server/core/memory_recall.py (1)

9-12: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Dead HYBRID_SEARCH_TEXT_WEIGHT import removed here, but setting itself left dangling.

The import is dropped but the underlying setting still exists elsewhere and is now inert for hybrid fusion — a knob operators could still tune with no effect. Consider removing/deprecating the setting itself (or documenting that it no longer affects hybrid ranking) so it doesn't silently mislead future tuning.

As per coding guidelines, "prefer the smallest change and avoid speculative abstractions or unused configuration flags."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@marm-mcp-server/marm_mcp_server/core/memory_recall.py` around lines 9 - 12,
Remove the now-unused HYBRID_SEARCH_TEXT_WEIGHT setting from its defining
configuration and any related exports or references, leaving hybrid ranking
unchanged and avoiding an inert tuning flag.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@marm-mcp-server/marm_mcp_server/core/memory_recall.py`:
- Around line 9-12: Remove the now-unused HYBRID_SEARCH_TEXT_WEIGHT setting from
its defining configuration and any related exports or references, leaving hybrid
ranking unchanged and avoiding an inert tuning flag.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: eb757444-a7a7-4875-9cbc-7f3b8bd13817

📥 Commits

Reviewing files that changed from the base of the PR and between c0ee777 and efc246a.

📒 Files selected for processing (3)
  • marm-mcp-server/marm_mcp_server/core/memory_recall.py
  • marm-mcp-server/marm_mcp_server/core/memory_scoring.py
  • marm-mcp-server/tests/test_hybrid_search.py
📜 Review details
🧰 Additional context used
📓 Path-based instructions (5)
**/*.{py,json}

📄 CodeRabbit inference engine (AGENTS.md)

Keep HTTP and STDIO MCP transports in exact parity; when adding or removing a tool, update the endpoint implementation, HTTP route and whitelist, STDIO registration, server manifest, canonical tool list, documentation, and tests for both transports.

Files:

  • marm-mcp-server/marm_mcp_server/core/memory_scoring.py
  • marm-mcp-server/tests/test_hybrid_search.py
  • marm-mcp-server/marm_mcp_server/core/memory_recall.py
marm-mcp-server/marm_mcp_server/**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

marm-mcp-server/marm_mcp_server/**/*.py: All memory writes must use the serialized asynchronous write queue; do not bypass it.
A semantic-store failure in marm_log_entry must never fail the corresponding log write.
Never share SQLite connections between the memory database and the isolated concept-graph database.
Graph and concept failures must never break the seven core memory tools; graph processes start lazily and run degraded on failure.
marm_smart_recall must keep primary memory ranking authoritative; graph enrichment is bounded, read-only, fail-open, and trimmed before primary results when enforcing limits.
Use one lazy-loaded, lock-serialized jinaai/jina-embeddings-v2-small-en encoder with 512 dimensions; writes must succeed when it is unavailable.
Keep orchestration in its current owner file and extract modules only at real boundaries; prefer the smallest change and avoid speculative abstractions or unused configuration flags.
Use minimal comments that explain only non-obvious reasons; never narrate what the next line does.
When upgrading embeddings from MiniLM, migrate existing data with marm-mcp-server --migrate-embeddings before restarting.

Files:

  • marm-mcp-server/marm_mcp_server/core/memory_scoring.py
  • marm-mcp-server/marm_mcp_server/core/memory_recall.py
**/*.py

⚙️ CodeRabbit configuration file

**/*.py: Prioritize runtime correctness, async/concurrency safety, SQLite transaction safety, auth/rate-limit behavior, release-breaking packaging issues, and MCP protocol compatibility.

Files:

  • marm-mcp-server/marm_mcp_server/core/memory_scoring.py
  • marm-mcp-server/tests/test_hybrid_search.py
  • marm-mcp-server/marm_mcp_server/core/memory_recall.py
marm-mcp-server/tests/**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

marm-mcp-server/tests/**/*.py: Run tests with pytest from marm-mcp-server/; use real FastAPI endpoints and real SQLite, mocking only when it meaningfully speeds tests and matches real behavior with at least 95% fidelity.
Every new MARM Console API route must have at least one happy-path FastAPI response-contract test with the MCP adapter stubbed.
Do not write existence-check or coded-to-pass tests; prefer deep tests exercising real paths over broad shallow coverage.
Use pytest.mark.skip only for genuinely unavailable dependencies, never merely for effort.

Files:

  • marm-mcp-server/tests/test_hybrid_search.py
**/tests/**

⚙️ CodeRabbit configuration file

**/tests/**: Focus on tests that are flaky, non-isolated, incorrectly asserting behavior, or missing coverage for a changed high-risk path. Skip minor naming, comments, and layout preferences.

Files:

  • marm-mcp-server/tests/test_hybrid_search.py
🔇 Additional comments (2)
marm-mcp-server/marm_mcp_server/core/memory_scoring.py (1)

22-46: LGTM!

marm-mcp-server/marm_mcp_server/core/memory_recall.py (1)

267-284: 🎯 Functional Correctness

RRF's reliance on score-sorted input lists is unverified in both production code and tests.

The new fusion introduces a dependency that didn't exist before: _rrf_scores treats list position as rank, so bm25_ranked/cosine_ranked must already be sorted best-first by their respective retrieval functions. Pre-PR, fusion used raw score values directly, so ordering never mattered.

  • marm-mcp-server/marm_mcp_server/core/memory_recall.py#L267-L284: confirm _fetch_and_score_by_ids/_score_chunk_aware and _fetch_fts_candidate_ids return their tuples sorted by descending score before relying on positional rank for RRF; if not guaranteed, sort explicitly before building bm25_ranked/cosine_ranked.
  • marm-mcp-server/tests/test_hybrid_search.py#L644-L705: both tests stub the ranked-id lists directly, so they can't catch a regression where the real scoring functions stop returning sorted output; add (or request) a test exercising the real (non-mocked) _fetch_and_score_by_ids/_fetch_fts_candidate_ids path with distinguishable scores to assert the returned order matches score order.

@Lyellr88

Copy link
Copy Markdown
Owner

I went through this carefully, and it's strong work. The RRF implementation is clean and correct, the tests are genuinely deep (the reversed-order cancellation case especially), and you held to every constraint. The consolidation.py cross-file catch was the standout for me; that's real systems thinking, not just patching the one file.

Three things before this can merge.

  1. Consolidation regression confirmed, but your first fix has a hole. Great catch, and I traced it: find_semantic_duplicate calls recall_similar (exact_mode defaults to "auto") and tests results[0]["similarity"] >= threshold, where CONSOLIDATION_THRESHOLD defaults to 0.92. On the hybrid path, that similarity is now RRF-scaled, not cosine, so the 0.92 gate is measuring a different quantity. Scoped to it, it's the only affected caller; it's behind CONSOLIDATION_ENABLED (default off), and concept_store.py:397 computes its own cosine directly, so it's not touched. So: real, low blast radius. (Minor: the PR says the threshold is "~0.88+"; the actual default is 0.92.)

One thing though: exact_mode="semantic" (your suggested fix #1) still runs the FTS-candidate + RRF fusion path (memory_recall.py:208-266); it only skips the exact lane. No mode returns pure cosine, so that remedy wouldn't actually restore the threshold. Your remedy #2 (report cosine separately from the ranking score) is the sound one. Can you open a tracked issue for this and sketch how you'd surface a cosine value alongside the RRF score? I don't want a known regression riding in on just a description note that disappears once merged.

  1. HYBRID_SEARCH_TEXT_WEIGHT is now dead on the hybrid path. Import removed, no consumers left; the only remaining refs are the settings definition/clamp/warning and the README (line 984), which still describes it as "Text-vs-semantic blend in hybrid scoring." Let's either remove it + the doc line, or comment it as "unused on hybrid path." At the definition, a config value that silently does nothing is a future foot-gun.

  2. Before we merge: prove it's a net win with the accuracy benchmark. RRF trades away magnitude for rank-robustness, and I'd like to see it's actually better on our corpus, not just cleaner in theory. Rather than a hand-built query set, we have a real harness. I ran the baseline on MARM-main so you have a reference:

LoCoMo10 retrieval accuracy, MARM-main (v2.29.0), limit=5:

category n any-hit all-hit ev-recall sem-any
single-hop 281 52.0% 8.5% 26.7% 52.0%
temporal 320 65.0% 59.4% 62.2% 65.0%
multi-hop 89 39.3% 23.6% 30.2% 39.3%
open-domain 841 57.2% 53.9% 55.5% 57.2%
adversarial 446 39.7% 38.1% 38.9% 39.7%
OVERALL 1977 53.0% 43.4% 47.6% 53.0%

Run config (please match it exactly, or the delta is meaningless):

  • scripts/benchmarking/accuracy/locomo/run_eval.py --ingest --recall --limit 5, full LoCoMo10
  • Server in trusted mode (marm-memory http --profile trusted) so the limiter doesn't choke the turn-by-turn ingest, fresh throwaway DB via MARM_DB_PATH
  • Confirm semantic search is available in the health check before you run, or the RRF path never gets exercised

Run the same eval on your branch and report the delta. Compare the semantic lane (sem-any) that's the lane RRF actually changes. Heads up: the log lane came back empty in my run, so compare semantic like-for-like rather than the union.

Being straight about what this benchmark is: pure evidence-ID retrieval accuracy "did the right memory come back." It is NOT an LLM or answer-quality benchmark; it says nothing about answer generation, and one number can't fully capture "robustness across query types," which is the whole reason you picked RRF over normalizing both scores. So treat it as one strong signal, not the final verdict. And if it comes back a wash, that's useful too; report it honestly either way. A wash doesn't sink the PR; it just changes the conversation.

The weighted BM25/cosine blend setting is unused now that hybrid recall
fuses rank lists via RRF. Drop the env var from settings and README tables.

Co-authored-by: Cursor <cursoragent@cursor.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CHANGELOG.md`:
- Line 557: Update the changelog entry describing marm_smart_recall to state
that hybrid recall uses scaled reciprocal rank fusion (RRF) of FTS5 BM25 and
cosine similarity rank orders before temporal blending, replacing the current
semantic/BM25 score-merging description.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a38f78a6-5e1f-4809-9b77-94a7126de495

📥 Commits

Reviewing files that changed from the base of the PR and between efc246a and 0abae39.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • README.md
  • marm-mcp-server/README.md
  • marm-mcp-server/marm-docs/README.md
  • marm-mcp-server/marm_mcp_server/config/settings.py
💤 Files with no reviewable changes (4)
  • README.md
  • marm-mcp-server/marm-docs/README.md
  • marm-mcp-server/marm_mcp_server/config/settings.py
  • marm-mcp-server/README.md
📜 Review details
🧰 Additional context used
📓 Path-based instructions (1)
**/*.md

⚙️ CodeRabbit configuration file

**/*.md: Only flag documentation issues that are materially wrong, misleading for installation/release behavior, or inconsistent with live MCP behavior. Skip style, phrasing, formatting, and wording preferences.

Files:

  • CHANGELOG.md

Comment thread CHANGELOG.md
### Recall & Search

- Added SQLite FTS5 indexing for memory content with automatic insert/update/delete triggers; `marm_smart_recall` merges semantic similarity with FTS BM25 keyword scoring via `HYBRID_SEARCH_TEXT_WEIGHT`, improving recall for commands, config keys, filenames, and error strings.
- Added SQLite FTS5 indexing for memory content with automatic insert/update/delete triggers; `marm_smart_recall` merges semantic similarity with FTS BM25 keyword scoring, improving recall for commands, config keys, filenames, and error strings.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Update the changelog to describe RRF rank fusion.

Line 557 still describes semantic/BM25 score merging, but hybrid recall now fuses the FTS BM25 rank order with the cosine rank order using scaled RRF before temporal blending. This is materially misleading for users recalibrating similarity thresholds.

Proposed wording
-- Added SQLite FTS5 indexing for memory content with automatic insert/update/delete triggers; `marm_smart_recall` merges semantic similarity with FTS BM25 keyword scoring, improving recall for commands, config keys, filenames, and error strings.
+- Added SQLite FTS5 indexing for memory content with automatic insert/update/delete triggers; `marm_smart_recall` combines FTS BM25 and semantic rank orders using Reciprocal Rank Fusion, improving recall for commands, config keys, filenames, and error strings.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- Added SQLite FTS5 indexing for memory content with automatic insert/update/delete triggers; `marm_smart_recall` merges semantic similarity with FTS BM25 keyword scoring, improving recall for commands, config keys, filenames, and error strings.
- Added SQLite FTS5 indexing for memory content with automatic insert/update/delete triggers; `marm_smart_recall` combines FTS BM25 and semantic rank orders using Reciprocal Rank Fusion, improving recall for commands, config keys, filenames, and error strings.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` at line 557, Update the changelog entry describing
marm_smart_recall to state that hybrid recall uses scaled reciprocal rank fusion
(RRF) of FTS5 BM25 and cosine similarity rank orders before temporal blending,
replacing the current semantic/BM25 score-merging description.

Source: Path instructions

@Mxneeb

Mxneeb commented Jul 25, 2026

Copy link
Copy Markdown
Author

Pushed all three fixes (commit 0abae39):

PR description corrected: 0.92 (not ~0.88+), and updated to note exact_mode="semantic" doesn't bypass RRF
Opened #113 for the consolidation regression with a proposed fix (surface cosine separately from the RRF ranking score)
Removed HYBRID_SEARCH_TEXT_WEIGHT entirely (setting, clamp, warning, README line), verified no remaining references

Also sent the LoCoMo10 benchmark delta separately, result was a wash on sem-any, with my read on why that's plausible given this is a ranking-only change on a membership-insensitive metric.

Ready for another pass whenever you have time.

@Lyellr88

Lyellr88 commented Jul 26, 2026

Copy link
Copy Markdown
Owner

@Mxneeb I reviewed the new commits. All three items are cleanly resolved:

  • Dead HYBRID_SEARCH_TEXT_WEIGHT: fully removed, definition + clamp + warning, and the config row across all the
    README copies. Confirmed no references left anywhere, so nothing dangles.
  • Consolidation regression → Hybrid recall similarity is RRF-scaled, breaking cosine-based consolidation threshold #113: right call filing it as a tracked issue rather than fixing it in this PR. The
    write-up nails it, including that exact_mode="semantic" doesn't actually resolve it.
  • Threshold note: corrected to 0.92.

Nothing to send back on the code. As you already called out yourself, this doesn't prove a retrieval win until the
candidate-generation side is addressed, so I'm going to hold the merge here rather than land a change to a path that
effectively isn't firing yet. I'm picking up the candidate-generation work (_safe_fts_query gating) next once that
path actually produces candidates; we can run a real A/B on the fusion and land whichever wins, which may well be
this. #113 stays open for that same window.

@Lyellr88

Copy link
Copy Markdown
Owner

Thanks for this, and for the care in the writeup. We took it seriously enough to build a full benchmark around it rather than accept or reject it on reasoning. Closing it unmerged, with the data below.

Why it could not be measured as submitted

The PR removes HYBRID_SEARCH_TEXT_WEIGHT, which gives the lexical and semantic retrievers equal say. Shipped min-max runs at W=0.05. Equal say is W=0.5.

That is a 10x change in lexical influence landing in the same diff as the fusion change, so any accuracy difference could not be attributed to rank-vs-score fusion. Two variables, one measurement.

Weighted RRF, so the comparison was fair

Rather than reject on that, we derived a weighted RRF that preserves the shipped lexical weight:

raw(d)       = W/(k + rank_lexical) + (1-W)/(k + rank_semantic)
relevance(d) = (k + 1) * raw(d)

k = 60,  W = HYBRID_SEARCH_TEXT_WEIGHT = 0.05

The (k+1) factor puts output on the same [0,1] range min-max produces, so both arms have identical ceilings: best-in-both 1.00, best-semantic-only 0.95, best-lexical-only 0.05. It reduces exactly to unweighted RRF at W=0.5, so it is your fusion, not a different algorithm.

Both arms then got identical candidates, an identical keyword weight, and an identical output range. The only variable was magnitude vs rank position.

Results

LoCoMo, 1,977 scored questions, 5,882 memories, top-5, no LLM judge. One corpus, three server processes, /status captured before every arm, no re-ingest between arms.

Metric min-max (run 1) weighted RRF min-max (run 2) RRF delta
Any evidence hit 63.5% 56.2% 62.9% -6.8 to -7.3 pp
All evidence hit 53.5% 46.2% 53.1% -6.8 to -7.3 pp
Mean evidence recall 57.9% 50.6% 57.4% -6.8 to -7.3 pp

Two min-max control runs of the identical configuration differ by 0.56 pp, so that is the noise floor. RRF lost by roughly 12x the noise. It gained 48 questions and lost 192.

By category:

Category n min-max weighted RRF RRF delta
Single-hop 281 59.4% 56.9% -2.5 pp
Temporal 320 68.1% 65.3% -2.8 pp
Multi-hop 89 38.2% 34.8% -3.4 pp
Open-domain 841 68.5% 60.5% -8.0 pp
Adversarial 446 58.3% 45.3% -13.0 pp

Worse in all five categories, and worst in the two where precision matters most. Latency was measured separately and was the same either way, so there was no speed benefit to trade against the accuracy loss. Result held against both min-max references and in both cross-fold directions.

What we are not claiming

  • Not that RRF is worse in general. This is one corpus at one keyword weight. It says RRF did not win here.
  • Not a confirmed mechanism. The intuitive story is that min-max keeps how strong each match was while RRF keeps only what order they came in. That story is appealing and we could not make the arithmetic support it: ordering by RRF's semantic term is strictly decreasing in rank, and rank comes from cosine, so converting cosine to rank cannot reorder anything on its own. We would rather leave the mechanism open than publish a plausible explanation that does not hold up.
  • Your premise about the failure mode min-max has was not wrong. A per-query BM25 stretch really can let a tight cluster amplify noise. It just costs less than what rank-only fusion gives up on this workload.

Full protocol, per-arm outputs, and the noise-floor diagnostic are in docs/current/fts-candidate-generation-and-fusion-bakeoff.md.

Chunk-pooling bias: real, deferred

From your "what I'd do next" list, this one is correct on the mechanism. We measured it on a live database, holding the document constant and sampling k of its 16 chunks against 400 unrelated queries:

chunks scored mean max cosine
1 0.0278
2 0.0625
4 0.0844
8 0.1065
16 0.1202

Same content, same queries, none relevant. +0.09 cosine purely from more shots at the max. Confirmed, not theoretical.

Deferring it for now, for three reasons:

  1. The two suggested remedies pull against each other. logsumexp sums over chunks, so it grows with k faster than max does — it makes the count bias worse. And adding the parent embedding as a competitor is near-inert under max pooling: measured across all six chunked rows, the parent scored below the chunk max every time (0.081-0.093 vs 0.120-0.224). Where it would win, it raises the chunked memory's score, amplifying the bias the other half of the finding objects to.
  2. No corpus exercises it. LoCoMo's longest memory is 97 words against a 500-word chunk threshold. Zero chunked rows, so it never fired in any of the numbers above, and there is no way to tell whether a pooling change helps or hurts. A length-corrected max means inventing a tuning constant and shipping it on intuition, which is what this whole exercise was about avoiding.
  3. It affects 0.8% of rows on a real install.

Revisiting when there is a workload that actually shows it. Two adjacent defects we did find while investigating it are being fixed instead, since they have correct answers that do not depend on ranking quality: chunk writes are dropped on shutdown by an untracked background task, and stale chunk boundaries survive every migration.

FTS-as-hard-gate, your third candidate, is the one still on the table. You were right that it is higher impact and riskier, and it is the more interesting question.

What landed from this

  • Hybrid recall similarity is RRF-scaled, breaking cosine-based consolidation threshold #113, which you filed separately, shipped in v2.33.1. It turned out to be live under min-max as well, not introduced by this PR — details in that thread.
  • The weighted RRF formula, the benchmark harness changes, and the noise-floor methodology are all in the repo. Before this, we assumed run-to-run noise was around 0.1 pp. It is 0.56 pp. That correction outlives the experiment.

Declining the fusion change, keeping the review. Genuinely useful work, and the separation of #113 into its own issue is what let that fix land independently. Thanks @Mxneeb.

@Lyellr88

Copy link
Copy Markdown
Owner

Closing unmerged. Full reasoning and benchmark data in the comment above.

@Lyellr88 Lyellr88 closed this Jul 31, 2026
Lyellr88 added a commit that referenced this pull request Jul 31, 2026
Fusion is unchanged across six versions and that was a decision, not an
omission. Documents the weighted-RRF bake-off against #112: why the
submitted change could not be measured as-is, the weighted variant built to
isolate fusion from keyword weight, the 6.8 to 7.3 point loss across all
five categories, and the 0.56 point noise floor the experiment established
in place of the 0.1 previously assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants