Skip to content

Commit 8a1f322

Browse files
committed
fix(consolidation): interleave fusion for dedup recall so the twin is always shown
The residual near-duplicate observations were recall misses, not LLM errors: the existing near-identical observation (the merge 'twin') is semantic rank #1 for the new fact, but shares no source-fact graph link and little lexical overlap, so RRF averaged it below the per-fact recall budget (512 tokens, ~8 observations) and the LLM never saw it -> created a duplicate. The cross-encoder demoted it the same way (semantic #1 -> reranked #37). Add round-robin interleave fusion: take each retrieval arm's #1, then each #2, ... (semantic, bm25, graph, temporal), de-duplicating, until the budget fills. This guarantees every arm's top hit a slot, so the semantic-#1 twin is always shown and the LLM UPDATEs instead of creating a duplicate. Replace the recall 'rerank: bool' / ad-hoc passthrough with a single named 'reranking' strategy: 'cross_encoder' (default), 'rrf', 'interleave'. Consolidation dedup recall uses 'interleave'. For interleave the fusion order is authoritative — combined scoring's recency/temporal re-sort is skipped (that re-sort is what buried the twin). Measured on an English translation of the hermes transcript (removes the cross-lingual embedding confound): near-dup observations 4% (@0.97) -> 0% at both 1/10 and 1/4 cuts, coverage 89% -> 94%, no false merges (heavy observations are coherent single themes; distinct facets stay separate).
1 parent 11d2d5c commit 8a1f322

4 files changed

Lines changed: 170 additions & 28 deletions

File tree

hindsight-api-slim/hindsight_api/engine/consolidation/consolidator.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1501,12 +1501,13 @@ async def _find_related_observations(
15011501
include_source_facts=True, # Embed source facts so we avoid a separate DB fetch
15021502
max_source_facts_tokens=config.consolidation_source_facts_max_tokens,
15031503
max_source_facts_tokens_per_observation=config.consolidation_source_facts_max_tokens_per_observation,
1504-
# Rank by RRF, skipping the cross-encoder reranker: for consolidation we are
1505-
# looking for an existing near-identical observation to merge into, and the
1506-
# cross-encoder was observed to demote that twin far below the budget cutoff
1507-
# (semantic rank #1 -> reranked #37). RRF keeps strong lexical/semantic
1508-
# matches near the top so the LLM is shown the twin and UPDATEs it.
1509-
rerank=False,
1504+
# Round-robin interleave fusion (no cross-encoder): consolidation is looking
1505+
# for an existing near-identical observation to merge into. Both the
1506+
# cross-encoder (semantic #1 -> reranked #37) and RRF (semantic #1 -> outside
1507+
# the 512-token budget) were measured to bury that twin; interleave guarantees
1508+
# each retrieval arm's top hits a slot, so the semantic-#1 twin is always shown
1509+
# to the LLM, which then UPDATEs instead of creating a duplicate.
1510+
reranking="interleave",
15101511
_quiet=True, # Suppress logging
15111512
)
15121513
finally:

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

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,15 @@ def validate_sql_schema(sql: str) -> None:
255255
from .search.tags import TagGroup, TagsMatch, build_tag_groups_where_clause, build_tags_where_clause
256256
from .search.types import ScoredResult
257257
from .task_backend import TaskBackend
258+
259+
# Recall ranking strategy: how the per-arm (semantic/bm25/graph/temporal) results are
260+
# fused and reranked into the final order.
261+
# "cross_encoder" — RRF fusion + cross-encoder rerank (default, user-facing recall).
262+
# "rrf" — RRF fusion, no cross-encoder (RRF score is the order).
263+
# "interleave" — round-robin interleave fusion, no cross-encoder. Guarantees each
264+
# arm's top hits a slot (used by consolidation dedup recall, where RRF
265+
# buried the near-identical twin below budget). See interleave_fusion.
266+
RecallReranking = Literal["cross_encoder", "rrf", "interleave"]
258267
from .token_encoding import get_token_encoding
259268

260269
RetainOutboxCallback = Callable[[asyncpg.Connection], Awaitable[None]]
@@ -3186,7 +3195,7 @@ async def recall_async(
31863195
created_before: datetime | None = None,
31873196
_connection_budget: int | None = None,
31883197
_quiet: bool = False,
3189-
rerank: bool = True,
3198+
reranking: RecallReranking = "cross_encoder",
31903199
) -> RecallResultModel:
31913200
"""
31923201
Recall memories using N*4-way parallel retrieval (N fact types × 4 retrieval methods).
@@ -3343,7 +3352,7 @@ async def recall_async(
33433352
include_source_facts=include_source_facts,
33443353
max_source_facts_tokens=max_source_facts_tokens,
33453354
max_source_facts_tokens_per_observation=max_source_facts_tokens_per_observation,
3346-
rerank=rerank,
3355+
reranking=reranking,
33473356
)
33483357
break # Success - exit retry loop
33493358
except Exception as e:
@@ -3475,7 +3484,7 @@ async def _search_with_retries(
34753484
include_source_facts: bool = False,
34763485
max_source_facts_tokens: int = 4096,
34773486
max_source_facts_tokens_per_observation: int = -1,
3478-
rerank: bool = True,
3487+
reranking: RecallReranking = "cross_encoder",
34793488
) -> RecallResultModel:
34803489
"""
34813490
Search implementation with modular retrieval and reranking.
@@ -3792,9 +3801,13 @@ def to_tuple_format(results):
37923801
if _dur > 0:
37933802
tracer.add_phase_metric(f"retrieval_{_method}", _dur)
37943803

3795-
# Step 3: Merge with RRF
3804+
# Step 3: Merge ranked lists. RRF by default; interleave (round-robin) when
3805+
# requested by consolidation dedup recall — RRF averages a strong-in-one-arm
3806+
# result down and buried the near-identical "twin" observation below budget
3807+
# (semantic #1 -> outside the shown set), whereas interleave guarantees each
3808+
# arm's top hits a slot. See interleave_fusion docstring.
37963809
step_start = time.time()
3797-
from .search.fusion import reciprocal_rank_fusion
3810+
from .search.fusion import interleave_fusion, reciprocal_rank_fusion
37983811

37993812
fusion_span = tracer_otel.start_span("hindsight.recall_fusion")
38003813
fusion_span.set_attribute("hindsight.bank_id", bank_id)
@@ -3805,16 +3818,16 @@ def to_tuple_format(results):
38053818

38063819
try:
38073820
# Merge 3 or 4 result lists depending on temporal constraint
3821+
result_lists = [semantic_results, bm25_results, graph_results]
38083822
if temporal_results:
3809-
merged_candidates = reciprocal_rank_fusion(
3810-
[semantic_results, bm25_results, graph_results, temporal_results]
3811-
)
3812-
else:
3813-
merged_candidates = reciprocal_rank_fusion([semantic_results, bm25_results, graph_results])
3823+
result_lists.append(temporal_results)
3824+
fuse = interleave_fusion if reranking == "interleave" else reciprocal_rank_fusion
3825+
merged_candidates = fuse(result_lists)
38143826

38153827
step_duration = time.time() - step_start
38163828
log_buffer.append(
3817-
f" [3] RRF merge: {len(merged_candidates)} unique candidates in {step_duration:.3f}s"
3829+
f" [3] {'interleave' if reranking == 'interleave' else 'RRF'} merge: "
3830+
f"{len(merged_candidates)} unique candidates in {step_duration:.3f}s"
38183831
)
38193832
finally:
38203833
fusion_span.set_attribute("hindsight.merged_count", len(merged_candidates))
@@ -3849,18 +3862,17 @@ def to_tuple_format(results):
38493862
pre_filtered_count = len(merged_candidates) - reranker_max_candidates
38503863
merged_candidates = merged_candidates[:reranker_max_candidates]
38513864

3852-
if rerank:
3865+
if reranking == "cross_encoder":
38533866
# Ensure reranker is initialized (for lazy initialization mode)
38543867
await reranker_instance.ensure_initialized()
38553868
scored_results = await reranker_instance.rerank(query, merged_candidates)
38563869
else:
3857-
# RRF-only: skip the cross-encoder and rank by RRF score. Used by
3858-
# consolidation recall, where the cross-encoder was observed to demote
3859-
# a near-identical existing observation (the dedup "twin") far below the
3860-
# budget cutoff (semantic rank #1 -> reranked #37), causing the LLM to
3861-
# never see it and create a duplicate. Passthrough ScoredResults keep the
3862-
# downstream recency/temporal boosts working, seeded from RRF rank.
3863-
rerank_kind = "rrf-passthrough"
3870+
# "rrf" / "interleave": skip the cross-encoder and keep the fusion order
3871+
# (rrf_score is descending by fusion position for both). The cross-encoder
3872+
# was observed to demote a near-identical existing observation (the dedup
3873+
# "twin") far below the budget cutoff (semantic rank #1 -> reranked #37),
3874+
# causing the LLM to never see it and create a duplicate.
3875+
rerank_kind = f"{reranking}-passthrough"
38643876
scored_results = [
38653877
ScoredResult(
38663878
candidate=mc,
@@ -3888,10 +3900,18 @@ def to_tuple_format(results):
38883900
# is_passthrough_reranker tells the scoring code to seed CE scores
38893901
# from RRF rank — only meaningful when the configured reranker is
38903902
# the slim/passthrough one that returns a constant score per pair.
3891-
if scored_results:
3903+
if scored_results and reranking == "interleave":
3904+
# Interleave order is authoritative for dedup recall: do NOT re-sort by the
3905+
# recency/temporal boosts — that re-sort is precisely what buried the twin
3906+
# under RRF. Seed weight from the interleave-position rrf_score so the order
3907+
# survives Step 5 truncation and the Step 6 token-budget cut.
3908+
for sr in scored_results:
3909+
sr.weight = sr.candidate.rrf_score
3910+
log_buffer.append(" [4.6] Interleave order preserved (combined scoring skipped)")
3911+
elif scored_results:
38923912
ce = reranker_instance.cross_encoder
3893-
# RRF-only mode is passthrough by construction; so is a configured "rrf" CE.
3894-
is_passthrough = (not rerank) or (ce is not None and ce.provider_name == "rrf")
3913+
# "rrf" mode is passthrough by construction; so is a configured "rrf" CE.
3914+
is_passthrough = (reranking == "rrf") or (ce is not None and ce.provider_name == "rrf")
38953915
apply_combined_scoring(
38963916
scored_results,
38973917
now=_recall_scoring_now(question_date),

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,66 @@ def reciprocal_rank_fusion(result_lists: list[list[RetrievalResult]], k: int = 6
7777
return merged_results
7878

7979

80+
def interleave_fusion(result_lists: list[list[RetrievalResult]]) -> list[MergedCandidate]:
81+
"""Round-robin (interleaved) fusion — an alternative to RRF for dedup-style recall.
82+
83+
RRF scores a doc by the *sum* of its reciprocal ranks across arms, so a result
84+
that is #1 in one arm but absent/low in the others gets averaged down. That is
85+
exactly the consolidation-dedup failure mode: the near-identical existing
86+
observation (the "twin" to merge into) is semantic rank #1, yet shares no
87+
source-fact graph link and little lexical overlap, so RRF drops it below the
88+
recall budget cutoff and the LLM never sees it → creates a duplicate.
89+
90+
Interleave instead *guarantees every arm's top hits a slot*: take each arm's
91+
#1, then each arm's #2, … in arm-priority order, de-duplicating, until all
92+
results are placed. The arm priority is the order of ``result_lists``
93+
(semantic, bm25, graph, temporal), so semantic #1 is always first.
94+
95+
``rrf_score`` is assigned strictly decreasing by final interleave position so
96+
downstream order-by-score sorts preserve the interleave order; ``source_ranks``
97+
mirrors the RRF bookkeeping (each doc's rank within every arm it appears in).
98+
"""
99+
source_names = ["semantic", "bm25", "graph", "temporal"]
100+
source_ranks: dict[str, dict[str, int]] = {}
101+
all_retrievals: dict[str, RetrievalResult] = {}
102+
103+
for source_idx, results in enumerate(result_lists):
104+
source_name = source_names[source_idx] if source_idx < len(source_names) else f"source_{source_idx}"
105+
for rank, retrieval in enumerate(results, start=1):
106+
if not isinstance(retrieval, RetrievalResult):
107+
raise TypeError(
108+
f"Expected RetrievalResult but got {type(retrieval).__name__} in {source_name} results at rank {rank}"
109+
)
110+
doc_id = retrieval.id
111+
all_retrievals.setdefault(doc_id, retrieval)
112+
source_ranks.setdefault(doc_id, {})[f"{source_name}_rank"] = rank
113+
114+
# Round-robin pick across arms in priority order: all #1s, then all #2s, ...
115+
ordered_ids: list[str] = []
116+
seen: set[str] = set()
117+
max_len = max((len(r) for r in result_lists), default=0)
118+
for r in range(max_len):
119+
for results in result_lists:
120+
if r < len(results):
121+
doc_id = results[r].id
122+
if doc_id not in seen:
123+
seen.add(doc_id)
124+
ordered_ids.append(doc_id)
125+
126+
n = len(ordered_ids)
127+
return [
128+
MergedCandidate(
129+
retrieval=all_retrievals[doc_id],
130+
# Strictly decreasing by interleave position → sorting desc by rrf_score
131+
# reproduces the interleave order downstream.
132+
rrf_score=float(n - pos),
133+
rrf_rank=pos + 1,
134+
source_ranks=source_ranks[doc_id],
135+
)
136+
for pos, doc_id in enumerate(ordered_ids)
137+
]
138+
139+
80140
def normalize_scores_on_deltas(results: list[dict[str, Any]], score_keys: list[str]) -> list[dict[str, Any]]:
81141
"""
82142
Normalize scores based on deltas (min-max normalization within result set).
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Unit tests for round-robin interleave fusion (consolidation dedup recall)."""
2+
3+
from hindsight_api.engine.search.fusion import interleave_fusion
4+
from hindsight_api.engine.search.types import RetrievalResult
5+
6+
7+
def _r(doc_id: str) -> RetrievalResult:
8+
return RetrievalResult(id=doc_id, text=f"text-{doc_id}", fact_type="observation")
9+
10+
11+
def _ids(merged) -> list[str]:
12+
return [mc.id for mc in merged]
13+
14+
15+
def test_round_robin_order_takes_each_arm_in_turn():
16+
semantic = [_r("s1"), _r("s2"), _r("s3")]
17+
bm25 = [_r("b1"), _r("b2")]
18+
graph = [_r("g1")]
19+
20+
merged = interleave_fusion([semantic, bm25, graph])
21+
22+
# Round 0: s1, b1, g1 ; round 1: s2, b2 ; round 2: s3
23+
assert _ids(merged) == ["s1", "b1", "g1", "s2", "b2", "s3"]
24+
25+
26+
def test_semantic_top_hit_is_always_first():
27+
# The dedup twin: semantic #1 but absent from every other arm. Must still lead.
28+
semantic = [_r("twin"), _r("s2")]
29+
bm25 = [_r("b1"), _r("b2"), _r("b3")]
30+
graph = [_r("g1")]
31+
32+
merged = interleave_fusion([semantic, bm25, graph])
33+
34+
assert merged[0].id == "twin"
35+
36+
37+
def test_dedup_keeps_first_occurrence_and_records_all_arm_ranks():
38+
# "x" is semantic #1 and bm25 #2; it should appear once, at its first (semantic) slot,
39+
# but carry ranks from every arm it appears in.
40+
semantic = [_r("x"), _r("s2")]
41+
bm25 = [_r("b1"), _r("x")]
42+
43+
merged = interleave_fusion([semantic, bm25])
44+
45+
assert _ids(merged) == ["x", "b1", "s2"]
46+
x = next(mc for mc in merged if mc.id == "x")
47+
assert x.source_ranks == {"semantic_rank": 1, "bm25_rank": 2}
48+
49+
50+
def test_rrf_score_strictly_decreasing_preserves_order_on_sort():
51+
merged = interleave_fusion([[_r("a"), _r("b")], [_r("c")]])
52+
scores = [mc.rrf_score for mc in merged]
53+
assert scores == sorted(scores, reverse=True)
54+
assert len(set(scores)) == len(scores) # strictly decreasing, no ties
55+
# rrf_rank reflects the interleave position
56+
assert [mc.rrf_rank for mc in merged] == [1, 2, 3]
57+
58+
59+
def test_empty_inputs():
60+
assert interleave_fusion([]) == []
61+
assert interleave_fusion([[], []]) == []

0 commit comments

Comments
 (0)