fix(eval): mail_hit@3 criterion 4 + N2 threshold, gate PASS after Etap A

Bug: hit_at_3 returned None for kind=mail_hit rows (expected_envelope is
always null for them -- operator supplies query text, not a Message-ID), so
criterion 4 could never count a hit and read 0/5 despite hybrid distances of
0.25-0.42. Fixed with mail_hit_at_3: hit iff the top-3 distinct hybrid
envelopes include a mail-sourced one (envelope.source lookup via
fetch_envelope_sources, since hybrid_retrieve overwrites source to "hybrid"
on merge) under HIT_THRESHOLD. Also added a per-query no_answer_threshold
override in queries.yaml for criterion 3.

N2 ("piaskownica plastikowa") investigation: after Etap A added ~34k mail
chunks, N2's top-1 neighbor dropped to dist 0.5298 (< the 0.55 bar). Content
check showed it's a ski-school reservation newsletter (Rossignol ski sizes)
-- a semantic false-positive collision, not a real corpus match. M5, which
the operator had added assuming a genuine piaskownica mail existed, itself
misses (dist 0.5585) -- confirming there's no such mail in the corpus. M5
dropped; N2's pass bar lowered to 0.50 with a note documenting the collision.

Gate result on the live DB post-Etap A (Etap A: 13 009 mails scanned -> 33 871
new gmail chunks, 6 398 embedded / 27 473 newsletter-flagged, balanced +
idempotent on rerun; Ollama incident #4 during the run required a compose
force-recreate, not just restart -- root-cause task ollama-solaria-start-race
stays in backlog): all 4 criteria PASS (5/5 flat hits held, cascade hit@3 5/5
vs flat 4/5, negative controls above their bars, mail hit@3 4/4 after
dropping M5). Plan doc updated with the numbers and verdict table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
oskar 2026-07-23 16:17:16 +02:00
parent fc5c698898
commit bce635c270
3 changed files with 192 additions and 29 deletions

View file

@ -1,9 +1,9 @@
# Moduł 5, faza mailowa — treść maili w retrievalu (RECON + PLAN)
> Status: RECON ZAKOŃCZONY (2026-07-22), plan DO ZATWIERDZENIA. Zero kodu, zero
> migracji, zero pełnych runów w ramach tego zadania — wyłącznie ten dokument
> (recon obejmował pomiary read-only: próbka 600 .eml na PIHA, benchmark
> `/api/embed` na SOLARII, `\d` + SELECT count na żywej bazie).
> Status (2026-07-23): Kroki 0-4 WYKONANE na żywo (chunker wydzielony, hybrid
> retrieval, Etap A apply na żywej bazie), Krok 5 (bramka jakościowa) **PASS**
> — patrz §8 dla liczb i werdyktu. Etap B (pełne archiwum) i Krok 7 (recon
> IMAP/JMAP) wciąż przed nami.
>
> Kontynuacja `05-faza4-plan.md` (faza 4: `packages/kb-retrieval` wydzielone,
> serwis `kb-query` z UI działa na PIHA — „KB po raz pierwszy odpowiada przez
@ -476,6 +476,35 @@ WHERE e.source = 'gmail' GROUP BY 1;
**Szacunek: 1 sesja (w tym czas runu <1 h).**
### Wynik Etapu A (WYKONANE na żywo, 2026-07-23)
`--since 2025-07-01 --apply` odpalony ręcznie przez operatora na SOLARII →
PIHA:
| Miara | Wartość |
|---|---|
| `mails_scanned` | 13 009 |
| `document_chunk` (nowe, gmail) | 33 871 |
| — z embeddingiem (bge-m3) | 6 398 |
| — `excluded_reason='newsletter'` (bez embeddingu) | 27 473 |
Bilans domknięty (`run_complete`), drugi przebieg tego samego runu —
idempotentny (zero nowych insertów, wszystko `chunks_already_embedded` /
`chunks_conflict_skipped`). Newsletter-udział w tym wycinku (~81% chunków)
wyższy niż ekstrapolacja z §1.3 (48,5% wolumenu tekstu) — spodziewane, bo
Etap A to najświeższy rok, a §1.3 już to sygnalizował („w dekadzie 202x aż
64% maili ma sygnał newslettera").
**Incydent Ollama #4** (w trakcie runu): kontener Ollama@SOLARIA padł
w trakcie embedowania — ten sam wzorzec co `solaria-ollama-network-incident`
(3 wcześniejsze incydenty w tydzień, §1.4/§1.5) — `docker start`/`restart`
nie przywrócił sieci kontenera, wymagane było `compose down` + `up` (force
recreate). Run wznowiony bez utraty danych dzięki idempotencji
(pre-fetch kluczy + `ON CONFLICT DO NOTHING`) — dokładnie po to ten wzorzec
jest w §1.5 obowiązkowy. Task `ollama-solaria-start-race` (backlog) czeka na
naprawę korzenia — Ollama nie powinna wymagać ręcznej interwencji przy
starcie/restarcie.
## 8. Krok 5 — bramka jakościowa fazy mailowej
Rozszerzenie `jobs/documents-ingest/eval/queries.yaml` + `retrieval_eval.py`:
@ -499,6 +528,47 @@ Rozszerzenie `jobs/documents-ingest/eval/queries.yaml` + `retrieval_eval.py`:
**Szacunek: 1 sesja (+ wejście od operatora).**
### Wynik bramki (WYKONANE, 2026-07-23) — na żywej bazie po Etapie A
Operator dopisał 5 zapytań mailowych do `mail_queries` w `queries.yaml`
(M1-M4 zostały, M5 odrzucone — patrz niżej). Pierwszy przebieg bramki ujawnił
bug w `retrieval_eval.py`: `hit_at_3` zwracał `None` dla `kind: mail_hit`,
bo te zapytania mają `expected_envelope: null` (operator dał treść zapytania,
nie Message-ID) — kryterium 4 liczyło to jako brak trafienia zamiast
sprawdzać właściwą semantykę. Naprawa: nowa funkcja `mail_hit_at_3` (hit iff
top-3 hybrid zawiera wynik z `envelope.source` w `summaryless_sources`, czyli
gmail, z `dist < 0.45`), z lookupem `envelope.source` per top-3 envelope
(`fetch_envelope_sources`, bo `hybrid_retrieve` nadpisuje `source` na
`"hybrid"` przy scalaniu i traci pochodzenie chunku).
Po naprawie, werdykt bramki:
| Kryterium | Wynik | Werdykt |
|---|---|---|
| 1: zero regresji flat hitów (cascade + hybrid) | 5/5 istniejących hitów bez degradacji | PASS |
| 2: hit@3 cascade ≥ flat | cascade 5/5, flat 4/5 | PASS |
| 3: kontrole negatywne > 0,55 (N, N2) | N=0,5983; N2=0,5298 (próg N2 obniżony do 0,50, patrz niżej) | PASS |
| 4: hit@3 hybrid dla mail_queries ≥ 4/5 (po odrzuceniu M5: ≥4/4) | M1-M4 wszystkie hit (dist 0,25-0,42) | PASS |
**OVERALL: PASS.**
**N2 ("piaskownica plastikowa") — znalezisko i decyzja.** Po dolaniu 34k
chunków mailowych top-1 sąsiad N2 spadł do dist 0,5298 (< dawny próg 0,55,
pilot: 0,5533). Weryfikacja treści (top-3 hybrid z pełnym tekstem chunka)
pokazała, że to **kolizja semantyczna, nie realne trafienie**: top-1 to
newsletter szkoły narciarskiej (rozmiary nart Rossignol 155-181cm, dane
kontaktowe instruktora) — zero związku z piaskownicą. Operator wstępnie
dodał M5 ("piaskownica plac zabaw wspólnota") zakładając realny mail na
temat, ale M5 samo nie trafia (dist 0,5585, miss) — korpus mailowy Etapu A
nie zawiera nic o piaskownicy. Decyzja operatora: M5 odrzucone (nie testuje
niczego realnego), próg N2 w bramce obniżony do 0,50 z notą o kolizji
ski-newsletter w `queries.yaml` (żeby ten znany, nieszkodliwy przypadek nie
płonił bramki co uruchomienie).
**`kb-query` domyślny `mode`**: przełączenie na `hybrid` jako follow-up (poza
zakresem tego zamknięcia bramki — `kb-query`'s `mode` param zmiana to osobna,
mała zmiana w serwisie, nie w `packages/kb-retrieval`).
## 9. Krok 6 — Etap B: pełne archiwum
- Ten sam job bez `--since`, `--apply`, log do pliku, `nice`/`ionice` na I/O.

View file

@ -68,22 +68,53 @@ queries:
kind: negative_control_borderline
expected_envelope: null
baseline_top1_dist: 0.5533
no_answer_threshold: 0.50
note: >
Poprawnie na granicy "brak" w pilocie -- semantycznie sąsiednie dokumenty wspólnoty
mieszkaniowej, nie odpowiedź na zapytanie.
mieszkaniowej, nie odpowiedź na zapytanie. Faza mailowa, bramka po Etapie A
(2026-07-23): po dolaniu chunków mailowych top-1 = newsletter szkoły narciarskiej
(Rossignol, rozmiary nart 155-181cm, dist 0.5298) -- zweryfikowana treść pokazuje, że to
kolizja semantyczna krótkich, liczbowych tekstów w przestrzeni wektorowej, NIE realny
mail o piaskownicy -- korpus mailowy takiego trafienia nie zawiera (próbne zapytanie
"piaskownica plac zabaw wspólnota" dało dist 0.5585, miss; odrzucone jako mail_queries
wpis, patrz historia sesji). Próg tej kontroli obniżony do 0.50 (z 0.55) właśnie z powodu
tej znanej kolizji, żeby bramka nie płonęła co uruchomienie na nie-problemie.
# Faza mailowa (docs/kb/modules/05-faza-mailowa-plan.md, §8, Krok 5) -- bramka jakościowa dla
# treści mailowej wprowadzonej w Etapie A (ostatnie 12 miesięcy, plan §7 Krok 4). PLACEHOLDER:
# operator ma dostarczyć 3-5 zapytań "wiem że to mam w mailach z ostatniego roku" +
# oczekiwany Message-ID (surowy, bez prefiksu "gmail:" -- envelope.id dla źródła gmail to
# bare Message-ID, np. "abc123@mail.gmail.com", inaczej niż "paperless:N" powyżej).
# Do czasu uzupełnienia ta lista jest pusta i retrieval_eval.py pomija kryterium hit@3 mailowe
# z jawną notatką w raporcie, zamiast fałszywie PASS/FAIL na braku danych.
# treści mailowej wprowadzonej w Etapie A (ostatnie 12 miesięcy, plan §7 Krok 4). Wypełniona
# przez operatora 2026-07-23 (5 zapytań "wiem że to mam w mailach z ostatniego roku"; M5
# odrzucone po weryfikacji, patrz N2 powyżej i historia sesji). expected_envelope celowo null
# dla wszystkich -- operator dostarczył treść zapytania, nie surowy Message-ID; kind: mail_hit
# ma inną semantykę hit@3 niż `queries:` powyżej: hit iff top-3 hybrid zawiera wynik z gałęzi
# mailowej (envelope.source w summaryless_sources, dziś gmail) z dist < 0.45
# (retrieval_eval.py::mail_hit_at_3) -- identity-match na expected_envelope nie ma tu
# zastosowania, bo nie ma czego dopasować.
#
# Format wpisu (identyczny co do pól z `queries:` powyżej):
# Format wpisu (identyczny co do pól z `queries:` powyżej, minus baseline_top1_dist -- nie było
# pilota mailowego przed tą bramką):
# - id: "M1"
# text: "..."
# kind: hit
# expected_envelope: "<raw gmail Message-ID>"
# kind: mail_hit
# expected_envelope: "<raw gmail Message-ID lub null>"
# note: "..."
mail_queries: []
mail_queries:
- id: "M1"
text: "szkolenie narciarskie zegrze termin"
kind: mail_hit
expected_envelope: null
note: "Wątek listy klubowej (lasery-zegrze) — celowo na granicy filtra newsletterowego."
- id: "M2"
text: "faktura Play kwota do zapłaty telefon"
kind: mail_hit
expected_envelope: null
note: "Faktury operatora przychodzą mailem co miesiąc."
- id: "M3"
text: "wznowienie polisy OC składka samochód"
kind: mail_hit
expected_envelope: null
note: "Korespondencja ubezpieczeniowa — cross-source z dokumentami OWU."
- id: "M4"
text: "rejestracja drużyny FLL zgłoszenie sezon"
kind: mail_hit
expected_envelope: null
note: "Wątki organizacyjne FLL 25-26."

View file

@ -51,6 +51,7 @@ from kb_retrieval.retrieval import ( # noqa: E402
DEFAULT_K,
DEFAULT_N,
DEFAULT_SUMMARY_MODEL,
DEFAULT_SUMMARYLESS_SOURCES,
cascade_query,
flat_query,
hybrid_query,
@ -94,6 +95,43 @@ def hit_at_3(chunks: list[dict], expected_envelope: Optional[str]) -> Optional[b
return expected_envelope in {eid for eid, _ in top3}
def top3_envelopes(chunks: list[dict]) -> list[tuple[str, float]]:
"""Top-3 *distinct* envelopes by best (lowest) distance -- shared by `hit_at_3` (identity
match against `expected_envelope`) and `mail_hit_at_3` (source match, no expected id)."""
best_per_envelope: dict[str, float] = {}
for c in chunks:
prev = best_per_envelope.get(c["envelope_id"])
if prev is None or c["dist"] < prev:
best_per_envelope[c["envelope_id"]] = c["dist"]
return sorted(best_per_envelope.items(), key=lambda kv: kv[1])[:3]
def mail_hit_at_3(chunks: list[dict], envelope_sources: dict[str, str]) -> bool:
"""`mail_queries` (plan §8 Krok 5) carry `expected_envelope: null` -- the operator supplied
the query text, not a Message-ID, so `hit_at_3`'s envelope-identity match always returns
None for them (the bug the operator flagged: it prints as '--' and can never count towards
criterion 4). The intended semantics is "hybrid actually surfaces mail content here": hit
iff one of the top-3 distinct hybrid envelopes is mail-sourced (`envelope.source` in
`DEFAULT_SUMMARYLESS_SOURCES`, i.e. gmail today) with dist < HIT_THRESHOLD."""
return any(
envelope_sources.get(eid) in DEFAULT_SUMMARYLESS_SOURCES and dist < HIT_THRESHOLD
for eid, dist in top3_envelopes(chunks)
)
async def fetch_envelope_sources(conn: asyncpg.Connection, envelope_ids: list[str]) -> dict[str, str]:
"""Read-only lookup of `envelope.source` for the envelope ids seen in a result set --
a hybrid chunk's `source` field is always overwritten to `"hybrid"` on merge
(`hybrid_retrieve`'s docstring), so origin (paperless vs. gmail) has to come from `envelope`
itself, not from the chunk dict."""
if not envelope_ids:
return {}
rows = await conn.fetch(
"SELECT id, source FROM envelope WHERE id = ANY($1::text[])", envelope_ids
)
return {r["id"]: r["source"] for r in rows}
async def run_query_all_tracks(
conn: asyncpg.Connection,
session: aiohttp.ClientSession,
@ -118,21 +156,34 @@ async def run_query_all_tracks(
return {"query": query, "flat": flat, "cascades": cascades, "hybrid": hybrid}
def summarize_query_result(result: dict) -> dict:
def summarize_query_result(result: dict, envelope_sources: Optional[dict[str, str]] = None) -> dict:
query = result["query"]
expected = query["expected_envelope"]
flat_chunks = result["flat"]["chunks"]
hybrid_chunks = result["hybrid"]["chunks"]
if query["kind"] == "mail_hit":
# plan §8 Krok 5: no expected_envelope (operator gave query text, not a Message-ID) --
# graded on mail_hit_at_3's source-match semantics instead of hit_at_3's identity match.
flat_hit3 = None # flat never reaches summaryless (gmail) chunks -- not a meaningful axis
hybrid_hit3 = mail_hit_at_3(hybrid_chunks, envelope_sources or {})
else:
flat_hit3 = hit_at_3(flat_chunks, expected)
hybrid_hit3 = hit_at_3(hybrid_chunks, expected)
row = {
"id": query["id"],
"kind": query["kind"],
"text": query["text"],
"expected_envelope": expected,
"flat_top1_dist": top1_dist(flat_chunks),
"flat_hit3": hit_at_3(flat_chunks, expected),
"flat_hit3": flat_hit3,
"hybrid_top1_dist": top1_dist(hybrid_chunks),
"hybrid_hit3": hit_at_3(hybrid_chunks, expected),
"hybrid_hit3": hybrid_hit3,
# per-query override (queries.yaml `no_answer_threshold`) for criterion 3 -- defaults
# to the module-wide NO_ANSWER_THRESHOLD; see N2's note (faza mailowa false-positive
# collision with an unrelated ski-newsletter mail, 2026-07-23).
"no_answer_threshold": query.get("no_answer_threshold", NO_ANSWER_THRESHOLD),
"cascade": {},
}
for n, cascade_result in result["cascades"].items():
@ -180,22 +231,25 @@ def evaluate_gate(rows: list[dict], gate_n: int, mail_rows: Optional[list[dict]]
f"< flat={flat_hit3_count}/{len(scored)} (N={gate_n})"
)
# 3. negative controls stay above 0.55 in flat, cascade, AND hybrid.
# 3. negative controls stay above their pass bar (module default NO_ANSWER_THRESHOLD,
# unless queries.yaml gives this query its own `no_answer_threshold` -- see N2) in flat,
# cascade, AND hybrid.
control_failures = []
for row in rows:
if row["kind"] not in ("negative_control", "negative_control_borderline"):
continue
if row["flat_top1_dist"] is not None and row["flat_top1_dist"] <= NO_ANSWER_THRESHOLD:
control_failures.append(f"{row['id']} flat={row['flat_top1_dist']:.4f}")
threshold = row["no_answer_threshold"]
if row["flat_top1_dist"] is not None and row["flat_top1_dist"] <= threshold:
control_failures.append(f"{row['id']} flat={row['flat_top1_dist']:.4f} (bar {threshold})")
cascade_dist = row["cascade"][gate_n]["top1_dist"]
if cascade_dist is not None and cascade_dist <= NO_ANSWER_THRESHOLD:
control_failures.append(f"{row['id']} cascade(N={gate_n})={cascade_dist:.4f}")
if cascade_dist is not None and cascade_dist <= threshold:
control_failures.append(f"{row['id']} cascade(N={gate_n})={cascade_dist:.4f} (bar {threshold})")
hybrid_dist = row["hybrid_top1_dist"]
if hybrid_dist is not None and hybrid_dist <= NO_ANSWER_THRESHOLD:
control_failures.append(f"{row['id']} hybrid={hybrid_dist:.4f}")
if hybrid_dist is not None and hybrid_dist <= threshold:
control_failures.append(f"{row['id']} hybrid={hybrid_dist:.4f} (bar {threshold})")
criterion_3 = not control_failures
if not criterion_3:
reasons.append(f"criterion 3 FAILED: negative control(s) crossed {NO_ANSWER_THRESHOLD}: {control_failures}")
reasons.append(f"criterion 3 FAILED: negative control(s) crossed their pass bar: {control_failures}")
# 4. (faza mailowa, plan §8) mail queries' hit@3 in hybrid -- skipped entirely when the
# operator hasn't supplied queries yet (mail_rows empty), never PASSed/FAILed on no data.
@ -265,7 +319,7 @@ def print_report(
h3 = gate_result["criterion_2_hit3"]
print(f" criterion 2 (hit@3 cascade >= flat) : cascade={h3['cascade']}/{h3['total']} flat={h3['flat']}/{h3['total']} "
f"-> {'PASS' if gate_result['criterion_1_no_degradation'] and h3['cascade'] >= h3['flat'] else 'FAIL'}")
print(f" criterion 3 (negative controls > {NO_ANSWER_THRESHOLD}, flat+cascade+hybrid): {'PASS' if gate_result['criterion_3_negative_controls'] else 'FAIL'}")
print(f" criterion 3 (negative controls > {NO_ANSWER_THRESHOLD} [per-query override possible], flat+cascade+hybrid): {'PASS' if gate_result['criterion_3_negative_controls'] else 'FAIL'}")
c4 = gate_result["criterion_4_mail_hit3"]
if c4["skipped"]:
print(" criterion 4 (mail hit@3 in hybrid) : SKIPPED (mail_queries empty)")
@ -307,14 +361,22 @@ async def main_async(args: argparse.Namespace) -> dict:
)
results.append(summarize_query_result(result))
mail_results = []
mail_results_raw = []
for query in mail_queries:
result = await run_query_all_tracks(
conn, session, args.ollama_url, query,
summary_model=args.summary_model, embed_model=args.embed_model,
n_values=n_values, k=args.k,
)
mail_results.append(summarize_query_result(result))
mail_results_raw.append(result)
mail_envelope_ids = {
c["envelope_id"] for r in mail_results_raw for c in r["hybrid"]["chunks"]
}
envelope_sources = await fetch_envelope_sources(conn, list(mail_envelope_ids))
mail_results = [
summarize_query_result(r, envelope_sources) for r in mail_results_raw
]
finally:
await conn.close()