diff --git a/hindsight-api-slim/hindsight_api/config.py b/hindsight-api-slim/hindsight_api/config.py index 6d6ff5aa19..b5fcaab77a 100644 --- a/hindsight-api-slim/hindsight_api/config.py +++ b/hindsight-api-slim/hindsight_api/config.py @@ -501,6 +501,14 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: # Recall candidate gating (per-source cap + BM25 score floor) ENV_BM25_MIN_SCORE = "HINDSIGHT_API_BM25_MIN_SCORE" ENV_RECALL_MAX_CANDIDATES_PER_SOURCE = "HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_SOURCE" +# Per-strategy recall boost. Prioritises specific retrieval arms (semantic, +# bm25, graph, temporal) on recall via a human priority level — e.g. +# "graph:high" to strongly favour graph hits, or "graph:high,semantic:low". +# Valid levels: low | medium | high. The level (not a raw number) is the knob +# because the boost is applied on two different score scales — see +# engine/search/recall_boost.py for the level -> magnitude mapping and rationale. +# Empty disables the feature. +ENV_RECALL_STRATEGY_BOOSTS = "HINDSIGHT_API_RECALL_STRATEGY_BOOSTS" # Audit log settings ENV_AUDIT_LOG_ENABLED = "HINDSIGHT_API_AUDIT_LOG_ENABLED" @@ -615,6 +623,56 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: # temporal) before RRF, so a single over-expanding backend cannot fill the # reranker's global candidate budget on its own. 0 disables the cap. DEFAULT_RECALL_MAX_CANDIDATES_PER_SOURCE = 0 +# Per-strategy recall boost, as a comma-separated "strategy:level" list (e.g. +# "graph:high,semantic:low"). Empty disables the feature. See +# ENV_RECALL_STRATEGY_BOOSTS for the full rationale. +DEFAULT_RECALL_STRATEGY_BOOSTS = "" +# Retrieval arms that can be boosted; mirrors fusion.py source_names. +RECALL_STRATEGY_NAMES = ("semantic", "bm25", "graph", "temporal") +# User-facing priority levels. Kept in sync with recall_boost.BOOST_LEVELS by a +# guard test; defined here (not imported) so config stays free of the heavy +# engine.search import graph. +RECALL_BOOST_LEVELS = ("low", "medium", "high") +# Level applied when a strategy is listed without one (e.g. "graph" or "graph:"). +DEFAULT_RECALL_BOOST_LEVEL = "medium" + + +def _parse_strategy_boosts(raw: str | None) -> dict[str, str]: + """Parse a "strategy:level,strategy:level" string into a boost map. + + A strategy listed without a level (``"graph"`` or ``"graph:"``) defaults to + ``medium``. Only the strategies you list are boosted; any strategy you omit + keeps its normal, unboosted weight. Unknown strategy names, unknown levels, + and malformed entries are skipped with a warning so a typo degrades to a + no-op boost rather than breaking recall. + """ + if not raw or not raw.strip(): + return {} + boosts: dict[str, str] = {} + for entry in raw.split(","): + entry = entry.strip() + if not entry: + continue + name, _sep, level = entry.partition(":") + name = name.strip().lower() + level = level.strip().lower() or DEFAULT_RECALL_BOOST_LEVEL + if name not in RECALL_STRATEGY_NAMES: + logger.warning( + "Ignoring unknown recall strategy %r in boost (valid: %s)", name, ", ".join(RECALL_STRATEGY_NAMES) + ) + continue + if level not in RECALL_BOOST_LEVELS: + logger.warning( + "Ignoring unknown recall boost level %r for %r (valid: %s)", + level, + name, + ", ".join(RECALL_BOOST_LEVELS), + ) + continue + boosts[name] = level + return boosts + + DEFAULT_RERANKER_FLASHRANK_MODEL = "ms-marco-MiniLM-L-12-v2" # Best balance of speed and quality DEFAULT_RERANKER_FLASHRANK_CACHE_DIR = None # Use default cache directory DEFAULT_RERANKER_FLASHRANK_CPU_MEM_ARENA = False # Disable ONNX CPU memory arena to bound RSS @@ -1193,6 +1251,7 @@ class HindsightConfig: reranker_max_candidates: int bm25_min_score: float recall_max_candidates_per_source: int + recall_strategy_boosts: dict[str, str] reranker_cohere_api_key: str | None reranker_cohere_model: str reranker_cohere_base_url: str | None @@ -1923,6 +1982,9 @@ def from_env(cls) -> "HindsightConfig": recall_max_candidates_per_source=int( os.getenv(ENV_RECALL_MAX_CANDIDATES_PER_SOURCE, str(DEFAULT_RECALL_MAX_CANDIDATES_PER_SOURCE)) ), + recall_strategy_boosts=_parse_strategy_boosts( + os.getenv(ENV_RECALL_STRATEGY_BOOSTS, DEFAULT_RECALL_STRATEGY_BOOSTS) + ), # Cohere reranker (with backward-compatible fallback to shared API key) reranker_cohere_api_key=os.getenv(ENV_RERANKER_COHERE_API_KEY) or os.getenv(ENV_COHERE_API_KEY), reranker_cohere_model=os.getenv(ENV_RERANKER_COHERE_MODEL, DEFAULT_RERANKER_COHERE_MODEL), diff --git a/hindsight-api-slim/hindsight_api/engine/memory_engine.py b/hindsight-api-slim/hindsight_api/engine/memory_engine.py index 28d93bf6b9..4ce008b073 100644 --- a/hindsight-api-slim/hindsight_api/engine/memory_engine.py +++ b/hindsight-api-slim/hindsight_api/engine/memory_engine.py @@ -4097,7 +4097,13 @@ def to_tuple_format(results): # RRF already provides good ranking; this caps cross-encoder cost. reranker_max_candidates = get_config().reranker_max_candidates if len(merged_candidates) > reranker_max_candidates: - merged_candidates.sort(key=lambda mc: mc.rrf_score, reverse=True) + # Sort by RRF score (boosted per-strategy if configured) and take top + # candidates. The weighted-RRF boost keeps boosted-arm candidates from + # being trimmed out of the reranker's global budget. + from .search.recall_boost import boosted_rrf_score + + strategy_boosts = get_config().recall_strategy_boosts + merged_candidates.sort(key=lambda mc: boosted_rrf_score(mc, strategy_boosts), reverse=True) pre_filtered_count = len(merged_candidates) - reranker_max_candidates merged_candidates = merged_candidates[:reranker_max_candidates] @@ -4156,8 +4162,18 @@ def to_tuple_format(results): now=_recall_scoring_now(question_date), is_passthrough_reranker=is_passthrough, ) + # Per-strategy additive boost: nudge candidates surfaced by a + # prioritised retrieval arm up the final ordering. + strategy_boosts = get_config().recall_strategy_boosts + if strategy_boosts: + from .search.recall_boost import additive_strategy_boost + + for sr in scored_results: + sr.weight += additive_strategy_boost(sr.candidate.source_ranks, strategy_boosts) scored_results.sort(key=lambda x: x.weight, reverse=True) log_buffer.append(" [4.6] Combined scoring: ce * recency_boost(0.2) * temporal_boost(0.2)") + if strategy_boosts: + log_buffer.append(f" [4.7] Strategy boosts applied: {strategy_boosts}") # Add reranked results to tracer AFTER combined scoring (so normalized values are included) if tracer: diff --git a/hindsight-api-slim/hindsight_api/engine/search/recall_boost.py b/hindsight-api-slim/hindsight_api/engine/search/recall_boost.py new file mode 100644 index 0000000000..65a6fe22bf --- /dev/null +++ b/hindsight-api-slim/hindsight_api/engine/search/recall_boost.py @@ -0,0 +1,117 @@ +"""Per-strategy recall boosting. + +A deployment can prioritise one retrieval arm (semantic, bm25, graph, temporal) +over the others via ``HINDSIGHT_API_RECALL_STRATEGY_BOOSTS``, expressed as a +human priority *level* rather than an opaque number — e.g. ``graph:high`` to +strongly favour graph hits. + +A level is chosen instead of a raw weight because the boost is applied in two +structurally different places that live on different score scales, so a single +number could not mean the same thing in both. The level maps to a tuned +:class:`BoostWeights` pair: + +1. **Before the reranker cap** — :func:`boosted_rrf_score` uses ``BoostWeights.rrf`` + as a weighted-RRF multiplier on the boosted arm's rank contribution, so its + candidates survive the global reranker candidate budget instead of being + trimmed by raw RRF score. Rank-aware: a candidate ranked #1 in the boosted + arm is protected more than one ranked #200. + +2. **After the reranker** — :func:`additive_strategy_boost` uses + ``BoostWeights.additive`` as a flat bump to the final ranking weight (which + sits in ~[0, 1] after cross-encoder + recency/temporal scoring), nudging the + boosted arm's candidates up the final ordering. + +Both functions are no-ops when ``boosts`` is empty, preserving current behaviour. +""" + +from dataclasses import dataclass + +from .types import MergedCandidate + + +@dataclass(frozen=True) +class BoostWeights: + """Per-stage boost magnitudes for one priority level. + + The two fields live on different scales on purpose (see module docstring): + ``rrf`` multiplies an arm's ``1/(k+rank)`` RRF contribution; ``additive`` is + added directly to the post-rerank weight in ~[0, 1]. + """ + + rrf: float + additive: float + + +# Priority level -> per-stage boost magnitudes. Tuned against real recall traces +# (LoCoMo bank, 336 merged candidates → 300-cap, local ms-marco cross-encoder): +# +# Stage 1 (rrf, weighted-RRF multiplier on the arm's 1/(k+rank) contribution). +# The observed 300-cap boundary RRF score was ~0.0055; a graph-only candidate +# falls below it past graph-rank ~120. The multipliers map to that boundary: +# low=1.0 doubles the arm's vote — rescues at-risk candidates from the cut +# (graph-rank 150: 0.0048 → 0.0095) without reshuffling much. +# medium=3.0 promotes them into the middle of the pool (~rank 60). +# high=6.0 makes the boosted arm dominate the top of the candidate pool. +# +# Stage 2 (additive, flat bump to the post-rerank weight in [0, 1]). The local +# cross-encoder is sharply bimodal: strong direct matches score 0.5–0.999, while +# everything else — including graph hits the CE undervalues, which is exactly +# what we boost — collapses near 0. So the additive lifts a ~0 candidate up the +# weight scale. Levels are calibrated as relevance thresholds it can outrank: +# low=0.05 nudges above the near-0 tail; loses to any real CE match. +# medium=0.2 competes with weak/moderate matches. +# high=0.5 wins over most semantic matches (honouring "prioritise graph over +# semantic"); only a strong direct match (>0.5 normalized) still wins. +# +# The keys are the user-facing contract; config.py validates env input against +# them (kept in sync by a guard test). +BOOST_LEVELS: dict[str, BoostWeights] = { + "low": BoostWeights(rrf=1.0, additive=0.05), + "medium": BoostWeights(rrf=3.0, additive=0.2), + "high": BoostWeights(rrf=6.0, additive=0.5), +} + + +def boosted_rrf_score(candidate: MergedCandidate, boosts: dict[str, str], k: int = 60) -> float: + """Return ``candidate``'s RRF score plus a weighted-RRF boost delta. + + For each boosted arm the candidate appeared in, adds ``level.rrf * 1/(k+rank)`` + — i.e. scales that arm's RRF contribution by the level's multiplier. Staying + in RRF units keeps the boost comparable to the base score and rank-aware. + + Args: + candidate: Merged candidate carrying ``rrf_score`` and ``source_ranks``. + boosts: Map of strategy name -> priority level. Empty means no boost. + k: RRF constant; must match the value used during fusion. + + Returns: + The (possibly) boosted score to sort by. Equal to ``rrf_score`` when no + boosted arm surfaced this candidate. + """ + if not boosts: + return candidate.rrf_score + delta = 0.0 + for strategy, level in boosts.items(): + rank = candidate.source_ranks.get(f"{strategy}_rank") + if rank is not None: + delta += BOOST_LEVELS[level].rrf * (1.0 / (k + rank)) + return candidate.rrf_score + delta + + +def additive_strategy_boost(source_ranks: dict[str, int], boosts: dict[str, str]) -> float: + """Return the flat additive boost for a candidate given its source ranks. + + Sums the ``additive`` magnitude of every boosted arm that surfaced the + candidate. Flat by design: the bump does not depend on the candidate's rank + within the arm, matching the post-rerank "additive boost" semantics. + + Args: + source_ranks: ``{"graph_rank": 3, "semantic_rank": 50, ...}`` from RRF. + boosts: Map of strategy name -> priority level. Empty means no boost. + + Returns: + The additive boost (0.0 when no boosted arm surfaced this candidate). + """ + if not boosts: + return 0.0 + return sum(BOOST_LEVELS[level].additive for strategy, level in boosts.items() if f"{strategy}_rank" in source_ranks) diff --git a/hindsight-api-slim/tests/test_recall_boost.py b/hindsight-api-slim/tests/test_recall_boost.py new file mode 100644 index 0000000000..6e585c8007 --- /dev/null +++ b/hindsight-api-slim/tests/test_recall_boost.py @@ -0,0 +1,126 @@ +"""Tests for per-strategy recall boosting (config parsing + boost math).""" + +import pytest + +from hindsight_api.config import RECALL_BOOST_LEVELS, _parse_strategy_boosts +from hindsight_api.engine.search.recall_boost import ( + BOOST_LEVELS, + additive_strategy_boost, + boosted_rrf_score, +) +from hindsight_api.engine.search.types import MergedCandidate, RetrievalResult + + +def _candidate(rrf_score: float, source_ranks: dict[str, int]) -> MergedCandidate: + retrieval = RetrievalResult(id="x", text="t", fact_type="world") + return MergedCandidate(retrieval=retrieval, rrf_score=rrf_score, source_ranks=source_ranks) + + +# --- level table integrity ---------------------------------------------------- + + +def test_config_levels_match_boost_table(): + """The user-facing level names in config must match the weights table keys.""" + assert set(RECALL_BOOST_LEVELS) == set(BOOST_LEVELS) + + +def test_levels_are_monotonic(): + """Higher levels must boost more in both stages, or the names lie.""" + low, medium, high = (BOOST_LEVELS[lvl] for lvl in ("low", "medium", "high")) + assert low.rrf < medium.rrf < high.rrf + assert low.additive < medium.additive < high.additive + + +# --- _parse_strategy_boosts --------------------------------------------------- + + +def test_parse_empty_is_noop(): + assert _parse_strategy_boosts("") == {} + assert _parse_strategy_boosts(None) == {} + assert _parse_strategy_boosts(" ") == {} + + +def test_parse_single_and_multiple(): + assert _parse_strategy_boosts("graph:high") == {"graph": "high"} + assert _parse_strategy_boosts("graph:high,semantic:low") == {"graph": "high", "semantic": "low"} + + +def test_parse_is_case_insensitive_and_strips_whitespace(): + assert _parse_strategy_boosts(" GRAPH : HIGH , BM25:Low ") == {"graph": "high", "bm25": "low"} + + +def test_parse_skips_unknown_strategy(): + assert _parse_strategy_boosts("graphh:high,graph:low") == {"graph": "low"} + + +def test_parse_skips_unknown_level(): + # A raw number (the old format) is now an invalid level and skipped. + assert _parse_strategy_boosts("graph:0.1,semantic:medium") == {"semantic": "medium"} + assert _parse_strategy_boosts("graph:huge") == {} + + +def test_parse_bare_strategy_defaults_to_medium(): + # A strategy with no level (or a trailing colon) defaults to medium. + assert _parse_strategy_boosts("graph") == {"graph": "medium"} + assert _parse_strategy_boosts("graph:") == {"graph": "medium"} + assert _parse_strategy_boosts("graph,semantic:high") == {"graph": "medium", "semantic": "high"} + + +def test_parse_skips_empty_name(): + assert _parse_strategy_boosts(":high") == {} + + +# --- boosted_rrf_score (pre-rerank, rank-aware) ------------------------------- + + +def test_boosted_rrf_noop_when_no_boosts(): + cand = _candidate(0.5, {"graph_rank": 1}) + assert boosted_rrf_score(cand, {}) == 0.5 + + +def test_boosted_rrf_adds_weighted_contribution(): + cand = _candidate(0.5, {"graph_rank": 1}) + expected = 0.5 + BOOST_LEVELS["high"].rrf * (1.0 / 61) + assert boosted_rrf_score(cand, {"graph": "high"}, k=60) == expected + + +def test_boosted_rrf_higher_level_boosts_more(): + cand = _candidate(0.5, {"graph_rank": 5}) + low = boosted_rrf_score(cand, {"graph": "low"}) + high = boosted_rrf_score(cand, {"graph": "high"}) + assert high > low > 0.5 + + +def test_boosted_rrf_is_rank_aware(): + """A better rank in the boosted arm yields a larger boost.""" + top = _candidate(0.5, {"graph_rank": 1}) + deep = _candidate(0.5, {"graph_rank": 200}) + assert boosted_rrf_score(top, {"graph": "high"}) > boosted_rrf_score(deep, {"graph": "high"}) + + +def test_boosted_rrf_ignores_non_matching_arm(): + # Candidate only came from semantic; a graph boost must not touch it. + cand = _candidate(0.5, {"semantic_rank": 3}) + assert boosted_rrf_score(cand, {"graph": "high"}) == 0.5 + + +# --- additive_strategy_boost (post-rerank, flat) ------------------------------ + + +def test_additive_noop_when_no_boosts(): + assert additive_strategy_boost({"graph_rank": 1}, {}) == 0.0 + + +def test_additive_is_flat_regardless_of_rank(): + assert additive_strategy_boost({"graph_rank": 1}, {"graph": "high"}) == BOOST_LEVELS["high"].additive + assert additive_strategy_boost({"graph_rank": 999}, {"graph": "high"}) == BOOST_LEVELS["high"].additive + + +def test_additive_sums_matched_arms(): + ranks = {"graph_rank": 2, "semantic_rank": 5} + expected = BOOST_LEVELS["high"].additive + BOOST_LEVELS["low"].additive + assert additive_strategy_boost(ranks, {"graph": "high", "semantic": "low"}) == pytest.approx(expected) + + +def test_additive_ignores_unmatched_arm(): + assert additive_strategy_boost({"semantic_rank": 1}, {"graph": "high"}) == 0.0 diff --git a/hindsight-docs/docs/developer/configuration.md b/hindsight-docs/docs/developer/configuration.md index fe95fccb63..a434702aff 100644 --- a/hindsight-docs/docs/developer/configuration.md +++ b/hindsight-docs/docs/developer/configuration.md @@ -844,6 +844,7 @@ For advanced authentication (JWT, OAuth, multi-tenant schemas), implement a cust | `HINDSIGHT_API_RERANKER_MAX_CANDIDATES` | Max candidates to rerank per recall (RRF pre-filters the rest) | `300` | | `HINDSIGHT_API_BM25_MIN_SCORE` | Minimum BM25 score a row must exceed to enter fusion. Gates out zero-score, non-matching rows on backends (notably `vchord`) whose operator ranks every document instead of pre-filtering to query-term matches. `0` keeps only genuine term matches; raise it to require stronger matches. | `0` | | `HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_SOURCE` | Cap on candidates each retrieval source (semantic, BM25, graph, temporal) contributes to RRF, applied before the global reranker cap. Prevents one over-expanding backend from filling the reranker budget on its own. `0` disables the cap. | `0` | +| `HINDSIGHT_API_RECALL_STRATEGY_BOOSTS` | Prioritise one or more retrieval sources over the others on recall, as a comma-separated `strategy:level` list (e.g. `graph:high` to strongly favour graph hits, or `graph:high,bm25:low`). Strategies: `semantic`, `bm25`, `graph`, `temporal`. Levels: `low` (gentle — mainly protects the source's candidates from being dropped before reranking), `medium` (moderate preference), `high` (strong — the source dominates the candidate pool and outranks most other matches, only a strong direct match still wins). The boost is applied in two places: before the reranker cap (so favoured candidates survive the `HINDSIGHT_API_RERANKER_MAX_CANDIDATES` budget) and after reranking (to nudge them up the final order); a named level is used because those two stages live on different score scales. Only the strategies you list are boosted — any you omit keep their normal weight (no implicit boost). A strategy written without a level (`graph` or `graph:`) defaults to `medium`. Empty disables the feature. | _(empty)_ | | `HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY` | Max concurrent mental model refreshes | `8` | | `HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY` | Track history of content changes to each mental model (previous content + timestamp). Disable to reduce storage if audit trails are not needed. | `true` | | `HINDSIGHT_API_MENTAL_MODEL_HISTORY_MAX_ENTRIES` | Max entries retained in the per-mental-model history jsonb array. Older entries are dropped at write time. Prevents the array from crossing Postgres's hard 256MB jsonb size limit (which would otherwise make further UPDATEs to the row fail with SQLSTATE 54000). Each entry stores only the slim `{based_on}` slice of the prior `reflect_response` (the only field consumed by the control-plane UI's history view) so per-row size stays bounded and HOT updates apply. | `50` | diff --git a/skills/hindsight-docs/references/developer/configuration.md b/skills/hindsight-docs/references/developer/configuration.md index 9734df3590..9c75c6dc67 100644 --- a/skills/hindsight-docs/references/developer/configuration.md +++ b/skills/hindsight-docs/references/developer/configuration.md @@ -844,6 +844,7 @@ For advanced authentication (JWT, OAuth, multi-tenant schemas), implement a cust | `HINDSIGHT_API_RERANKER_MAX_CANDIDATES` | Max candidates to rerank per recall (RRF pre-filters the rest) | `300` | | `HINDSIGHT_API_BM25_MIN_SCORE` | Minimum BM25 score a row must exceed to enter fusion. Gates out zero-score, non-matching rows on backends (notably `vchord`) whose operator ranks every document instead of pre-filtering to query-term matches. `0` keeps only genuine term matches; raise it to require stronger matches. | `0` | | `HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_SOURCE` | Cap on candidates each retrieval source (semantic, BM25, graph, temporal) contributes to RRF, applied before the global reranker cap. Prevents one over-expanding backend from filling the reranker budget on its own. `0` disables the cap. | `0` | +| `HINDSIGHT_API_RECALL_STRATEGY_BOOSTS` | Prioritise one or more retrieval sources over the others on recall, as a comma-separated `strategy:level` list (e.g. `graph:high` to strongly favour graph hits, or `graph:high,bm25:low`). Strategies: `semantic`, `bm25`, `graph`, `temporal`. Levels: `low` (gentle — mainly protects the source's candidates from being dropped before reranking), `medium` (moderate preference), `high` (strong — the source dominates the candidate pool and outranks most other matches, only a strong direct match still wins). The boost is applied in two places: before the reranker cap (so favoured candidates survive the `HINDSIGHT_API_RERANKER_MAX_CANDIDATES` budget) and after reranking (to nudge them up the final order); a named level is used because those two stages live on different score scales. Only the strategies you list are boosted — any you omit keep their normal weight (no implicit boost). A strategy written without a level (`graph` or `graph:`) defaults to `medium`. Empty disables the feature. | _(empty)_ | | `HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY` | Max concurrent mental model refreshes | `8` | | `HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY` | Track history of content changes to each mental model (previous content + timestamp). Disable to reduce storage if audit trails are not needed. | `true` | | `HINDSIGHT_API_MENTAL_MODEL_HISTORY_MAX_ENTRIES` | Max entries retained in the per-mental-model history jsonb array. Older entries are dropped at write time. Prevents the array from crossing Postgres's hard 256MB jsonb size limit (which would otherwise make further UPDATEs to the row fail with SQLSTATE 54000). Each entry stores only the slim `{based_on}` slice of the prior `reflect_response` (the only field consumed by the control-plane UI's history view) so per-row size stays bounded and HOT updates apply. | `50` |