pdf je nepřátelský vstup
Většina document pipelines nepadá na žádných exotických selháních modelů. Padají na nudných věcech. Vstup je odpad. Smlouva z procurementu vyexportovaná ze SAPu má neviditelný text přes raster scan, footer opakuje na každé stránce doložku o důvěrnosti, na straně 7 se dokument přepne do dvou sloupců, strana 11 je otočená o 90 stupňů a jedna příloha je vložená s tak rozbitou font mapou, že pdfminer.six v klidu vrátí '(cid:31)(cid:52)(cid:92)' místo slov. Jestli váš ingest vypadá jako extract text -> chunk -> embed -> pray, škody si všimnete až ve chvíli, kdy spadne kvalita search, odpovědi začnou být mlhavé nebo parser faktur začne zaměňovat celkovou částku a daňové ID.
Viděl jsem týmy, které obvinily retriever, pak model, pak prompt, přitom skutečný problém ležel o dvě vrstvy níž. V extrakci textu se sloučil levý a pravý sloupec do jednoho slovního guláše a chunker tuhle korupci poslušně zakonzervoval. Embeddings umí rozbitý text udělat zdánlivě dobře prohledatelný. Právě proto jsou tyhle problémy drahé. Zhoršují se pomalu, nevyhazují exceptions a generují dost použitelných výstupů na to, aby byli všichni v klidu, zatímco recall je každý týden horší.
Rozumná pipeline bere PDF jako nedůvěryhodný vstup a dělá z každé transformace pozorovatelný krok. Ve Steezru jsme stavěli document processing systémy pro zákaznické portály, interní ERP workflow i backoffice nástroje postavené na OCR a ten vzorec je pořád stejný: produkci přežijí týmy, které přestanou ingest brát jako jednorázový preprocessing skript. Potřebujete reprodukovatelný stack, explicitní práci s layoutem, kanonická pravidla pro chunking, která nedriftují mezi reruny, a verification hooky, které failují natvrdo. Jinak vám update dependency z sentence-transformers==2.2.2 na 3.0.1 nebo nový Tesseract language pack potichu změní chunky a otráví všechny navazující indexy.
nejdřív extrakce s layoutem
První rozhodnutí je jednoduché: obsahuje PDF použitelný text, nebo ne. Nespouštějte OCR nad vším jen proto, že je to pohodlné. Je to líné a zničíte tím signál. Začněte s pdfminer.six==20231228 a kontrolujte po stránkách hustotu znaků, font mapy, bounding boxy a proxy signály důvěryhodnosti extrakce. Když stránka vrátí smysluplný text se stabilními souřadnicemi, nechte ji být. Když vrací nesmysly, málo textu nebo podezřele opakované souřadnice, spadněte do OCR jen pro tu konkrétní stránku.
Praktický stack je pdfminer.six pro nativní text, layoutparser==0.3.4 pro odhad struktury z obrázku stránky a pro OCR buď tesseract==5.3.x, nebo Google Vision. Tesseract je levnější a líp se drží on-prem. Vision je lepší na ošklivé scany a smíšené layouty, hlavně účtenky a křivě naskenované faktury. Oba bych schoval za stejné page-level rozhraní a routoval podle třídy dokumentu a failure heuristik. Třeba takhle v Pythonu:
1page = load_pdf_page(pdf_path, page_num)2text_blocks = extract_pdfminer_blocks(page)3if low_text_density(text_blocks) or broken_encoding(text_blocks):4 image = render_page(page, dpi=300)5 layout = detect_layout(image) # columns, tables, headers6 ocr_blocks = run_tesseract(image, psm=1, lang="eng+deu")7 blocks = align_blocks_with_layout(ocr_blocks, layout)8else:9 blocks = normalize_pdfminer_blocks(text_blocks)
Layout pass je důležitější, než si většina lidí připouští. Text ve dvou sloupcích bez rekonstrukce reading order spolehlivě zničí retrieval. Stačí špatný join a věta z levého sloupce se spojí s odrážkou z pravého do jednoho chunku, embedder pak s naprostou jistotou zakóduje nesmysl. My obvykle řadíme bloky podle detekovaného regionu, potom podle y souřadnice a uvnitř regionu podle x souřadnice, s explicitním zacházením pro tabulky a sidebary. layoutparser s detektorem postaveným na PubLayNetu funguje na běžné business dokumenty docela dobře, ale u faktur a formulářů bych přidal vlastní region heuristiky, protože obecné document detektory často míjejí boxy, razítka a gridy položek.
Stránky navíc renderujte vždy na fixní DPI. Vyberte 300 a zmrazte to. OCR výstup se mění podle rasterizace a ve chvíli, kdy vám jde o reprodukovatelnost, nenecháte defaulty ImageMagicku rozhodovat o podobě korpusu.
kanonické chunky, nebo chaos
Chunking je místo, kde se spousta pipelines stane nedeterministická a nikdo si toho nevšimne. Lidi řeší velikost chunku, jako by jediná otázka byla 512 tokenů nebo 1024. Jasně, i tohle je důležité. Těžší problém je ale zajistit, aby stejný zdrojový dokument zítra vyrobil stejné hranice chunků, po rerunu, po upgradu parseru, po tom, co se jedna stránka znovu prohnala OCR, protože minulý týden timeoutnul Vision.
Řešení je kanonizace před embeddingem. Ořezat headery a footery pomocí analýzy opakování mezi stránkami, normalizovat whitespace, odstranit soft hyphenation, pokud možno zachovat hranice řádků v tabulkách a page metadata připojit přímo do identity chunku, ne do textového těla, které jde do modelu. Mně sedí dvouprůchodový přístup. Nejprve postavit kanonický text na úrovni stránky, potom z něj odvodit chunky omezené tokeny se stabilními pravidly overlapu.
Jednoduchý stripper headerů a footerů může hashovat horních N a spodních N řádků na každé stránce a mazat ty řádky, jejichž normalizovaná forma se objeví třeba na víc než 60 procentech stránek. Normalizovaná znamená lowercase, maskované číslice a zkolabovaný whitespace. Tím odchytíte Confidential, Page 7 of 42, timestamp šum i ty příšerné bannery z ERP exportů. Pak spočítejte hash stránky z očištěného kanonického textu plus souřadnic bloků zaokrouhlených na toleranci, třeba 5 px, když jedete přes OCR. Teprve potom chunkujte deterministickým tokenizerem, například sentence-transformers/all-MiniLM-L6-v2 s fixní verzí Hugging Face tokenizeru, fixní max délkou a overlapem vyjádřeným v tokenech, ne znacích.
1canon = canonicalize_page(blocks)2page_sha = sha256(canon.encode("utf-8")).hexdigest()3chunks = chunk_by_tokens(4 canon,5 tokenizer_name="sentence-transformers/all-MiniLM-L6-v2",6 max_tokens=350,7 overlap_tokens=60,8 break_on=["\n\n", "\n", ". "]9)10for idx, chunk in enumerate(chunks):11 chunk_id = sha256(f"{doc_id}:{page_num}:{page_sha}:{idx}:{chunk}".encode()).hexdigest()
Vypadá to přehnaně, dokud nemusíte reindexovat 4 miliony chunků po zjištění, že jeden OCR worker měl tessdata namountovaná z jiného image. V tu chvíli vám deterministická chunk ID zachrání týden. Můžete porovnat starý a nový ingest run po stránkách i po chuncech a rozhodnout, jestli je změna očekávaná, nebo je čas na rollback.
verification musí být nudná
Verification musí být deterministická, levná a nepříjemná. Jestli čeká, až si člověk všimne, že search funguje hůř, tak žádnou verification nemáte. Máte jen víru. Každý ingest run by měl emitovat artefakty, které umíte mechanicky porovnat: statistiky extrakce textu, page hashe, počty chunků, míru OCR fallbacku, průměrnou délku v tokenech, jazykové rozdělení a malou sadu assertions specifických pro daný typ dokumentu.
U faktur typicky víte, že musí existovat právě jeden kandidát na číslo faktury, aspoň jedna částka v měně a total někde ve spodní polovině dokumentu. U interních směrnic víte, že číslování sekcí má být monotónní a headery by po stripnutí měly z většiny zmizet. To nejsou AI kontroly. To jsou deterministické sanity testy. Pište je jako testy.
Pro každou verzi dokumentu ukládáme manifest do PostgreSQL a object storage. Manifest obsahuje verze parserů, OCR engine, language packy, DPI rasterizace, verzi tokenizeru, konfiguraci chunkingu a k tomu hashe raw souboru, kanonického textu po stránkách a finálního seznamu chunků. Při reingestu porovnáváme nový manifest s předchozím. Když changed_chunk_ratio > 0.15 a hash raw souboru se nezměnil, job failne. Když OCR fallback po deployi vyskočí z 8 procent na 73 procent, failne batch. Když stránka, která dřív vracela 1 200 znaků, najednou vrátí 74, failne to hned.
Spousta týmů tohle přeskočí, protože si myslí, že výstupy zkontrolují ručně. Nezkontrolují. Jakmile máte desítky tisíc dokumentů, tichý drift vyhraje pokaždé. Pattern selhání je pořád stejný: žádný alarm, žádná exception, jen trochu horší retrieval a Slack vlákno plné vágních stížností.
Ještě jedna věc: verzujte každou dependency, která sahá na text. Pinujte pdfminer.six, pinujte pytesseract, pinujte Docker image, tokenizer files i language data. Pokud voláte Google Vision, ukládejte i verzi API a předpoklady nad response schema. Reprodukovatelnost zmizí ve vteřině, kdy jeden worker běží na jiném image.
kvalita retrieval začíná nahoře
Lidi milují benchmarky retrieverů a rerankerů, pak je ale krmí rozbitými chunky a diví se, proč MRR lítá. Kvalita retrieval se ve většině případů rozhodne dřív, než se spustí embedder. Čistý chunk se zachovaným reading order, bez boilerplate a se stabilními sémantickými hranicemi udělá dobrou práci i s průměrnějším modelem. Špinavý chunk naopak nutí drahé modely kompenzovat chyby z extrakce, které nikdy neměly opravovat.
Pro obecné searchable korpusy je sentence-transformers/all-mpnet-base-v2 pořád solidní baseline, pokud vás netrápí brutální latence. Pro lehčí deploymenty je all-MiniLM-L6-v2 úplně v pohodě a líp se servíruje. Zmrazte revizi modelu a zapište ji do manifestu. Když později přejdete na bge-small-en-v1.5 nebo na multilingual model, berte to jako migraci korpusu, ne jako tichý config tweak. Re-embedujte záměrně, porovnejte retrieval na fixním eval datasetu a držte staré indexy dost dlouho na rollback.
Stejná disciplína nahoře ve stacku pomáhá i document QA a automatizaci faktur. Když se line items ve faktuře chunkují bez awareness tabulek, extractor začne párovat množství ke špatným popisům. Když chunky ve znalostní bázi obsahují každých 300 tokenů opakovaný footer, semantic search přestřelí relevanci ve prospěch právního boilerplate. Když do body textu natečou čísla stránek, citace začnou být zašuměné. Není to nijak sexy práce. Je to ta práce.
Náš výchozí pattern pro špinavé business dokumenty je schválně nudný: Django workery, PostgreSQL manifesty, S3-compatible object storage, page images cachované pomocí content hashů, deterministický chunking v Pythonu, embeddings generované v odděleném verzovaném jobu a Next.js admin view, kde si ops může porovnat dva ingest runy nad stejným souborem. Ten poslední bod je důležitý. Inženýři věří systémům, do kterých vidí, a PDF pipeline potřebuje možnost inspekce víc než chytrost.
stack, který bych shipnul
Kdybych to měl příští týden stavět pro document QA systém nebo searchable zákaznický portál, poslal bych do produkce pipeline, která vypadá nějak takhle. Raw PDF ukládat do immutable storage, při příchodu je hashovat přes SHA-256, zařadit joby na extrakci po stránkách, nejdřív zkusit nativní extrakci textu přes pdfminer.six==20231228, neúspěšné nebo podezřelé stránky renderovat na 300 DPI pomocí pdftoppm z Poppleru 24.x, pustit layout detection přes layoutparser==0.3.4, OCR přes Tesseract 5.3 a eng, deu nebo jazyky, které ve vašem korpusu skutečně jsou, a teprve potom kanonizovat, chunkovat, embedovat, verifikovat a publikovat do search indexu.
Důležitý je i datový model. Mějte oddělené tabulky documents, document_versions, pages, chunks a ingestion_manifests. Ukládejte zvlášť raw extrahovaný text, kanonický text a finální text chunku. Ano, duplikuje to data. Disk je levný, debugging ne. Řádek v chunks by měl obsahovat document_version_id, page_start, page_end, chunk_index, chunk_sha, embedding_model a canonical_config_version. Díky tomu máte dost lineage na jedinou otázku, která při incidentu fakt zajímá: proč tenhle chunk vznikl a proč se změnil.
Chcete také úplně jednoduchou replay cestu. Pro document_version_id=8421 znovu spusťte extrakci s přesně historickým manifestem, porovnejte výstupy a publikujte diff report. Žádné schované defaulty, žádná magie typu current-config. Když report řekne:
1page 14 canonical hash changed2old chars: 18323new chars: 6114ocr_fallback: false -> true5header_strip_lines_removed: 2 -> 19
hned víte, kam se dívat. Možná regrese parseru, možná špatný render stránky, možná stripper headerů začal být moc agresivní. Pointa je, že to víte.
Špinavá PDF se nikdy sama nezlepší. Přesto můžete postavit pipeline, která se chová předvídatelně, a předvídatelnost porazí chytrost úplně pokaždé.
