homelab-codex-ws/kb/phases/kb-m5-faza2.md

584 lines
32 KiB
Markdown
Raw Normal View History

---
okf: "0.1"
type: phase
visibility: private
status: active
updated: 2026-07-13
links: []
---
# Modul 5, faza 2 — koperta dokumentow + embeddingi + cross-source (RECON + PLAN)
> Status: RECON ZAKONCZONY (2026-07-13), architektura DO ZATWIERDZENIA. Zaden kod nie
> zostal napisany, zadna migracja nie zostala odpalona, zaden model nie zostal pobrany.
> Uzupelnia `05-documents-ingest.md` (faza 1 — ekstrakcja PDF->Paperless, JUZ DZIALA).
>
> **v2 (2026-07-13, ta sama sesja):** korekta po decyzji Oskara — `correspondent` z
> Paperlessa PORZUCONY jako zrodlo cross-source (byl pusty, i tak by byl tylko zgadywany).
> Zamiast tego: deterministyczny link mail->zalacznik->dokument (sha256/nazwa pliku), juz
> obecny w danych faktury-1. Dodano tez krytyczne odkrycie: koperty mailowe NIE MAJA
> naglowkow (from/to/cc/subject) — tylko manifest zalacznikow. Plan rozszerzony o backfill.
---
## 1. Stan faktyczny (zweryfikowany na zywo, read-only)
### 1.1 Filar maili — CZY MA EMBEDDINGI? **NIE.**
Sprawdzone bezposrednio w bazie i w kodzie:
- `envelope` na kb-postgres@PIHA ma **wylacznie 6 zamrozonych kolumn**
(`id, source, ts, geo, raw_ref, entities`) — zero kolumny wektorowej.
- `SELECT source, count(*) FROM envelope GROUP BY source`**jeden wiersz: `gmail`, 225030**.
Zaden inny `source` nie istnieje jeszcze w bazie.
- `pg_extension` potwierdza `vector 0.8.3` **zainstalowane, ale NIEUZYWANE** — brak
jakiejkolwiek tabeli/kolumny typu `vector` w calej bazie.
- `grep -r "embed|vector|bge|chunk"` w `jobs/gmail-bulk-import/`, `packages/kb-mail/`,
`jobs/documents-ingest/`**zero trafien**. Indexer opisany w `kb-01-email-design.md`
§5 (`parse -> chunk -> embed -> pgvector`) jest **wylacznie speca, nie kodem**.
**Wniosek:** to nie jest "reuzyj wzorzec maili" — to jest **pierwszy embedding w calym KB**.
Projektujemy schemat/chunking/pipeline od zera, dla dokumentow, ale w sposob ktory
mail-indexer (przyszly etap 6 z `kb-01-email-design.md`) bedzie mogl reuzyc bez zmian
(ta sama tabela chunkow, ten sam model, ten sam wzorzec joba).
### 1.2 Paperless — dane
- **189 dokumentow** (nie 186 — przybylo od czasu briefu; Paperless zyje, liczba rosnie).
- `SUM(length(content))` = 4 202 184 znakow. `AVG` = 22 234, `MIN` = 0, `MAX` = 360 466 (114 stron).
Co najmniej jeden dokument ma **pusty content** (OCR nie wyprodukowal tekstu — pewnie
czysty obraz bez warstwy tekstowej lub blad OCR) — trzeba to obslugiwac (pomijac embed,
nie crashowac).
- **`documents_correspondent` ma 0 wierszy. `documents_tag` ma 0 wierszy.** Wszystkie 189
dokumentow maja `correspondent_id = NULL`. Paperless auto-detekcja korespondenta/tagow
**nie jest skonfigurowana ani wytrenowana** — nie ma dzisiaj ZADNYCH danych do cross-source
linku poprzez pole `correspondent`. To odkrycie doprowadzilo do korekty cross-source w v2 —
patrz decyzja 4 i §1.9.
- Jakosc OCR/tekstu: **niejednolita**. Dokument #1 (polisa WARTA z 2020) ma mojibake w polskich
znakach (`integraln¹ czêœæ` zamiast `integralną część`) — najpewniej zle zdekodowana
warstwa tekstowa oryginalnego PDF, nie problem samego OCR. Dokument #191 (faktura proforma
2026) ma czyste polskie znaki (`Świdnica`, `wyłącznie`). Nie jest to systemowy problem calego
korpusu, ale chunking/embed pipeline musi to tolerowac (nie failowac na dziwnych bajtach) i
warto to logowac jako jakosciowy sygnal per-dokument.
- **Brak page-break markerow** w polu `content` (sprawdzone: `chr(12)` — form feed — nie
wystepuje w tekscie wielostronicowych dokumentow). Nie da sie dzielic po stronach z samego
tekstu; `page_count` istnieje jako osobna metadana, ale nie jest wyrownana do offsetow w tekscie.
- Losowa probka tytulow ujawnila **szum z fazy-1** (`documents-ingest` sample z zalacznikow
maili): `cryptocurrency_terms_conditions`, `crypto_exchange_terms_and_conditions`,
dokumenty konkursu robotycznego FLL dziecka. To nie sa dokumenty finansowe/tozsamosciowe —
to przypadkowe duze PDFy ktore przeszly filtr rozmiaru (>50KB) w probce fazy-1. Potwierdza
potrzebe filtra selektywnosci PRZED embedowaniem (zasada kb-00: archiwizuj/referuj wszystko,
**indeksuj selektywnie**).
- Paperless OCR: `PAPERLESS_OCR_LANGUAGE=pol+eng`, `PAPERLESS_OCR_LANGUAGES=pol` — polski
potwierdzony jako jezyk OCR.
### 1.3 Paperless API
- REST/DRF pod `http://192.168.31.5:8210/api/` (LAN, PIHA). Root bez auth zwraca mape
endpointow: `documents, correspondents, document_types, tags, storage_paths,
custom_fields, mail_accounts, mail_rules, workflows, tasks, users, groups, logs,
share_links, config, saved_views`.
- **Chronione endpointy wymagaja auth** — `GET /api/documents/` bez tokenu → `401`.
Standardowy mechanizm Paperless: `Authorization: Token <token>` (DRF TokenAuth),
token generowany w UI (My Profile) albo `manage.py drf_create_token <user>`.
- **Nie ma dzis zadnego tokenu API.** Haslo z `PAPERLESS_ADMIN_PASSWORD` (env, wartosc
bootstrapowa) **juz nie dziala** — wedlug `docs/sessions/2026-07-10-paperless-deploy.md`
haslo konta `oskar` zostalo zmienione recznie przez `manage.py shell` po skonfigurowaniu
OIDC. Nie probowalem zgadywac/resetowac hasla (poza zakresem read-only recon) —
**generowanie tokenu API to pierwszy krok implementacji**, nie recon (patrz decyzja #5).
- Paginacja: standardowy DRF pattern (`count/next/previous/results`, `?page_size=`) — wedlug
dokumentacji Paperless-ngx; nie zweryfikowane bezposrednio (401 zablokowal body), ale to
utrwalony, udokumentowany kontrakt API Paperless-ngx, nie zgadywanie.
### 1.4 Model embeddingowy — bge-m3
Decyzja "bge-m3" jest juz **zamknieta w `kb-00-overview.md`** ("multilingual, dlugi kontekst,
lepszy pod polski niz multilingual-e5"). Zweryfikowalem, ze to nadal trafny wybor (nie zgadywanie):
| Cecha | Wartosc | Zrodlo |
|---|---|---|
| Parametry | 568M (XLM-RoBERTa base) | ollama.com/library/bge-m3, HF BAAI/bge-m3 |
| Rozmiar na dysku (Ollama) | 1.2 GB | ollama.com/library/bge-m3 |
| Wymiar wektora (dense) | **1024** | HF BAAI/bge-m3 model card |
| Max kontekst | **8192 tokenow** | ollama.com/library/bge-m3, HF |
| Jezyki | 100+ working languages, w tym polski | ollama.com/library/bge-m3 |
| Tryby | dense + sparse (lexical) + multi-vector (ColBERT) w jednym modelu | HF BAAI/bge-m3 |
**Nie jest pobrany na SOLARII.** `curl http://solaria:11434/api/tags` (przez Tailscale z PIHA)
pokazuje wylacznie: `qwen2.5-coder:14b, qwen3-coder:30b, deepseek-coder:latest, deepcoder:14b`
same modele coder, zero embeddingowych. `ollama pull bge-m3` (1.2GB) nie zostal wykonany
(poza zakresem recon — nic nie pobieram).
SOLARIA: RTX 4070, **12GB VRAM** (`hosts/solaria/capabilities.yaml`) — 1.2GB modelu mieści
się bez trudu obok/zamiast modeli coder; nie jest to wąskie gardło.
### 1.5 kb-postgres@PIHA — zasoby
- Kontener ograniczony do `mem_limit: 1g`, `shared_buffers=256MB`,
`maintenance_work_mem=64MB`, `max_connections=30` — celowo ciasno (Pi 5 dzieli RAM z HA/Immich).
- Dane obecnie: 202 MB (`du -sh` na wolumenie) dla 225k kopert maili. Zapas na PIHA:
`df -h /` → 29 GB wolne z 58 GB. Nie jest to problem dla pilota (189 dok × kilkanascie
chunkow ≈ kilka tysiecy wektorow × 4KB/wektor ≈ pojedyncze MB), ale
`maintenance_work_mem=64MB` (budowa indeksu HNSW) bedzie trzeba zrewidowac przy docelowej
skali (miliony chunkow po dolozeniu maili) — nie w tej fazie.
### 1.6 `packages/kb-mail` — czy da sie reuzyc?
Tak, **bezposrednio, bez nowego pakietu**:
- `envelope.py` (`Envelope` dataclass) i `db.py` (`insert_envelope`/`get_envelope`) sa juz
**w pelni zrodlo-agnostyczne** — zero logiki specyficznej dla maila. Dzialaja identycznie
dla `source='paperless'`.
- `archive.py` (`save_eml`) jest **specyficzny dla .eml** i **niepotrzebny** dla adaptera
Paperless — Paperless jest REFERENCJA (`raw_ref` = doc-id, nie kopiujemy bajtow), wiec nie
ma czego archiwizowac lokalnie.
- Wniosek: **nie tworzymy `packages/kb-documents`**. Adapter Paperless w
`jobs/documents-ingest/` robi `pip install /packages/kb-mail/` i importuje
`Envelope`/`insert_envelope` wprost. Gdy przyjdzie faza Nextcloud (KOPIA, nie referencja),
wtedy `archive.py` bedzie potrzebny do rozszerzenia — ale to inny adapter, nie ten.
### 1.7 Graf encji (warstwa 3) — stan
`grep -r "entity_link|entity_graph|warstwa.3|entity_resolution"` w calym repo → trafienia
wylacznie w dokumentacji (kb-00, kb-02, modul 5) i w kolumnie `entities` samej koperty.
**Zero kodu, zero tabeli.** Warstwa 3 zaczyna sie od zera w tej fazie — zgodnie z zasada
kb-00 #7 ("kontrakt encji definiujemy wczesnie, przy 2 zrodlach; cienka warstwa 4 po
drugim zrodle).
### 1.8 Koperty mailowe — brak naglowkow (potwierdzone)
Sprawdzone bezposrednio w `jobs/gmail-bulk-import/src/gmail_bulk_import/importer.py`:
`_parse_attachments()` (L84) buduje jedyne obiekty ktore trafiaja do `entities[]`
wylacznie `{"type": "attachment", filename, content_type, size, sha256}`. Funkcja
`_parse_date()` (L70) parsuje `Date:` wylacznie po to, by wypelnic `envelope.ts` — wartosc
naglowka NIE trafia do `entities`. **Zero from/to/cc/delivered_to/subject gdziekolwiek w
bazie.** Te dane istnieja wylacznie wewnatrz zarchiwizowanych plikow `.eml` (27 GB,
225 030 plikow) — nigdy nie zostaly wyciagniete do struktury zapytywalnej.
To jest realny problem semantyczny, nie tylko brak wygody: Oskar ma wiele adresow/aliasow
na tym samym koncie Gmail. Bez `to`/`delivered_to` nie da sie odpowiedziec na pytania typu
"czy ta polisa jest na mnie czy na zone" ani odroznic poczty firmowej od prywatnej, gdy
oba konteksty ladowaly na rozne aliasy tej samej skrzynki. Naprawiane w §5 (backfill).
### 1.9 Cross-source: dowod juz istnieje w danych (odkrycie, nie projekt od zera)
`jobs/documents-ingest/` (faza 1, JUZ DZIALA) zostawia po sobie
`/opt/homelab/data/documents-ingest/registry.json`, klucz = **sha256 zalacznika** (z
manifestu mailowego `entities[]`), wartosc zawiera m.in. `envelope_id` (Message-ID maila
zrodlowego) i `consume_name` (nazwa pliku wrzuconego do `consume/`). Zweryfikowane na zywo:
- Registry ma dzis **185 wpisow**, wszystkie `consume_name` **unikalne** (185/185) —
bezpieczny klucz join.
- **KRYTYCZNE odkrycie:** `documents_document.checksum` / `.archive_checksum` w Paperlessie
to **MD5** (32 znaki hex — sprawdzone bezposrednio w bazie), a registry.json jest
kluczowany **SHA256** (64 znaki) tego samego pliku. Rozne algorytmy — **nie da sie ich
bezposrednio porownac**. To by unieważniło plan "dopasuj po checksumie", gdyby ktos
zaproponowal go bez sprawdzenia.
- Za to `documents_document.original_filename` (nazwa pliku jaki wszedl przez `consume/`)
**dokladnie odpowiada** `registry[sha256].consume_name` — zweryfikowane join na 8 losowych
przykladach (np. `2026-06-03_Regulamin_PL-ENG.pdf`). To jest **deterministyczny,
bezstratny klucz** miedzy Paperless a rejestrem faktury-1, bez zadnej heurystyki tekstowej.
- Dokumenty spoza tej sciezki (np. `polisa 920008969228.pdf`, `fll-challenge-...` — bez
prefiksu daty charakterystycznego dla `documents-ingest`) nie maja wpisu w registry —
to sa dokumenty dodane recznie/wczesniej, poprawnie NIE beda mialy cross-source linku.
**Wniosek:** cross-source link mail↔dokument nie wymaga zadnej nowej infrastruktury ani
entity-resolution — dane juz sa, trzeba je tylko odczytac i zapisac w kopercie dokumentu
przy jego tworzeniu (§4).
---
## 2. Otwarte decyzje dla Oskara
### Decyzja 1 — Gdzie wektory: kolumna w `envelope` czy osobna tabela?
**Rekomendacja: osobna tabela `document_chunk` (1:N do envelope).**
Uzasadnienie: sredni dokument (~22k znakow) jest blisko/ponad granice sensownego
pojedynczego embeda, a najwieksze (360k znakow / 114 stron) drastycznie ja przekraczaja —
**chunking jest obowiazkowy**, nie opcjonalny. Koperta jest zamrozona i ma byc addytywna
(kb-00 zasada #2) — N-wartosciowa relacja nie pasuje do plaskiej kolumny. Dodatkowo:
mail-indexer (przyszly etap) bedzie mial dokladnie ten sam problem (dlugie watki mailowe) —
projektujemy jedna tabele chunkow reuzywalna dla obu zrodel, zamiast osobnych rozwiazan.
### Decyzja 2 — Model embeddingowy
**bge-m3 pozostaje trafny wybor** (patrz §1.4) — nie ma potrzeby zmiany zamknietej decyzji
z kb-00. Jedyna otwarta czynnosc: `ollama pull bge-m3` na SOLARII — to akcja implementacyjna,
nie recon, wiec jej nie wykonalem.
### Decyzja 3 — Chunking
**Rekomendacja: ~500800 tokenow/chunk (≈20003200 znakow polskiego tekstu), overlap
~100150 tokenow, split preferujacy granice akapitow/pustych linii, twardy fallback
znakowy gdy akapit przekracza limit.**
Uzasadnienie:
- bge-m3 (limit 8192 tok) technicznie zmiesci wiekszosc dokumentow (sredni ~22k znakow ≈
5.56k tok) w JEDNYM embedzie — ale maly chunk daje lepsza precyzje retrievalu dla pytan
punktowych ("jaka suma ubezpieczenia w polisie GA431JA") niz jeden wektor uśredniajacy
caly dokument.
- Najwieksze dokumenty (360k znakow / 114 stron) i tak **wymagaja** twardego chunkingu — nie
ma opcji embedowania calosci.
- Brak page-break markerow w tekscie (§1.2) wyklucza czysty split po stronach — dzielimy po
strukturze tekstu (akapity/puste linie), nie po metadanej `page_count`.
### Decyzja 4 — Cross-source link — **ROZSTRZYGNIETA przez Oskara (v2)**
~~Pierwotna rekomendacja (v1) opierala sie o `correspondent` z Paperlessa — Oskar to
odrzucil, slusznie: pole jest puste (recon to wykazalo), a nawet gdyby nie bylo, to tylko
ZGADYWANIE Paperlessa "kto wystawil dokument", nie fakt.~~
**Decyzja: link mail↔dokument idzie po deterministycznej sciezce danych, nie po polu
`correspondent`.** Sciezka (opisana w §1.9): `envelope(gmail).entities[attachment].sha256`
`registry.json[sha256]``{envelope_id, consume_name}` → JOIN
`documents_document.original_filename = consume_name``paperless doc_id`. Wynik zapisany
w kopercie DOKUMENTU jako `entities[type=source_mail]` (ksztalt w §4.2).
`correspondent`/`tag` z Paperlessa **zostaja w entities jako metadane informacyjne**
(moga sie kiedys wypelnic — auto-matcher albo reczne tagowanie), ale **przestaja byc
mechanizmem cross-source** — nic w pipeline nie polega juz na tym, ze sa niepuste.
**Konsekwencja dla schematu (§3 zaktualizowane):** tabele `entity`/`entity_link` (graf
encji person/organizacja) **nie sa wymagane do domkniecia kryterium ukonczenia modulu 5**
("min. jeden cross-source link zademonstrowany") — ten dowod daje wprost pole
`source_mail` w kopercie dokumentu, bez potrzeby modelowania tozsamosci. Tabele zostaja w
planie jako **przyszla praca, odlozona** (prawdziwa identity-resolution: gdy `correspondent`
kiedys sie wypelni, gdy dojda nadawcy mailowi jako encje, gdy dojda twarze ze zdjec z
Immich) — nie blokuja pilota. To realne uproszczenie zakresu tej fazy, nie kompromis.
### Decyzja 5 — Konto serwisowe dla API Paperless
Trzeba wygenerowac token API — **to mutuje baze Paperless (jeden rekord `authtoken_token`)**,
wiec nie zrobilem tego w ramach read-only recon. Do decyzji: token na istniejacym koncie
`oskar` (prosciej) czy dedykowany user tylko-do-odczytu `kb-ingest` (czystsze rozdzielenie
uprawnien, ale trzeba sprawdzic czy Paperless ma granularne read-only role). **Rekomendacja:
dedykowany user readonly jesli Paperless to latwo wspiera** — do zweryfikowania w
pierwszym kroku implementacji, nie blokuje planu.
### Decyzja 6 — Filtr selektywnosci przed embedowaniem
§1.2 pokazal, ze aktualna zawartosc Paperless zawiera szum niezwiazany z celem KB (crypto
T&C, PDFy z konkursu robotycznego) — pozostalosc po size-only filtrze fazy-1. **Rekomendacja:
na pilot embedowac WSZYSTKO** (189 dok to tani eksperyment, latwo przebudowac indeks —
kb-00 zasada #1: indeks jest odtwarzalny/wyrzucalny), **ale zaprojektowac filtr jako
parametr adaptera od razu** (np. blacklist po `document_type`/tagach gdy beda ustawione,
lub po korespondencie), zeby nie trzeba bylo tego retrofitowac przy skalowaniu do tysiecy
dokumentow z faktycznego batcha 70k zalacznikow.
### Decyzja 7 — Backfill naglowkow: osobny job czy rozszerzenie `gmail-bulk-import`? (NOWA, v2)
**Rekomendacja: osobny jednorazowy job**, np. `jobs/gmail-header-backfill/`, nie
rozszerzenie `gmail-bulk-import`.
Uzasadnienie:
- `gmail-bulk-import` ma semantyke **INSERT** (nowe koperty z mbox/Takeout) — juz
wykonany, historyczny, uzasadniony przez wlasny modul/etap. Backfill ma semantyke
**UPDATE** istniejacych 225 030 wierszy — inna operacja, inne ryzyko (dotyka danych juz
w produkcyjnej bazie), inny cykl zycia.
- Precedens w repo juz istnieje: `jobs/documents-ingest/` zostal swiadomie wydzielony jako
NOWY job mimo ze czyta ten sam `.eml`-archiwum co `gmail-bulk-import` — bo robi cos
innego (ekstrakcja, nie import). Ta sama logika stosuje sie tu.
- Osobny job = latwiej o dry-run/--apply, `--limit`, wznawialnosc i testy jednostkowe
bez ryzyka regresji w kodzie ktory juz odpowiada za 225k-wierszowy bulk import.
Alternatywa (odrzucona): dopisanie `--backfill-headers` do `gmail-bulk-import` — mniej
kodu, ale miesza dwie odrebne operacje w jednym CLI i utrudnia code review/testy. Do
przyklepania przez Oskara, ale rekomendacja jest jasna.
---
## 3. Proponowany schemat SQL (addytywny, nowe migracje)
```sql
-- services/kb-postgres/init/002_chunks.sql
-- Chunk-level embeddings, 1:N do envelope. Addytywne — envelope (001) nietkniete.
CREATE TABLE IF NOT EXISTS document_chunk (
id BIGSERIAL PRIMARY KEY,
envelope_id TEXT NOT NULL REFERENCES envelope(id) ON DELETE CASCADE,
chunk_index INT NOT NULL, -- kolejnosc w obrebie envelope, 0-based
text TEXT NOT NULL,
embedding VECTOR(1024), -- bge-m3 dense; NULL dopoki nie zembedowany
model TEXT NOT NULL, -- np. 'bge-m3' — sledzenie re-indexu przy zmianie modelu
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (envelope_id, chunk_index)
);
CREATE INDEX IF NOT EXISTS document_chunk_envelope_idx ON document_chunk (envelope_id);
CREATE INDEX IF NOT EXISTS document_chunk_embedding_hnsw_idx
ON document_chunk USING hnsw (embedding vector_cosine_ops);
```
`model` na `document_chunk` istnieje wylacznie po to, by re-indeks przy lepszym modelu
(kb-00 zasada #1) byl trywialny: nowy embed = nowy wiersz z innym `model`, stary do
skasowania po weryfikacji, bez migracji schematu.
### ODLOZONE (v2): `entity` / `entity_link`
W v1 tego planu byla tu tabela grafu encji (`entity` + `entity_link`) jako mechanizm
cross-source. **Po decyzji 4 (v2) nie jest juz potrzebna do domkniecia modulu 5** — dowod
cross-source daje bezposrednio `entities[type=source_mail]` w kopercie dokumentu (§4.2), bez
modelowania tozsamosci osoby/firmy. Schemat zostaje udokumentowany tutaj jako **przyszla
praca** (prawdziwa identity-resolution — gdy `correspondent` w Paperless kiedys sie wypelni,
gdy dojda nadawcy mailowi jako encje, gdy dojda twarze z Immich), nie jest czescia planu
implementacji tej fazy:
```sql
-- ODLOZONE — nie czesc migracji fazy 2, tylko szkic na przyszlosc
CREATE TABLE IF NOT EXISTS entity (
id BIGSERIAL PRIMARY KEY,
type TEXT NOT NULL, -- 'person' | 'organization' | ...
canonical_name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS entity_link (
id BIGSERIAL PRIMARY KEY,
entity_id BIGINT NOT NULL REFERENCES entity(id) ON DELETE CASCADE,
envelope_id TEXT NOT NULL REFERENCES envelope(id) ON DELETE CASCADE,
role TEXT NOT NULL, -- 'correspondent' | 'sender' | ...
raw_value TEXT, -- surowa wartosc jak wystapila w zrodle
UNIQUE (entity_id, envelope_id, role)
);
```
---
## 4. Ksztalt `entities[]` — ujednolicony miedzy zrodlami
Oba zrodla uzywaja **tej samej konwencji**: `entities` to plaska lista obiektow
roznotypowych, kazdy z kluczem `"type"` (wzorzec juz ustalony przez `gmail-bulk-import`'s
`{"type": "attachment", ...}`). Nowosc w v2: wspolne slownictwo typow rozszerzone o
naglowki (mail) i link zrodlowy (dokument), oba dodane **addytywnie obok** istniejacych
wpisow — zaden istniejacy element listy nie jest ruszany ani restrukturyzowany.
### 4.1 Koperta MAILOWA (`source='gmail'`) — po backfillu
```json
[
{"type": "attachment", "filename": "...", "content_type": "application/pdf",
"size": 143514, "sha256": "00866aa3..."},
...
{
"type": "headers",
"from": {"name": "WARTA", "address": "no-reply@warta.pl"},
"to": [{"name": "...", "address": "oskar@gmail.com"}],
"cc": [],
"delivered_to": ["oskar+alias@gmail.com"],
"subject": "Twoja polisa OC/AC",
"date_raw": "Mon, 9 Jun 2026 12:34:56 +0200"
}
]
```
Uwagi projektowe:
- `from`/`to`/`cc` **parsowane** (`email.utils.getaddresses`) do `{name, address}`, nie
surowy string — ulatwia przyszla entity-resolution (§ decyzja 4) bez re-parsowania.
`to`/`cc` to listy (wielu adresatow mozliwych); `from` pojedynczy obiekt (RFC dopuszcza
wiele, w praktyce prawie zawsze jeden — jesli wielu, bierzemy pierwszy i logujemy).
- `delivered_to` to **lista surowych stringow**, NIE parsowana — naglowek `Delivered-To`
moze wystapic wielokrotnie (hop-by-hop), a to wlasnie te wartosci mowia "ktory alias
Oskara odebral wiadomosc" (motywacja z §1.8). Zachowujemy wszystkie wystapienia.
- `date_raw` to surowy string naglowka `Date:` (dla porownania/debug) — `envelope.ts`
juz ma sparsowana wersje UTC z importu (`_parse_date`), wiec to nie jest duplikacja
zrodla prawdy, tylko provenance.
- Parsowanie przez `email.policy.default` (dekoduje RFC 2047 encoded-words do realnego
Unicode) — ta sama decyzja co `documents-ingest` juz podjal dla MIME-parts (patrz jego
README, sekcja o rozbieznosci compat32 vs default).
### 4.2 Koperta DOKUMENTU (`source='paperless'`)
```json
[
{"type": "content", "text": "<pelny OCR-text>"},
{"type": "correspondent", "name": null},
{"type": "tag", "name": "..."},
{"type": "filename", "value": "..."},
{"type": "content_type", "value": "application/pdf"},
{
"type": "source_mail",
"envelope_id": "CABtrY-KrCCZ0EgSDkg8xf-2pvkaEyxo1Y12Pfi=p=A3R9NFQKw@mail.gmail.com",
"attachment_sha256": "00866aa304e65f90ef3538fd9994a6e9d8af6a76480543698c42dd7ea17e016d",
"attachment_filename": "31657539_13049.pdf"
}
]
```
`source_mail`**obecny tylko gdy jest deterministyczny match** (§1.9: join
`documents_document.original_filename` = `registry.json[sha256].consume_name`). Dla
dokumentow dodanych recznie/spoza pipeline'u faktury-1 (np. `polisa 920008969228.pdf`,
`fll-challenge-...`) ten wpis **nie wystepuje** — brak linku jest poprawnym stanem, nie
bledem. `correspondent`/`tag` zostaja jako metadane informacyjne (§ decyzja 4) — nie sa juz
mechanizmem cross-source, ale nie ma powodu ich wyrzucac (tanie, Paperless i tak je daje).
Przykladowe zapytanie po dowod cross-source (mail -> dokument):
```sql
SELECT e_mail.id AS mail_id, e_mail.entities, e_doc.id AS doc_id
FROM envelope e_doc
JOIN LATERAL jsonb_array_elements(e_doc.entities) AS link ON link->>'type' = 'source_mail'
JOIN envelope e_mail ON e_mail.id = link->>'envelope_id'
WHERE e_doc.source = 'paperless';
```
### 4.3 Adapter Paperless -> koperta (pelny mapping)
```
source = 'paperless'
id = f"paperless:{document_id}" -- PREFIX wymagany: PK envelope.id jest globalny
-- plaski TEXT bez kolumny source w kluczu; Paperless
-- doc-id to male sekwencyjne inty (1,2,3...) —
-- bez prefiksu koliduja z kazdym przyszlym zrodlem
-- uzywajacym prostych ID (np. Nextcloud fileid).
ts = documents_document.created -- Paperless wykrywa date z tresci/nazwy pliku,
-- lepsze niz plikowy mtime (potwierdzone: dok #1
-- created=2020-04-21 zgadza sie z data polisy w OCR)
geo = NULL -- jak w specyfikacji modulu 5
raw_ref = str(document_id) -- REFERENCJA, zrodlem prawdy jest Paperless
entities = jak w §4.2, w tym `source_mail` gdy join z registry.json sie powiedzie
```
Rozwiazanie `source_mail`: adapter wczytuje `registry.json` raz do pamieci, buduje indeks
`consume_name -> {envelope_id, sha256, filename}`, dla kazdego dokumentu z Paperless API
sprawdza `original_filename` w tym indeksie. O(1) per dokument, zero dodatkowych zapytan do
Paperless czy do bazy maili.
---
## 5. Backfill naglowkow mailowych (225 030 kopert)
### 5.1 Zakres i podejscie
Cel: dopisac `{"type": "headers", ...}` (§4.1) do kazdej z 225 030 istniejacych kopert
`source='gmail'`, bez ruszania istniejacych elementow `entities` (manifesty zalacznikow).
**Zrodlo danych: zarchiwizowane pliki `.eml`**, nie oryginalny plik Takeout/mbox.
Uzasadnienie: archiwum `.eml` (`/home/oskar/kb/mail/archive` na PIHA, 27 GB, 225 030
plikow) jest juz warstwa "surowa/niezmienna" (kb-00 zasada #1) i kazdy `.eml` niesie pelne
oryginalne naglowki — nie trzeba wracac do zrodlowego mboxa (ktory moze juz nie byc
dostepny/aktualny na dysku roboczym). To tez dokladnie ten sam wzorzec dostepu co
`documents-ingest` juz uzywa (`archive_root / row["raw_ref"]` -> `read_bytes()` ->
`email.message_from_bytes(raw, policy=email.policy.default)`).
### 5.2 Idempotencja i wznawialnosc
`entities` jest JSONB **tablica** — dopisanie nowego elementu to konkatenacja, nie
nadpisanie:
```sql
UPDATE envelope
SET entities = entities || $2::jsonb -- $2 = '[{"type": "headers", ...}]'::jsonb
WHERE id = $1
AND NOT EXISTS (
SELECT 1 FROM jsonb_array_elements(entities) e WHERE e->>'type' = 'headers'
);
```
Warunek `NOT EXISTS` w `WHERE` czyni operacje **bezpiecznie powtarzalna**: przerwany
backfill (crash, restart) po prostu pomija wiersze juz zaktualizowane przy ponownym
uruchomieniu — dokladnie ten sam wzorzec `ON CONFLICT DO NOTHING` co reszta pipeline'u, tylko
wyrazony przez `WHERE NOT EXISTS` (bo to `UPDATE`, nie `INSERT`).
### 5.3 Wydajnosc (szacunek, nie pomiar)
225 030 plikow, srednio ~124 KB kazdy (27 GB / 225 030 — z faktycznych liczb importu w
`kb-00-overview.md`). Operacja per wiersz: `open + read + parse headers (tanie, tylko
naglowki, nie cale MIME-walk po zalacznikach) + UPDATE`. Pelny bulk-import (parsowanie
calego mboxa **wlacznie z** MIME-walk zalacznikow i insertami) zajal ~29 min
(`kb-00-overview.md`, zmierzone). Backfill robi mniej pracy per-wiadomosc (tylko naglowki,
nie MIME-walk zalacznikow) ale placi za **osobne otwarcie 225k malych plikow** zamiast
strumieniowego czytania jednego mboxa — te dwa efekty czesciowo sie znosza. **Szacunek:
tego samego rzedu wielkosci, prawdopodobnie kilkadziesiat minut**, nie pomierzone
bezposrednio (nie odpalilem tego joba — nie istnieje jeszcze). Rekomendacja: batch UPDATE
(multi-row, nie jeden `UPDATE` na transakcje), `--limit`/`--offset` jak inne joby w repo,
zeby dalo sie uruchomic partiami i zweryfikowac progres bez czekania na cale 225k na raz.
---
## 6. Plan implementacji (kolejnosc)
1. **Migracja** `002_chunks.sql` na kb-postgres@PIHA (addytywna, nie rusza
`001_envelope.sql`). `003_entities.sql` **odlozona** (§3) — nie jest czescia tego kroku.
2. **`ollama pull bge-m3`** na SOLARII — zweryfikowac czas pobrania i realne zuzycie VRAM
przy pierwszym `ollama run`/embed call.
3. **Token API Paperless** — decyzja #5 wyzej (konto dedykowane vs `oskar`), wygenerowac
przez `manage.py drf_create_token` lub UI.
4. **`jobs/gmail-header-backfill/`** (decyzja #7) — nowy one-shot job: parsuje naglowki z
225 030 zarchiwizowanych `.eml`, dopisuje `{"type": "headers", ...}` (§4.1) do
`envelope.entities` przez idempotentny `UPDATE ... WHERE NOT EXISTS` (§5.2). Wykonac
PRZED adapterem Paperless, bo krok 6 (dowod cross-source) czyta te naglowki.
5. **Rozbudowa `jobs/documents-ingest/`** o adapter Paperless→koperta: fetch (paginowany
`GET /api/documents/`), zaladowanie `registry.json` do indeksu `consume_name -> wpis`,
mapping wg §4.24.3 (w tym `source_mail` gdy join sie powiedzie),
`insert_envelope` (reuzyte z `packages/kb-mail`). Idempotentnie —
`ON CONFLICT (id) DO NOTHING` juz jest w `kb_mail.db.insert_envelope`.
6. **Chunking + embed job** — nowy modul (rozwazyc czy zyje w `jobs/documents-ingest/` czy
jako osobny `jobs/kb-indexer/` reuzywalny tez dla maili pozniej — rekomendacja: zaczac w
`documents-ingest`, wydzielic gdy mail-indexer bedzie realny, nie przed czasem).
Pipeline: `entities[content] -> chunk (§ decyzja 3) -> POST /api/embeddings (ollama,
model=bge-m3) -> INSERT document_chunk`.
7. **Pilot na 189 dok** — uruchomic, zweryfikowac jakosciowo retrieval (recznie, kilka
zapytan przez SQL `ORDER BY embedding <=> query_vector`).
8. **Weryfikacja dowodu cross-source** — po kroku 5, policzyc ile dokumentow dostalo
`source_mail` (oczekiwane: ~185, wedlug dzisiejszego stanu registry.json), zapytaniem
z §4.2 pokazac jeden konkretny przyklad mail↔dokument koniec-do-konca. To jest dowod
wymagany przez kryterium ukonczenia modulu 5 — bez recznego tagowania w UI.
9. **Testy jednostkowe** (wzorzec `jobs/gmail-bulk-import/tests/`) — dla kazdego nowego/
zmienionego joba: mapping Paperless→koperta, parsowanie naglowkow (w tym multi-`Delivered-To`,
RFC 2047 encoded-words), join `source_mail`, chunking (granice, overlap, dokument
pusty/za dlugi), idempotencja UPDATE-backfillu.
10. **Definition of Done** (CLAUDE.md): `docker build` + smoke run + `pytest` przed
jakimkolwiek deployem/commitem.
---
## 7. Wydajnosc
- **189 dokumentow, ~22k znakow sredniej** → przy chunkach ~600 tok / ~2400 znakow +
overlap 150 tok: srednio ~10 chunkow/dok dla typowych dokumentow, ~150 chunkow dla
najwiekszego (360k znakow/114 stron). Szacunkowo **~20003000 chunkow lacznie** dla
calego pilota.
- **bge-m3 na RTX 4070**: maly model (568M, 1.2GB) — inferencja embeddingu w batchu to
rzedu dziesiatek-set chunkow/s. Caly pilot (23k chunkow) → **rzedu minut**, nie wymaga
specjalnego batchowania/partii.
- **Skala docelowa (70k zalacznikow z maili)**: modul 5 faza-1 to swiadomie **probka, nie
bulk** (`jobs/documents-ingest/README.md` — decyzja architektoniczna). Realny wolumen
ktory trafi do embeddingu zalezy od (a) throughput OCR-workera na SOLARII (modul 3) —
**to jest waskie gardlo skalowania, nie embedding** — oraz (b) filtra selektywnosci
(decyzja #6). Sam embedding bge-m3 nie bedzie bottleneckiem nawet przy tysiacach
dokumentow.
- **kb-postgres@PIHA**: budzet 1GB / `maintenance_work_mem=64MB` wystarcza na budowe HNSW
dla pilotowych 23k wektorow. Przy docelowej skali (miliony chunkow po dolozeniu maili)
ten limit trzeba bedzie zrewidowac — **poza zakresem tej fazy**, ale warto zanotowac w
backlogu jako przyszly checkpoint operacyjny.
- **Backfill naglowkow (225 030 kopert)** — szacunek jest osobny, patrz §5.3: rzedu
dziesiatek minut, dominowane przez I/O otwarcia 225k malych plikow, nie przez sam
parsing naglowkow (tani). Nie mierzone bezposrednio (job nie istnieje jeszcze).
---
## 8. Podsumowanie dla Oskara
**Co jest:** Paperless dziala (189 dok, 4.2M znakow OCR), koperta+pgvector-extension stoi,
`packages/kb-mail` reuzywalny bez zmian dla adaptera Paperless, **dowod cross-source juz
lezy w danych** (registry.json z faktury-1 + `original_filename` w Paperless) — nie trzeba
go budowac, tylko odczytac i zapisac.
**Czego brakuje (do zrobienia w tej fazie):** tabela `document_chunk` (nie istnieje nigdzie
w KB — mail pillar tez jej nie ma), model bge-m3 (nie pobrany), token API Paperless (nie
wygenerowany), naglowki w 225 030 kopertach mailowych (dzis tylko manifest zalacznikow —
**krytyczna luka odkryta przez Oskara**, naprawiana backfillem w §5), jakikolwiek kod
chunking/embed/backfill (nie istnieje).
**7 otwartych decyzji** wypisanych w §2 (byla 6, doszla #7 — backfill: osobny job czy
rozszerzenie `gmail-bulk-import`). Decyzja #4 (cross-source) jest juz **rozstrzygnieta**
przez Oskara w tej korekcie — mechanizm to deterministyczny sha256/nazwa-pliku join, nie
`correspondent`. To jednoczesnie **upraszcza zakres**: tabele `entity`/`entity_link`
(graf encji) sa teraz odlozone, nie blokuja domkniecia modulu 5.
Nic nie zostalo zaimplementowane, zdeployowane, pobrane ani zacommitowane w ramach tego
recon — wlacznie z ta korekta (v2).