homelab-codex-ws/docs/kb/modules/05-faza-mailowa-plan.md
oskar 8a44dfe3fe 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>
2026-07-23 16:17:16 +02:00

36 KiB
Raw Blame History

Moduł 5, faza mailowa — treść maili w retrievalu (RECON + PLAN)

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 HTTP", 2026-07-22, docs/sessions/2026-07-22.md). Faza mailowa = odpowiedź na feedback operatora z POC wyszukiwarki: „mało danych, brak połączeń". 225 030 kopert gmail ma dziś w bazie tylko nagłówki — treści leżą wyłącznie w archiwum .eml na PIHA. Ta faza wprowadza treści maili do document_chunk i udostępnia je w retrievalu. To nadal wyszukiwarka, nie chat — synteza, Drive Takeout i backfill 70k załączników PDF pozostają poza zakresem (§11).


1. Stan faktyczny (recon, zweryfikowany w repo i na żywo 2026-07-22)

1.1 Baza — co jest, czego nie ma

Live (docker exec kb-postgres psql na PIHA):

Miara Wartość
envelope source='gmail' 225 030 (100% z entities[type=headers] po backfillu)
envelope source='paperless' 191
document_chunk 2 767 (2 627 aktywnych, 130 duplicate, 10 ocr_junk; 2 765 z embeddingiem) — wszystkie paperless
document_summary 317 (162 claude-haiku-4-5 + 155 gemma3:12b) — wszystkie paperless
Rozmiar bazy kb 250 MB
Dysk PIHA /home 410 GB, wolne 144 GB
RAM PIHA 8 GB (dostępne ~3,8 GB; obok HA, Paperless, kb-postgres mem_limit: 1g)

Treści maili NIE ma w bazie. gmail-bulk-import celowo pomijał inline text/plain/text/html (_parse_attachments robi continue na częściach tekstowych) — do DB trafił tylko manifest załączników, potem backfill dopisał nagłówki. Ta luka jest dokładnie tym, co faza mailowa wypełnia.

1.2 Archiwum .eml — jedyne źródło treści

  • /home/oskar/kb/mail/archive/gmail/YYYY/MM/<sanitized_message_id>.eml225 057 plików, 27 GB (append-only; 27 plików to duplikaty Message-ID, które w DB skleiły się przez ON CONFLICT (id) DO NOTHING).
  • Mapowanie koperta→plik: envelope.raw_ref (ścieżka względna) + archive root. Rejestrem jest sama kolumna — osobnego manifestu nie ma i nie potrzeba.
  • Rozkład rozmiarów plików (pełny skan find -printf '%s'): p50 = 24 KB, p90 = 116 KB, p99 = 1,9 MB, średnia 125 KB — ale to rozmiary Z załącznikami (base64), więc NIE nadają się do szacowania chunków. Stąd pomiar §1.3.

1.3 Zmierzony rozkład długości TREŚCI (próbka 600 losowych .eml, parse na PIHA)

Deterministyczna próbka 600 plików, parser stdlib email (policy.default z fallbackiem compat32 — wzorzec z backfillu), HTML→tekst własnym HTMLParser, 0 błędów parsowania:

Miara Wartość
Długość body (znaki): p25 / p50 / p75 / p90 / p99 656 / 1 616 / 3 752 / 8 894 / 24 796
Średnia 3 335 znaków
Puste body (attachment-only itp.) 0,3%
HTML-only (bez części text/plain) 15%
Sygnał newslettera (List-Unsubscribe OR List-Id OR Precedence: bulk/list) 40% maili, 48,5% wolumenu tekstu (w dekadzie 202x: 64% maili!)
Maile z >30% linii cytowanych (>) 31%

Ekstrapolacja chunków (600 tok / 150 overlap = 2400/600 znaków, jak paperless): ~496k chunków dla całości, ~271k bez newsletterów (2,02,5 chunka/mail). Bez obcinania cytowań — po obcięciu (Decyzja 2) realnie mniej. Mediana maila (1,6k znaków) = 1 chunk.

Rozkład kopert po latach (SQL, envelope.ts): 20112012 szczyt (~23k/rok), ostatnie 12 miesięcy = 13 712 maili, epoch_fallback (1970) = 2 559.

1.4 Ollama SOLARIA — batch embed ZMIERZONY, działa

  • Ollama 0.32.0, RTX 4070 Ti SUPER 16 GB (driver 595.71.05, GPU żywe).
  • /api/embed z input jako listą działa. Benchmark (bge-m3, na żywo):
Długość tekstu batch=1 batch=32 batch=64 batch=128
~900 znaków 148 ms 9,8 ms/szt 8,5 ms/szt 6,6 ms/szt
~3500 znaków 160 ms 26,8 ms/szt 18,1 ms/szt

Wniosek: batch 64 daje ~818 ms/chunk zamiast 207 ms sekwencyjnie — ~271k aktywnych chunków ≈ 11,5 h GPU (vs ~16 h sekwencyjnie). Batching przestaje być ryzykiem, jest zmierzonym faktem.

  • Obecny klient (packages/kb-retrieval/src/kb_retrieval/embed.py::embed_chunk) używa starego endpointu /api/embeddings z pojedynczym prompt — wymaga rozszerzenia o embed_batch (Krok 1).
  • Kontekst: bge-m3 ma limit 8192 tok; chunk 600 tok — bez ryzyka obcięcia.
  • Ollama@SOLARIA ma udokumentowaną niestabilność (3 incydenty w tydzień: zniknięcie kontenera, network-detach, bind-race przy starcie) — run musi być wznawialny i logowany do pliku (inwarianty §1.5).

1.5 Wzorce jobów — dziedzictwo obowiązkowe

Z gmail-bulk-import / gmail-header-backfill / chunk_embed (rodzina jobów, faza 3 §1.4):

  • dry-run domyślny, --apply jawnie; --limit/--offset po stabilnym ORDER BY id; idempotencja = pre-fetch istniejących kluczy + ON CONFLICT DO NOTHING (z parsowaniem command taga); bilans statystyk jako inwariant (suma wyników = scanned, stats_mismatch → niezerowy exit); izolacja błędów per wiersz (lekcja 4999 wierszy: json.dumps i wszystko per-row W try); zawsze > run.log 2>&1, nigdy goły tmux.
  • Hardening 8-bitowych nagłówków: typed parse (policy.default) z fallbackiem compat32 + kb_mail.text.sanitize_surrogates — korpus udowodnił, że zawiera surowe bajty 8-bit w nagłówkach (9 maili wymaga fallbacku).
  • Znany wart do naprawy w nowym jobie: pre-fetch kluczy w chunk_embed pomija model (README to dokumentuje) — job mailowy od początku kluczuje (envelope_id, chunk_index, model).

1.6 Schema document_chunk — GOTOWA, migracje NIEPOTRZEBNE

Zweryfikowane \d na żywej bazie: UNIQUE (envelope_id, chunk_index, model) (migracja 003 zaaplikowana), excluded_reason TEXT obecne, FK envelope(id) ON DELETE CASCADE, HNSW vector_cosine_ops (domyślne m=16, ef_construction=64). excluded_reason nie ma CHECK-a — nowa wartość 'newsletter' (Decyzja 4) nie wymaga ALTER-a. Zero migracji w tej fazie.

1.7 Retrieval — koperty bez summary są dziś NIEWIDOCZNE w kaskadzie

cascade_retrieve (packages/kb-retrieval/src/kb_retrieval/retrieval.py:57-100): stage 2 rankuje wyłącznie chunki kopert wyłonionych w stage 1 z document_summary WHERE model='claude-haiku-4-5'. Koperta bez streszczenia nigdy nie wejdzie do wyniku kaskady (docstring mówi to wprost). flat je widzi, ale w UI to tryb debug. kb-query domyślnie mode=cascade. Inwariant startowy startup.py (bge-m3 w document_chunk.model I document_summary.embedding_model) pozostaje spełniony przez paperless — dolanie chunków mailowych bez summaries go nie łamie.


2. Otwarte decyzje dla Oskara (z rekomendacjami)

Decyzja 1 — Skąd treść: ponowny MIME-walk archiwum .eml, ekstrakcja stdlib

Rekomendacja: nowy przebieg po archiwum .eml (nie po mbox), parser stdlib z dziedziczonym hardeningiem, HTML→tekst własnym html.parser.HTMLParser.

Uzasadnienie:

  • Archiwum jest źródłem kanonicznym z gotowym mapowaniem raw_ref; re-run mboxa to pełny rebuild indeksu 27 GB przy każdym otwarciu (lekcja z sesji importu) i brak związku z id kopert.
  • Ekstrakcja body = dokładnie te części, które _parse_attachments dziś pomija: inline text/plain preferowane; gdy mail jest HTML-only (15%), HTML→tekst. Charset: get_content_charset() z errors="replace" + sanitize_surrogates (korpus ma łamane kodowania — udowodnione).
  • HTML→tekst: własny HTMLParser (pomija style/script/head, skleja tekst, redukuje whitespace) — zero nowych zależności, zweryfikowany na próbce 600 (0 błędów). Newslettery HTML dają po konwersji czysty tekst nawigacyjny, ale te i tak podlegają Decyzji 4.
  • Typed parse policy.default (dekoduje RFC 2047) z fallbackiem compat32 — wzorzec 1:1 z gmail-header-backfill, razem z jego testami regresyjnymi.

Odrzucona alternatywa: biblioteki html2text/beautifulsoup/talon — nowe zależności w venv na dwóch nodach dla problemu, który stdlib rozwiązuje wystarczająco dobrze na tym korpusie.

Decyzja 2 — Cytowania-łańcuszki: TAK, obcinać

Rekomendacja: obcinać quoted reply chains przed chunkowaniem, własną heurystyką (bez zależności), z licznikiem w bilansie.

Uzasadnienie:

  • 31% maili ma >30% linii cytowanych. Bez obcinania każda odpowiedź w wątku dubluje treść poprzedników — chunki zdominowane powtórzeniami, retrieval zwraca N kopii tego samego akapitu z różnych kopert.
  • Heurystyka: (a) linie ^\s*>; (b) wszystko od markera odpowiedzi w dół: On ... wrote:, Dnia ... napisał(a):, W dniu ... pisze:, -----Original Message-----, ________________________________ (Outlook); (c) w HTML: poddrzewa div.gmail_quote / blockquote przed konwersją.
  • Odwracalne: archiwum nietknięte; zmiana heurystyki = re-run joba (idempotencja po kluczu z model — nowa wersja chunkera może iść pod nowym model-tagiem lub po DELETE starych chunków gmail — decyzja operacyjna przy re-runie).
  • Bilans: quoted_chars_stripped sumarycznie + per-mail flaga w logu, żeby bramka jakościowa mogła wykryć nadgorliwe cięcie.

Odrzucona alternatywa: talon (Mailgun) — cięższy, nieutrzymywany, uczony na korpusie EN; nasza heurystyka musi znać polskie markery.

Decyzja 3 — Nagłówki jako kontekst chunka: TAK, prefiks w każdym chunku

Rekomendacja: każdy chunk maila zaczyna się od jednej linii kontekstu Temat: … | Od: … | Data: YYYY-MM-DD, budowanej z entities[type=headers] (już w DB — zero ponownego parsowania nagłówków).

Uzasadnienie:

  • bge-m3 embeduje chunk w izolacji; środkowy chunk długiego maila bez tematu i nadawcy traci sens zapytań typu „mail od X o Y". Prefiks kosztuje ~100200 znaków z budżetu 2400 (48%).
  • Prefiks w document_chunk.text (nie osobna kolumna) — trafia też do wyników kb-query, co od razu poprawia czytelność UI dla maili.

Odrzucona alternatywa: goły body (tańsze o 5% tokenów, gubi kontekst); prefiks tylko w chunk_index=0 (niespójne — retrieval zwraca pojedyncze chunki).

Decyzja 4 — Newslettery: chunkować, NIE embedować (excluded_reason='newsletter')

Rekomendacja: tanie kryterium nagłówkowe — List-Unsubscribe OR List-Id OR Precedence: bulk|list ⇒ chunki zapisane z excluded_reason='newsletter' i embedding=NULL (bez kosztu GPU, poza HNSW i retrievalem). Reszta korpusu embedowana w całości.

Uzasadnienie:

  • Kryterium jest darmowe (nagłówki czytamy i tak), deterministyczne i zmierzone: łapie 40% maili niosących 48,5% wolumenu tekstu — w tym praktycznie cały szum komercyjny, który zatapiałby retrieval (feedback „mało danych" nie znaczy „chcę promocji z 2019").
  • Odwracalne w obie strony: tekst chunków newsletterów JEST w bazie — jeśli operator zechce ich szukać, wystarczy UPDATE … SET excluded_reason=NULL WHERE excluded_reason='newsletter' + doembedowanie (backlog-query cyclic ingest ich nie widzi, bo filtruje excluded_reason IS NULL — nie zawyżą metryki kb_ingest_embed_backlog).
  • Koszt magazynowy flagowanych chunków: ~225k wierszy × ~1,5 KB tekstu ≈ 0,5 GB — akceptowalny za odwracalność.
  • Spam: Takeout „All Mail" nie zawiera folderu Spam — osobna kategoria nie jest potrzebna; epoch_fallback (2 559 maili z ts=1970) chunkujemy normalnie (data w prefiksie z date_raw, jeśli jest).

Odrzucona alternatywa: embedować wszystko (2× GPU, szum w wynikach — a i tak odwracalne tylko przez excluded_reason); pomijać newslettery całkiem (nieodwracalne bez re-parse 27 GB).

Decyzja 5 — Batching embeddingów: /api/embed, batch 64, sekwencyjnie

Rekomendacja: nowa funkcja embed_batch(session, base_url, model, texts) -> list[vector] w packages/kb-retrieval/embed.py na endpoint /api/embed (input jako lista), batch 64, batche sekwencyjnie (bez równoległości HTTP).

Uzasadnienie:

  • Zmierzone na żywo (§1.4): batch 64 = 818 ms/chunk, ~1122× szybciej niż obecna ścieżka; 271k chunków ≈ 11,5 h. Równoległość pojedynczych requestów nie jest potrzebna — GPU i tak saturuje się batchem, a sekwencyjny pętla = prostszy bilans i wznawialność.
  • embed_chunk (pojedynczy) zostaje bez zmian dla kb-query (zapytanie użytkownika = 1 tekst) i dla cyclic ingest paperless.
  • Plan benchmarku przed pełnym runem (wzorzec „zmierz, obejrzyj, dopiero wtedy zaufaj progowi"): pierwszy run Etapu A (§8) loguje avg_embed_ms_per_chunk per batch; jeśli >30 ms/chunk — stop i diagnoza (CPU fallback? model zewisiony?) zanim ruszy Etap B. Dodatkowo sanity-check wymiaru 1024 na KAŻDYM elemencie odpowiedzi batcha (guard EXPECTED_DIM jak w chunk_embed).

Odrzucona alternatywa: równoległe requesty na /api/embeddings (HTTP overhead, nieprzewidywalna kolejność błędów w bilansie); zewnętrzny serwis embeddingów (koszt, prywatność korpusu mailowego).

Decyzja 6 — Streszczenia maili: NIE. Kaskada dostaje tryb hybrydowy

Rekomendacja: maile NIE dostają document_summary. Zamiast tego kaskada w kb-retrieval zyskuje tryb hybrydowy: stage 1 po streszczeniach dla źródeł, które je mają (paperless), plus równoległy bezpośredni HNSW po chunkach źródeł bez streszczeń (gmail), scalenie po dist (ta sama metryka: cosine na bge-m3).

Uzasadnienie architektoniczne:

  • Kaskada powstała, by prefiltrować duże dokumenty OCR przez ich streszczenia. Mail to inny kształt danych: mediana 1 chunk/mail — „streszczenie" maila byłoby zwykle dłuższe od niego samego. Prefiltr nic nie wnosi, a HNSW po 271k chunków to wciąż milisekundy (log-scale).
  • Scalanie jest uczciwe: oba tory zwracają dist z tego samego embeddera i tej samej przestrzeni — merge top-k po min-dist bez normalizacji.
  • Zmiana w jednym miejscu: packages/kb-retrieval/retrieval.py (nowa funkcja hybrid_retrieve / hybrid_query; stage 2 obecnej kaskady nietknięty)
    • kb-query mode rozszerzony o hybrid (docelowo domyślny PO przejściu bramki, §8). Inwariant startowy startup.py bez zmian.

Ekonomia streszczeń, gdyby jednak (dla porządku, liczby do decyzji późniejszej):

  • Haiku 4.5 na 225k maili: ~1,7k tok input + ~150 tok output/mail → ~380M input + ~34M output ≈ ~550 USD (i zderzenie z capem $200/mies.).
  • gemma3:12b lokalnie: pilot paperless ~10 s/dok; maile krótsze, ~48 s → 1121 dni ciągłej pracy GPU. Nierealne dla całości.
  • Hybryda selektywna (np. non-newsletter z ostatnich 2 lat, ~2025k maili ≈ 6080 USD Haiku) — sensowna dopiero, jeśli bramka pokaże, że tryb hybrydowy nie wystarcza. Odłożona, nie odrzucona.

Odrzucona alternatywa: streszczenia całego korpusu (koszt/czas j.w.); wpuszczenie maili do obecnej kaskady bez zmian (są w niej niewidoczne — §1.7); przełączenie kb-query na flat (regresja jakości dla paperless, po to była faza 3).

Decyzja 7 — Nowy job jobs/mail-body-ingest/, chunker wydzielony do pakietu

Rekomendacja: nowy job jobs/mail-body-ingest/ (MIME-walk → quote-strip → chunk → klasyfikacja newsletter → batch embed → insert). Wspólny chunker (chunk_text, hard_split) wydzielony do packages/kb-mail (kb_mail/chunking.py); documents-ingest importuje go z pakietu.

Uzasadnienie:

  • chunk_embed jest spleciony z założeniem source='paperless' + entities[type=content]; adapter treści mailowej to inne źródło (pliki), inny preprocessing (quote-strip, HTML), inna pętla embed (batch). Wspólna jest tylko logika chunkowania — i ją wydzielamy (dokładnie wzorzec fazy 4: retrieval.pypackages/kb-retrieval).
  • is_ocr_junk zostaje w documents-ingest (progi kalibrowane na OCR, nie na mailach); mail-body-ingest ma własne, prostsze wykluczenie: body_empty.
  • Job czyta nagłówki z entities[type=headers] (prefiks, Decyzja 3) i klasyfikuje newsletter z surowego .eml (nagłówki List-* nie są w entities — są tanie do odczytu w trakcie i tak wykonywanego parse'u).
  • Rodzina jobów: pełny zestaw inwariantów §1.5 (dry-run domyślny, --apply, --limit/--offset po ORDER BY id, --since DATE dla etapowania po envelope.ts, bilans, log do pliku, testy na fixture'ach .eml z 8-bit nagłówkami / HTML-only / quoted-chain).

Odrzucona alternatywa: rozszerzanie documents-ingest o adapter mailowy (rozrost joba o dwóch tożsamościach); copy-paste chunkera (dryf dwóch implementacji — dokładnie to, czego faza 4 zabroniła dla retrievalu).

Decyzja 8 — Wykonanie: job na SOLARII, archiwum rsync-owane, zapis do PIHA

Rekomendacja: jednorazowy rsync archiwum PIHA→SOLARIA (27 GB po LAN ~5 min, SOLARIA ma 2 TB NVMe), job biegnie na SOLARII: parse na 24 rdzeniach, Ollama na localhost, zapis do kb-postgres@PIHA (--dsn …@piha:5433, batched executemany po 500 — wzorzec _insert_batch).

Uzasadnienie:

  • PIHA (Pi-klasa, 8 GB RAM, dźwiga HA + Paperless + kb-postgres) nie jest miejscem na godziny parsowania MIME 225k plików; SOLARIA i tak musi być włączona (GPU). Embed z localhost eliminuje 271k×(round-trip Tailscale).
  • Archiwum jest append-only → kopia jest spójnym snapshotem; lista roboczą i tak wyznacza DB (SELECT … FROM envelope WHERE source='gmail'), nie filesystem. missing_file w bilansie łapie ewentualny dryf kopii.
  • Zapis do PIHA przez Tailscale batchami — dokładnie tak dziś działa chunk_embed na SOLARII (kierunek przećwiczony).

Odrzucona alternatywa: NFS PIHA→SOLARIA (kruche przy 225k małych plików, nic nie daje vs snapshot); run na PIHA z batch embedem przez Tailscale (wykonalne — batch amortyzuje sieć — ale obciąża najbardziej krytyczny node floty na godziny).

Decyzja 9 — Etapowanie: najpierw ostatnie 12 miesięcy, bramka, potem reszta

Rekomendacja: Etap A = --since 2025-07-01 (13 712 maili → ~2530k chunków, z czego po filtrze newsletterów ~1012k aktywnych; embed <15 min) → bramka jakościowa (§8) + ręczna ocena operatora w kb-query → Etap B = pełne archiwum.

Uzasadnienie:

  • Ostatni rok to dane, o które operator realnie pyta; tanio weryfikuje cały łańcuch (quote-strip, prefiks, hybrydowy retrieval) zanim spalimy godziny GPU i ~5 GB bazy na dekady archiwum.
  • Uwaga kalibracyjna: w dekadzie 202x aż 64% maili ma sygnał newslettera — Etap A od razu pokaże, czy heurystyka nie tnie za szeroko (bilans per kategoria + przegląd próbki flagowanych).
  • Idempotencja sprawia, że Etap B to po prostu ten sam run bez --since — chunki Etapu A zostaną policzone jako already_embedded.

Odrzucona alternatywa: pełny run od razu (ryzyko odkrycia złej heurystyki po 496k chunkach); etapowanie po --offset (kolejność po id ≠ kolejność merytoryczna; ts jest indeksowane i czytelne).

Decyzja 10 — Threading przy okazji (odpowiedź na „brak połączeń"): TAK, tanio

Rekomendacja: podczas i tak wykonywanego MIME-walku dopisać do kopert entities[type=threading] z {in_reply_to, references[]} — wzorzec idempotentnego appendu 1:1 z gmail-header-backfill (WHERE NOT EXISTS type='threading').

Uzasadnienie: dziś NIC nie łączy maili w wątki (In-Reply-To/References nie są nigdzie zapisane — zweryfikowane grepem). To najtańszy krok w kierunku „połączeń" z feedbacku operatora: drugi odczyt 27 GB tylko po to byłby marnotrawstwem. Sama nawigacja po wątkach / graf to osobna przyszła praca (§11) — tu tylko zapisujemy surowiec.

Odrzucona alternatywa: osobny backfill później (drugi pełny odczyt archiwum); pominięcie (strata jedynej okazji taniego zbioru danych).


3. Krok 0 — wydzielenie chunkera do packages/kb-mail

  • packages/kb-mail/src/kb_mail/chunking.py: przeniesione 1:1 chunk_text, hard_split + stałe (2400/600) z documents_ingest/chunk_embed.py; chunk_embed importuje z pakietu (zero zmian zachowania, testy chunkera przeniesione + smoke że documents-ingest nadal przechodzi pytest).
  • Przy okazji NIE ruszamy is_ocr_junk ani klienta embed (Krok 1 osobno).

Szacunek: 0,5 sesji.

4. Krok 1 — embed_batch w packages/kb-retrieval

  • embed.py: embed_batch(session, base_url, model, texts: list[str]) na POST /api/embed ({"model": …, "input": [...]}), zwraca listę wektorów + czas; walidacja: len(embeddings) == len(texts), każdy wymiar == 1024 (w przeciwnym razie wyjątek klasy abort-run, jak EmbeddingDimensionError).
  • embed_chunk (pojedynczy, /api/embeddings) zostaje nietknięty — kb-query i cyclic ingest bez zmian.
  • Testy: mock aiohttp (kształt odpowiedzi /api/embed), mismatch długości, zły wymiar.

Szacunek: 0,5 sesji.

5. Krok 2 — job jobs/mail-body-ingest/

Pipeline per koperta (SELECT id, ts, raw_ref, entities FROM envelope WHERE source='gmail' [AND ts >= --since] ORDER BY id, --limit/--offset):

  1. Read: archive_root / raw_ref (--archive-root, default snapshotu na SOLARII); missing_file / read_error jak w backfillu.
  2. Parse: typed parse + fallback compat32 (wzorzec i testy z backfillu); body = inline text/plain, a gdy brak — HTML→tekst (HTMLParser stdlib); charset errors="replace" + sanitize_surrogates.
  3. Quote-strip (Decyzja 2) z licznikiem quoted_chars_stripped.
  4. Klasyfikacja: newsletter (nagłówki List-*/Precedence z tego samego parse'u); body_empty (po strippingu) → koperta liczona, zero chunków.
  5. Prefiks kontekstu (Decyzja 3) z entities[type=headers].
  6. Chunk: kb_mail.chunking.chunk_text (2400/600).
  7. Embed: tylko chunki nie-newsletter, embed_batch po 64 (batch może sklejać chunki wielu kopert — pętla buforuje do 64 i flushuje).
  8. Insert: INSERT … ON CONFLICT (envelope_id, chunk_index, model) DO NOTHING, batched po 500; newsletter → excluded_reason='newsletter', embedding=NULL; model = bge-m3. Pre-fetch kluczy Z modelem (naprawa warta z §1.5).
  9. Threading append (Decyzja 10): entities || [{"type":"threading",…}] z podwójną idempotencją (client-side check + WHERE NOT EXISTS).

Bilans (inwariant, exit 1 przy niedomknięciu):

mails_scanned = parse_errors + read_errors + missing_file + body_empty + mails_chunked
chunks_total  = chunks_inserted + chunks_newsletter_flagged
              + chunks_already_embedded + chunks_conflict_skipped + chunks_errors

CLI: --dsn/KB_DSN, --archive-root, --ollama-url, --model, --since, --limit, --offset, --batch-size (64), --apply (dry-run domyślnie: parse+chunk+count, zero Ollamy, zero writes). Log ZAWSZE do pliku.

Testy (jobs/mail-body-ingest/tests/): fixture'y .eml — 8-bit nagłówki, HTML-only, quoted-chain (gmail/outlook/polskie markery), newsletter (List-Unsubscribe), pusty body, multipart z załącznikiem; testy bilansu i idempotencji (drugi przebieg = zero insertów). Definition of Done z CLAUDE.md (build/smoke + pytest przed commitem).

Szacunek: 2 sesje.

6. Krok 3 — tryb hybrydowy w kb-retrieval + kb-query

  • retrieval.py: hybrid_retrieve(conn, query_vec, n, k, summary_model, summaryless_sources: list[str]) — stage 1+2 jak w kaskadzie, PLUS równoległe zapytanie: chunki kopert źródeł z summaryless_sources (JOIN envelope … WHERE source = ANY($…)), merge po dist, top-k; hybrid_query analogicznie do cascade_query (jeden embed zapytania).
  • kb-query: mode pattern ^(cascade|flat|hybrid)$; domyślny mode przełączany na hybrid dopiero po PASS bramki (§8) — do tego czasu hybrid dostępny jawnie. Wyniki gmail w UI już obsłużone (faza 4: subject/from z headers + „Kopiuj Message-ID").
  • Testy jednostkowe na mockach (merge, pusta gałąź summary, pusta gałąź mail).

Szacunek: 1 sesja.

7. Krok 4 — przygotowanie SOLARII + Etap A (ostatnie 12 miesięcy)

  • rsync archiwum PIHA→SOLARIA (rsync -a --info=stats po LAN; ~27 GB).
  • Run Etapu A: --since 2025-07-01 --apply z logiem do pliku; weryfikacja bilansu; kalibracja: przegląd ~20 flagowanych newsletterów i ~20 maili po quote-strip (czy heurystyki nie tną za szeroko), avg_embed_ms_per_chunk vs benchmark (§ Decyzja 5).
  • Weryfikacyjne SQL po runie (przykład):
-- ile kopert gmail ma chunki, w podziale na status
SELECT c.excluded_reason, count(*) chunks, count(DISTINCT c.envelope_id) mails
FROM document_chunk c JOIN envelope e ON e.id = c.envelope_id
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:

  • Operator dostarcza 35 zapytań mailowych (rzeczy, o których wie, że ma je w mailach z ostatniego roku — Etap A) → nowe wpisy kind: hit z expected_envelope: "<message-id>" (uwaga: envelope_id gmail = surowy Message-ID bez prefiksu source — inaczej niż paperless:N).
  • retrieval_eval.py uczy się trybu hybrid (trzecia ścieżka obok flat/cascade).
  • Kryteria PASS (wszystkie trzy tory na żywej bazie po Etapie A):
    1. Regresja zero: istniejące 7 zapytań paperless — żaden hit (<0,45) nie degraduje we flat ani w hybrid po dolaniu chunków mailowych (to mierzy realne ryzyko tej fazy: nowa masa wektorów konkuruje w HNSW).
    2. Zapytania mailowe: hit@3 w trybie hybrid dla ≥ 4/5 (lub 3/34/4 przy mniejszej liczbie), top1 dist < 0,45 dla większości.
    3. Kontrole negatywne (sernik, piaskownica) > 0,55 we wszystkich trybach.
  • PASS ⇒ kb-query przełącza domyślny mode na hybrid + zapis wyników w tym dokumencie (tabela | Kryterium | Wynik | Werdykt |).
  • FAIL ⇒ diagnoza przed Etapem B (podejrzani wg kolejności: zbyt szeroki quote-strip, brak prefiksu w praktyce, hnsw.ef_search do podbicia).

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. Oczekiwane: ~496k chunków total (~271k embedowanych; 12 h GPU + parse), Etap A policzony jako already_embedded.
  • Po runie: weryfikacyjne SQL (j.w.), VACUUM ANALYZE document_chunk, ponowny przebieg retrieval_eval.py (pełna regresja na 100% korpusu — dystrybucja starych dekad może przesunąć sąsiedztwa HNSW).
  • Obserwacja PIHA: rozmiar bazy (oczekiwane ~57 GB, dysk 144 GB — zapas

    20×), latencja /search w kb-query, RAM kb-postgres (mem_limit: 1g, shared_buffers=256MB). Świadomie bez prewencyjnego podnoszenia limitów — HNSW jest log-scale i 271k wektorów to wciąż mało; jeśli latencja zapytań zauważalnie wzrośnie, osobna mikro-decyzja o mem_limit 1g→1,5g (PIHA ma ~3,8 GB luzu, ale dzieli go z HA). REINDEX HNSW nie jest w planie (inserty inkrementalne); gdyby kiedyś był potrzebny — wymaga sesyjnego podbicia maintenance_work_mem (64 MB nie pomieści grafu ~1,1 GB) i godzin, co odnotowuję jako znany koszt odroczony.

Szacunek: 1 sesja (run w tle).

10. Krok 7 — IMAP/JMAP przyrostówka (zarys; szczegóły = osobny recon)

Zakotwiczone w kb-00 jako etapy 34 (jobs/fastmail-poller, jobs/gmail-imap-poller). Zarys decyzji do tamtego reconu:

  • Poll, nie IDLE: wzorzec systemd-timer jak kb-ingest (offline-tolerancja, brak długotrwałych połączeń); świeżość „raz na godzinę/dobę" wystarcza KB.
  • Fastmail przez JMAP (natywne, stronicowanie po state), source='fastmail'; Gmail przez IMAP (OAuth2 lub app-password — do rozstrzygnięcia tam).
  • Reuse wprost: kb_mail.save_eml (append-only, FileExistsError=skip) + insert_envelope (ON CONFLICT DO NOTHING, dedup po Message-ID jak w bulk-imporcie) + hardened parse + pipeline body z Kroku 2 jako biblioteka (przyrostówka od pierwszego dnia pisze chunki, nie tylko koperty).
  • Otwarte tam: sekrety (konta IMAP) w /opt/homelab/config/, częstotliwość, koperty fastmail sprzed epoki gmaila.

Szacunek: sam recon 1 sesja; poza kryterium ukończenia tej fazy.

11. Poza zakresem fazy mailowej

Temat Gdzie zakotwiczone Kiedy
Drive Takeout backlog KB osobny moduł
Backfill 70k załączników PDF → Paperless OCR → ingest 05-documents-ingest.md, DECYZJE #9 osobny pakiet (sizing OCR workera)
Synteza odpowiedzi / chat faza 5 po fazie mailowej
mail_ui_url (klikalny link do maila w UI) kb-00 etap 6, pole zarezerwowane w kb-query z modułem mail-UI
Graf wątków / entity_link z entities[type=threading] ta faza tylko zapisuje surowiec (Decyzja 10) przyszła faza „połączenia"
Streszczenia selektywne maili (hybryda Haiku) Decyzja 6 — odłożona po ocenie trybu hybrid w praktyce
IMAP/JMAP przyrostówka — implementacja Krok 7 (zarys) osobny recon + pakiet

12. Plan implementacji (kolejność = zależności)

# Krok Zależy od Szacunek
0 Chunker → packages/kb-mail 0,5 sesji
1 embed_batch w kb-retrieval 0,5 sesji
2 Job mail-body-ingest 0, 1 2 sesje
3 Tryb hybrid (kb-retrieval + kb-query) — (równolegle z 2) 1 sesja
4 rsync + Etap A (12 mies.) + kalibracja 2 1 sesja
5 Bramka jakościowa (eval mailowy + regresja) 3, 4 + zapytania od operatora 1 sesja
6 Etap B (pełne archiwum) + regresja + obserwacja PIHA 5 = PASS 1 sesja
7 Recon przyrostówki IMAP/JMAP — (po 6) 1 sesja (poza DoD fazy)

Kryterium ukończenia fazy mailowej: (a) pełny korpus gmail zchunkowany (bilans domknięty, parse_errors na poziomie pojedynczych sztuk jak w backfillu), (b) chunki nie-newsletter zembedowane bge-m3 i widoczne w trybie hybrid, (c) bramka §8 PASS wraz z regresją po Etapie B, (d) kb-query domyślnie odpowiada trybem hybrid na kb.kapala.org, (e) koperty gmail mają entities[type=threading].

13. Szacunki zbiorcze

  • Dane: +~496k wierszy document_chunk (~271k z embeddingiem, ~225k flagowanych newsletter); baza 250 MB → ~57 GB (dysk PIHA: 144 GB wolne, zapas >20×). HNSW rośnie inkrementalnie przy insertach — bez rebuildu.
  • GPU/czas runów: Etap A <1 h e2e; Etap B: parse ~0,51 h (24 rdzenie)
    • embed ~11,5 h (batch 64, zmierzone 818 ms/chunk) + inserty do PIHA.
  • Koszty zewnętrzne: 0 USD (bez streszczeń — Decyzja 6).
  • Czas sesyjny: ~7 sesji (kroki 06) + 1 recon przyrostówki.
  • Ryzyka: (1) regresja istniejących 7 zapytań po dolaniu ćwierci miliona wektorów — mierzona bramką na Etapie A ZANIM spalimy pełny run; (2) jakość HTML→tekst i quote-strip — kalibracja na próbce w Kroku 4; (3) niestabilność Ollama@SOLARIA (3 incydenty/tydzień) — idempotencja + bilans + log do pliku czynią każdy run wznawialnym; (4) latencja kb-postgres przy 1 GB mem_limit — obserwacja po Etapie B, decyzja o limicie osobno.

14. Podsumowanie dla Oskara

Treści Twoich 225 tysięcy maili leżą dziś martwe w 27 GB archiwum na PIHA — w bazie są tylko nagłówki, a wyszukiwarka z fazy 4 słusznie skarży się „mało danych". Ten plan wprowadza je do retrievalu w ~7 sesji i za 0 USD: ponowny przebieg po archiwum sprawdzonym parserem z backfillu, obcięcie łańcuszków cytowań (co trzeci mail jest nimi zdominowany), tani filtr newsletterów (40% korpusu, odwracalny — tekst zostaje w bazie), chunki z prefiksem Temat/Od/Data i batchowy embedding na GPU SOLARII, który zmierzyłem na żywo: zamiast 16 godzin sekwencyjnie — nieco ponad godzinę batchami po 64. Streszczeń NIE robimy (Haiku ≈ 550 USD, gemma lokalnie ≈ 2 tygodnie GPU) — zamiast tego kaskada dostaje tryb hybrydowy, w którym maile konkurują bezpośrednio chunkami. Etapowanie chroni jakość: najpierw ostatni rok i bramka (Twoje 35 pytań „wiem, że to mam w mailach" + zero regresji na 7 istniejących zapytaniach), dopiero potem dekady archiwum. Po drodze, przy okazji jedynego pełnego odczytu archiwum, zapisujemy In-Reply-To/References — surowiec pod „połączenia", których brakowało Ci w POC. Schema jest gotowa (zweryfikowane na żywej bazie — zero migracji), dysk na PIHA ma zapas dwudziestokrotny. Nic nie zostało zaimplementowane, zdeployowane ani pobrane w ramach tego recon — wynik to wyłącznie ten dokument.