Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions hindsight-api-slim/hindsight_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
18 changes: 17 additions & 1 deletion hindsight-api-slim/hindsight_api/engine/memory_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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:
Expand Down
117 changes: 117 additions & 0 deletions hindsight-api-slim/hindsight_api/engine/search/recall_boost.py
Original file line number Diff line number Diff line change
@@ -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)
126 changes: 126 additions & 0 deletions hindsight-api-slim/tests/test_recall_boost.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions hindsight-docs/docs/developer/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
Loading
Loading