fix: replace additive combined scoring with multiplicative CE boosts (#494)

This commit is contained in:
Nicolò Boschi 2026-03-05 11:12:28 +01:00 committed by GitHub
parent ad2cf72aab
commit aa8e5475c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 230 additions and 369 deletions

View file

@ -187,7 +187,7 @@ from .response_models import RecallResult as RecallResultModel
from .retain import bank_utils, embedding_utils from .retain import bank_utils, embedding_utils
from .retain.types import RetainContentDict from .retain.types import RetainContentDict
from .search import think_utils from .search import think_utils
from .search.reranking import CrossEncoderReranker from .search.reranking import CrossEncoderReranker, apply_combined_scoring
from .search.tags import TagsMatch, build_tags_where_clause from .search.tags import TagsMatch, build_tags_where_clause
from .task_backend import BrokerTaskBackend, SyncTaskBackend, TaskBackend from .task_backend import BrokerTaskBackend, SyncTaskBackend, TaskBackend
@ -2858,57 +2858,12 @@ class MemoryEngine(MemoryEngineInterface):
rerank_span.set_attribute("hindsight.pre_filtered_count", pre_filtered_count) rerank_span.set_attribute("hindsight.pre_filtered_count", pre_filtered_count)
rerank_span.end() rerank_span.end()
# Step 4.5: Combine cross-encoder score with retrieval signals # Step 4.5: Combine cross-encoder score with retrieval signals via multiplicative boosts.
# This preserves retrieval work (RRF, temporal, recency) instead of pure cross-encoder ranking # See apply_combined_scoring for the full rationale and formula.
if scored_results: if scored_results:
# Normalize RRF scores to [0, 1] range using min-max normalization apply_combined_scoring(scored_results, now=utcnow())
rrf_scores = [sr.candidate.rrf_score for sr in scored_results]
max_rrf = max(rrf_scores) if rrf_scores else 0.0
min_rrf = min(rrf_scores) if rrf_scores else 0.0
rrf_range = max_rrf - min_rrf # Don't force to 1.0, let fallback handle it
# Calculate recency based on occurred_start (more recent = higher score)
now = utcnow()
for sr in scored_results:
# Normalize RRF score (0-1 range, 0.5 if all same)
if rrf_range > 0:
sr.rrf_normalized = (sr.candidate.rrf_score - min_rrf) / rrf_range
else:
# All RRF scores are the same, use neutral value
sr.rrf_normalized = 0.5
# Calculate recency (decay over 365 days, minimum 0.1)
sr.recency = 0.5 # default for missing dates
if sr.retrieval.occurred_start:
occurred = sr.retrieval.occurred_start
if hasattr(occurred, "tzinfo") and occurred.tzinfo is None:
occurred = occurred.replace(tzinfo=UTC)
days_ago = (now - occurred).total_seconds() / 86400
sr.recency = max(0.1, 1.0 - (days_ago / 365)) # Linear decay over 1 year
# Get temporal proximity if available (already 0-1)
sr.temporal = (
sr.retrieval.temporal_proximity if sr.retrieval.temporal_proximity is not None else 0.5
)
# Weighted combination
# Cross-encoder: 60% (semantic relevance)
# RRF: 20% (retrieval consensus)
# Temporal proximity: 10% (time relevance for temporal queries)
# Recency: 10% (prefer recent facts)
sr.combined_score = (
0.6 * sr.cross_encoder_score_normalized
+ 0.2 * sr.rrf_normalized
+ 0.1 * sr.temporal
+ 0.1 * sr.recency
)
sr.weight = sr.combined_score # Update weight for final ranking
# Re-sort by combined score
scored_results.sort(key=lambda x: x.weight, reverse=True) scored_results.sort(key=lambda x: x.weight, reverse=True)
log_buffer.append( log_buffer.append(" [4.6] Combined scoring: ce * recency_boost(0.2) * temporal_boost(0.2)")
" [4.6] Combined scoring: cross_encoder(0.6) + rrf(0.2) + temporal(0.1) + recency(0.1)"
)
# Add reranked results to tracer AFTER combined scoring (so normalized values are included) # Add reranked results to tracer AFTER combined scoring (so normalized values are included)
if tracer: if tracer:

View file

@ -2,8 +2,72 @@
Cross-encoder neural reranking for search results. Cross-encoder neural reranking for search results.
""" """
from datetime import datetime, timezone
from .types import MergedCandidate, ScoredResult from .types import MergedCandidate, ScoredResult
UTC = timezone.utc
# Multiplicative boost alphas for recency and temporal proximity.
# Each signal contributes at most ±(alpha/2) relative adjustment to the base CE score,
# so the max combined boost is (1 + alpha/2)^2 ≈ +21% and min is (1 - alpha/2)^2 ≈ -19%.
_RECENCY_ALPHA: float = 0.2
_TEMPORAL_ALPHA: float = 0.2
def apply_combined_scoring(
scored_results: list[ScoredResult],
now: datetime,
recency_alpha: float = _RECENCY_ALPHA,
temporal_alpha: float = _TEMPORAL_ALPHA,
) -> None:
"""Apply combined scoring to a list of ScoredResults in-place.
Uses the cross-encoder score as the primary relevance signal, with recency
and temporal proximity applied as multiplicative boosts. This ensures the
influence of these secondary signals is always proportional to the base
relevance score, regardless of the cross-encoder model's score calibration.
Formula::
recency_boost = 1 + recency_alpha * (recency - 0.5) # in [1-α/2, 1+α/2]
temporal_boost = 1 + temporal_alpha * (temporal - 0.5) # in [1-α/2, 1+α/2]
combined_score = cross_encoder_score_normalized * recency_boost * temporal_boost
Temporal proximity is treated as neutral (0.5) when not set by temporal retrieval,
so temporal_boost collapses to 1.0 for non-temporal queries.
Args:
scored_results: Results from the cross-encoder reranker. Mutated in place.
now: Current UTC datetime for recency calculation.
recency_alpha: Max relative recency adjustment (default 0.2 ±10%).
temporal_alpha: Max relative temporal adjustment (default 0.2 ±10%).
"""
if now.tzinfo is None:
now = now.replace(tzinfo=UTC)
for sr in scored_results:
# Recency: linear decay over 365 days → [0.1, 1.0]; neutral 0.5 if no date.
sr.recency = 0.5
if sr.retrieval.occurred_start:
occurred = sr.retrieval.occurred_start
if occurred.tzinfo is None:
occurred = occurred.replace(tzinfo=UTC)
days_ago = (now - occurred).total_seconds() / 86400
sr.recency = max(0.1, min(1.0, 1.0 - (days_ago / 365)))
# Temporal proximity: meaningful only for temporal queries; neutral otherwise.
sr.temporal = sr.retrieval.temporal_proximity if sr.retrieval.temporal_proximity is not None else 0.5
# RRF: kept at 0.0 for trace continuity but excluded from scoring.
# RRF is batch-relative (min-max normalised) and redundant after reranking.
sr.rrf_normalized = 0.0
recency_boost = 1.0 + recency_alpha * (sr.recency - 0.5)
temporal_boost = 1.0 + temporal_alpha * (sr.temporal - 0.5)
sr.combined_score = sr.cross_encoder_score_normalized * recency_boost * temporal_boost
sr.weight = sr.combined_score
class CrossEncoderReranker: class CrossEncoderReranker:
""" """

View file

@ -43,8 +43,8 @@ dependencies = [
"cohere>=5.0.0", "cohere>=5.0.0",
"flashrank>=0.2.0", "flashrank>=0.2.0",
"litellm>=1.0.0", "litellm>=1.0.0",
"markitdown[pdf,docx,pptx,xlsx,xls]>=0.1.4", # File to markdown conversion "markitdown[pdf,docx,pptx,xlsx,xls]>=0.1.4", # File to markdown conversion
"obstore>=0.4.0", # S3/GCS/Azure object storage client (Rust-backed) "obstore>=0.4.0", # S3/GCS/Azure object storage client (Rust-backed)
# Local ML models for embeddings/reranking - can be excluded in Docker with INCLUDE_LOCAL_MODELS=false # Local ML models for embeddings/reranking - can be excluded in Docker with INCLUDE_LOCAL_MODELS=false
"sentence-transformers>=3.3.0", "sentence-transformers>=3.3.0",
"transformers>=4.53.0", # Security fixes for ReDoS vulnerabilities "transformers>=4.53.0", # Security fixes for ReDoS vulnerabilities
@ -62,6 +62,7 @@ dependencies = [
"authlib>=1.6.6", # Account takeover vulnerability fix "authlib>=1.6.6", # Account takeover vulnerability fix
"aiohttp>=3.13.3", # Multiple DoS vulnerabilities "aiohttp>=3.13.3", # Multiple DoS vulnerabilities
"claude-agent-sdk>=0.1.27", "claude-agent-sdk>=0.1.27",
"einops>=0.8.2",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View file

@ -1,334 +1,171 @@
""" """
Tests for combined scoring functionality. Tests for combined scoring (apply_combined_scoring).
Verifies that: The function applies multiplicative recency/temporal boosts to the cross-encoder
1. RRF scores are properly normalized to [0, 1] range score so that the relative influence of these signals is proportional to the base
2. Combined scoring formula is applied correctly relevance score, independent of the cross-encoder model's score calibration.
3. Tracer captures normalized values (not raw values)
""" """
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
import pytest import pytest
from datetime import datetime, timezone
from hindsight_api.engine.search.types import RetrievalResult, MergedCandidate, ScoredResult from hindsight_api.engine.search.reranking import apply_combined_scoring, _RECENCY_ALPHA, _TEMPORAL_ALPHA
from hindsight_api.engine.memory_engine import Budget from hindsight_api.engine.search.types import MergedCandidate, RetrievalResult, ScoredResult
from hindsight_api import RequestContext
UTC = timezone.utc
NOW = datetime(2024, 6, 1, tzinfo=UTC)
class TestRRFNormalization: def _make_result(
"""Test that RRF scores are properly normalized.""" ce_norm: float,
occurred_start: datetime | None = None,
temporal_proximity: float | None = None,
) -> ScoredResult:
retrieval = MagicMock(spec=RetrievalResult)
retrieval.occurred_start = occurred_start
retrieval.temporal_proximity = temporal_proximity
def test_rrf_normalized_range(self): candidate = MagicMock(spec=MergedCandidate)
"""RRF normalized values should be in [0, 1] range, not raw [0.04, 0.06].""" candidate.retrieval = retrieval
# Simulate RRF scores like what we get from actual retrieval candidate.rrf_score = 0.05
raw_rrf_scores = [0.0607, 0.0550, 0.0480, 0.0390]
max_rrf = max(raw_rrf_scores) return ScoredResult(
min_rrf = min(raw_rrf_scores) candidate=candidate,
rrf_range = max_rrf - min_rrf cross_encoder_score=1.0,
cross_encoder_score_normalized=ce_norm,
normalized = [] weight=ce_norm,
for score in raw_rrf_scores: )
if rrf_range > 0:
norm = (score - min_rrf) / rrf_range
else:
norm = 0.5
normalized.append(norm)
# Verify normalized values are in [0, 1]
for i, norm in enumerate(normalized):
assert 0.0 <= norm <= 1.0, f"Normalized RRF {norm} not in [0, 1] for raw {raw_rrf_scores[i]}"
# Highest raw should be 1.0
assert normalized[0] == 1.0, f"Highest RRF should normalize to 1.0, got {normalized[0]}"
# Lowest raw should be 0.0
assert normalized[-1] == 0.0, f"Lowest RRF should normalize to 0.0, got {normalized[-1]}"
def test_rrf_all_same_scores(self):
"""When all RRF scores are the same, normalized should be 0.5 (neutral)."""
raw_rrf_scores = [0.0500, 0.0500, 0.0500]
max_rrf = max(raw_rrf_scores)
min_rrf = min(raw_rrf_scores)
rrf_range = max_rrf - min_rrf
normalized = []
for score in raw_rrf_scores:
if rrf_range > 0:
norm = (score - min_rrf) / rrf_range
else:
norm = 0.5 # Neutral value when all same
normalized.append(norm)
# All should be 0.5 when scores are identical
for norm in normalized:
assert norm == 0.5, f"Expected 0.5 for identical scores, got {norm}"
class TestCombinedScoringFormula: class TestBoostFormula:
"""Test that the combined scoring formula is applied correctly.""" def test_neutral_signals_leave_score_unchanged(self):
"""recency=0.5 and temporal=0.5 both produce boost=1.0, so weight == ce."""
sr = _make_result(ce_norm=0.6)
apply_combined_scoring([sr], now=NOW)
assert abs(sr.weight - 0.6) < 1e-9
def test_combined_score_calculation(self): def test_max_recency_boost(self):
"""Verify the weighted combination: 0.6*CE + 0.2*RRF + 0.1*temporal + 0.1*recency.""" """A memory from today (recency≈1.0) should boost by (1 + alpha*0.5)."""
# Test case 1: All components at 1.0 sr = _make_result(ce_norm=0.5, occurred_start=NOW)
ce_norm = 1.0 apply_combined_scoring([sr], now=NOW)
rrf_norm = 1.0 expected = 0.5 * (1.0 + _RECENCY_ALPHA * 0.5) * 1.0 # temporal neutral
temporal = 1.0 assert abs(sr.weight - expected) < 1e-6
recency = 1.0
expected = 0.6 * ce_norm + 0.2 * rrf_norm + 0.1 * temporal + 0.1 * recency def test_min_recency_penalty(self):
assert expected == 1.0, f"All 1.0 should give 1.0, got {expected}" """A memory from >365 days ago (recency=0.1) should penalise score."""
old = NOW - timedelta(days=400)
sr = _make_result(ce_norm=0.5, occurred_start=old)
apply_combined_scoring([sr], now=NOW)
expected = 0.5 * (1.0 + _RECENCY_ALPHA * (0.1 - 0.5)) * 1.0
assert abs(sr.weight - expected) < 1e-6
# Test case 2: All components at 0.0 def test_max_temporal_boost(self):
ce_norm = 0.0 """temporal_proximity=1.0 should boost by (1 + alpha*0.5)."""
rrf_norm = 0.0 sr = _make_result(ce_norm=0.5, temporal_proximity=1.0)
temporal = 0.0 apply_combined_scoring([sr], now=NOW)
recency = 0.0 expected = 0.5 * 1.0 * (1.0 + _TEMPORAL_ALPHA * 0.5) # recency neutral
assert abs(sr.weight - expected) < 1e-6
expected = 0.6 * ce_norm + 0.2 * rrf_norm + 0.1 * temporal + 0.1 * recency def test_temporal_none_is_neutral(self):
assert expected == 0.0, f"All 0.0 should give 0.0, got {expected}" """temporal_proximity=None must be treated as 0.5 (no boost/penalty)."""
sr_none = _make_result(ce_norm=0.5, temporal_proximity=None)
sr_half = _make_result(ce_norm=0.5, temporal_proximity=0.5)
apply_combined_scoring([sr_none], now=NOW)
apply_combined_scoring([sr_half], now=NOW)
assert abs(sr_none.weight - sr_half.weight) < 1e-9
# Test case 3: High CE, low RRF (cross-encoder finds something retrieval missed) def test_both_signals_combined(self):
ce_norm = 0.999 """Both boosts are applied multiplicatively."""
rrf_norm = 0.0 # Lowest in set sr = _make_result(ce_norm=0.5, occurred_start=NOW, temporal_proximity=1.0)
temporal = 0.5 apply_combined_scoring([sr], now=NOW)
recency = 0.5 recency_boost = 1.0 + _RECENCY_ALPHA * (1.0 - 0.5)
temporal_boost = 1.0 + _TEMPORAL_ALPHA * (1.0 - 0.5)
expected = 0.5 * recency_boost * temporal_boost
assert abs(sr.weight - expected) < 1e-6
expected = 0.6 * ce_norm + 0.2 * rrf_norm + 0.1 * temporal + 0.1 * recency def test_boost_is_proportional_to_ce(self):
# 0.5994 + 0.0 + 0.05 + 0.05 = 0.6994 """The absolute boost from recency scales with the CE score."""
assert abs(expected - 0.6994) < 0.001, f"Expected ~0.6994, got {expected}" sr_high = _make_result(ce_norm=0.9, occurred_start=NOW)
sr_low = _make_result(ce_norm=0.3, occurred_start=NOW)
apply_combined_scoring([sr_high, sr_low], now=NOW)
# Test case 4: Medium CE, high RRF (retrieval consensus) # Both get the same recency boost factor — absolute gain is proportional to CE
ce_norm = 0.8 boost_factor = 1.0 + _RECENCY_ALPHA * 0.5
rrf_norm = 1.0 # Highest in set assert abs(sr_high.weight - 0.9 * boost_factor) < 1e-6
temporal = 0.5 assert abs(sr_low.weight - 0.3 * boost_factor) < 1e-6
recency = 0.5
expected = 0.6 * ce_norm + 0.2 * rrf_norm + 0.1 * temporal + 0.1 * recency def test_boost_capped(self):
# 0.48 + 0.2 + 0.05 + 0.05 = 0.78 """Max boost: recency=1.0 + temporal=1.0 gives ≤21% uplift on CE."""
assert abs(expected - 0.78) < 0.001, f"Expected ~0.78, got {expected}" sr = _make_result(ce_norm=1.0, occurred_start=NOW, temporal_proximity=1.0)
apply_combined_scoring([sr], now=NOW)
assert sr.weight <= 1.0 * (1 + _RECENCY_ALPHA / 2) * (1 + _TEMPORAL_ALPHA / 2) + 1e-9
def test_rrf_contribution_is_significant(self): def test_rrf_normalized_always_zero(self):
"""Verify RRF actually contributes to the final score (not negligible).""" """RRF is excluded from scoring; rrf_normalized is set to 0.0 for trace clarity."""
# Same CE, different RRF sr = _make_result(ce_norm=0.5)
ce_norm = 0.8 apply_combined_scoring([sr], now=NOW)
temporal = 0.5 assert sr.rrf_normalized == 0.0
recency = 0.5
# Low RRF def test_combined_score_equals_weight(self):
score_low_rrf = 0.6 * ce_norm + 0.2 * 0.0 + 0.1 * temporal + 0.1 * recency """combined_score and weight must stay in sync."""
sr = _make_result(ce_norm=0.7, occurred_start=NOW, temporal_proximity=0.8)
apply_combined_scoring([sr], now=NOW)
assert sr.combined_score == sr.weight
# High RRF def test_model_calibration_independence(self):
score_high_rrf = 0.6 * ce_norm + 0.2 * 1.0 + 0.1 * temporal + 0.1 * recency """
A low-calibration model (low CE scores) and a high-calibration model
(high CE scores) should produce the same ranking for identical content.
# Difference should be 0.2 (20% contribution) With additive scoring the recency term would dominate for low-CE models;
diff = score_high_rrf - score_low_rrf with multiplicative boosting the relative ranking is stable.
assert abs(diff - 0.2) < 0.001, f"RRF should contribute 0.2 difference, got {diff}" """
recent = NOW - timedelta(days=10)
old = NOW - timedelta(days=300)
# High-calibration model: clear winner is #1 (more relevant, slightly older)
h_relevant = _make_result(ce_norm=0.85, occurred_start=old)
h_recent = _make_result(ce_norm=0.60, occurred_start=recent)
apply_combined_scoring([h_relevant, h_recent], now=NOW)
assert h_relevant.weight > h_recent.weight, "High-CE model: relevance should win"
@pytest.mark.asyncio # Low-calibration model: same relative difference, just compressed scores
async def test_trace_has_normalized_rrf(memory, request_context): l_relevant = _make_result(ce_norm=0.34, occurred_start=old)
"""Integration test: verify trace contains normalized RRF values, not raw.""" l_recent = _make_result(ce_norm=0.24, occurred_start=recent)
bank_id = f"test_scoring_{datetime.now(timezone.utc).timestamp()}" apply_combined_scoring([l_relevant, l_recent], now=NOW)
assert l_relevant.weight > l_recent.weight, "Low-CE model: relevance should still win"
try: def test_no_occurred_start_defaults_recency_neutral(self):
# Store multiple memories to ensure different RRF scores """Missing occurred_start → recency=0.5 → no boost/penalty."""
await memory.retain_async( sr = _make_result(ce_norm=0.5, occurred_start=None)
bank_id=bank_id, apply_combined_scoring([sr], now=NOW)
content="Python is a programming language created by Guido van Rossum", assert sr.recency == 0.5
context="tech facts", assert abs(sr.weight - 0.5) < 1e-9
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="JavaScript was created by Brendan Eich at Netscape",
context="tech facts",
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="The Eiffel Tower is located in Paris, France",
context="geography facts",
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="Mount Everest is the tallest mountain on Earth",
context="geography facts",
request_context=request_context,
)
# Search with tracing def test_timezone_naive_occurred_start_handled(self):
result = await memory.recall_async( """Naive datetimes in occurred_start should not raise."""
bank_id=bank_id, naive_date = datetime(2024, 1, 1) # no tzinfo
query="programming languages", sr = _make_result(ce_norm=0.5, occurred_start=naive_date)
fact_type=["world"], apply_combined_scoring([sr], now=NOW) # must not raise
budget=Budget.LOW, assert 0.0 < sr.weight < 1.0
max_tokens=1024,
enable_trace=True,
request_context=request_context,
)
assert result.trace is not None, "Trace should be present" def test_custom_alpha_values(self):
trace = result.trace """Custom alpha parameters are respected."""
sr = _make_result(ce_norm=0.5, occurred_start=NOW)
apply_combined_scoring([sr], now=NOW, recency_alpha=0.4, temporal_alpha=0.0)
expected = 0.5 * (1.0 + 0.4 * 0.5) * 1.0
assert abs(sr.weight - expected) < 1e-6
# Check reranked results have proper score_components def test_future_event_recency_capped_at_one(self):
assert "reranked" in trace, "Trace should have reranked results" """Events in the future must not produce recency > 1.0, keeping boost within bounds."""
assert len(trace["reranked"]) > 0, "Should have reranked results" future = NOW + timedelta(days=180)
sr = _make_result(ce_norm=0.5, occurred_start=future)
apply_combined_scoring([sr], now=NOW)
assert sr.recency == 1.0
expected_max_boost = 1.0 + _RECENCY_ALPHA * 0.5
assert sr.weight <= 0.5 * expected_max_boost + 1e-9
has_valid_rrf = False def test_empty_list_is_noop(self):
has_valid_temporal = False apply_combined_scoring([], now=NOW) # must not raise
has_valid_recency = False
for r in trace["reranked"]:
sc = r.get("score_components", {})
# Check RRF normalized is present and in valid range
if "rrf_normalized" in sc:
rrf_norm = sc["rrf_normalized"]
assert 0.0 <= rrf_norm <= 1.0, f"rrf_normalized {rrf_norm} should be in [0, 1]"
# Should NOT be raw RRF score (which would be ~0.04-0.06)
# A normalized value of exactly 0.0 or 1.0 is valid (min/max of set)
# But raw scores like 0.0607 should never appear as normalized
if rrf_norm > 0.1: # Any value > 0.1 is likely properly normalized
has_valid_rrf = True
# Check temporal is present and in valid range
if "temporal" in sc:
temporal = sc["temporal"]
assert 0.0 <= temporal <= 1.0, f"temporal {temporal} should be in [0, 1]"
has_valid_temporal = True
# Check recency is present and in valid range
if "recency" in sc:
recency = sc["recency"]
assert 0.0 <= recency <= 1.0, f"recency {recency} should be in [0, 1]"
has_valid_recency = True
# At least some results should have these components
# (might not have rrf > 0.1 if all scores are same, which is fine)
assert has_valid_temporal, "Should have temporal scores in trace"
assert has_valid_recency, "Should have recency scores in trace"
print("\n✓ Combined scoring trace test passed!")
print(f" - Reranked results: {len(trace['reranked'])}")
if trace["reranked"]:
sc = trace["reranked"][0].get("score_components", {})
print(f" - First result score components: {sc}")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_rrf_normalized_not_raw_in_trace(memory, request_context):
"""Verify that raw RRF scores (0.04-0.06 range) don't appear as normalized values."""
bank_id = f"test_rrf_raw_{datetime.now(timezone.utc).timestamp()}"
try:
# Store enough memories to get varied RRF scores
for i in range(5):
await memory.retain_async(
bank_id=bank_id,
content=f"Test fact number {i} about various topics",
context="test context",
request_context=request_context,
)
result = await memory.recall_async(
bank_id=bank_id,
query="test fact",
fact_type=["world"],
budget=Budget.LOW,
max_tokens=512,
enable_trace=True,
request_context=request_context,
)
trace = result.trace
assert trace is not None
# Check that rrf_normalized values are NOT in the raw range
raw_rrf_range = (0.01, 0.08) # Raw RRF scores are typically in this range
for r in trace.get("reranked", []):
sc = r.get("score_components", {})
if "rrf_normalized" in sc and "rrf_score" in sc:
rrf_norm = sc["rrf_normalized"]
rrf_raw = sc["rrf_score"]
# Raw should be in the typical range
assert raw_rrf_range[0] <= rrf_raw <= raw_rrf_range[1], \
f"Raw RRF {rrf_raw} should be in typical range {raw_rrf_range}"
# Normalized should either be:
# - 0.0 (min in set)
# - 1.0 (max in set)
# - 0.5 (all same)
# - Something in between (0.0 to 1.0)
# But NOT the same as raw (which would indicate no normalization)
if len(trace["reranked"]) > 1:
# If we have multiple results, normalized should differ from raw
# (unless by coincidence, which is very unlikely)
assert rrf_norm != rrf_raw, \
f"Normalized RRF ({rrf_norm}) should differ from raw ({rrf_raw})"
print("\n✓ RRF raw vs normalized test passed!")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_combined_score_matches_components(memory, request_context):
"""Verify the final score actually equals the weighted sum of components."""
bank_id = f"test_combined_{datetime.now(timezone.utc).timestamp()}"
try:
await memory.retain_async(
bank_id=bank_id,
content="The quick brown fox jumps over the lazy dog",
context="test",
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="A quick test of the emergency broadcast system",
context="test",
request_context=request_context,
)
result = await memory.recall_async(
bank_id=bank_id,
query="quick test",
fact_type=["world"],
budget=Budget.LOW,
max_tokens=512,
enable_trace=True,
request_context=request_context,
)
trace = result.trace
assert trace is not None
for r in trace.get("reranked", []):
sc = r.get("score_components", {})
final_score = r.get("rerank_score", 0)
# Get components (use defaults if missing)
ce = sc.get("cross_encoder_score_normalized", 0)
rrf = sc.get("rrf_normalized", 0.5)
tmp = sc.get("temporal", 0.5)
rec = sc.get("recency", 0.5)
# Calculate expected score
expected = 0.6 * ce + 0.2 * rrf + 0.1 * tmp + 0.1 * rec
# Allow small floating point difference
assert abs(final_score - expected) < 0.01, \
f"Final score {final_score} doesn't match expected {expected} from components"
print("\n✓ Combined score verification test passed!")
finally:
await memory.delete_bank(bank_id, request_context=request_context)

View file

@ -871,7 +871,7 @@ export function SearchDebugView() {
</div> </div>
<div className="text-sm text-muted-foreground mt-0.5"> <div className="text-sm text-muted-foreground mt-0.5">
<span className="font-mono text-xs"> <span className="font-mono text-xs">
0.6×cross_encoder + 0.2×rrf + 0.1×temporal + 0.1×recency ce × recency_boost(±10%) × temporal_boost(±10%)
</span> </span>
</div> </div>
</div> </div>
@ -924,24 +924,17 @@ export function SearchDebugView() {
= {(r.rerank_score || r.score || 0).toFixed(4)} = {(r.rerank_score || r.score || 0).toFixed(4)}
</span> </span>
{sc.cross_encoder_score_normalized !== undefined && ( {sc.cross_encoder_score_normalized !== undefined && (
<span title="Cross-encoder (60%)"> <span title="Cross-encoder score (primary relevance signal)">
CE: {sc.cross_encoder_score_normalized.toFixed(3)} CE: {sc.cross_encoder_score_normalized.toFixed(3)}
</span> </span>
)} )}
{sc.rrf_normalized !== undefined && ( {sc.temporal !== undefined && sc.temporal !== 0.5 && (
<span <span title="Temporal proximity boost (±10% — only active for temporal queries)">
title={`RRF normalized (20%) - raw: ${sc.rrf_score?.toFixed(4) || "N/A"}`}
>
RRF: {sc.rrf_normalized.toFixed(3)}
</span>
)}
{sc.temporal !== undefined && (
<span title="Temporal proximity (10%)">
Tmp: {sc.temporal.toFixed(3)} Tmp: {sc.temporal.toFixed(3)}
</span> </span>
)} )}
{sc.recency !== undefined && ( {sc.recency !== undefined && (
<span title="Recency (10%)"> <span title="Recency boost (±10% — based on memory age)">
Rec: {sc.recency.toFixed(3)} Rec: {sc.recency.toFixed(3)}
</span> </span>
)} )}

11
uv.lock
View file

@ -839,6 +839,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/11/a8/c6a4b901d17399c77cd81fb001ce8961e9f5e04d3daf27e8925cb012e163/docutils-0.22.3-py3-none-any.whl", hash = "sha256:bd772e4aca73aff037958d44f2be5229ded4c09927fcf8690c577b66234d6ceb", size = 633032 }, { url = "https://files.pythonhosted.org/packages/11/a8/c6a4b901d17399c77cd81fb001ce8961e9f5e04d3daf27e8925cb012e163/docutils-0.22.3-py3-none-any.whl", hash = "sha256:bd772e4aca73aff037958d44f2be5229ded4c09927fcf8690c577b66234d6ceb", size = 633032 },
] ]
[[package]]
name = "einops"
version = "0.8.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/77/850bef8d72ffb9219f0b1aac23fbc1bf7d038ee6ea666f331fa273031aa2/einops-0.8.2.tar.gz", hash = "sha256:609da665570e5e265e27283aab09e7f279ade90c4f01bcfca111f3d3e13f2827", size = 56261 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/09/f8d8f8f31e4483c10a906437b4ce31bdf3d6d417b73fe33f1a8b59e34228/einops-0.8.2-py3-none-any.whl", hash = "sha256:54058201ac7087911181bfec4af6091bb59380360f069276601256a76af08193", size = 65638 },
]
[[package]] [[package]]
name = "email-validator" name = "email-validator"
version = "2.3.0" version = "2.3.0"
@ -1457,6 +1466,7 @@ dependencies = [
{ name = "cohere" }, { name = "cohere" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "dateparser" }, { name = "dateparser" },
{ name = "einops" },
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "fastmcp" }, { name = "fastmcp" },
{ name = "filelock" }, { name = "filelock" },
@ -1535,6 +1545,7 @@ requires-dist = [
{ name = "cohere", specifier = ">=5.0.0" }, { name = "cohere", specifier = ">=5.0.0" },
{ name = "cryptography", specifier = ">=46.0.5" }, { name = "cryptography", specifier = ">=46.0.5" },
{ name = "dateparser", specifier = ">=1.2.2" }, { name = "dateparser", specifier = ">=1.2.2" },
{ name = "einops", specifier = ">=0.8.2" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.120.3" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.120.3" },
{ name = "fastmcp", specifier = ">=2.14.0" }, { name = "fastmcp", specifier = ">=2.14.0" },
{ name = "filelock", specifier = ">=3.20.1" }, { name = "filelock", specifier = ">=3.20.1" },