Skip to content

Commit 758f346

Browse files
authored
feat(recall): structured per-stage scores and two-level min_scores filtering (#2422)
Replace the recall result's single `score` with a `scores` object exposing the scores from each pipeline stage, and replace the `min_score` request param with `min_scores`, a per-stage filter that operates at two levels. Response — each result carries `scores`: - final : the value results are ranked by - reranker : cross-encoder normalized relevance (null for passthrough rerankers) - semantic : raw vector cosine similarity (null if not surfaced semantically) - text : raw keyword/BM25 score (null if not surfaced by keyword search) Per-arm semantic/text scores are aggregated across retrieval arms during RRF / interleave fusion (ArmScores on MergedCandidate), since fusion otherwise keeps only the first-seen arm's score per doc. Request — `min_scores` floors (inclusive, AND-ed, opt-in; default no filtering): - semantic / text : retrieval-level cutoffs pushed into the SQL arms, overriding the global similarity / BM25 minimums for the request (prune before fusion) - reranker / final: post-query filters on the scored results There is deliberately no default threshold: the cross-encoder's absolute scores are reliable for ordering but not calibrated across queries (a clearly-relevant match can score ~0.001 on one query and ~1.0 on another), so a fixed cutoff would silently drop good results. Also surfaces proof_norm in the search trace and reworks the control-plane trace view to render scores at full precision (no rounding) and show the per-stage `scores` breakdown; relabels the trace's "CE" column to "reranker score". Threaded through engine, HTTP, MCP (both recall tools), and the control-plane proxy; OpenAPI spec, Python/TS/Go/Rust clients, and the docs-skill mirror regenerated; docs updated.
1 parent 78d32cd commit 758f346

36 files changed

Lines changed: 1820 additions & 32 deletions

File tree

hindsight-api-slim/hindsight_api/api/http.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ def FieldWithDefault(default_factory: Callable, **kwargs) -> Any:
154154
VALID_RECALL_FACT_TYPES,
155155
DryRunExtractionResult,
156156
MemoryFact,
157+
MinScores,
158+
RecallScores,
157159
TokenUsage,
158160
)
159161
from hindsight_api.engine.search.tags import TagGroup, TagsMatch
@@ -312,6 +314,15 @@ class RecallRequest(BaseModel):
312314
description="Compound tag filter using boolean groups. Groups in the list are AND-ed. "
313315
"Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}.",
314316
)
317+
min_scores: MinScores | None = Field(
318+
default=None,
319+
description="Optional per-stage score floors (all inclusive, AND-ed). `semantic` and `keyword` are "
320+
"retrieval-level cutoffs pushed into the SQL arms (overriding the global similarity/BM25 minimums for "
321+
"this request); `reranker` and `final` are post-ranking filters on the scored results. Any field left "
322+
"unset imposes no floor; omitting `min_scores` entirely (the default) applies no score filtering. Use "
323+
"with care — the reranker's absolute scores are not calibrated across queries (a clearly-relevant match "
324+
"may score ~0.001 even though it is ranked first).",
325+
)
315326

316327
@field_validator("query")
317328
@classmethod
@@ -367,6 +378,7 @@ class RecallResult(BaseModel):
367378
source_fact_ids: list[str] | None = (
368379
None # IDs of source facts (observation type only, when source_facts is enabled)
369380
)
381+
scores: RecallScores | None = None # Per-stage recall scores (final/reranker/semantic/text)
370382

371383

372384
class EntityObservationResponse(BaseModel):
@@ -3897,6 +3909,7 @@ async def api_recall(
38973909
tags=request.tags,
38983910
tags_match=request.tags_match,
38993911
tag_groups=request.tag_groups,
3912+
min_scores=request.min_scores,
39003913
),
39013914
operation="recall",
39023915
bank_id=bank_id,
@@ -3918,6 +3931,7 @@ def _fact_to_result(fact: "MemoryFact") -> RecallResult:
39183931
chunk_id=fact.chunk_id,
39193932
tags=fact.tags,
39203933
source_fact_ids=fact.source_fact_ids,
3934+
scores=fact.scores,
39213935
)
39223936

39233937
recall_results = [_fact_to_result(fact) for fact in core_result.results]

hindsight-api-slim/hindsight_api/engine/memory_engine.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,8 @@ def validate_sql_schema(sql: str) -> None:
360360
EntityState,
361361
LLMCallTrace,
362362
MemoryFact,
363+
MinScores,
364+
RecallScores,
363365
ReflectResult,
364366
TokenUsage,
365367
ToolCallTrace,
@@ -3955,6 +3957,7 @@ async def recall_async(
39553957
tag_groups: list[TagGroup] | None = None,
39563958
created_after: datetime | None = None,
39573959
created_before: datetime | None = None,
3960+
min_scores: MinScores | None = None,
39583961
_connection_budget: int | None = None,
39593962
_quiet: bool = False,
39603963
reranking: RecallReranking = "cross_encoder",
@@ -4120,6 +4123,7 @@ async def recall_async(
41204123
tag_groups=tag_groups,
41214124
created_after=created_after,
41224125
created_before=created_before,
4126+
min_scores=min_scores,
41234127
connection_budget=_connection_budget,
41244128
quiet=_quiet,
41254129
include_source_facts=include_source_facts,
@@ -4257,6 +4261,7 @@ async def _search_with_retries(
42574261
tag_groups: list[TagGroup] | None = None,
42584262
created_after: datetime | None = None,
42594263
created_before: datetime | None = None,
4264+
min_scores: MinScores | None = None,
42604265
connection_budget: int | None = None,
42614266
quiet: bool = False,
42624267
include_source_facts: bool = False,
@@ -4393,6 +4398,8 @@ async def _search_with_retries(
43934398
tag_groups=tag_groups,
43944399
created_after=created_after,
43954400
created_before=created_before,
4401+
min_semantic=min_scores.semantic if min_scores else None,
4402+
min_keyword=min_scores.keyword if min_scores else None,
43964403
)
43974404
parallel_duration = time.time() - parallel_start
43984405
finally:
@@ -4759,6 +4766,30 @@ def to_tuple_format(results):
47594766
if strategy_boosts:
47604767
log_buffer.append(f" [4.7] Strategy boosts applied: {strategy_boosts}")
47614768

4769+
# Step 4.9: post-query min_scores filters (reranker + final). The
4770+
# semantic/text floors are applied earlier inside the SQL arms (see
4771+
# retrieve_semantic_bm25_combined); here we apply the post-rank floors on
4772+
# the scored results, after the final sort and before truncation, so every
4773+
# downstream step (prefer_observations dedup, truncation, token filtering)
4774+
# operates on the filtered set. Inclusive (>=), AND-ed, opt-in: a None
4775+
# threshold is a no-op. There is deliberately no default — the
4776+
# cross-encoder's absolute scores are not calibrated for a fixed cutoff
4777+
# (a clearly-relevant match can score ~0.001 while its *ranking* is right).
4778+
min_reranker = min_scores.reranker if min_scores else None
4779+
min_final = min_scores.final if min_scores else None
4780+
if (min_reranker is not None or min_final is not None) and scored_results:
4781+
before_min_score = len(scored_results)
4782+
scored_results = [
4783+
sr
4784+
for sr in scored_results
4785+
if (min_reranker is None or sr.cross_encoder_score_normalized >= min_reranker)
4786+
and (min_final is None or sr.weight >= min_final)
4787+
]
4788+
log_buffer.append(
4789+
f" [4.9] min_scores(reranker={min_reranker}, final={min_final}): "
4790+
f"{before_min_score}->{len(scored_results)} results"
4791+
)
4792+
47624793
# Add reranked results to tracer AFTER combined scoring (so normalized values are included)
47634794
if tracer:
47644795
results_dict = [sr.to_dict() for sr in scored_results]
@@ -5170,6 +5201,25 @@ def _make_source_fact(sid: str, r: Any) -> MemoryFact:
51705201
)
51715202

51725203
# Convert results to MemoryFact objects
5204+
# Build per-result scores (final/reranker/semantic/text) keyed by id.
5205+
# reranker is None when the configured reranker is a passthrough (rrf /
5206+
# interleave modes, or the RRFPassthroughCrossEncoder), since its
5207+
# cross_encoder_score_normalized is then a rank-derived placeholder, not a
5208+
# true relevance score.
5209+
ce_model = self._cross_encoder_reranker.cross_encoder
5210+
reranker_passthrough = (reranking != "cross_encoder") or (
5211+
ce_model is not None and getattr(ce_model, "provider_name", None) == "rrf"
5212+
)
5213+
scores_by_id: dict[str, RecallScores] = {
5214+
sr.id: RecallScores(
5215+
final=sr.weight,
5216+
reranker=None if reranker_passthrough else sr.cross_encoder_score_normalized,
5217+
semantic=sr.candidate.arm_scores.semantic,
5218+
keyword=sr.candidate.arm_scores.keyword,
5219+
)
5220+
for sr in top_scored
5221+
}
5222+
51735223
memory_facts = []
51745224
for result_dict in top_results_dicts:
51755225
result_id = str(result_dict.get("id"))
@@ -5193,6 +5243,7 @@ def _make_source_fact(sid: str, r: Any) -> MemoryFact:
51935243
chunk_id=result_dict.get("chunk_id"),
51945244
tags=result_dict.get("tags"),
51955245
source_fact_ids=source_fact_ids_by_obs.get(result_id) if include_source_facts else None,
5246+
scores=scores_by_id.get(result_id),
51965247
)
51975248
)
51985249

hindsight-api-slim/hindsight_api/engine/response_models.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,47 @@ class DispositionTraits(BaseModel):
172172
model_config = ConfigDict(json_schema_extra={"example": {"skepticism": 3, "literalism": 3, "empathy": 3}})
173173

174174

175+
class RecallScores(BaseModel):
176+
"""Per-result recall scores from different stages of the pipeline.
177+
178+
``final`` is the value results are ranked by. The others are diagnostic and
179+
can be filtered on via the recall ``min_scores`` request parameter. ``semantic``
180+
and ``keyword`` are the raw per-strategy retrieval scores (``None`` when that
181+
strategy did not surface this result); ``reranker`` is the cross-encoder's
182+
normalized relevance.
183+
"""
184+
185+
final: float = Field(description="Final ranking score (combined reranker + recency/temporal/proof boosts)")
186+
reranker: float | None = Field(
187+
default=None,
188+
description="Cross-encoder relevance, normalized 0-1. None when the reranker is a passthrough (rrf/interleave modes).",
189+
)
190+
semantic: float | None = Field(
191+
default=None, description="Vector cosine similarity (0-1). None if this result was not surfaced semantically."
192+
)
193+
keyword: float | None = Field(
194+
default=None,
195+
description="Keyword/full-text (BM25) score (>= 0, unbounded). None if this result was not surfaced by keyword search.",
196+
)
197+
198+
199+
class MinScores(BaseModel):
200+
"""Optional per-stage score floors for recall (all inclusive, AND-ed).
201+
202+
``semantic`` and ``keyword`` are **retrieval-level** cutoffs pushed into the SQL
203+
arms (overriding the global ``semantic_min_similarity`` / ``bm25_min_score``
204+
config for this request), so they prune weak matches before fusion. ``reranker``
205+
and ``final`` are **post-query** filters applied to the scored results after
206+
reranking. Any field left None imposes no floor; all-None (the default) means
207+
no score filtering.
208+
"""
209+
210+
semantic: float | None = Field(default=None, description="Retrieval-level: minimum vector similarity (0-1).")
211+
keyword: float | None = Field(default=None, description="Retrieval-level: minimum keyword/full-text (BM25) score.")
212+
reranker: float | None = Field(default=None, description="Post-query: minimum normalized reranker score (0-1).")
213+
final: float | None = Field(default=None, description="Post-query: minimum final ranking score.")
214+
215+
175216
class MemoryFact(BaseModel):
176217
"""
177218
A single memory fact returned by search or think operations.
@@ -231,6 +272,10 @@ def parse_metadata(cls, v: Any) -> dict[str, str] | None:
231272
None,
232273
description="IDs of source facts this observation was derived from (observation type only, when source_facts is enabled)",
233274
)
275+
scores: RecallScores | None = Field(
276+
None,
277+
description="Recall scores from each pipeline stage (final/reranker/semantic/text). Not returned for source facts.",
278+
)
234279

235280

236281
class ChunkInfo(BaseModel):

hindsight-api-slim/hindsight_api/engine/search/fusion.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Helper functions for hybrid search (semantic + BM25 + graph).
33
"""
44

5-
from .types import MergedCandidate, RetrievalResult
5+
from .types import ArmScores, MergedCandidate, RetrievalResult
66

77

88
def cap_per_source(results: list[RetrievalResult], cap: int) -> list[RetrievalResult]:
@@ -51,6 +51,7 @@ def reciprocal_rank_fusion(result_lists: list[list[RetrievalResult]], k: int = 6
5151
rrf_scores = {}
5252
source_ranks = {} # Track rank from each source for each doc_id
5353
all_retrievals = {} # Store the actual RetrievalResult (use first occurrence)
54+
arm_scores: dict[str, ArmScores] = {} # doc_id -> raw per-strategy scores across arms
5455

5556
source_names = ["semantic", "bm25", "graph", "temporal"]
5657

@@ -79,17 +80,29 @@ def reciprocal_rank_fusion(result_lists: list[list[RetrievalResult]], k: int = 6
7980
if doc_id not in rrf_scores:
8081
rrf_scores[doc_id] = 0.0
8182
source_ranks[doc_id] = {}
83+
arm_scores[doc_id] = ArmScores()
8284

8385
rrf_scores[doc_id] += 1.0 / (k + rank)
8486
source_ranks[doc_id][f"{source_name}_rank"] = rank
8587

88+
# Capture this arm's raw score for the doc (the merged RetrievalResult
89+
# below keeps only the first arm's score, so record each arm here).
90+
if source_name == "semantic" and retrieval.similarity is not None:
91+
arm_scores[doc_id].semantic = retrieval.similarity
92+
elif source_name == "bm25" and retrieval.bm25_score is not None:
93+
arm_scores[doc_id].keyword = retrieval.bm25_score
94+
8695
# Combine into final results with metadata
8796
merged_results = []
8897
for rrf_rank, (doc_id, rrf_score) in enumerate(
8998
sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True), start=1
9099
):
91100
merged_candidate = MergedCandidate(
92-
retrieval=all_retrievals[doc_id], rrf_score=rrf_score, rrf_rank=rrf_rank, source_ranks=source_ranks[doc_id]
101+
retrieval=all_retrievals[doc_id],
102+
rrf_score=rrf_score,
103+
rrf_rank=rrf_rank,
104+
source_ranks=source_ranks[doc_id],
105+
arm_scores=arm_scores[doc_id],
93106
)
94107
merged_results.append(merged_candidate)
95108

@@ -118,6 +131,7 @@ def interleave_fusion(result_lists: list[list[RetrievalResult]]) -> list[MergedC
118131
source_names = ["semantic", "bm25", "graph", "temporal"]
119132
source_ranks: dict[str, dict[str, int]] = {}
120133
all_retrievals: dict[str, RetrievalResult] = {}
134+
arm_scores: dict[str, ArmScores] = {}
121135

122136
for source_idx, results in enumerate(result_lists):
123137
source_name = source_names[source_idx] if source_idx < len(source_names) else f"source_{source_idx}"
@@ -129,6 +143,11 @@ def interleave_fusion(result_lists: list[list[RetrievalResult]]) -> list[MergedC
129143
doc_id = retrieval.id
130144
all_retrievals.setdefault(doc_id, retrieval)
131145
source_ranks.setdefault(doc_id, {})[f"{source_name}_rank"] = rank
146+
arm = arm_scores.setdefault(doc_id, ArmScores())
147+
if source_name == "semantic" and retrieval.similarity is not None:
148+
arm.semantic = retrieval.similarity
149+
elif source_name == "bm25" and retrieval.bm25_score is not None:
150+
arm.keyword = retrieval.bm25_score
132151

133152
# Round-robin pick across arms in priority order: all #1s, then all #2s, ...
134153
ordered_ids: list[str] = []
@@ -151,6 +170,7 @@ def interleave_fusion(result_lists: list[list[RetrievalResult]]) -> list[MergedC
151170
rrf_score=float(n - pos),
152171
rrf_rank=pos + 1,
153172
source_ranks=source_ranks[doc_id],
173+
arm_scores=arm_scores[doc_id],
154174
)
155175
for pos, doc_id in enumerate(ordered_ids)
156176
]

hindsight-api-slim/hindsight_api/engine/search/reranking.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,9 @@ def apply_combined_scoring(
177177
else:
178178
# Neutral baseline is precisely 0.5, ensuring neutral multiplier (1.0)
179179
proof_norm = 0.5
180+
# Surface the proof signal so the trace can show the proof_count_boost
181+
# factor (otherwise the reranked breakdown can't reconcile CE × boosts).
182+
sr.proof_norm = proof_norm
180183

181184
# RRF: kept at 0.0 for trace continuity but excluded from scoring.
182185
# RRF is batch-relative (min-max normalised) and redundant after reranking.

hindsight-api-slim/hindsight_api/engine/search/retrieval.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ async def retrieve_semantic_bm25_combined(
104104
tag_groups: list[TagGroup] | None = None,
105105
created_after: datetime | None = None,
106106
created_before: datetime | None = None,
107+
min_semantic: float | None = None,
108+
min_keyword: float | None = None,
107109
) -> dict[str, tuple[list[RetrievalResult], list[RetrievalResult]]]:
108110
"""
109111
Combined semantic + BM25 retrieval for multiple fact types in a single query.
@@ -143,6 +145,12 @@ async def retrieve_semantic_bm25_combined(
143145
config = get_config()
144146
tokens = tokenize_query(query_text)
145147

148+
# Per-request retrieval-level score floors (recall min_scores.semantic / .keyword)
149+
# override the global config defaults for this query, pruning weak matches in
150+
# the SQL arms before fusion.
151+
sem_min = min_semantic if min_semantic is not None else config.semantic_min_similarity
152+
bm25_min = min_keyword if min_keyword is not None else config.bm25_min_score
153+
146154
# Over-fetch for HNSW approximation; semantic results trimmed to limit in Python.
147155
hnsw_fetch = max(limit * 5, 100)
148156

@@ -203,7 +211,7 @@ async def retrieve_semantic_bm25_combined(
203211
embedding_param="$1",
204212
bank_id_param="$2",
205213
fetch_limit=hnsw_fetch,
206-
min_similarity=config.semantic_min_similarity,
214+
min_similarity=sem_min,
207215
tags_clause=tags_clause,
208216
groups_clause=groups_clause,
209217
extra_where=created_range_clause,
@@ -229,7 +237,7 @@ async def retrieve_semantic_bm25_combined(
229237
arm_index=i,
230238
text_search_extension=text_ext,
231239
bm25_language=config.text_search_extension_native_language,
232-
bm25_min_score=config.bm25_min_score,
240+
bm25_min_score=bm25_min,
233241
extra_where=created_range_clause,
234242
)
235243
)
@@ -277,7 +285,7 @@ async def retrieve_semantic_bm25_combined(
277285
embedding_param="$1",
278286
bank_id_param="$2",
279287
fetch_limit=hnsw_fetch,
280-
min_similarity=config.semantic_min_similarity,
288+
min_similarity=sem_min,
281289
tags_clause=fb_tags_clause,
282290
groups_clause=fb_groups_clause,
283291
extra_where=fb_created_clause,
@@ -706,6 +714,8 @@ async def retrieve_all_fact_types_parallel(
706714
tag_groups: list[TagGroup] | None = None,
707715
created_after: datetime | None = None,
708716
created_before: datetime | None = None,
717+
min_semantic: float | None = None,
718+
min_keyword: float | None = None,
709719
) -> MultiFactTypeRetrievalResult:
710720
"""
711721
Optimized retrieval for multiple fact types using batched queries.
@@ -766,6 +776,8 @@ async def retrieve_all_fact_types_parallel(
766776
tag_groups=tag_groups,
767777
created_after=created_after,
768778
created_before=created_before,
779+
min_semantic=min_semantic,
780+
min_keyword=min_keyword,
769781
)
770782
semantic_bm25_time = time.time() - semantic_bm25_start
771783

@@ -781,7 +793,7 @@ async def retrieve_all_fact_types_parallel(
781793
tc_start,
782794
tc_end,
783795
budget=thinking_budget,
784-
semantic_threshold=0.1,
796+
semantic_threshold=min_semantic if min_semantic is not None else 0.1,
785797
tags=tags,
786798
tags_match=tags_match,
787799
tag_groups=tag_groups,

hindsight-api-slim/hindsight_api/engine/search/tracer.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ def add_reranked(self, reranked_results: list[dict[str, Any]], rrf_merged: list)
392392

393393
# Extract score components (only include non-None values)
394394
# Keys from ScoredResult.to_dict(): cross_encoder_score, cross_encoder_score_normalized,
395-
# rrf_normalized, temporal, recency, combined_score, weight
395+
# rrf_normalized, temporal, recency, proof_norm, combined_score, weight
396396
score_components = {}
397397
for key in [
398398
"cross_encoder_score",
@@ -401,6 +401,7 @@ def add_reranked(self, reranked_results: list[dict[str, Any]], rrf_merged: list)
401401
"rrf_normalized",
402402
"temporal",
403403
"recency",
404+
"proof_norm",
404405
"combined_score",
405406
]:
406407
if key in result and result[key] is not None:

0 commit comments

Comments
 (0)