Skip to content

Commit bc23cfb

Browse files
nickrociclaude
andcommitted
fix(priming): weighted lexical-leaning fusion + drop READMEs from recall
Natural "what do you know about <project>?" queries surfaced READMEs and off-topic entries instead of the many real docs, because: - the embedding lane returns generic overview text (READMEs) for vague / rare-proper-noun queries — the embedder has no representation for a coined name, so a near-contentless query lands near the centroid; and - rank-only RRF discarded BM25's score magnitude, so the lexical lane's confident rare-term hit was flattened and out-voted by that flat embedding lane. Two fixes, both in _hybrid_search so the RPC and refresh paths share them: 1. Exclude README files from retrieval candidates (navigation, not lessons; the embedding lane's "universal donors"). 2. Replace _rrf_merge with _weighted_merge — convex score fusion, lexical-leaning (0.6/0.4), fixed-reference normalisation (not per-query min-max, which amplifies a flat lane into noise). An empty lane drops out, so a no-semantic-match query degrades to pure lexical order — exactly right for rare-token queries. Live check (real ~/.agent-mem library): "what do you know about ultan?" goes from a README-dominated top-6 (~0 real docs) to 2 substantive agent-mem docs, 0 READMEs; a semantic control query is unaffected. Hoists EMBEDDING_NOISE_FLOOR to a module constant (shared by the lane and the fusion normaliser). Leaves the scope-bonus scale mismatch as a separate, documented follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 33e00a7 commit bc23cfb

2 files changed

Lines changed: 160 additions & 82 deletions

File tree

daemon/agent_mem_daemon/priming.py

Lines changed: 108 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -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

257280
def _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

daemon/tests/test_priming_internals.py

Lines changed: 52 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -249,42 +249,72 @@ def search(self, q, k):
249249
assert "good.md" in str(out[0][0])
250250

251251

252-
# ── _rrf_merge ───────────────────────────────────────────────────────
252+
# ── _is_readme ───────────────────────────────────────────────────────
253253

254254

255-
def test_rrf_merge_handles_empty_inputs() -> None:
256-
assert priming._rrf_merge([], k_top=3) == []
257-
assert priming._rrf_merge([[]], k_top=3) == []
258-
# Lone non-empty list — same items come back, ordered.
259-
rankings = [[(Path("a.md"), 1.0), (Path("b.md"), 0.5)]]
260-
result = priming._rrf_merge(rankings, k_top=2)
261-
assert len(result) == 2
262-
assert result[0][0] == Path("a.md")
255+
def test_is_readme_matches_readme_files_case_insensitively() -> None:
256+
assert priming._is_readme(Path("README.md"))
257+
assert priming._is_readme(Path("projects/x/README.md"))
258+
assert priming._is_readme(Path("docs/Readme.md"))
259+
# Not READMEs.
260+
assert not priming._is_readme(Path("projects/x/concepts/foo.md"))
261+
assert not priming._is_readme(Path("global/readme-notes.md"))
263262

264263

265-
def test_rrf_merge_combines_two_rankings() -> None:
266-
"""A path that ranks 2nd in one list and 1st in another should beat
267-
a path that's only top in one list."""
264+
# ── _weighted_merge ──────────────────────────────────────────────────
265+
266+
267+
def test_weighted_merge_handles_empty_inputs() -> None:
268+
assert priming._weighted_merge([], [], k_top=3) == []
269+
270+
271+
def test_weighted_merge_single_lane_orders_by_that_lane() -> None:
272+
"""Empty embedding lane → pure BM25 order. This is the rare-token
273+
degrade path: no semantic match, lexical carries the result."""
268274
a, b, c = Path("a.md"), Path("b.md"), Path("c.md")
269-
rankings = [
270-
[(a, 1.0), (b, 0.5), (c, 0.1)],
271-
[(b, 1.0), (a, 0.5)], # b is 1st here
272-
]
273-
result = priming._rrf_merge(rankings, k_top=3)
274-
paths = [p for p, _ in result]
275-
# a and b both score: a = 1/(60+1)+1/(60+2); b = 1/(60+2)+1/(60+1) → tie
276-
# Tie-break is stable on path string → a sorts before b.
277-
assert set(paths[:2]) == {a, b}
275+
result = priming._weighted_merge([(a, 12.0), (b, 6.0), (c, 3.0)], [], k_top=3)
276+
assert [p for p, _ in result] == [a, b, c]
277+
278278

279+
def test_weighted_merge_leans_lexical() -> None:
280+
"""A strong BM25-only hit beats a doc the (flat) embedding lane likes,
281+
because lexical is weighted higher and BM25 magnitude is preserved."""
282+
strong_bm25, emb_fav = Path("strong.md"), Path("emb.md")
283+
# strong_bm25: 0.6 * (18/18) = 0.60 ; emb_fav: 0.4 * (0.70-0.5)/0.5 = 0.16
284+
result = priming._weighted_merge([(strong_bm25, 18.0)], [(emb_fav, 0.70)], k_top=2)
285+
assert result[0][0] == strong_bm25
279286

280-
# ── _hybrid_search lane fallbacks ────────────────────────────────────
287+
288+
def test_weighted_merge_combines_both_lanes() -> None:
289+
"""A doc present in both lanes can beat one strong in only one lane."""
290+
both, bm_only = Path("both.md"), Path("bm.md")
291+
# both: 0.6*(8/10) + 0.4*1.0 = 0.88 ; bm_only: 0.6*1.0 = 0.60
292+
result = priming._weighted_merge([(bm_only, 10.0), (both, 8.0)], [(both, 1.0)], k_top=2)
293+
assert result[0][0] == both
294+
295+
296+
# ── _hybrid_search lane fallbacks + README exclusion ─────────────────
281297

282298

283299
def test_hybrid_search_returns_empty_when_both_lanes_empty(tmp_path: Path) -> None:
284300
"""No library / no hits in either lane → []."""
285301
assert priming._hybrid_search(tmp_path / "missing", "anything", k=5) == []
286302

287303

304+
def test_hybrid_search_excludes_readmes(tmp_path: Path, monkeypatch) -> None:
305+
"""READMEs from either lane are dropped before fusion/rerank — they're
306+
navigation, and dominate the embedding lane on vague queries."""
307+
readme = tmp_path / "knowledge" / "global" / "README.md"
308+
real = tmp_path / "knowledge" / "global" / "x" / "use-uv.md"
309+
monkeypatch.setattr(priming, "_bm25_search", lambda *a, **k: [(readme, 10.0), (real, 8.0)])
310+
monkeypatch.setattr(priming, "_embedding_search", lambda *a, **k: [(readme, 0.9)])
311+
# Pass-through rerank so we observe the fused candidate set directly.
312+
monkeypatch.setattr(priming, "_rerank_candidates", lambda q, cands, k: cands[:k])
313+
paths = [p for p, _ in priming._hybrid_search(tmp_path, "anything", k=5)]
314+
assert readme not in paths
315+
assert real in paths
316+
317+
288318
# ── _render_bullet edge cases ────────────────────────────────────────
289319

290320

0 commit comments

Comments
 (0)