Skip to content

Commit ad442e4

Browse files
Rushour0claude
andcommitted
feat(memory): retune RRF k + back-load success slots so hybrid wins across the board
Running the M4 eval head-to-head after the dense->hybrid flip showed hybrid only won at recall@5 while LOSING at recall@1/@3. Two eval-driven fixes make hybrid dominate every metric, and one trace-ordering bug M3 exposed is fixed. retrieval.py: - RRF k 60 -> 20 (new configurable RetrievalConfig.rrf_k / memory.rrf_k). The web-scale 60 flattens 1/(k+rank) over fabri's short two-pool fusion so mere agreement outranks the single best match. k=20 -> hybrid recall@3 0.60->0.90, recall@5 unchanged. - success_pattern guaranteed slots back-loaded. They were front-loaded into ranks 1-2 ahead of the most relevant guideline; only ~5/24 eval queries want a success pattern, so the rest lost rank 1. Relevance now owns the head and the guarantee fills reserved TAIL slots (never rank 1). hybrid recall@1 0.13->0.58, MRR 0.45->0.84 -- and it lifted dense identically, since the front-load hurt every strategy. The reserved-slot count is min(top_k//2, top_k-1) so rank 1 is always relevance. core/agent.py: - Emit the run's root START event BEFORE retrieval. Threading session_id into retrieval (M3) made the `retrieval` event land ahead of `start`, breaking the "start is first" invariant (and test_e2e_first_run_smoke). context_block no longer rides on START -- the M3 retrieval event captures the injected set. Re-baselined the gate (dense MRR floor 0.39->0.74; new hybrid recall@3 0.84 + MRR 0.79 floors) and added test_success_pattern_does_not_steal_rank_one. New first-user tuning guide docs/retrieval-tuning.md; BENCHMARKS.md numbers + findings updated. Full suite: 914 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9e9967a commit ad442e4

10 files changed

Lines changed: 383 additions & 47 deletions

BENCHMARKS.md

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -126,39 +126,49 @@ real `_retrieve_inner` over a labeled fixture — 40 guidelines, 24 queries,
126126
top_k=5). Runs in CI as a regression gate on the shipped `dense` default.
127127
Reproduce: `python -m fabri.benchmarks.retrieval_eval`.
128128

129-
Numbers below are **post the BM25 FTS5 fix** (2026-07-07 — see finding 1).
129+
Numbers below are **post the BM25 FTS5 fix, the RRF-k retune, and the
130+
success-slot back-load** (2026-07-07 — see findings).
130131

131132
| strategy | recall@1 | recall@3 | recall@5 | MRR |
132133
|---|---|---|---|---|
133-
| dense (pre-v0.9.x default, now fallback) | 0.125 | 0.688 | 0.792 | 0.442 |
134-
| sparse (BM25) | 0.167 | 0.750 | 0.875 | 0.533 |
135-
| **hybrid (RRF) — shipped default since D3** | 0.125 | 0.604 | **0.938** | 0.451 |
136-
| hybrid+mmr | 0.479 | 0.542 | 0.646 | 0.697 |
134+
| dense (pre-v0.9.x default, now fallback) | 0.583 | 0.688 | 0.792 | 0.790 |
135+
| sparse (BM25) | 0.500 | 0.792 | 0.875 | 0.772 |
136+
| **hybrid (RRF) — shipped default** | **0.583** | **0.896** | **0.938** | **0.844** |
137+
| hybrid+mmr | 0.583 | 0.625 | 0.729 | 0.804 |
137138

138-
**Default flipped dense → hybrid (D3, 2026-07-07):** hybrid's recall@5 (0.938) is
139-
the clear win and it degrades gracefully to dense wherever BM25 is unavailable
140-
(Qdrant without `fabri[bm25]`), so it is never worse than the old default. MMR
141-
stays opt-in — it trades recall@5 for better rank-1/diversity, which this
142-
fixture doesn't reward. The gate now protects the hybrid recall@5 floor too.
139+
**hybrid is now the best strategy on every metric**, not just recall@5 — the
140+
default flip is unambiguous. It also degrades gracefully to dense wherever BM25
141+
is unavailable (Qdrant without `fabri[bm25]`), so it is never worse than the old
142+
default. MMR stays opt-in — on this fixture it trades recall for diversity with
143+
no rank-1 gain left to capture, so it is not the default.
143144

144145
**Findings** (this is what the gate is _for_):
145146
1. **BM25 was a silent no-op on the SQLite backend — found and fixed.** Before
146-
the fix, `sparse` and `hybrid` were byte-identical to `dense` (all recall@5 =
147-
0.792) because `SqliteMemoryStore.query_bm25` returned `[]` for any
148-
multi-word query: `_fts5_query` space-joined tokens, and FTS5 reads a space
149-
as implicit **AND**, so a guideline had to contain _every_ query word to
150-
match. The fix (OR-join + split tool-name underscores) lifts **hybrid
151-
recall@5 0.792 → 0.938** and sparse to 0.875. A regression guard
152-
(`test_hybrid_bm25_is_alive`) now asserts hybrid beats dense so BM25 can't
153-
silently die again. This is the evidence base for the Track-M `M5`/`D3`
154-
default-strategy flip (still deferred — a separate reviewed decision).
155-
2. **MMR massively improves early rank.** `hybrid+mmr` lifts recall@1 0.125→0.479
156-
and MRR 0.44→0.70 (trading recall@5) — it surfaces the single most relevant
157-
guideline first far more often, which is what matters when only the top few
158-
entries fit the prompt budget.
159-
160-
_Gate floors (dense − 0.05) live in `tests/test_retrieval_eval_gate.py`; bump
161-
them only with a new row here._
147+
the fix, `sparse` and `hybrid` were byte-identical to `dense` because
148+
`SqliteMemoryStore.query_bm25` returned `[]` for any multi-word query:
149+
`_fts5_query` space-joined tokens, and FTS5 reads a space as implicit **AND**,
150+
so a guideline had to contain _every_ query word to match. Fix: OR-join +
151+
split tool-name underscores. Guarded by `test_hybrid_bm25_is_alive`.
152+
2. **RRF `k` was mistuned for the pool size (60 → 20).** RRF scores
153+
`Σ 1/(k+rank)`; the web-scale default `k=60` flattens the rank term over
154+
fabri's short two-pool fusion, so "appears in both lists" outranks "is the
155+
single best match in either." That lifts recall@5 but sinks recall@3. Retuning
156+
to `k=20` (configurable via `memory.rrf_k`) lifted **hybrid recall@3
157+
0.60 → 0.90** with recall@5 unchanged.
158+
3. **`success_pattern` guaranteed slots were front-loaded — the big one.** The
159+
merge reserved the **top** `top_k//2` slots for success patterns, injected
160+
ahead of the most relevant guideline. Only ~1/5 of queries actually want a
161+
success pattern, so for the rest ranks 1–2 were spent on non-relevant entries,
162+
capping recall@1. Back-loading the guarantee (relevance owns the head; success
163+
patterns fill reserved *tail* slots, never rank 1) lifted **recall@1
164+
0.13 → 0.58 and MRR 0.45 → 0.84** — and it helped dense too (recall@1 also
165+
0.13 → 0.58), since the front-load hurt every strategy. Guarded by
166+
`test_success_pattern_does_not_steal_rank_one` and the hybrid MRR/recall@3
167+
gate floors.
168+
169+
_Gate floors (measured − 0.05) live in `tests/test_retrieval_eval_gate.py`; bump
170+
them only with a new row here. See `docs/retrieval-tuning.md` for how to tune
171+
these knobs on your own corpus._
162172

163173
## Honest gaps
164174

docs/ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ features for any future consumer.
5151

5252
## In Progress
5353

54-
- **Memory + observability initiative** (scoped 2026-07-07) — decided plan in **`docs/design/memory-observability-plan.md`**. **Shipped:** `M4` (offline retrieval eval gate), the BM25 FTS5 no-op fix, `M5/D3` (default flipped dense→hybrid), `M3` (retrieval-decision observability event), `M6` (memory-health report section). **Remaining:** `X1` (OTel export — own PR), `M5` `D1`/`D2`/`D4` (reranker / query-expansion / embedding upgrade, each gated on the eval).
54+
- **Memory + observability initiative** (scoped 2026-07-07) — decided plan in **`docs/design/memory-observability-plan.md`**. **Shipped:** `M4` (offline retrieval eval gate), the BM25 FTS5 no-op fix, `M5/D3` (default flipped dense→hybrid), `M3` (retrieval-decision observability event), `M6` (memory-health report section), plus two eval-driven retrieval-quality fixes — **RRF `k` 60→20** (hybrid recall@3 0.60→0.90) and **success-slot back-load** (hybrid recall@1 0.13→0.58, MRR 0.45→0.84; also fixed a START-before-retrieval trace-ordering bug). First-user tuning guide: **`docs/retrieval-tuning.md`**. **Remaining:** `X1` (OTel export — own PR), `M5` `D1`/`D2`/`D4` (reranker / query-expansion / embedding upgrade, each gated on the eval).
5555

5656
## Backlog
5757

docs/design/memory-observability-plan.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,20 @@ on unit A; hard prerequisite for all of unit D.**
140140
### D · Retrieval quality upgrades (M5) — ALL DEFERRED, each gated on C
141141
Nothing ships enabled-by-default in v1. Each is an opt-in config flag whose
142142
default-flip is gated on a measured C delta. Order after C: **D3 → D1 → D4 → D2.**
143+
144+
> **Post-flip fixes (2026-07-07, eval-driven).** Running the C eval head-to-head
145+
> after the D3 flip surfaced that hybrid only won at recall@5 while *losing* at
146+
> recall@1/@3. Two fixes, both measured on the C fixture, made hybrid dominate
147+
> every metric: **(1) RRF `k` 60 → 20** (`memory.rrf_k`) — the web-scale constant
148+
> flattened rank over fabri's short two-pool fusion; recall@3 0.60 → 0.90. **(2)
149+
> `success_pattern` slots back-loaded** — they were front-loaded into ranks 1-2
150+
> ahead of the most relevant guideline, capping recall@1; relevance now owns the
151+
> head and the guarantee fills reserved *tail* slots (recall@1 0.13 → 0.58, MRR
152+
> 0.45 → 0.84, and it lifted dense identically — the front-load hurt all
153+
> strategies). Threading `session_id` into retrieval (M3) also exposed a
154+
> **START-before-retrieval trace-ordering bug**: the run's root `start` event was
155+
> logged *after* retrieval, so the `retrieval` event landed first; `start` now
156+
> emits before retrieval. New first-user tuning guide: `docs/retrieval-tuning.md`.
143157
- **D3 (flip dense→hybrid+mmr):** machinery exists; stage
144158
`retrieval_strategy: hybrid+mmr` in `configs/benchmark.yaml` **only**; run C
145159
dense-vs-hybrid-vs-hybrid+mmr; flip the two default sites (`retrieval.py`

docs/retrieval-tuning.md

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
# Tuning memory retrieval
2+
3+
Fabri retrieves guidelines from memory and injects the most relevant ones into
4+
each run's prompt. The defaults are eval-backed and good for most stores — **if
5+
you're just starting out, change nothing.** This guide is for when you want to
6+
tune retrieval to your own corpus, and it shows the exact knobs, sane values,
7+
and how to *measure* whether a change helped instead of guessing.
8+
9+
Every knob lives under the `memory:` block of your agent config (or the global
10+
`DEFAULT_CONFIG`). All are optional; omitting one keeps its default.
11+
12+
> **Golden rule: never flip a retrieval default blind.** Fabri ships an offline
13+
> eval (`python -m fabri.benchmarks.retrieval_eval`) precisely so retrieval
14+
> changes move a *number*, not a vibe. Measure before and after.
15+
16+
---
17+
18+
## Start here: the default is `hybrid`, and why
19+
20+
```yaml
21+
memory:
22+
retrieval_strategy: hybrid # the default — you can delete this line
23+
```
24+
25+
On fabri's labeled eval fixture (40 guidelines / 24 queries, top_k=5), `hybrid`
26+
is the best strategy on **every** metric:
27+
28+
| strategy | recall@1 | recall@3 | recall@5 | MRR |
29+
|---|---|---|---|---|
30+
| dense | 0.583 | 0.688 | 0.792 | 0.790 |
31+
| sparse | 0.500 | 0.792 | 0.875 | 0.772 |
32+
| **hybrid (default)** | **0.583** | **0.896** | **0.938** | **0.844** |
33+
| hybrid+mmr | 0.583 | 0.625 | 0.729 | 0.804 |
34+
35+
`hybrid` fuses dense (vector) and sparse (BM25) retrieval, and **degrades
36+
gracefully to dense** wherever BM25 is unavailable (a Qdrant store without the
37+
`fabri[bm25]` extra), so it is never worse than plain `dense`. That's why it's a
38+
safe unconditional default.
39+
40+
---
41+
42+
## The four strategies — when to pick each
43+
44+
```yaml
45+
memory:
46+
retrieval_strategy: dense # | sparse | hybrid | hybrid+mmr
47+
```
48+
49+
| strategy | what it does | reach for it when |
50+
|---|---|---|
51+
| `hybrid` | RRF fusion of vector + BM25 | **default.** Best all-round; keyword + semantic. |
52+
| `dense` | vector similarity only | your queries are paraphrases with little keyword overlap, or you want the lowest-dependency path. |
53+
| `sparse` | BM25 keyword only | your guidelines are keyword/identifier-heavy (tool names, error codes, API paths) and you don't want vector cost. |
54+
| `hybrid+mmr` | hybrid, then diversify the final set | your store has many **near-duplicate** guidelines and the injected set keeps repeating the same point. MMR trades a little recall for diversity — measure first. |
55+
56+
---
57+
58+
## Knob reference (with recommended values)
59+
60+
### `rrf_k` — RRF fusion sharpness (hybrid only)
61+
62+
```yaml
63+
memory:
64+
rrf_k: 20 # default
65+
```
66+
67+
RRF scores each candidate `Σ 1/(rrf_k + rank)`. The classic web-scale default is
68+
`60`, but fabri fuses **two short pools** (~2·top_k each), where `60` flattens
69+
the rank term so much that "appears in both lists" outranks "is the single best
70+
match." Retuning `60 → 20` lifted hybrid **recall@3 0.60 → 0.90** with recall@5
71+
unchanged.
72+
73+
- **Lower (10–20):** sharper — the top hit of either signal keeps its rank.
74+
Best for small/medium stores.
75+
- **Higher (40–60):** rewards agreement between signals over peak relevance.
76+
Only worth it for very large stores where both pools are long.
77+
78+
### `temporal_decay` / `temporal_half_life_days` — prefer recent guidelines
79+
80+
```yaml
81+
memory:
82+
temporal_decay: true # default false
83+
temporal_half_life_days: 30.0 # a 30-day-old entry is weighted ~0.5
84+
```
85+
86+
Turn on when your domain **drifts** (APIs change, last month's lesson is stale).
87+
Leave off when guidelines are timeless. Shorter half-life = more aggressive
88+
recency bias.
89+
90+
### `importance_weight` — boost proven guidelines
91+
92+
```yaml
93+
memory:
94+
importance_weight: 0.2 # default; 0 disables
95+
```
96+
97+
Boosts entries by how often they've been retrieved plus a bonus for `strategic`
98+
ones: `score *= 1 + importance_weight * importance`. Raise (→0.4) to lean harder
99+
on battle-tested guidelines; set `0` for pure relevance.
100+
101+
### `domain_routing` — soft-boost same-domain guidelines
102+
103+
```yaml
104+
memory:
105+
domain_routing: true # default false
106+
```
107+
108+
A zero-cost keyword classifier tags each query as code/planning/search/api/
109+
generic and gives same-domain entries a 1.15× boost (never a hard filter). Turn
110+
on when one agent spans clearly different domains and cross-domain guidelines
111+
leak in.
112+
113+
### `mmr_lambda` — relevance vs diversity (hybrid+mmr only)
114+
115+
```yaml
116+
memory:
117+
retrieval_strategy: hybrid+mmr
118+
mmr_lambda: 0.7 # 1.0 = pure relevance, 0.0 = pure diversity
119+
```
120+
121+
Only applies to `hybrid+mmr`. Lower it toward 0.5 if the injected set is
122+
redundant; keep ≥0.7 so relevance still dominates.
123+
124+
### `top_k` — how many guidelines get injected
125+
126+
```yaml
127+
memory:
128+
top_k: 5 # default
129+
```
130+
131+
More guidelines = more context (and tokens) per turn. Raising `top_k` trades
132+
prompt cost for coverage; the recall@k numbers above are at `top_k=5`.
133+
134+
---
135+
136+
## About the `success_pattern` guarantee (no knob — just so you know)
137+
138+
Fabri always reserves up to `top_k // 2` slots for `success_pattern`
139+
guidelines ("what worked last time"), so learned strategies surface even when a
140+
query doesn't obviously match one. These slots are **back-loaded** — the most
141+
relevant guideline always keeps rank 1, and success patterns fill reserved
142+
*tail* slots. (Front-loading them, an earlier bug, sank recall@1 from 0.58 to
143+
0.13.) You don't configure this; it's just why you may see a strategy guideline
144+
in the injected set that wasn't the closest match.
145+
146+
---
147+
148+
## Recipes
149+
150+
**"My store is tiny (a fresh agent)."** Defaults are fine. Retrieval short-
151+
circuits entirely on an empty store (no model load), so cold starts are cheap.
152+
153+
**"I want the cheapest / most self-contained path."** `dense` with the `sqlite`
154+
backend — no Qdrant, no `fabri[bm25]`, no BM25 index.
155+
156+
**"My guidelines are full of tool names / error codes / paths."** `sparse` or
157+
keep `hybrid` and lower `rrf_k` to ~10 so exact keyword hits rank first.
158+
159+
**"The same advice keeps repeating in the prompt."** `hybrid+mmr` with
160+
`mmr_lambda: 0.6`. Measure — MMR can cost recall.
161+
162+
**"My domain changes fast."** `temporal_decay: true`,
163+
`temporal_half_life_days: 14`.
164+
165+
**"Large store (100k+ entries), fusion feels muddy."** Try `rrf_k: 40` and
166+
consider `domain_routing: true`.
167+
168+
---
169+
170+
## Measure your tuning
171+
172+
Run the head-to-head eval — it drives the **real** retrieval path, so the
173+
numbers reflect production:
174+
175+
```bash
176+
python -m fabri.benchmarks.retrieval_eval # markdown table
177+
python -m fabri.benchmarks.retrieval_eval --strategies dense,hybrid --json
178+
python -m fabri.benchmarks.retrieval_eval --fixture my_corpus.json --top-k 8
179+
```
180+
181+
To tune on **your** data, copy `tests/fixtures/retrieval_eval.json` and replace
182+
`corpus` (your guidelines) + `queries` (tasks tagged with the guideline
183+
label(s) that *should* surface). Keep guideline text unique within a `kind`
184+
(identical text collapses under the store's id hash). Then compare strategies /
185+
`rrf_k` values on your own numbers.
186+
187+
The CI gate in `tests/test_retrieval_eval_gate.py` locks the shipped defaults to
188+
`measured − 0.05` so a future change can't silently regress them.
189+
190+
## Debug what retrieval actually decided
191+
192+
Every retrieval emits one structured `retrieval` trace event (strategy, dense/
193+
sparse pool sizes, whether BM25 fired or silently fell back to dense, per-
194+
candidate `inclusion_reason`, MMR). It's trace-only (zero prompt cost). Read it
195+
straight from the run's JSONL trace to see *why* a guideline was or wasn't
196+
injected — the fastest way to tell "hybrid is secretly running as dense" (look
197+
for `sparse_fallback: true`) from a genuine relevance miss.
198+
199+
See `docs/design/memory-observability-plan.md` for the event contract and
200+
`BENCHMARKS.md` for the full eval findings.

src/fabri/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,12 @@
185185
# MMR lambda: 0=pure diversity, 1=pure relevance. Only applies when
186186
# retrieval_strategy is "hybrid+mmr".
187187
"mmr_lambda": 0.7,
188+
# RRF fusion constant for "hybrid". The web-scale default is 60, but
189+
# fabri fuses two short pools where 60 flattens the rank term and lets
190+
# mere agreement outrank the single best match — the offline eval
191+
# measured recall@3 0.60 (k=60) -> 0.90 (k=20). Lower = sharper rank
192+
# discrimination; raise toward 60 only for very large stores.
193+
"rrf_k": 20,
188194
# Keyword heuristic domain classifier (code/planning/search/api/generic).
189195
# When true, entries whose domain matches the query get a 1.15× score boost.
190196
"domain_routing": False,

src/fabri/core/agent.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,13 @@ def _run_single_attempt(
179179
session_id = session_id or str(uuid.uuid4())
180180
logger.info("agent run starting: task=%r session_id=%s", task, session_id)
181181

182+
# START is the run's root event and MUST be the first line in the trace —
183+
# reports and the OTel exporter treat it as the parent of everything. Emit
184+
# it BEFORE retrieval so M3's `retrieval` event lands as a child rather than
185+
# ahead of the root. (The injected context is captured by the retrieval
186+
# event's candidate list, so it no longer needs to ride on START.)
187+
log_event(session_id, {"type": EventType.START.value, "task": task})
188+
182189
context_block, retrieval_meta = retrieve_context_with_meta(
183190
store, task, top_k=top_k, tool_names=[t.name for t in tools.list()],
184191
retrieval_config=retrieval_config, session_id=session_id,
@@ -215,8 +222,6 @@ def _run_single_attempt(
215222
result_format=result_format,
216223
)
217224

218-
log_event(session_id, {"type": EventType.START.value, "task": task, "context_block": context_block})
219-
220225
final_text = None
221226
# O1: the validated JSON value when response_schema is configured (None
222227
# otherwise, or when validation never succeeded). `schema_failed` flips on

0 commit comments

Comments
 (0)