diff --git a/src/brainlayer/engine.py b/src/brainlayer/engine.py index b1b1a1ee..76633cda 100644 --- a/src/brainlayer/engine.py +++ b/src/brainlayer/engine.py @@ -181,6 +181,7 @@ def think( include_audit: bool = False, agent_id: str | None = None, consumer_scope: Any | None = None, + warm_rrf: bool = False, ) -> ThinkResult: """Given current task context, retrieve relevant past knowledge. @@ -212,6 +213,7 @@ def think( include_audit=include_audit, agent_id=agent_id, consumer_scope=consumer_scope, + warm_rrf=warm_rrf, ) if not results["documents"][0]: @@ -248,6 +250,7 @@ def recall( include_audit: bool = False, agent_id: str | None = None, consumer_scope: Any | None = None, + warm_rrf: bool = False, ) -> RecallResult: """Proactive smart retrieval based on file or topic. @@ -290,6 +293,7 @@ def recall( include_audit=include_audit, agent_id=agent_id, consumer_scope=consumer_scope, + warm_rrf=warm_rrf, ) for doc, meta in zip(search_results["documents"][0], search_results["metadatas"][0]): result.related_chunks.append( @@ -314,6 +318,7 @@ def recall( include_audit=include_audit, agent_id=agent_id, consumer_scope=consumer_scope, + warm_rrf=warm_rrf, ) for doc, meta in zip(search_results["documents"][0], search_results["metadatas"][0]): result.related_chunks.append( diff --git a/src/brainlayer/mcp/search_handler.py b/src/brainlayer/mcp/search_handler.py index 87459369..b9097002 100644 --- a/src/brainlayer/mcp/search_handler.py +++ b/src/brainlayer/mcp/search_handler.py @@ -2456,6 +2456,7 @@ def _embed(text: str) -> list[float]: include_audit=include_audit, agent_id=agent_id, consumer_scope=consumer_scope, + warm_rrf=True, ), ) result = _filter_think_result_for_consumer_scope(result, normalized_project, consumer_scope) @@ -2506,6 +2507,7 @@ def _embed(text: str) -> list[float]: include_audit=include_audit, agent_id=agent_id, consumer_scope=consumer_scope, + warm_rrf=True, ), ) result = _filter_recall_result_for_consumer_scope(result, normalized_project, consumer_scope) diff --git a/src/brainlayer/search_repo.py b/src/brainlayer/search_repo.py index 192a7dc6..b9582702 100644 --- a/src/brainlayer/search_repo.py +++ b/src/brainlayer/search_repo.py @@ -338,6 +338,7 @@ def _hybrid_cache_key( include_operational: bool = False, content_class_filter: Optional[str] = None, recency_rerank: bool = False, + recency_decay: bool = False, importance_rerank: bool = False, warm_rrf: bool = False, rrf_vector_alpha: float = RRF_VECTOR_ALPHA, @@ -367,6 +368,7 @@ def _hybrid_cache_key( include_operational, normalize_content_class(content_class_filter) if content_class_filter else None, recency_rerank, + recency_decay, importance_rerank, warm_rrf, rrf_vector_alpha, @@ -1801,7 +1803,7 @@ def hybrid_search( Cache is module-level LRU (128 entries) with defensive copy-on-read. """ - recency_rerank = recency_rerank or _env_flag_enabled("BRAINLAYER_SCORE_RECENCY_DECAY") + recency_decay = recency_rerank or _env_flag_enabled("BRAINLAYER_SCORE_RECENCY_DECAY") importance_rerank = importance_rerank or _env_flag_enabled("BRAINLAYER_SCORE_IMPORTANCE_BOOST") rrf_vector_alpha = _rrf_vector_alpha() if warm_rrf else RRF_VECTOR_ALPHA project_filter = _effective_project_filter(project_filter, consumer_scope) @@ -1837,6 +1839,7 @@ def hybrid_search( include_operational, content_class_filter, recency_rerank, + recency_decay, importance_rerank, warm_rrf, rrf_vector_alpha, @@ -2320,6 +2323,14 @@ def _ingest_keyword_rows(rows: list[tuple], ranks: dict[str, int]) -> None: if not warm_rrf: all_chunk_ids |= set(trigram_ranks.keys()) + # A degraded single-leg search must retain that leg's rank signal even + # when the configured fusion weight assigns it zero weight. + fusion_alpha = rrf_vector_alpha + if warm_rrf and not semantic_by_id: + fusion_alpha = 0.0 + elif warm_rrf and not fts_ranks: + fusion_alpha = 1.0 + scored = [] match_features_by_id: dict[str, dict[str, bool]] = {} for cid in all_chunk_ids: @@ -2331,7 +2342,7 @@ def _ingest_keyword_rows(rows: list[tuple], ranks: dict[str, int]) -> None: semantic_rank=sem_entry["rank"] if sem_entry is not None else None, fts_rank=fts_rank, k=k, - alpha=rrf_vector_alpha, + alpha=fusion_alpha, ) if not warm_rrf and trigram_rank is not None: score += (1.0 - RRF_VECTOR_ALPHA) / (k + trigram_rank) @@ -2434,7 +2445,7 @@ def _ingest_keyword_rows(rows: list[tuple], ranks: dict[str, int]) -> None: # Recency boost: exponential decay with 30-day half-life created = meta.get("created_at") - if recency_rerank and created and isinstance(created, str): + if recency_decay and created and isinstance(created, str): try: dt = datetime.fromisoformat(created.replace("Z", "+00:00")) if dt.tzinfo is None: diff --git a/tests/test_hybrid_search.py b/tests/test_hybrid_search.py index 5dc99afe..bd8ff0f4 100644 --- a/tests/test_hybrid_search.py +++ b/tests/test_hybrid_search.py @@ -284,6 +284,38 @@ def test_warm_weighted_rrf_drops_trigram_fusion_leg(self, store): assert results["ids"][0] == [] + def test_warm_fts_fallback_preserves_fts_rank_when_alpha_is_one(self, store, monkeypatch): + monkeypatch.setenv("BRAINLAYER_RRF_ALPHA", "1.0") + _insert_chunk( + store, + chunk_id="exact-fts", + content="alpha fallback", + embedding=_embed("distant exact"), + ) + _insert_chunk( + store, + chunk_id="verbose-fts", + content="alpha fallback with several unrelated padding words", + embedding=_embed("distant verbose"), + ) + captured: list[tuple] = [] + + def capture_ranked(scored, *, n_results): + captured.extend(scored) + return scored + + monkeypatch.setattr(store, "_mmr_rerank_scored_results", capture_ranked) + + store.hybrid_search( + query_embedding=None, + query_text="alpha fallback", + n_results=2, + warm_rrf=True, + ) + + assert [item[1] for item in captured] == ["exact-fts", "verbose-fts"] + assert captured[0][0] > captured[1][0] > 0.0 + def test_hybrid_search_default_does_not_apply_recency_or_importance_boost(self, store, monkeypatch): monkeypatch.delenv("BRAINLAYER_SCORE_IMPORTANCE_BOOST", raising=False) monkeypatch.delenv("BRAINLAYER_SCORE_RECENCY_DECAY", raising=False) @@ -409,6 +441,24 @@ def capture_scores(scored, *, n_results): assert captured_scores["old-result"] == pytest.approx(base_rrf * old_recency_boost) assert captured_scores["fresh-result"] == pytest.approx(base_rrf) + def test_recency_decay_env_does_not_enable_recent_candidate_fallback(self, store, monkeypatch): + monkeypatch.setenv("BRAINLAYER_SCORE_RECENCY_DECAY", "1") + _insert_chunk( + store, + chunk_id="recent-unrelated", + content="fresh notes without query overlap", + embedding=_embed("unrelated recent content"), + created_at="2999-01-01T00:00:00Z", + ) + + results = store.hybrid_search( + query_embedding=None, + query_text="latest deployment status", + n_results=5, + ) + + assert results["ids"][0] == [] + def test_hybrid_search_opt_in_recency_and_importance_rerank_change_scores(self, store, monkeypatch): monkeypatch.delenv("BRAINLAYER_SCORE_IMPORTANCE_BOOST", raising=False) monkeypatch.delenv("BRAINLAYER_SCORE_RECENCY_DECAY", raising=False) diff --git a/tests/test_search_handler.py b/tests/test_search_handler.py index d482d8d1..3b3011ce 100644 --- a/tests/test_search_handler.py +++ b/tests/test_search_handler.py @@ -446,6 +446,7 @@ async def test_brain_search_mcp_threads_agent_id_to_hybrid_search(monkeypatch): assert store.hybrid_kwargs is not None assert store.hybrid_kwargs["agent_id"] == "codex-test-agent" + assert store.hybrid_kwargs["warm_rrf"] is True @pytest.mark.asyncio @@ -840,6 +841,7 @@ async def test_brain_search_think_route_threads_agent_id_to_hybrid_search(monkey assert store.hybrid_kwargs is not None assert store.hybrid_kwargs["agent_id"] == "codex-test-agent" + assert store.hybrid_kwargs["warm_rrf"] is True @pytest.mark.asyncio @@ -861,3 +863,4 @@ async def test_brain_search_recall_route_threads_agent_id_to_hybrid_search(monke assert store.hybrid_kwargs is not None assert store.hybrid_kwargs["agent_id"] == "codex-test-agent" + assert store.hybrid_kwargs["warm_rrf"] is True