32 KiB
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 —
correspondentz 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:
envelopena 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 innysourcenie istnieje jeszcze w bazie.pg_extensionpotwierdzavector 0.8.3zainstalowane, ale NIEUZYWANE — brak jakiejkolwiek tabeli/kolumny typuvectorw calej bazie.grep -r "embed|vector|bge|chunk"wjobs/gmail-bulk-import/,packages/kb-mail/,jobs/documents-ingest/→ zero trafien. Indexer opisany wkb-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_correspondentma 0 wierszy.documents_tagma 0 wierszy. Wszystkie 189 dokumentow majacorrespondent_id = NULL. Paperless auto-detekcja korespondenta/tagow nie jest skonfigurowana ani wytrenowana — nie ma dzisiaj ZADNYCH danych do cross-source linku poprzez polecorrespondent. 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êœæzamiastintegralną 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_countistnieje jako osobna metadana, ale nie jest wyrownana do offsetow w tekscie. - Losowa probka tytulow ujawnila szum z fazy-1 (
documents-ingestsample 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) albomanage.py drf_create_token <user>. - Nie ma dzis zadnego tokenu API. Haslo z
PAPERLESS_ADMIN_PASSWORD(env, wartosc bootstrapowa) juz nie dziala — wedlugdocs/sessions/2026-07-10-paperless-deploy.mdhaslo kontaoskarzostalo zmienione recznie przezmanage.py shellpo 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 -shna 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), alemaintenance_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(Envelopedataclass) idb.py(insert_envelope/get_envelope) sa juz w pelni zrodlo-agnostyczne — zero logiki specyficznej dla maila. Dzialaja identycznie dlasource='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 wjobs/documents-ingest/robipip install /packages/kb-mail/i importujeEnvelope/insert_envelopewprost. Gdy przyjdzie faza Nextcloud (KOPIA, nie referencja), wtedyarchive.pybedzie 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_nameunikalne (185/185) — bezpieczny klucz join. - KRYTYCZNE odkrycie:
documents_document.checksum/.archive_checksumw 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 przezconsume/) dokladnie odpowiadaregistry[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 dladocuments-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: ~500–800 tokenow/chunk (≈2000–3200 znakow polskiego tekstu), overlap ~100–150 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.5–6k 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-importma 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 cogmail-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)
-- 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:
-- 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
[
{"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/ccparsowane (email.utils.getaddresses) do{name, address}, nie surowy string — ulatwia przyszla entity-resolution (§ decyzja 4) bez re-parsowania.to/ccto listy (wielu adresatow mozliwych);frompojedynczy obiekt (RFC dopuszcza wiele, w praktyce prawie zawsze jeden — jesli wielu, bierzemy pierwszy i logujemy).delivered_toto lista surowych stringow, NIE parsowana — naglowekDelivered-Tomoze wystapic wielokrotnie (hop-by-hop), a to wlasnie te wartosci mowia "ktory alias Oskara odebral wiadomosc" (motywacja z §1.8). Zachowujemy wszystkie wystapienia.date_rawto surowy string naglowkaDate:(dla porownania/debug) —envelope.tsjuz 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 codocuments-ingestjuz podjal dla MIME-parts (patrz jego README, sekcja o rozbieznosci compat32 vs default).
4.2 Koperta DOKUMENTU (source='paperless')
[
{"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):
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:
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)
- Migracja
002_chunks.sqlna kb-postgres@PIHA (addytywna, nie rusza001_envelope.sql).003_entities.sqlodlozona (§3) — nie jest czescia tego kroku. ollama pull bge-m3na SOLARII — zweryfikowac czas pobrania i realne zuzycie VRAM przy pierwszymollama run/embed call.- Token API Paperless — decyzja #5 wyzej (konto dedykowane vs
oskar), wygenerowac przezmanage.py drf_create_tokenlub UI. jobs/gmail-header-backfill/(decyzja #7) — nowy one-shot job: parsuje naglowki z 225 030 zarchiwizowanych.eml, dopisuje{"type": "headers", ...}(§4.1) doenvelope.entitiesprzez idempotentnyUPDATE ... WHERE NOT EXISTS(§5.2). Wykonac PRZED adapterem Paperless, bo krok 6 (dowod cross-source) czyta te naglowki.- Rozbudowa
jobs/documents-ingest/o adapter Paperless→koperta: fetch (paginowanyGET /api/documents/), zaladowanieregistry.jsondo indeksuconsume_name -> wpis, mapping wg §4.2–4.3 (w tymsource_mailgdy join sie powiedzie),insert_envelope(reuzyte zpackages/kb-mail). Idempotentnie —ON CONFLICT (id) DO NOTHINGjuz jest wkb_mail.db.insert_envelope. - Chunking + embed job — nowy modul (rozwazyc czy zyje w
jobs/documents-ingest/czy jako osobnyjobs/kb-indexer/reuzywalny tez dla maili pozniej — rekomendacja: zaczac wdocuments-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. - Pilot na 189 dok — uruchomic, zweryfikowac jakosciowo retrieval (recznie, kilka
zapytan przez SQL
ORDER BY embedding <=> query_vector). - 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. - 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), joinsource_mail, chunking (granice, overlap, dokument pusty/za dlugi), idempotencja UPDATE-backfillu. - Definition of Done (CLAUDE.md):
docker build+ smoke run +pytestprzed 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 ~2000–3000 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 (2–3k 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=64MBwystarcza na budowe HNSW dla pilotowych 2–3k 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).