Recon + plan wprowadzenia tresci 225k maili gmail do document_chunk: MIME-walk archiwum .eml, quote-strip, filtr newsletterow (excluded_reason), batch embed /api/embed (zmierzone 8-18 ms/chunk na SOLARII), tryb hybrydowy kaskady zamiast streszczen, etapowanie 12 mies. -> bramka -> reszta. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
32 KiB
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/embedna SOLARII,\d+ SELECT count na żywej bazie).Kontynuacja
05-faza4-plan.md(faza 4:packages/kb-retrievalwydzielone, serwiskb-queryz 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 dodocument_chunki 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>.eml— 225 057 plików, 27 GB (append-only; 27 plików to duplikaty Message-ID, które w DB skleiły się przezON 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,0–2,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): 2011–2012 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/embedzinputjako 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 ~8–18 ms/chunk zamiast 207 ms sekwencyjnie — ~271k aktywnych chunków ≈ 1–1,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/embeddingsz pojedynczymprompt— wymaga rozszerzenia oembed_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,
--applyjawnie;--limit/--offsetpo stabilnymORDER 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.dumpsi 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_embedpomijamodel(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_attachmentsdziś pomija: inlinetext/plainpreferowane; gdy mail jest HTML-only (15%), HTML→tekst. Charset:get_content_charset()zerrors="replace"+sanitize_surrogates(korpus ma łamane kodowania — udowodnione). - HTML→tekst: własny
HTMLParser(pomijastyle/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 zgmail-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: poddrzewadiv.gmail_quote/blockquoteprzed konwersją. - Odwracalne: archiwum nietknięte; zmiana heurystyki = re-run joba (idempotencja
po kluczu z
model— nowa wersja chunkera może iść pod nowymmodel-tagiem lub poDELETEstarych chunków gmail — decyzja operacyjna przy re-runie). - Bilans:
quoted_chars_strippedsumarycznie + 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 ~100–200 znaków z budżetu 2400 (4–8%).
- Prefiks w
document_chunk.text(nie osobna kolumna) — trafia też do wynikówkb-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 filtrujeexcluded_reason IS NULL— nie zawyżą metrykikb_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 zdate_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 = 8–18 ms/chunk, ~11–22× szybciej niż obecna ścieżka; 271k chunków ≈ 1–1,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_chunkper 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 (guardEXPECTED_DIMjak 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ą
distz 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 funkcjahybrid_retrieve/hybrid_query; stage 2 obecnej kaskady nietknięty)kb-querymoderozszerzony ohybrid(docelowo domyślny PO przejściu bramki, §8). Inwariant startowystartup.pybez 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, ~4–8 s → 11–21 dni ciągłej pracy GPU. Nierealne dla całości.
- Hybryda selektywna (np. non-newsletter z ostatnich 2 lat, ~20–25k maili ≈ 60–80 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_embedjest spleciony z założeniemsource='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.py→packages/kb-retrieval).is_ocr_junkzostaje 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/--offsetpoORDER BY id,--since DATEdla etapowania poenvelope.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_filew bilansie łapie ewentualny dryf kopii. - Zapis do PIHA przez Tailscale batchami — dokładnie tak dziś działa
chunk_embedna 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 → ~25–30k chunków,
z czego po filtrze newsletterów ~10–12k 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 jakoalready_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:1chunk_text,hard_split+ stałe (2400/600) zdocuments_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_junkani 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])naPOST /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, jakEmbeddingDimensionError).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):
- Read:
archive_root / raw_ref(--archive-root, default snapshotu na SOLARII);missing_file/read_errorjak w backfillu. - Parse: typed parse + fallback compat32 (wzorzec i testy z backfillu);
body = inline text/plain, a gdy brak — HTML→tekst (
HTMLParserstdlib); charseterrors="replace"+sanitize_surrogates. - Quote-strip (Decyzja 2) z licznikiem
quoted_chars_stripped. - Klasyfikacja:
newsletter(nagłówki List-*/Precedence z tego samego parse'u);body_empty(po strippingu) → koperta liczona, zero chunków. - Prefiks kontekstu (Decyzja 3) z
entities[type=headers]. - Chunk:
kb_mail.chunking.chunk_text(2400/600). - Embed: tylko chunki nie-newsletter,
embed_batchpo 64 (batch może sklejać chunki wielu kopert — pętla buforuje do 64 i flushuje). - 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). - 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ł zsummaryless_sources(JOIN envelope … WHERE source = ANY($…)), merge podist, top-k;hybrid_queryanalogicznie docascade_query(jeden embed zapytania).kb-query:modepattern^(cascade|flat|hybrid)$; domyślnymodeprzełączany nahybriddopiero 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=statspo LAN; ~27 GB). - Run Etapu A:
--since 2025-07-01 --applyz 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_chunkvs 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).
8. Krok 5 — bramka jakościowa fazy mailowej
Rozszerzenie jobs/documents-ingest/eval/queries.yaml + retrieval_eval.py:
- Operator dostarcza 3–5 zapytań mailowych (rzeczy, o których wie, że ma je
w mailach z ostatniego roku — Etap A) → nowe wpisy
kind: hitzexpected_envelope: "<message-id>"(uwaga: envelope_id gmail = surowy Message-ID bez prefiksu source — inaczej niżpaperless:N). retrieval_eval.pyuczy się trybuhybrid(trzecia ścieżka obok flat/cascade).- Kryteria PASS (wszystkie trzy tory na żywej bazie po Etapie A):
- 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).
- Zapytania mailowe: hit@3 w trybie hybrid dla ≥ 4/5 (lub 3/3–4/4 przy mniejszej liczbie), top1 dist < 0,45 dla większości.
- Kontrole negatywne (sernik, piaskownica) > 0,55 we wszystkich trybach.
- PASS ⇒
kb-queryprzełącza domyślnymodenahybrid+ 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_searchdo podbicia).
Szacunek: 1 sesja (+ wejście od operatora).
9. Krok 6 — Etap B: pełne archiwum
- Ten sam job bez
--since,--apply, log do pliku,nice/ionicena I/O. Oczekiwane: ~496k chunków total (~271k embedowanych; 1–2 h GPU + parse), Etap A policzony jakoalready_embedded. - Po runie: weryfikacyjne SQL (j.w.),
VACUUM ANALYZE document_chunk, ponowny przebiegretrieval_eval.py(pełna regresja na 100% korpusu — dystrybucja starych dekad może przesunąć sąsiedztwa HNSW). - Obserwacja PIHA: rozmiar bazy (oczekiwane ~5–7 GB, dysk 144 GB — zapas
20×), latencja
/searchw 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 omem_limit1g→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 podbiciamaintenance_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 3–4 (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 flagowanychnewsletter); baza 250 MB → ~5–7 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,5–1 h (24 rdzenie)
- embed ~1–1,5 h (batch 64, zmierzone 8–18 ms/chunk) + inserty do PIHA.
- Koszty zewnętrzne: 0 USD (bez streszczeń — Decyzja 6).
- Czas sesyjny: ~7 sesji (kroki 0–6) + 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 3–5 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.