Setiap tim maintenance punya tumpukan PDF yang sama: manual OEM, diagram listrik, katalog suku cadang, grafik pelumasan, lembar data keselamatan. Nilai yang terkunci di dalamnya jelas — teknisi yang bisa bertanya "berapa torsi spesifikasi untuk rumah bearing utama?" dan mendapat jawaban lengkap dengan nomor halaman jauh lebih cepat daripada mengurai binder 400 halaman. Itulah retrieval-augmented generation, atau RAG, di atas dokumen Anda sendiri.
Bagian tidak menarik yang tidak pernah diperingatkan kepada siapa pun adalah langkah nol: mengubah PDF itu menjadi teks yang benar-benar bisa dibaca model embedding. Dan pada manual industri, hampir setiap pustaka PDF siap pakai gagal tepat di langkah ini. Ini kisah mengapa, dan tentang ekstraktor Rust clean-room yang kami tulis untuk memperbaikinya — yang dikompilasi menjadi biner native untuk on-prem dan menjadi WebAssembly yang berjalan di dalam Cloudflare Worker yang sama yang melakukan embedding dan pengindeksan.
Masalahnya: PDF Industri Memakai Font yang Mengalahkan Parser JavaScript
PDF normal — faktur yang dihasilkan, dokumen Word yang diekspor — menyematkan pemetaan Unicode-ke-glyph yang bersih. Parser JavaScript seperti pdf.js (dan saudaranya di sisi server, unpdf) membaca teks dari file ini tanpa masalah.
Manual industri bukan PDF normal. Mereka diekspor dari toolchain CAD dan desktop-publishing yang melakukan subsetting font secara agresif untuk mengecilkan ukuran file. Pola umumnya: font Type 1 atau CID yang glyph-nya dialamatkan dengan ID numerik, dengan konvensi penamaan glyph privat seperti G42 atau G108, dan tanpa peta ToUnicode — tabel yang menerjemahkan ID itu kembali menjadi karakter nyata. Pembuat ekspor tahu glyph mana yang berarti "A" saat menggambar halaman; mereka tidak repot-repot mencatat fakta itu untuk siapa pun yang membaca file nanti.
Saat parser JavaScript menemui salah satunya, ia melakukan satu-satunya hal yang bisa: mengeluarkan nama glyph mentah atau codepoint area private-use. Hasilnya halaman demi halaman berupa G104 G42 G77 G91 di tempat yang seharusnya berisi teks "Torsi bearing: 120 Nm". Kami menyebutnya glyph garbage, dan pada korpus manual nyata kami hal ini memengaruhi mayoritas halaman.
Mengapa Ini Fatal Khusus untuk RAG
Bagi manusia yang melihat halaman yang dirender, ini tidak jadi masalah — halaman tergambar bagus karena program font masih punya glyph-nya. Bencana murni ada di lapisan teks yang diekstrak, yang merupakan satu-satunya lapisan yang pernah dilihat RAG.
Pengambilan RAG adalah rantai, dan setiap mata rantai menyebarkan kerusakan:
Chunking membelah sampah menjadi potongan sampah yang lebih kecil. Potongan G104 G42 G77 tidak membawa sinyal apa pun.
Embedding mengubahnya menjadi vektor, tetapi vektor dari derau. Model memetakan "G104 G42 G77" ke suatu wilayah ruang embedding yang tidak ada hubungannya dengan torsi bearing.
Pencarian vektor mengembalikan potongan yang salah karena vektor yang penuh derau mendarat di lingkungan yang tak bermakna.
LLM mendapat konteks yang rusak dan berhalusinasi dengan percaya diri, atau mengaku tidak bisa menemukan jawaban.
Sampah masuk, sampah keluar — tetapi sampah di langkah ekstraksi benar-benar mematikan karena tidak terlihat. Halaman terender dengan benar, pipeline melaporkan sukses, jumlah potongan terlihat sehat, dan satu-satunya gejala adalah chatbot yang salah dengan percaya diri. Anda tidak menelusuri "ekstraktor menghasilkan codepoint private-use"; Anda menelusuri "AI memberi jawaban buruk," tiga lapis abstraksi di hilir.
Karena itulah kami menolak mengirimkan RAG di atas ekstraktor yang diketahui rusak. Ekstraktornya harus diperbaiki dulu.
Perbaikan yang Jelas, dan Mengapa Kami Menolaknya
Perbaikan yang jelas adalah poppler — pustaka PDF lisensi-GPL yang teruji tempur, di balik sebagian besar tool pdftotext di Linux. Ia menangani font CID dengan benar. Kami bahkan pernah mengirimkannya: sidecar poppler yang berjalan di Cloudflare Container, dipanggil dari Worker lewat HTTP, yang melapisi teks otoritatif ke halaman-halaman yang dirusak parser JavaScript.
Ia bekerja. Kami tidak menyukainya. Empat masalah:
Lisensi. Poppler berlisensi GPL. Membundelnya menciptakan kewajiban copyleft yang tidak kami inginkan di dekat produk komersial.
Rantai dependensi. Build poppler menyeret conda atau micromamba, hutan pustaka bersama, dan folder lib yang dikelola environment. Itu persis yang tidak Anda inginkan di dalam tarball on-prem mandiri yang dipasang pelanggan di balik firewall tanpa internet.
Ekonomi edge. Container poppler adalah proses terpisah utuh, cold-start sendiri, ditagih sebagai sidecar, dijangkau lewat jaringan dari Worker. Untuk ekstraksi per-dokumen di edge itu berat dan lambat.
Pemisahan arsitektur. Setengah pipeline berjalan di Worker; langkah ekstraksi keluar dari Worker untuk memanggil container. Dua mode kegagalan, dua cerita scaling, dua tempat untuk di-debug.
Kami ingin ekstraktor berjalan di dalam Worker, di sebelah panggilan embedding dan upsert vektor, tanpa sidecar dan tanpa beban GPL.
Yang Kami Bangun: pdftotext-rs, Ekstraktor Clean-Room
Kami menulis ekstraktor teks PDF sendiri, pdftotext-rs. Ini implementasi clean-room: dibangun dari spesifikasi PDF ISO 32000-2 dan catatan teknis Adobe yang dipublikasikan (Type 1 charstrings, sumber daya CMap, OpenType, CIDFont), bukan dari kode sumber poppler, mupdf, xpdf, pdfium, atau Ghostscript. Disiplin itulah yang menjadikannya berlisensi MIT, bukan karya turunan dari mesin GPL.
Untuk primitif objek, cross-reference, dan dekompresi stream tingkat rendah ia bersandar pada lopdf dan flate2 — keduanya pustaka MIT independen. Segalanya di atasnya — interpreter content-stream, resolusi font dan encoding, pencarian Adobe Glyph List, rekonstruksi CID-ke-Unicode untuk font tanpa peta ToUnicode — milik kami, ditulis dari spesifikasi.
Aturan clean-room ditegakkan keras. Tidak ada kontributor yang boleh membaca sumber mesin PDF yang ada, dan file keluaran-ekspektasi mesin mana pun tidak boleh diimpor sebagai oracle uji. Goldens ujian kami dihasilkan oleh biner kami sendiri dan dibekukan setelah persetujuan keterbacaan manusia. Paritas diukur dengan dua gerbang: kurang dari lima persen karakter private-use, replacement, atau kontrol per halaman (kami mencapai 0,00% pada korpus manual nyata), dan word-recall versus poppler minimal 70% bila poppler tersedia untuk perbandingan (kami mencapai 100% — setiap kata yang diekstrak poppler adalah substring dari keluaran terflatten kami).
Hasilnya teks kualitas-poppler tanpa glyph garbage, pada manual CID-font persis yang merusak setiap parser JavaScript.
Satu Codebase, Dua Target: Biner Native dan WASM
Pilihan desain yang menentukan adalah menjadikan crate terkompilasi ke dua target dari satu pohon sumber.
Target satu: biner native statis. Di-cross-compile dengan cargo-zigbuild ke target host (musl, sepenuhnya tertaut statis, tanpa folder lib). Biner ini dikirim di dalam tarball on-prem di dist/opexmx-server-.../bin/pdftotext. Tanpa conda, tanpa micromamba, tanpa rantai pustaka bersama, tanpa kewajiban GPL. Server on-prem menyelesaikannya persis seperti biner poppler lama, jadi tidak ada yang berubah di server.
Target dua: WebAssembly. Crate yang sama dikompilasi ke wasm32-unknown-unknown dengan wasm-pack, dan WASM itu berjalan di dalam Cloudflare Worker sebagai ekstraktor PDF default. WASM rilis berukuran kira-kira 0,23 MB ter-gzip — nyaman di bawah langit-langit 3 MB tier gratis Cloudflare. Yang krusial, jalur kode pustaka deterministik, sehingga keluaran WASM bit-identik dengan biner native. Satu ekstraktor, satu perilaku, di mana saja.
Menjadikan lopdf bekerja di WASM berarti membangunnya dengan default feature off dan feature wasm_js on, yang menjatuhkan dependensi chrono, rayon, dan time serta memberinya sumber keacakan berbasis JavaScript. Profil rilis mengaktifkan optimasi ukuran, link-time optimization, stripping simbol, satu codegen unit, dan abort-on-panic supaya modul tetap kecil dan bebas panic. wasm-opt sengaja dimatikan — ia tersedak pada keluaran reference-types wasm-bindgen, dan kemenangan ukurannya yang marjinal tidak sepadan dengan dependensi binaryen di setiap host build.
Modul WASM diimpor secara dinamis oleh pemroses manual Worker, yang penting untuk bagian berikutnya.
Pipeline: Bagaimana Manual Menjadi Bisa Dicari
Mengekstrak teks adalah satu fase dari pipeline yang lebih panjang yang mengubah PDF yang diunggah menjadi basis pengetahuan yang bisa dicari. Semuanya berjalan di Durable Object Cloudflare, digerakkan oleh state-machine berbasis alarm, bukan satu fungsi berjalan-lama.
Pilihan itu bukan estetika. Request Worker punya langit-langit waktu CPU, dan promise yang tidak di-await yang dijatuhkan dari Durable Object dibunuh saat objek di-evict. Kami belajar susah payah bahwa ekstraksi fire-and-forget meninggalkan manual terjebak di "indexing" selamanya: promise mati, tidak ada alarm dipasang, dan tak ada yang pernah melanjutkan. Pipeline panjang di edge harus state-machine alarm yang memasang ulang alarm sendiri setelah setiap fase, sehingga tick yang dibunuh atau timeout cukup melanjutkan di alarm berikutnya.
Fasenya adalah extract, embed, dan finalize.
Extract. Unduh PDF dari object storage, lalu jalankan lewat ekstraktor WASM. Halaman yang kembali nyaris kosok ditandai sebagai image-heavy — gambar skematik, halaman hasil scan — dan gambar raster yang tertanam ditarik keluar (di sinilah unpdf masih berguna, sebagai penarik gambar murni) dan dikirim ke model vision untuk OCR. Manual padat-teks tidak pernah menyentuh jalur OCR sama sekali. Halaman yang diekstrak lalu di-chunk.
Embed. Potongan di-embed dalam batch lewat Cloudflare Workers AI, memakai model bge-base-en-v1.5 pada 768 dimensi, dan di-upsert ke indeks Vectorize yang dikonfigurasi untuk 768 dimensi dengan similaritas cosine. Baris potongan yang sama dipertahankan ke D1 (basis data relasional) sehingga teks lengkap, jalur bagian, dan nomor halaman bisa direjoinkan ke vektor yang cocok saat waktu kueri. Batching penting: manual 300 halaman menghasilkan ratusan potongan, dan meng-embed satu per satu akan meledakkan anggaran per-tick. Setiap tick alarm meng-embed satu batch, memajukan kursor, dan memasang ulang alarm.
Finalize. Catatan dokumen pengetahuan dibuat, baris manual ditandai siap, dan pass best-effort mencoba mengekstrak jadwal preventive-maintenance terstruktur dan tabel ambang batas dari teks potongan.
Chunking Lebih Penting Daripada Model
Kesalahan umum adalah menganggap model embedding yang lebih baik menyelamatkan chunking yang buruk. Tidak. Potongan adalah unit pengambilan, jadi batas potongan adalah tempat makna semantik dilestarikan atau dihancurkan. Pembelahan ukuran-tetap naif memecah sebuah prosedur melintasi dua potongan dan mengubur spesifikasi torsi di tengah paragraf.
Chunker kami sadar-struktur. Ia menelusuri header bagian yang terdeteksi untuk menjaga jalur bagian, memulai potongan baru di batas bagian, dan jika tidak, membelah saat anggaran token terlampaui. Setiap potongan diklasifikasikan — jadwal preventive-maintenance, troubleshooting, prosedur, spesifikasi, tabel, atau teks biasa — sehingga langkah hilir bisa menargetkan konten yang relevan. Anggaran 500 token dengan tumpang-tindih 50 token menjaga potongan di dalam jendela konteks model embedding sambil menjaga kesinambungan.
Satu detail yang tidak obvious: estimasi token mengambil maksimum dari estimasi berbasis kata dan estimasi berbasis karakter. Langit-langit karakter ada khusus karena mode-kegagalan glyph-garbage. Deretan codepoint private-use dengan sedikit spasi bisa terbaca sebagai lima puluh "kata" oleh pembelah naif padahal panjangnya delapan belas ribu karakter — diam-diam melampaui anggaran dan meluapkan jendela konteks model embedding. Ikatan karakter menangkap persis deretan itu.
Satu Antarmuka Vektor, Dua Backend
Lapisan embedding dan vektor diabstraksikan di balik satu antarmuka dengan dua implementasi: Cloudflare Workers AI plus Vectorize di cloud, dan pgvector plus endpoint embedding lokal yang kompatibel-OpenAI di on-prem. Dimensinya 768 di mana-mana, dan field scope menggantikan konvensi metadata domain Vectorize. Kode ingest dan kode kueri identik lintas platform; hanya backend yang diresolve yang berbeda. Saat tidak ada backend terkonfigurasi, backend null menurunkan fitur semantik menjadi no-op sementara fitur leksikal tetap bekerja, sehingga aplikasi tidak pernah gagal-total karena binding hilang.
Inilah yang memungkinkan fitur RAG yang sama berjalan di deployment multi-tenant Cloudflare dan di Postgres on-prem yang air-gapped milik pelanggan. Bagian yang sulit — ekstraksi, chunking, pengambilan — platform-agnostik.
Waktu Kueri: Fan-Out dan Merge
Saat waktu kueri, pertanyaan pengguna di-embed dengan model yang sama, dan vektor hasilnya dikuerikan terhadap indeks. Bagian yang menarik adalah bagaimana filtering bekerja.
Indeks vektor hanya mendukung filter AND. Tetapi pertanyaan maintenance tentang aset spesifik seharusnya memunculkan tiga hal sekaligus: dokumen spesifik-aset, dokumen untuk tipe peralatan aset itu (sehingga manual yang dipakai bersama semua mesin model yang sama ditemukan), dan dokumen global seperti SOP dan prosedur lockout. Itu kondisi OR, bukan AND.
Solusinya fan-out. Untuk setiap kueri kami menjalankan pencarian vektor paralel lintas scope (manual dan document) dan lintas varian filter (global, scoped-aset, scoped-tipe-peralatan), lalu menggabungkan hasil berdasarkan skor terbaik per potongan. Potongan yang cocok dengan beberapa varian dihitung sekali, pada skor tertingginya. Id potongan yang digabung lalu direjoin ke D1 untuk menarik teks, jalur bagian, dan nomor halaman yang sebenarnya, dan konteks yang dirakit itu diserahkan ke LLM. Primitif pengambilan yang sama menggerakkan pencarian manual semantik, saran problem-capture, dan pencocokan gejala-ke-penyebab di seluruh produk.
Penguatan Edge: Pelajaran yang Menjadikannya Siap-Produksi
Membuat ekstraktor bekerja adalah bagian mudah. Membuatnya bertahan di runtime edge butuh rekayasa yang sesungguhnya.
Jaga WASM tetap di luar bundel cold-start. Cloudflare Worker punya anggaran CPU-startup, dan modul WASM 0,23 MB yang diinstansiasi di setiap cold start memakannya. Modul ekstraktor diimpor secara dinamis hanya saat sebuah PDF benar-benar perlu diproses, sehingga request yang tidak pernah menyentuh PDF tidak pernah membayarnya.
Jaga memori sebelum ia menjagamu. Ekstraktor WASM memakai lopdf, yang menahan seluruh grafik objek terdekompresi di memori. Untuk PDF besar itu berarti puluhan kali ukuran file dalam objek hidup, dan Worker punya langit-langit memori keras. Yang krusial, melebihinya membunuh Worker langsung — bukan eksepsi yang bisa ditangkap. Manual di atas penjaga ukuran kami dilewati di jalur WASM dan jatuh ke ekstraktor JavaScript yang lebih ringan, yang menghasilkan sampah pada font CID tetapi setidaknya selesai, alih-alih membunuh Worker dan meninggalkan manual terjebak dalam loop retry tak terhingga.
Jaga fallback yang jujur. Ekstraktor JavaScript (unpdf) dipertahankan dengan sengaja: sebagai fallback saat WASM melempar, dan sebagai penarik gambar untuk OCR pada halaman hasil scan. Toggle bisa memaksa jalur legacy untuk debugging. Container poppler lama kini opsional — WASM setara kualitasnya — dan dipertahankan hanya untuk fallback legacy.
Pasang ulang, jangan await. Setiap fase pipeline mempertahankan kemajuannya dan memasang ulang alarm sebelum kembali. Fase yang timeout atau di-evict melanjutkan dari checkpoint terakhirnya pada tick berikutnya, alih-alih memulai ulang atau mogok.
Hasilnya
Pada korpus manual industri nyata kami yang lengkap, ekstraktor clean-room menghasilkan teks kualitas-poppler tanpa glyph garbage — halaman CID-font yang kembali sebagai codepoint private-use dari setiap parser JavaScript kini terbaca dengan benar, di dalam Worker, tanpa sidecar. Teks itu mengalir ke potongan sadar-struktur, embedding 768-dimensi, dan indeks Vectorize yang dikueri dengan fan-out dan merge, dan seluruh pipeline berjalan identik di Cloudflare dan di Postgres on-prem di balik firewall pelanggan.
Pelajarannya, jika ada: dalam sistem RAG ekstraktor bukan sekadar checkbox. Ia fondasi. Lapisan teks benar dan pengambilan, embedding, serta generasi semua berperilaku baik. Salah dan tak ada kualitas model di hilir yang menyelamatkanmu — kamu akan mengirimkan chatbot yang salah tanpa terlihat dan percaya diri, dan menghabiskan berbulan-bulan menyalahkan model atas masalah yang sebenarnya hidup di langkah nol.