@@ -251,7 +251,30 @@ def _collect_strings(obj: object) -> List[str]:
251251 return out
252252
253253
254- # ── Hybrid retrieval (BM25 + semantic embeddings via RRF) ────────────
254+ # ── Hybrid retrieval (BM25 + semantic embeddings, weighted fusion) ───
255+
256+ # Minimum cosine for an embedding hit to count as a real semantic match.
257+ # Calibrated for nomic-embed-text-v1.5 — its cosine distribution sits
258+ # noticeably higher than older models (unrelated ~0.40-0.50, genuine
259+ # paraphrase matches ~0.65+). A floor at 0.50 separates the populations:
260+ # nonsense queries against unrelated entries fall under it (so gibberish
261+ # in == gibberish-free out, the "no real match clears stale priming"
262+ # semantic), while real paraphrase matches comfortably clear it. Re-tune
263+ # if the embedder swaps again — see test_search_returns_empty_for_unrelated_query
264+ # in tools/search for the empirical handle.
265+ EMBEDDING_NOISE_FLOOR = 0.50
266+
267+
268+ def _is_readme (path : Path ) -> bool :
269+ """True for folder-overview README files.
270+
271+ READMEs are navigation/overview text, not stored lessons. They're
272+ generic enough to act as "universal donors" in the embedding lane — a
273+ vague or proper-noun query matches their overview text more strongly
274+ than any specific entry — so they crowd real lessons out of priming.
275+ Excluded from retrieval candidates entirely (see ``_hybrid_search``).
276+ """
277+ return path .name .lower () == "readme.md"
255278
256279
257280def _bm25_search (
@@ -321,44 +344,67 @@ def _embedding_search(
321344 log .exception ("priming: embedding search raised" )
322345 return []
323346
324- # Filter out very weak semantic matches before they reach RRF.
325- # Calibrated for nomic-embed-text-v1.5 — its cosine distribution
326- # sits noticeably higher than older models (unrelated ~0.40-0.50,
327- # genuine paraphrase matches ~0.65+). A floor at 0.50 separates the
328- # populations: nonsense queries against unrelated entries fall under
329- # it (so gibberish in == gibberish-free out, the "no real match
330- # clears stale priming" semantic), while real paraphrase matches
331- # comfortably clear it. Re-tune if the embedder swaps again — see
332- # test_search_returns_empty_for_unrelated_query in tools/search for
333- # the empirical handle.
334- EMBEDDING_NOISE_FLOOR = 0.50
347+ # Filter out very weak semantic matches (see EMBEDDING_NOISE_FLOOR).
335348 return [(Path (h .path ), float (h .score )) for h in hits if float (h .score ) >= EMBEDDING_NOISE_FLOOR ]
336349
337350
338- def _rrf_merge (
339- rankings : List [List [Tuple [Path , float ]]],
351+ # Fusion weights. Lexical-leaning on purpose: BM25 carries rare-token /
352+ # proper-noun matches (high-IDF terms the embedder has no representation
353+ # for — e.g. a coined project name), while the embedding lane adds
354+ # paraphrase recall. 0.6 / 0.4 favours lexical without silencing
355+ # semantics. Rationale + measurements: a contentless or proper-noun query
356+ # leaves the embedding lane near-flat (cosines clustered in a narrow band,
357+ # no discrimination), so a rank-only fusion let that flat lane out-vote a
358+ # strong, specific BM25 hit. Weighting by normalised magnitude lets the
359+ # confident lane win.
360+ _FUSION_W_BM25 = 0.6
361+ _FUSION_W_EMB = 0.4
362+
363+
364+ def _weighted_merge (
365+ bm25_hits : List [Tuple [Path , float ]],
366+ emb_hits : List [Tuple [Path , float ]],
340367 * ,
341368 k_top : int ,
342- rrf_k : int = 60 ,
369+ w_bm25 : float = _FUSION_W_BM25 ,
370+ w_emb : float = _FUSION_W_EMB ,
343371) -> List [Tuple [Path , float ]]:
344- """Reciprocal Rank Fusion across multiple ranked lists.
345-
346- For each path appearing in any input ranking, score = sum over
347- rankings of ``1 / (rrf_k + rank_in_that_ranking)``. The constant
348- ``rrf_k`` dampens the contribution from low ranks; 60 is the value
349- from the original RRF paper (Cormack et al. '09).
350-
351- The RRF score is rank-based, not magnitude-based, so it's robust
352- against BM25 and cosine-similarity living in different score ranges.
353- Returns the top ``k_top`` paths with their RRF scores.
372+ """Convex score fusion of the BM25 and embedding lanes, leaning lexical.
373+
374+ Replaces rank-only RRF. RRF discards score *magnitude*, so a strong
375+ high-IDF BM25 hit (a rare token the embedder can't place) was flattened
376+ to "just rank N" and out-voted by a flat embedding lane. Here each lane
377+ is normalised to ``[0, 1]`` and combined ``w_bm25 / w_emb``.
378+
379+ Normalisation is fixed-reference, NOT per-query min-max — min-max
380+ amplifies a flat lane into full-range noise, and the embedding lane's
381+ cosines cluster in a narrow band on this corpus:
382+
383+ - BM25: divided by the batch max (BM25 has no fixed ceiling).
384+ - Embeddings: ``(cos - floor) / (1 - floor)`` against
385+ :data:`EMBEDDING_NOISE_FLOOR` — hits are already floored there, so
386+ this maps ``[floor, 1] -> [0, 1]``.
387+
388+ When a lane is empty its term drops out, so a query with no semantic
389+ matches above the floor degrades to pure lexical order — exactly what
390+ we want for rare-token / proper-noun queries. Returns the top
391+ ``k_top`` ``(path, fused_score)`` pairs, descending, stable on tie.
354392 """
355- rrf_scores : Dict [Path , float ] = {}
356- for ranking in rankings :
357- if not ranking :
358- continue
359- for rank , (path , _score ) in enumerate (ranking ):
360- rrf_scores [path ] = rrf_scores .get (path , 0.0 ) + 1.0 / (rrf_k + rank + 1 )
361- ordered = sorted (rrf_scores .items (), key = lambda kv : (- kv [1 ], str (kv [0 ])))
393+ bm25_norm : Dict [Path , float ] = {}
394+ if bm25_hits :
395+ max_bm25 = max (s for _ , s in bm25_hits )
396+ if max_bm25 > 0 :
397+ bm25_norm = {p : s / max_bm25 for p , s in bm25_hits }
398+
399+ span = 1.0 - EMBEDDING_NOISE_FLOOR
400+ emb_norm : Dict [Path , float ] = {}
401+ if emb_hits and span > 0 :
402+ emb_norm = {p : max (0.0 , min (1.0 , (s - EMBEDDING_NOISE_FLOOR ) / span )) for p , s in emb_hits }
403+
404+ fused : Dict [Path , float ] = {}
405+ for path in set (bm25_norm ) | set (emb_norm ):
406+ fused [path ] = w_bm25 * bm25_norm .get (path , 0.0 ) + w_emb * emb_norm .get (path , 0.0 )
407+ ordered = sorted (fused .items (), key = lambda kv : (- kv [1 ], str (kv [0 ])))
362408 return ordered [:k_top ]
363409
364410
@@ -418,23 +464,25 @@ def _hybrid_search(
418464 * ,
419465 k : int ,
420466) -> List [Tuple [Path , float ]]:
421- """Run BM25 + embedding search, fuse with RRF , then cross-encoder rerank.
467+ """Run BM25 + embedding search, fuse by weighted score , then rerank.
422468
423469 Three-stage retrieval, low precision → high precision:
424470
425- 1. **Recall**: BM25 and embedding lanes each pull ``k * 4`` candidates.
426- BM25 catches exact-token matches; embeddings catch paraphrase.
427- 2. **Fusion**: RRF merges the two ranked lists into ``k * 4``
428- topically-relevant candidates (rank-based, robust to score scale
429- differences between BM25 and cosine).
471+ 1. **Recall**: BM25 and embedding lanes each pull ``2 * k`` candidates.
472+ BM25 catches exact-token / rare-term matches; embeddings catch
473+ paraphrase. README files are dropped here (see :func:`_is_readme`):
474+ they're navigation, and their generic overview text dominates the
475+ embedding lane for vague queries.
476+ 2. **Fusion**: :func:`_weighted_merge` combines the lanes by normalised
477+ score, leaning lexical, so a confident BM25 hit on a rare token
478+ isn't out-voted by a flat embedding lane (rank-only RRF was).
430479 3. **Rerank**: a cross-encoder co-attends over each ``(query, body)``
431480 pair and scores actual applicability — orthogonal signal to the
432481 overlap-based lanes upstream. Returns the top ``k`` reranked.
433482
434- Graceful degradation: if the embedding lane is unavailable, we
435- fall back to BM25-only feed into the reranker. If the reranker is
436- unavailable, we fall back to the RRF order verbatim. The hot path
437- must keep working when any single component breaks.
483+ Graceful degradation: an empty lane simply drops out of the weighted
484+ merge. If the reranker is unavailable, we fall back to the fused order
485+ verbatim. The hot path must keep working when any component breaks.
438486 """
439487 # Pull 2× k from each lane. Wider over-fetch (e.g. 4×) would give
440488 # the reranker more variety, but cross-encoder predict on MPS scales
@@ -443,30 +491,30 @@ def _hybrid_search(
443491 # the rerank stage alone overruns the 200ms hook budget. Stick to 2×
444492 # so end-to-end fits.
445493 per_lane_k = max (k * 2 , k + 5 )
446- bm25_hits = _bm25_search (knowledge_dir , query , k = per_lane_k )
447- emb_hits = _embedding_search (knowledge_dir , query , k = per_lane_k )
494+ bm25_hits = [
495+ h for h in _bm25_search (knowledge_dir , query , k = per_lane_k ) if not _is_readme (h [0 ])
496+ ]
497+ emb_hits = [
498+ h for h in _embedding_search (knowledge_dir , query , k = per_lane_k ) if not _is_readme (h [0 ])
499+ ]
448500 if not bm25_hits and not emb_hits :
449501 return []
450502
451- if not emb_hits :
452- fused = bm25_hits
453- elif not bm25_hits :
454- fused = emb_hits
455- else :
456- fused = _rrf_merge ([bm25_hits , emb_hits ], k_top = per_lane_k )
457-
503+ fused = _weighted_merge (bm25_hits , emb_hits , k_top = per_lane_k )
458504 return _rerank_candidates (query , fused , k = k )
459505
460506
461- # Scope bonuses (and one penalty) are calibrated against the RRF top-rank
462- # score (~0.016 for rank 1, ~0.033 with both lanes hitting the top).
463- # Current-project +0.020 is roughly a 4-rank bump; global +0.005 is roughly
464- # a 1-rank bump; cross-project entries get a small negative penalty so
465- # they only surface when nothing scoped beats them (the agent's complaint:
466- # vol-predictor entries surfacing during agent-mem work is noise). The
467- # penalty is gentle — a strong topical match from another project can
468- # still overcome it — but it kills the steady drizzle of cross-context
469- # bullets that dominated turns.
507+ # Scope bonuses (and one penalty) nudge the ranked score in
508+ # ``_boost_with_reinforcement``: prefer the current project, then global,
509+ # and gently penalise other projects so cross-context entries only surface
510+ # when nothing scoped beats them (the agent's complaint: vol-predictor
511+ # entries surfacing during agent-mem work is noise).
512+ #
513+ # NOTE: these magnitudes (±0.01-0.02) are small relative to the rerank
514+ # logits (~ -3..+10) and the reinforcement term (reinforced * 0.5) they're
515+ # added to, so on the post-rerank scale the scope penalty is close to a
516+ # no-op. Re-calibrating the boost formula's three terms onto a common
517+ # scale is tracked separately — out of scope for the fusion change.
470518_SCOPE_BONUS_CURRENT = 0.020
471519_SCOPE_BONUS_GLOBAL = 0.005
472520_SCOPE_PENALTY_CROSS_PROJECT = - 0.010
0 commit comments