Batching /api/embed juz istnial (Krok 1 fazy mailowej, batch 64). Recon przed Etapem B wykazal w torze backfillu blad blokujacy i dwie luki. BUG (blokujacy dla Etapu B): flush_embed_buffer lapal wylacznie aiohttp.ClientError, a wyczerpanie ClientTimeout(total=...) rzuca goly builtins.TimeoutError, ktory NIE jest jego podklasa (zweryfikowane empirycznie na aiohttp 3.14.3). Zawieszona Ollama — czyli jej udokumentowany failure mode, "przyjmuje polaczenie i milczy" — wywalala caly run nieobsluzonym wyjatkiem, bez breakera i bez flushu threadingu. Na plastrze 50k = utrata zarobionej pracy. Klasy przejsciowe nazwane teraz jawnie w TRANSIENT_EMBED_ERRORS. kb-retrieval: - embed_batch(timeout_s=...) — bound per zadanie, skalowalny z batch size - embed_batch_resilient() — retry z backoffem wykladniczym, a po ich wyczerpaniu probe /api/tags rozstrzyga: backend zywy -> bisekcja izolujaca trujacy chunk (jeden zly tekst kosztowal caly batch 64, bo /api/embed jest all-or-nothing); backend martwy -> natychmiastowe gave_up bez bisekcji, ktora spalilaby 2n-1 zadan i opoznila breaker. EmbeddingDimensionError nigdy nie jest retry'owane. - failed_indices wyprowadzane z wyniku, nie akumulowane per span — przy gave_up w srodku bisekcji porzucone poddrzewo nigdy nie dochodzi do liscia. mail-body-ingest: - breaker liczy give-upy (backend padl), nie dowolne nieudane batche; porazka czesciowa przy zywym backendzie nie przesuwa licznika, bo te chunki i tak zlapie kolejny run przez idempotencje - wiersze zembedowane w umierajacym batchu sa commitowane przed abortem - parametryzacja: --batch-size/--embed-retries/--embed-backoff/--embed-timeout, kazdy z odpowiednikiem env MAIL_INGEST_*; bledna wartosc env = glosny SystemExit - metryka embed_ms_per_chunk (porownywalna miedzy runami, w odroznieniu od sredniej per batch) + embed_requests_total/embed_calls jako sygnal zdrowia mail-body-ingest-bench: nowy entry point, sweep batch size na realnych chunkach. Read-only (SELECT + inferencja, zero sciezki zapisu), warmup przed pomiarem, ten sam zbior chunkow dla kazdego rozmiaru. Czyni liczby z planu §1.4 odtwarzalnymi. Fallback SOLARIA->PIHA dla backfillu SWIADOMIE nie powstaje (potwierdzone przez operatora): 271k chunkow x 790 ms CPU ~ 60 h na 8 GB PIHA dzielonym z HA i Paperlessem. Wlasciwa odpowiedzia na martwy backend jest exit 2 i wznowienie plastra. Tor online (kb-query -> embed_router) zachowuje fallback — rozdzial torow udokumentowany w docstringu embed.py i w kb/services/. Testy: 117 zielonych (62 job + 22 klient embed + reszta pakietow), w tym regresja na TimeoutError, bisekcja, ograniczony koszt przy martwym backendzie i porazka czesciowa nieprzesuwajaca breakera. Bez uruchamiania backfillu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
41 KiB
| okf | type | visibility | status | updated | links |
|---|---|---|---|---|---|
| 0.1 | phase | private | active | 2026-08-05 |
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-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).
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 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).
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/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).
Decyzje operatora do Etapu B (2026-08-04) — przed runem
Recon przed Etapem B (mirror archiwum na SOLARII żyje: 225 057 plików / 27 GB; RTT
SOLARIA→PIHA 0,83 ms; PIHA 140 GB wolne, baza 397 MB; M1 — NODE_TYPE=lte_node
na node-agencie SOLARII — zdeployowane, więc kontener Ollamy nie zniknie po
zatrzymaniu) wykazał dwie rzeczy do rozstrzygnięcia. Decyzje:
- Run w plastrach po 50k (
--limit 50000 --offset 0/50k/100k/150k/200k), log per plaster,nice/ionice. Powód: brak checkpointu (restart = ponowny parse od początku listy, ~1 h) + nocne wyłączanie SOLARII. Plaster ≈ 25–40 min. Tempo kolejnych plastrów po obserwacji PIHA po pierwszym. - Circuit breaker w jobie: TAK —
--max-embed-failures(domyślnie 5), abort z kodem wyjścia 2 po N kolejnych nieudanych batchach embed. Powód: parse jest jednowątkowy i wyprzedza GPU, więc martwa Ollama (4 incydenty) zamieniłaby 2-godzinny przebieg w 200k+chunks_errorsbez ani jednego zapisu. Licznik zeruje się po udanym batchu. - Dry-run całości pomijamy — idempotencja i odwracalność flag newsletterowych wystarczają; ewentualna kalibracja heurystyki na dekadzie 2010–2015 po fakcie, na już zapisanych flagach.
- Przełączenie domyślnego
modekb-query nahybrid(DoD (d)) — poza zakresem Etapu B, osobny task po PASS regresji.
Hardening toru embed przed Etapem B (2026-08-05)
Recon pod kątem batchingu potwierdził, że batch /api/embed (Krok 1) działa zgodnie
z §1.4 — ale wykazał w torze backfillu błąd blokujący dla Etapu B i dwie luki:
- BUG (naprawiony):
flush_embed_bufferłapał wyłącznieaiohttp.ClientError, a wyczerpanieClientTimeout(total=...)rzuca gołybuiltins.TimeoutError, który nie jest jego podklasą (zweryfikowane empirycznie na aiohttp 3.14.3). Zawieszona Ollama — czyli dokładnie jej udokumentowany failure mode, „przyjmuje połączenie i milczy", nie „odmawia" — wywalała cały run nieobsłużonym wyjątkiem: bez breakera i bez flushu threadingu. Na plastrze 50k oznaczało to utratę już zarobionej pracy. Klasy przejściowe nazwane teraz jawnie wkb_retrieval.embed.TRANSIENT_EMBED_ERRORS. - Brak retry — jeden blip sieciowy spisywał na straty cały batch (64 chunki). Dodane:
--embed-retries(default 2) z backoffem wykładniczym. - Brak obsługi błędu częściowego —
/api/embedjest all-or-nothing, więc jeden trujący chunk zabijał batch w kółko i mógł wywalić breaker przy żywym backendzie. Dodana bisekcja po nieudanych retry, ale tylko gdy/api/tagspotwierdza, że backend żyje; przy martwym batch od razu „gives up" (bisekcja martwego backendu kosztowałaby 2n-1 żądań i opóźniała breaker). Porażka częściowa nie przesuwa już breakera.
Decyzja 2 (circuit breaker) obowiązuje w zaostrzonej formie: licznik liczy give-upy
(backend padł), nie dowolne nieudane batche. Fallback SOLARIA→PIHA dla backfillu świadomie
nie powstaje — 271k chunków × 790 ms CPU ≈ 60 h na 8 GB PIHA dzielonym z HA i Paperlessem;
właściwą odpowiedzią na martwy backend jest exit 2 i wznowienie plastra. Tor online (kb-query
→ embed_router) ma fallback i tak zostaje — te dwie ścieżki są rozdzielone celowo.
Doszedł też mail-body-ingest-bench — sweep batch size na realnych chunkach (read-only),
żeby liczby z §1.4 dało się odtworzyć po zmianie GPU albo wersji Ollamy.
Uwaga do czytania wyników: na pełnym korpusie exit 1 jest spodziewany
(pojedyncze parse_errors — §1.5 dokumentuje ~9 maili na fallbacku compat32).
Werdyktem jest bilans i liczniki w linii summary, nie kod wyjścia. exit 2
oznacza co innego: backend embed padł, trzeba wznowić plaster po naprawie Ollamy.
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 | Stan | Dowód (2026-08-04) |
|---|---|---|---|---|---|
| 0 | Chunker → packages/kb-mail |
— | 0,5 sesji | WYKONANE | 348ce10; packages/kb-mail/src/kb_mail/chunking.py + tests/test_chunking.py |
| 1 | embed_batch w kb-retrieval |
— | 0,5 sesji | WYKONANE | 51998fd; kb_retrieval/embed.py:61 (embed_batch) + tests/test_embed.py |
| 2 | Job mail-body-ingest |
0, 1 | 2 sesje | WYKONANE | ad0ef40 (job), a95524c (README), fc5c698 (fix html_to_text); jobs/mail-body-ingest/ + tests/test_ingest.py |
| 3 | Tryb hybrid (kb-retrieval + kb-query) | — (równolegle z 2) | 1 sesja | WYKONANE | a640cf1; kb_retrieval/retrieval.py:112 (hybrid_retrieve), :195 (hybrid_query), kb-query/app/main.py:119 (mode pattern). Uwaga: domyślny mode to nadal cascade — przełączenie to follow-up z §8, nie część Kroku 3 |
| 4 | rsync + Etap A (12 mies.) + kalibracja | 2 | 1 sesja | WYKONANE | §7 „Wynik Etapu A" (run na żywo 2026-07-23); potwierdzone na żywej bazie 2026-08-04: document_chunk gmail = 33 871 (6 398 z embeddingiem + 27 473 newsletter) — zgodne co do sztuki z tabelą §7 |
| 5 | Bramka jakościowa (eval mailowy + regresja) | 3, 4 + zapytania od operatora | 1 sesja | WYKONANE (PASS) | §8 „Wynik bramki"; 56f64e9 (eval + queries.yaml dla hybrid), bce635c (mail_hit@3, próg N2, werdykt PASS), 71eb264 (--transport http) |
| 6 | Etap B (pełne archiwum) + regresja + obserwacja PIHA | 5 = PASS | 1 sesja | OTWARTE | Brak commitu, brak sekcji z wynikiem w tym dokumencie; żywa baza pokazuje wyłącznie wolumen Etapu A (33 871 chunków gmail vs oczekiwane ~496k), więc run bez --since nie był wykonany |
| 7 | Recon przyrostówki IMAP/JMAP | — (po 6) | 1 sesja (poza DoD fazy) | OTWARTE | Brak jobs/fastmail-poller / jobs/gmail-imap-poller, brak dokumentu reconu; IMAP/JMAP występuje wyłącznie jako zarys w §10 i w kb-00-overview.md |
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.