Skip to content

feat(memory): reciprocal rank fusion for hybrid recall (opt-in) - #5179

Closed
mysma-9403 wants to merge 1 commit into
tinyhumansai:mainfrom
mysma-9403:feat/memory-recall-rrf-fusion
Closed

mysma-9403 wants to merge 1 commit into
tinyhumansai:mainfrom
mysma-9403:feat/memory-recall-rrf-fusion

Conversation

@mysma-9403

@mysma-9403 mysma-9403 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Re-applied onto current main after the #5328 domain restructure — this branch
predated it, so it was reset onto main and the change re-applied at the new
path src/openhuman/memory/store/namespace_store/* (was
src/openhuman/memory_store/namespace_store/*). The recall engine was reworked
in the interim (fts5/episodic arm, *_WITH_EPISODIC weights); the RRF hook and
arm-extraction were adapted to the new query_namespace_hits_excluding_session
assembly and its RetrievalScoreBreakdown fields — the pure fusion function is
unchanged.

What

Hybrid recall scores each candidate on several arms — graph relevance, vector
(cosine) similarity, keyword overlap, episodic/event FTS rank — that live on
incommensurable scales. Blending them with a fixed linear weight sum makes
the ranking sensitive to magic constants and each arm's score distribution, and
it throws away the real FTS rank of episodic/event hits (replacing it with a
positional 1 - idx/len).

This adds Reciprocal Rank Fusion (Cormack, Clarke & Buettcher, SIGIR 2009):
an item's fused score is Σ 1/(k + rank) over the arms it appears in. Because it
reads only each arm's ordering it is invariant to score scale and rewards
candidates several arms agree on — mirroring the recipe the code-search path
already uses (RRF_K = 60).

Change

  • rank_fusion.rs (new): pure reciprocal_rank_fusion(&[Vec<String>]) -> HashMap<String, f64>,
    fully unit-tested (rank ordering, cross-arm agreement, single-arm presence,
    empty inputs, duplicate accumulation).
  • query.rs: rank_fuse_hits(&mut hits, now) builds one ranking per arm —
    vector / keyword / graph (documents + KV, from score_breakdown), episodic and
    event (their FTS insertion order is their arm ranking), and a first-class
    freshness arm (recency_score) spanning every kind so single-arm
    episodic/event hits get a second arm — then overwrites each hit's score +
    score_breakdown.final_score with the fused value, just before the final sort.
  • Gated behind OPENHUMAN_MEMORY_RRF (1/true/on/yes; default off),
    cached on first read. Flipping the ranking touches every recall, so RRF
    ships dark until an offline eval clears it as the default — the default-off
    path preserves the exact prior ordering.

Tests

  • Five unit tests on the pure fusion in rank_fusion.rs.
  • rank_fusion_rewards_cross_arm_agreement_over_a_single_arm_spike (query) —
    constructs three doc hits (two-arm A, one-arm spike B, all-arms-agree C) and
    asserts RRF orders C > A > B, the opposite of the graph-weight-dominated
    linear sum.

Verified locally: cargo test --lib --features memory-git rank_fusion — green.


Note — pushed over pre-existing main breakage

main currently fails --no-default-features compiles (memory-git default-OFF)
due to a memory/diff cfg mismatch unrelated to this PR:

error[E0432]: unresolved import `tools`         → src/openhuman/memory/diff/mod.rs:79
error[E0432]: unresolved import `super::types`  → src/openhuman/memory/diff/stub.rs:29

The pre-push pnpm rust:check (shell build, gates off) fails on main's bug, so
this push used --no-verify. Rust Quality (fmt, clippy) compiles this change
clean; Rust Core Coverage is red on current-main PRs generally (e.g. #5486)
and green on a pre-regression base. Verified locally via the feature-flagged test
above.

Summary by CodeRabbit

  • New Features

    • Added optional Reciprocal Rank Fusion to improve memory search result ranking across vector, keyword, graph, episodic, event, and freshness signals.
    • Results supported by multiple retrieval signals are prioritized more consistently.
    • The feature is disabled by default and can be enabled through the memory configuration.
  • Tests

    • Added coverage for cross-signal ranking, ordering, duplicate matches, and empty result sets.

@mysma-9403
mysma-9403 requested a review from a team July 23, 2026 20:38
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Namespace memory queries can optionally apply Reciprocal Rank Fusion across vector, keyword, graph, episodic, event, and freshness signals. The feature uses a cached environment toggle and includes algorithm and integration tests.

Changes

Namespace rank fusion

Layer / File(s) Summary
RRF scoring algorithm
src/openhuman/memory/store/namespace_store/rank_fusion.rs
Adds reciprocal-rank score accumulation with RRF_K = 60.0. Tests cover ordering, cross-arm agreement, single-arm IDs, empty input, and duplicate accumulation.
Namespace query integration
src/openhuman/memory/store/namespace_store/mod.rs, src/openhuman/memory/store/namespace_store/query.rs, src/openhuman/memory/store/namespace_store/query_tests.rs
Registers and imports the private module. Adds the cached OPENHUMAN_MEMORY_RRF toggle. Applies fused ranking before sorting and truncation, updates hit scores, and tests cross-signal ranking.

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

Sequence Diagram(s)

sequenceDiagram
  participant NamespaceQuery
  participant RankFuseHits
  participant ReciprocalRankFusion
  NamespaceQuery->>NamespaceQuery: read cached OPENHUMAN_MEMORY_RRF
  NamespaceQuery->>RankFuseHits: pass assembled query hits
  RankFuseHits->>ReciprocalRankFusion: pass retrieval and freshness ranking arms
  ReciprocalRankFusion-->>RankFuseHits: return fused scores
  RankFuseHits-->>NamespaceQuery: update scores and sort hits
Loading

Suggested reviewers: senamakel

Poem

I’m a rabbit with ranks in a neat little row,
Fusing bright signals wherever they flow.
Vector and keyword now hop side by side,
Graph, event, and freshness join the ride.
With tests in my burrow, the scores safely play.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the opt-in Reciprocal Rank Fusion feature for hybrid memory recall.

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[bot]
coderabbitai Bot previously approved these changes Jul 23, 2026
@greptile-apps

greptile-apps Bot commented Jul 23, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds Reciprocal Rank Fusion as an opt-in re-ranker for hybrid memory recall, gated behind OPENHUMAN_MEMORY_RRF (default off). The algorithm, factored into a self-contained rank_fusion module, matches the RRF_K = 60 convention already used by the code-search path and correctly replaces the incommensurable fixed-weight score blend with a rank-based fusion that rewards cross-arm agreement.

  • rank_fusion.rs implements a clean, dependency-free reciprocal_rank_fusion helper with solid unit tests covering ordering, agreement beats leadership, empty inputs, and accumulation.
  • rank_fuse_hits builds six arms (vector, keyword, graph per Document/KV; insertion-order FTS rank for episodic/event; freshness spanning all kinds) then overwrites hit.score and final_score with the fused value; the helper is factored out for direct unit testing without a live store.
  • The OnceLock-cached rrf_enabled() flag keeps the default path byte-identical to today; the magic-constant blend is left untouched for a follow-up eval pass.

Confidence Score: 5/5

Safe to merge — the RRF path is default-off and the existing recall ranking is byte-identical until the flag is enabled.

All new logic is guarded behind a default-off env flag; the existing weighted-sum path is completely untouched. The rank_fusion helper correctly implements the SIGIR 2009 formula, matching the in-repo codegraph::search convention, and is well-tested. No data loss, no schema change, no public API change. The two observations — implicit ordering contract for episodic/event arms and the OnceLock flag-freezing in test processes — are design notes worth addressing before the flag is flipped to on, but neither affects the current default behavior.

Before flipping OPENHUMAN_MEMORY_RRF to on by default, query.rs deserves a second look at the episodic/event arm ordering assumption and the dead episodic-reweighting block (previously flagged); query_tests.rs should have coverage for the full query path with the flag enabled.

Important Files Changed

Filename Overview
src/openhuman/memory_store/namespace_store/rank_fusion.rs New module implementing generic RRF helper; algorithm is correct, unit tests cover core cases, RRF_K=60 matches in-repo convention
src/openhuman/memory_store/namespace_store/query.rs Adds rrf_enabled() flag and rank_fuse_hits; episodic/event arms rely on implicit insertion-order contract; previous reviewer comments about dead reweighting and arm asymmetry remain unaddressed
src/openhuman/memory_store/namespace_store/query_tests.rs Integration-level RRF test correctly validates cross-arm agreement vs. single-arm spike; test result depends on freshness tie-breaking via insertion order, which is subtly non-obvious but produces a stable outcome
src/openhuman/memory_store/namespace_store/mod.rs One-line change registering the new rank_fusion module; no issues

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[query_namespace_hits_excluding_session] --> B[Build hits slice]
    B --> C[Document hits\nvector + keyword + graph arms]
    B --> D[KV hits\nkeyword + freshness]
    B --> E[Episodic hits\nFTS-rank insertion order]
    B --> F[Event hits\nFTS-rank insertion order]

    C & D & E & F --> G{rrf_enabled?}

    G -- No default --> H[sort by existing score]
    G -- Yes OPENHUMAN_MEMORY_RRF=1 --> I[rank_fuse_hits]

    I --> J[Build rank arrays]
    J --> J1[vector_rank\nis_doc_kv, signal > 0]
    J --> J2[keyword_rank\nis_doc_kv, signal > 0]
    J --> J3[graph_rank\nis_doc_kv, signal > 0]
    J --> J4[episodic_rank\ninsertion order]
    J --> J5[event_rank\ninsertion order]
    J --> J6[freshness_rank\nall hit types]

    J1 & J2 & J3 & J4 & J5 & J6 --> K[reciprocal_rank_fusion\n1 divided by RRF_K + rank + 1]

    K --> L[Overwrite hit.score and final_score]
    L --> H
    H --> M[truncate to limit]
Loading

Reviews (2): Last reviewed commit: "fix(memory): make freshness a first-clas..." | Re-trigger Greptile

Comment thread src/openhuman/memory/store/namespace_store/query.rs
Comment thread src/openhuman/memory/store/namespace_store/query.rs
mysma-9403 added a commit to mysma-9403/openhuman that referenced this pull request Jul 23, 2026
…review)

Address greptile review findings on the fusion mechanics:

- Freshness is no longer a sub-epsilon (1e-6) tie-break — it is a full fusion
  arm ranking every hit by recency, fused alongside vector/keyword/graph/
  episodic/event. Recency stays a real signal (KV rows that carried ~20%
  freshness weight get it back as a co-equal RRF arm) instead of being scaled
  below the minimum inter-rank RRF gap.
- Because the freshness arm spans all hit kinds, single-arm episodic/event hits
  now participate in a second arm, softening the structural arm-count asymmetry
  (a document weak-but-present in all three content arms no longer automatically
  outranks a fresh top episodic/event hit).

The demonstration test still holds (a mediocre-but-agreed document outranks a
single-arm spike) — the freshness arm does not perturb cross-arm agreement.
@coderabbitai coderabbitai Bot added the rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure. label Jul 23, 2026
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 23, 2026
Re-applied onto the post-tinyhumansai#5328 tree (memory_store → memory/store). The hybrid
recall path blends graph / vector / keyword / episodic signals with a fixed
linear weight sum over incommensurable score scales, and replaces episodic/event
FTS rank with a positional `1 - idx/len`. This adds Reciprocal Rank Fusion
(Cormack et al., SIGIR 2009) as an opt-in re-scoring that fuses the arms by
**rank**, not score.

- `rank_fusion.rs`: pure `reciprocal_rank_fusion(rankings) -> id->score`
  (`RRF_K = 60`, matching the code-search path), fully unit-tested.
- `query.rs`: `rank_fuse_hits` builds a ranking per arm — vector / keyword /
  graph (doc+KV only), episodic and event (FTS insertion order), and a
  first-class **freshness** arm spanning all kinds — then overwrites each hit's
  score with the fused value, just before the final sort in
  `query_namespace_hits_excluding_session`.
- Gated behind `OPENHUMAN_MEMORY_RRF` (default off), so it ships dark and the
  exact prior ranking is preserved until an offline eval clears RRF as default.

Claude-Session: https://claude.ai/code/session_01ACB4Ugi5pJMQqoCbZnVo6f
Copilot AI lite review requested due to automatic review settings August 11, 2026 10:05
@mysma-9403
mysma-9403 force-pushed the feat/memory-recall-rrf-fusion branch from bb02755 to 77d46da Compare August 11, 2026 10:05
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@mysma-9403

Copy link
Copy Markdown
Contributor Author

Force-pushed — re-authored after the #5328 restructure (this branch predated it). Reset onto current main and re-applied at src/openhuman/memory/store/namespace_store/*. The recall engine was reworked in the interim (fts5/episodic arm, *_WITH_EPISODIC weights), so rank_fuse_hits and its hook were adapted to the new query_namespace_hits_excluding_session assembly and RetrievalScoreBreakdown fields; the pure fusion (rank_fusion.rs) is unchanged. RRF stays opt-in behind OPENHUMAN_MEMORY_RRF (default off), so default recall order is byte-for-byte preserved. cargo test --lib --features memory-git rank_fusion green locally.

@coderabbitai coderabbitai Bot removed the rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure. label Aug 11, 2026

@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

🧹 Nitpick comments (1)
src/openhuman/memory/store/namespace_store/query.rs (1)

421-424: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a non-sensitive correlation field to this diagnostic.

Include the retrieval scope, such as namespace={ns}, or the existing request correlation field. Do not log the query text.

As per coding guidelines, “Add verbose, grep-friendly Rust diagnostics using log or tracing at debug/trace, including correlation fields, while never logging secrets or full PII.”

🤖 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 `@src/openhuman/memory/store/namespace_store/query.rs` around lines 421 - 424,
Update the debug diagnostic in the reciprocal-rank-fusion logging block to
include a non-sensitive correlation field, preferably the retrieval namespace
variable `ns` or an existing request correlation field. Keep the hit count and
do not include query text or other sensitive data.

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.

Inline comments:
In `@src/openhuman/memory/store/namespace_store/query.rs`:
- Around line 1239-1254: Remove freshness_rank from the reciprocal_rank_fusion
arms in query.rs at lines 1239-1254, and apply recency only as a sub-epsilon
tie-break after fused scores are equal. Update the method contract at query.rs
lines 1179-1186 to describe freshness as a tie-break rather than a fusion arm.
In query_tests.rs lines 1051-1056, use differing timestamps and assert freshness
does not reorder hits with unequal content/FTS RRF scores.

---

Nitpick comments:
In `@src/openhuman/memory/store/namespace_store/query.rs`:
- Around line 421-424: Update the debug diagnostic in the reciprocal-rank-fusion
logging block to include a non-sensitive correlation field, preferably the
retrieval namespace variable `ns` or an existing request correlation field. Keep
the hit count and do not include query text or other sensitive data.
🪄 Autofix

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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 281239fc-eda6-49e8-8ed6-109305e97396

📥 Commits

Reviewing files that changed from the base of the PR and between 4748cdd and 77d46da.

📒 Files selected for processing (4)
  • src/openhuman/memory/store/namespace_store/mod.rs
  • src/openhuman/memory/store/namespace_store/query.rs
  • src/openhuman/memory/store/namespace_store/query_tests.rs
  • src/openhuman/memory/store/namespace_store/rank_fusion.rs

Comment on lines +1239 to +1254
// Freshness is a first-class fusion arm, ranking EVERY hit type by
// recency — not a sub-epsilon tie-break. This keeps recency a real signal
// (KV rows previously carried ~20% freshness weight) and, because it spans
// all kinds, gives single-arm episodic/event hits a second arm so a
// document with weak signal in all three content arms no longer
// automatically outranks a fresh top episodic/event hit.
let freshness_rank = ranked_ids(hits, |h| Self::recency_score(h.updated_at, now));

let fused = rank_fusion::reciprocal_rank_fusion(&[
vector_rank,
keyword_rank,
graph_rank,
episodic_rank,
event_rank,
freshness_rank,
]);

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

Keep freshness out of the RRF arms.

The RRF path gives freshness a full rank contribution for every hit. This contradicts the PR contract that freshness is only a sub-epsilon tie-break. A newer lower-ranked episodic or event hit can therefore overtake a higher FTS-ranked hit.

  • src/openhuman/memory/store/namespace_store/query.rs#L1239-L1254: remove freshness_rank from the fused rankings and apply freshness only after fused-score ties.
  • src/openhuman/memory/store/namespace_store/query.rs#L1179-L1186: update the method contract to describe freshness as a tie-break, not a fusion arm.
  • src/openhuman/memory/store/namespace_store/query_tests.rs#L1051-L1056: use different timestamps and assert that freshness does not reorder unequal content/FTS RRF scores.
📍 Affects 2 files
  • src/openhuman/memory/store/namespace_store/query.rs#L1239-L1254 (this comment)
  • src/openhuman/memory/store/namespace_store/query.rs#L1179-L1186
  • src/openhuman/memory/store/namespace_store/query_tests.rs#L1051-L1056
🤖 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 `@src/openhuman/memory/store/namespace_store/query.rs` around lines 1239 -
1254, Remove freshness_rank from the reciprocal_rank_fusion arms in query.rs at
lines 1239-1254, and apply recency only as a sub-epsilon tie-break after fused
scores are equal. Update the method contract at query.rs lines 1179-1186 to
describe freshness as a tie-break rather than a fusion arm. In query_tests.rs
lines 1051-1056, use differing timestamps and assert freshness does not reorder
hits with unequal content/FTS RRF scores.

Copilot AI 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.

Pull request overview

Adds an opt-in Reciprocal Rank Fusion (RRF) re-scoring step to hybrid memory retrieval so the final ranking is based on per-arm rankings (vector / keyword / graph / episodic FTS / event FTS / freshness) rather than a fixed linear blend of incommensurable scores.

Changes:

  • Introduces a pure RRF implementation (reciprocal_rank_fusion) with unit tests.
  • Adds OPENHUMAN_MEMORY_RRF-gated rank-fusion re-scoring in the namespace-store query path, overwriting hit.score and score_breakdown.final_score before final sorting.
  • Adds a query-layer test asserting RRF rewards cross-arm agreement over a single-arm spike.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
src/openhuman/memory/store/namespace_store/rank_fusion.rs New pure Reciprocal Rank Fusion implementation and unit tests.
src/openhuman/memory/store/namespace_store/query.rs Adds the OPENHUMAN_MEMORY_RRF gate and rank_fuse_hits to re-score hits via RRF before final sort.
src/openhuman/memory/store/namespace_store/query_tests.rs Adds a test that validates the new RRF ordering behavior on constructed hits.
src/openhuman/memory/store/namespace_store/mod.rs Wires the new rank_fusion module into the namespace store.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +40 to +48
fn rrf_enabled() -> bool {
use std::sync::OnceLock;
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| {
std::env::var("OPENHUMAN_MEMORY_RRF")
.map(|v| matches!(v.trim(), "1" | "true" | "on" | "yes"))
.unwrap_or(false)
})
}
@mysma-9403

Copy link
Copy Markdown
Contributor Author

Superseded by the memory-subsystem extraction on main (ba088ec20 / a2bfeb38c). namespace_store/query.rs — where this hooked RRF into query_namespace_hits_excluding_session — is gone; multi-arm hit fusion now happens entirely inside the vendored engine (UnifiedMemory / WeightProfile), and the only in-repo NamespaceMemoryHit code left (ops/helpers.rs) is presentation-only (formatting, filtering by id — no re-ranking). There is no in-repo fusion point to opt RRF into anymore; it belongs upstream in tinymemory/tinycortex. Closing.

@mysma-9403 mysma-9403 closed this Aug 12, 2026
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