feat(eval): extend retrieval_eval.py + queries.yaml for hybrid mode (faza mailowa Krok 5, partial)

retrieval_eval.py now runs every existing query through hybrid_query
alongside flat/cascade, and gate criteria 1/3 (no degradation, negative
controls) check hybrid too -- this is the phase's key regression test: does
adding mail chunks to the HNSW index degrade the 7 existing paperless
queries? Criterion 4 (mail hit@3 in hybrid) is wired but skipped with an
explicit report note when queries.yaml's new `mail_queries` placeholder is
empty, rather than silently PASSing/FAILing on absent data.

queries.yaml: added an empty `mail_queries: []` placeholder + inline format
docs. The existing 7-query `queries:` list is untouched.

REMINDER for the operator (per plan §8, Krok 5): 3-5 mail queries are still
needed -- things you know are in last year's mail, plus each one's expected
gmail Message-ID -- before criterion 4 and the full plan §8 gate can PASS.
Until then this only re-verifies the paperless regression check.

docs/kb/modules/05-faza-mailowa-plan.md, §8.
This commit is contained in:
oskar 2026-07-22 19:04:02 +02:00
parent a95524c363
commit 56f64e9077
2 changed files with 134 additions and 27 deletions

View file

@ -71,3 +71,19 @@ queries:
note: > note: >
Poprawnie na granicy "brak" w pilocie -- semantycznie sąsiednie dokumenty wspólnoty Poprawnie na granicy "brak" w pilocie -- semantycznie sąsiednie dokumenty wspólnoty
mieszkaniowej, nie odpowiedź na zapytanie. mieszkaniowej, nie odpowiedź na zapytanie.
# 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.
#
# Format wpisu (identyczny co do pól z `queries:` powyżej):
# - id: "M1"
# text: "..."
# kind: hit
# expected_envelope: "<raw gmail Message-ID>"
# note: "..."
mail_queries: []

View file

@ -1,16 +1,23 @@
"""Retrieval quality gate -- module 5, phase 3, plan §6.2 (docs/kb/modules/05-faza3-plan.md). """Retrieval quality gate -- module 5, phase 3, plan §6.2 (docs/kb/modules/05-faza3-plan.md),
extended in faza mailowa Krok 5 (docs/kb/modules/05-faza-mailowa-plan.md, §8) to add the
`hybrid` track once mail content exists in `document_chunk` (faza mailowa Krok 2/4).
Read-only integration script (NOT collected by pytest -- it hits the live kb-postgres DB and Read-only integration script (NOT collected by pytest -- it hits the live kb-postgres DB and
the live Ollama instance, exactly like the plan asked for a separate eval script rather than a the live Ollama instance, exactly like the plan asked for a separate eval script rather than a
mocked test). Runs every query in `queries.yaml` through both the flat baseline mocked test). Runs every query in `queries.yaml`'s `queries:` list through flat
(`kb_retrieval.retrieval.flat_query`) and the cascade (`cascade_query`), for a sweep of N (`kb_retrieval.retrieval.flat_query`), cascade (`cascade_query`, swept over N), and hybrid
values, and checks the plan's three gate criteria: (`hybrid_query`), and checks the gate criteria:
1. every query the flat path hits (top-1 dist < 0.45) must still be a hit in the cascade 1. every query the flat path hits (top-1 dist < 0.45) must still be a hit in cascade AND in
(no degradation into the grey zone or a miss); hybrid (no degradation into the grey zone or a miss) -- faza mailowa's key risk: does
adding mail chunks to the HNSW index degrade existing paperless retrieval?
2. hit@3 (expected envelope among the top-3 *distinct* envelopes by best distance) for the 2. hit@3 (expected envelope among the top-3 *distinct* envelopes by best distance) for the
cascade must be >= hit@3 for the flat baseline, across the whole set; cascade must be >= hit@3 for the flat baseline, across the whole `queries:` set;
3. both negative controls must stay above 0.55 in both tracks. 3. both negative controls must stay above 0.55 in flat, cascade, AND hybrid;
4. (faza mailowa, only when `mail_queries` in queries.yaml is non-empty) hit@3 in hybrid for
the operator-supplied mail queries must be >= 4/5 (or a proportional threshold for fewer
queries). An empty `mail_queries` list skips this criterion with an explicit note in the
report, rather than silently passing or failing on absent data.
`envelope`, `document_chunk`, and `document_summary` are only ever `SELECT`ed -- this script `envelope`, `document_chunk`, and `document_summary` are only ever `SELECT`ed -- this script
writes nothing. Query embeddings go through Ollama on localhost/SOLARIA (bge-m3), same as writes nothing. Query embeddings go through Ollama on localhost/SOLARIA (bge-m3), same as
@ -25,6 +32,7 @@ from __future__ import annotations
import argparse import argparse
import asyncio import asyncio
import json import json
import math
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
@ -45,10 +53,12 @@ from kb_retrieval.retrieval import ( # noqa: E402
DEFAULT_SUMMARY_MODEL, DEFAULT_SUMMARY_MODEL,
cascade_query, cascade_query,
flat_query, flat_query,
hybrid_query,
) )
HIT_THRESHOLD = 0.45 HIT_THRESHOLD = 0.45
NO_ANSWER_THRESHOLD = 0.55 NO_ANSWER_THRESHOLD = 0.55
MAIL_HIT3_MIN_FRACTION = 0.8 # plan §8: ">= 4/5 (lub 3/3-4/4 przy mniejszej liczbie)"
DEFAULT_QUERIES_PATH = Path(__file__).resolve().parent / "queries.yaml" DEFAULT_QUERIES_PATH = Path(__file__).resolve().parent / "queries.yaml"
@ -58,6 +68,13 @@ def load_queries(path: Path) -> list[dict]:
return data["queries"] return data["queries"]
def load_mail_queries(path: Path) -> list[dict]:
"""Placeholder section (plan §8, Krok 5) -- empty until the operator supplies 3-5 queries."""
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f)
return data.get("mail_queries") or []
def top1_dist(chunks: list[dict]) -> Optional[float]: def top1_dist(chunks: list[dict]) -> Optional[float]:
return min((c["dist"] for c in chunks), default=None) return min((c["dist"] for c in chunks), default=None)
@ -77,7 +94,7 @@ def hit_at_3(chunks: list[dict], expected_envelope: Optional[str]) -> Optional[b
return expected_envelope in {eid for eid, _ in top3} return expected_envelope in {eid for eid, _ in top3}
async def run_query_both_tracks( async def run_query_all_tracks(
conn: asyncpg.Connection, conn: asyncpg.Connection,
session: aiohttp.ClientSession, session: aiohttp.ClientSession,
ollama_url: str, ollama_url: str,
@ -94,13 +111,18 @@ async def run_query_both_tracks(
conn, session, ollama_url, query["text"], conn, session, ollama_url, query["text"],
summary_model=summary_model, embed_model=embed_model, n=n, k=k, summary_model=summary_model, embed_model=embed_model, n=n, k=k,
) )
return {"query": query, "flat": flat, "cascades": cascades} hybrid = await hybrid_query(
conn, session, ollama_url, query["text"],
summary_model=summary_model, embed_model=embed_model, k=k,
)
return {"query": query, "flat": flat, "cascades": cascades, "hybrid": hybrid}
def summarize_query_result(result: dict) -> dict: def summarize_query_result(result: dict) -> dict:
query = result["query"] query = result["query"]
expected = query["expected_envelope"] expected = query["expected_envelope"]
flat_chunks = result["flat"]["chunks"] flat_chunks = result["flat"]["chunks"]
hybrid_chunks = result["hybrid"]["chunks"]
row = { row = {
"id": query["id"], "id": query["id"],
@ -109,6 +131,8 @@ def summarize_query_result(result: dict) -> dict:
"expected_envelope": expected, "expected_envelope": expected,
"flat_top1_dist": top1_dist(flat_chunks), "flat_top1_dist": top1_dist(flat_chunks),
"flat_hit3": hit_at_3(flat_chunks, expected), "flat_hit3": hit_at_3(flat_chunks, expected),
"hybrid_top1_dist": top1_dist(hybrid_chunks),
"hybrid_hit3": hit_at_3(hybrid_chunks, expected),
"cascade": {}, "cascade": {},
} }
for n, cascade_result in result["cascades"].items(): for n, cascade_result in result["cascades"].items():
@ -122,22 +146,28 @@ def summarize_query_result(result: dict) -> dict:
return row return row
def evaluate_gate(rows: list[dict], gate_n: int) -> dict: def evaluate_gate(rows: list[dict], gate_n: int, mail_rows: Optional[list[dict]] = None) -> dict:
"""Plan §6.2's three criteria, evaluated at one chosen N (the sweep is diagnostic, the """Plan §6.2/§8's criteria, evaluated at one chosen N (the sweep is diagnostic, the gate
gate verdict is always for one specific configuration).""" verdict is always for one specific configuration). `mail_rows` is None or empty when the
operator hasn't supplied `mail_queries` yet (plan §8 Krok 5 placeholder) -- criterion 4 is
then skipped with an explicit note, never silently PASSed or FAILed on absent data."""
reasons = [] reasons = []
# 1. no flat hit (dist < 0.45) may degrade under the cascade. # 1. no flat hit (dist < 0.45) may degrade under cascade OR hybrid (faza mailowa's core
# regression risk: does the mail chunk mass added to HNSW push existing hits out?).
degraded = [] degraded = []
for row in rows: for row in rows:
flat_dist = row["flat_top1_dist"] flat_dist = row["flat_top1_dist"]
if flat_dist is not None and flat_dist < HIT_THRESHOLD: if flat_dist is not None and flat_dist < HIT_THRESHOLD:
cascade_dist = row["cascade"][gate_n]["top1_dist"] cascade_dist = row["cascade"][gate_n]["top1_dist"]
if cascade_dist is None or cascade_dist >= HIT_THRESHOLD: if cascade_dist is None or cascade_dist >= HIT_THRESHOLD:
degraded.append(row["id"]) degraded.append(f"{row['id']} (cascade)")
hybrid_dist = row["hybrid_top1_dist"]
if hybrid_dist is None or hybrid_dist >= HIT_THRESHOLD:
degraded.append(f"{row['id']} (hybrid)")
criterion_1 = not degraded criterion_1 = not degraded
if not criterion_1: if not criterion_1:
reasons.append(f"criterion 1 FAILED: flat hits degraded under cascade N={gate_n}: {degraded}") reasons.append(f"criterion 1 FAILED: flat hits degraded: {degraded}")
# 2. hit@3 (cascade) >= hit@3 (flat), over queries that have an expected envelope. # 2. hit@3 (cascade) >= hit@3 (flat), over queries that have an expected envelope.
scored = [row for row in rows if row["expected_envelope"] is not None] scored = [row for row in rows if row["expected_envelope"] is not None]
@ -150,7 +180,7 @@ def evaluate_gate(rows: list[dict], gate_n: int) -> dict:
f"< flat={flat_hit3_count}/{len(scored)} (N={gate_n})" f"< flat={flat_hit3_count}/{len(scored)} (N={gate_n})"
) )
# 3. negative controls stay above 0.55 in both tracks. # 3. negative controls stay above 0.55 in flat, cascade, AND hybrid.
control_failures = [] control_failures = []
for row in rows: for row in rows:
if row["kind"] not in ("negative_control", "negative_control_borderline"): if row["kind"] not in ("negative_control", "negative_control_borderline"):
@ -160,28 +190,60 @@ def evaluate_gate(rows: list[dict], gate_n: int) -> dict:
cascade_dist = row["cascade"][gate_n]["top1_dist"] cascade_dist = row["cascade"][gate_n]["top1_dist"]
if cascade_dist is not None and cascade_dist <= NO_ANSWER_THRESHOLD: 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}") control_failures.append(f"{row['id']} cascade(N={gate_n})={cascade_dist:.4f}")
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}")
criterion_3 = not control_failures criterion_3 = not control_failures
if not criterion_3: 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 {NO_ANSWER_THRESHOLD}: {control_failures}")
passed = criterion_1 and criterion_2 and criterion_3 # 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.
mail_rows = mail_rows or []
if mail_rows:
mail_hit3_count = sum(1 for row in mail_rows if row["hybrid_hit3"])
required = math.ceil(len(mail_rows) * MAIL_HIT3_MIN_FRACTION)
criterion_4 = mail_hit3_count >= required
criterion_4_detail = {
"hit3": mail_hit3_count, "total": len(mail_rows), "required": required, "skipped": False,
}
if not criterion_4:
reasons.append(
f"criterion 4 FAILED: mail hit@3 (hybrid) = {mail_hit3_count}/{len(mail_rows)}, "
f"required >= {required}"
)
else:
criterion_4 = True # does not block PASS -- see note below
criterion_4_detail = {"hit3": 0, "total": 0, "required": 0, "skipped": True}
reasons.append(
"criterion 4 SKIPPED: mail_queries is empty in queries.yaml -- operator hasn't "
"supplied the 3-5 mail queries yet (plan §8, Krok 5). Gate PASS below reflects "
"only the paperless regression check, not full plan §8 completion."
)
passed = criterion_1 and criterion_2 and criterion_3 and criterion_4
return { return {
"gate_n": gate_n, "gate_n": gate_n,
"passed": passed, "passed": passed,
"criterion_1_no_degradation": criterion_1, "criterion_1_no_degradation": criterion_1,
"criterion_2_hit3": {"cascade": cascade_hit3_count, "flat": flat_hit3_count, "total": len(scored)}, "criterion_2_hit3": {"cascade": cascade_hit3_count, "flat": flat_hit3_count, "total": len(scored)},
"criterion_3_negative_controls": criterion_3, "criterion_3_negative_controls": criterion_3,
"criterion_4_mail_hit3": criterion_4_detail,
"reasons": reasons, "reasons": reasons,
} }
def print_report(rows: list[dict], n_values: list[int], gate_result: dict) -> None: def print_report(
rows: list[dict], n_values: list[int], gate_result: dict, mail_rows: Optional[list[dict]] = None
) -> None:
print("=" * 100) print("=" * 100)
print("RETRIEVAL QUALITY GATE -- plan §6.2 (docs/kb/modules/05-faza3-plan.md)") print("RETRIEVAL QUALITY GATE -- plan §6.2 (docs/kb/modules/05-faza3-plan.md), "
"+ hybrid/mail extension (05-faza-mailowa-plan.md §8)")
print("=" * 100) print("=" * 100)
header = f"{'id':<3} {'kind':<28} {'expected':<16} {'flat d1':>8} {'flat@3':>7}" header = f"{'id':<3} {'kind':<28} {'expected':<16} {'flat d1':>8} {'flat@3':>7}"
for n in n_values: for n in n_values:
header += f" N={n:<3} d1 hit3" header += f" N={n:<3} d1 hit3"
header += " hybrid d1 hyb@3"
print(header) print(header)
for row in rows: for row in rows:
flat_d1 = f"{row['flat_top1_dist']:.4f}" if row["flat_top1_dist"] is not None else "--" flat_d1 = f"{row['flat_top1_dist']:.4f}" if row["flat_top1_dist"] is not None else "--"
@ -192,26 +254,43 @@ def print_report(rows: list[dict], n_values: list[int], gate_result: dict) -> No
d1 = f"{c['top1_dist']:.4f}" if c["top1_dist"] is not None else "--" d1 = f"{c['top1_dist']:.4f}" if c["top1_dist"] is not None else "--"
h3 = "y" if c["hit3"] else ("--" if c["hit3"] is None else "n") h3 = "y" if c["hit3"] else ("--" if c["hit3"] is None else "n")
line += f" {d1:>8} {h3:>4}" line += f" {d1:>8} {h3:>4}"
hyb_d1 = f"{row['hybrid_top1_dist']:.4f}" if row["hybrid_top1_dist"] is not None else "--"
hyb_h3 = "y" if row["hybrid_hit3"] else ("--" if row["hybrid_hit3"] is None else "n")
line += f" {hyb_d1:>9} {hyb_h3:>6}"
print(line) print(line)
print("-" * 100) print("-" * 100)
print(f"\nGate verdict at N={gate_result['gate_n']}, k={DEFAULT_K}:") print(f"\nGate verdict at N={gate_result['gate_n']}, k={DEFAULT_K}:")
print(f" criterion 1 (no flat hit degrades) : {'PASS' if gate_result['criterion_1_no_degradation'] else 'FAIL'}") print(f" criterion 1 (no flat hit degrades, cascade+hybrid): {'PASS' if gate_result['criterion_1_no_degradation'] else 'FAIL'}")
h3 = gate_result["criterion_2_hit3"] h3 = gate_result["criterion_2_hit3"]
print(f" criterion 2 (hit@3 cascade >= flat) : cascade={h3['cascade']}/{h3['total']} flat={h3['flat']}/{h3['total']} " 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'}") 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}) : {'PASS' if gate_result['criterion_3_negative_controls'] else 'FAIL'}") print(f" criterion 3 (negative controls > {NO_ANSWER_THRESHOLD}, 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)")
else:
print(f" criterion 4 (mail hit@3 in hybrid) : {c4['hit3']}/{c4['total']} (required >= {c4['required']}) "
f"-> {'PASS' if gate_result['criterion_1_no_degradation'] else 'FAIL'}")
print(f"\n OVERALL: {'PASS' if gate_result['passed'] else 'FAIL'}") print(f"\n OVERALL: {'PASS' if gate_result['passed'] else 'FAIL'}")
for reason in gate_result["reasons"]: for reason in gate_result["reasons"]:
print(f" - {reason}") print(f" - {reason}")
if mail_rows:
print("\nMail queries (hybrid only, plan §8 criterion 4):")
for row in mail_rows:
d1 = f"{row['hybrid_top1_dist']:.4f}" if row["hybrid_top1_dist"] is not None else "--"
h3 = "y" if row["hybrid_hit3"] else ("--" if row["hybrid_hit3"] is None else "n")
print(f" {row['id']:<4} {row['text']:<50} d1={d1:>8} hit3={h3}")
print("\nCost per query: flat = 1 embed + 1 SQL query. " print("\nCost per query: flat = 1 embed + 1 SQL query. "
f"cascade = 1 embed (shared) + 2 SQL queries (stage1 top-N summaries, stage2 top-k chunks). " f"cascade = 1 embed (shared) + 2 SQL queries (stage1 top-N summaries, stage2 top-k chunks). "
"Cascade never costs an extra Ollama call, only one extra SQL round-trip.") "hybrid = 1 embed (shared) + cascade's queries + 1 extra SQL query (mail branch).")
async def main_async(args: argparse.Namespace) -> dict: async def main_async(args: argparse.Namespace) -> dict:
queries = load_queries(Path(args.queries)) queries = load_queries(Path(args.queries))
mail_queries = load_mail_queries(Path(args.queries))
n_values = [int(n) for n in args.n_sweep.split(",")] n_values = [int(n) for n in args.n_sweep.split(",")]
if args.gate_n not in n_values: if args.gate_n not in n_values:
n_values = sorted(set(n_values) | {args.gate_n}) n_values = sorted(set(n_values) | {args.gate_n})
@ -221,19 +300,31 @@ async def main_async(args: argparse.Namespace) -> dict:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60)) as session: async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60)) as session:
results = [] results = []
for query in queries: for query in queries:
result = await run_query_both_tracks( result = await run_query_all_tracks(
conn, session, args.ollama_url, query, conn, session, args.ollama_url, query,
summary_model=args.summary_model, embed_model=args.embed_model, summary_model=args.summary_model, embed_model=args.embed_model,
n_values=n_values, k=args.k, n_values=n_values, k=args.k,
) )
results.append(summarize_query_result(result)) results.append(summarize_query_result(result))
mail_results = []
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))
finally: finally:
await conn.close() await conn.close()
gate_result = evaluate_gate(results, gate_n=args.gate_n) gate_result = evaluate_gate(results, gate_n=args.gate_n, mail_rows=mail_results)
print_report(results, n_values, gate_result) print_report(results, n_values, gate_result, mail_rows=mail_results)
output = {"rows": results, "gate": gate_result, "n_sweep": n_values, "k": args.k} output = {
"rows": results, "mail_rows": mail_results, "gate": gate_result,
"n_sweep": n_values, "k": args.k,
}
if args.json_out: if args.json_out:
Path(args.json_out).write_text(json.dumps(output, indent=2, default=str), encoding="utf-8") Path(args.json_out).write_text(json.dumps(output, indent=2, default=str), encoding="utf-8")
print(f"\nFull JSON written to {args.json_out}") print(f"\nFull JSON written to {args.json_out}")