01
pdfs are hostile input
Most retrieval problems in document-heavy products start before the embedding model ever runs. A contract exported from an ERP has invisible text layered over a scan, every page repeats a confidentiality footer, page 7 switches to two columns, and one appendix has a broken font map, so the extractor returns (cid:31)(cid:52) instead of words. If ingestion is "extract text, chunk, embed", all of that goes into the index, and because embeddings make even broken text look searchable, nothing fails. Recall just drifts down while everyone blames the prompt or the retriever.
The fix is to treat ingestion like a build: deterministic steps, pinned versions, artifacts you can compare between runs, and checks that fail the run when the output changes for no reason. This post walks through the four places where pipelines usually go wrong (extraction, canonicalization, chunk size and chunk identity) and ends with a manifest that makes re-ingestion diffable. The chunking code below runs as-is with uv run.
02
extract per page and OCR only where needed
Most business PDFs have a usable text layer on most pages, and OCR on a page that already has good text throws signal away. So decide per page. Extract native text first, then check each page: how many characters it produced relative to its area, how much of the output is (cid:N) placeholders or replacement characters, and whether the characters form words in the languages you expect. Pages that pass keep their native text. Pages that fail get rendered to an image and sent to OCR, and the manifest records which pages took that path.
For native extraction, pdfminer.six (MIT) is still maintained and gives you characters with coordinates. pypdfium2 wraps PDFium, the PDF engine from Chromium, under BSD and Apache licenses. PyMuPDF is excellent but dual-licensed under AGPL or a commercial license, which matters if you ship it inside a product. When you render a page for OCR, fix the DPI (300 is a sensible default) and record it, because OCR output changes with rasterization and you don't want a library default deciding what your corpus says.
Reading order is the next trap. Two-column pages extracted naively interleave the columns line by line, and tables come out as a soup of cells. Layout-aware tools fix this by detecting regions and their order first. If you find older tutorials built on layoutparser, note that its last release, 0.3.4, dates from April 2022; for new work, Docling (MIT) covers page layout, reading order, table structure and OCR for scanned pages, and it can also run vision-language models. For OCR itself, Tesseract 5.5 is the free on-premise option and handles clean scans well, while open VLM-based OCR models such as olmOCR (Apache 2.0) and the cloud providers' hosted document AI services are the ones to evaluate for bad scans, handwriting and dense tables, at a real cost per page in GPU time or fees. Route by page and document type instead of sending everything to the most expensive engine.
Whatever you choose, keep the raw output of each stage per page, together with the tool and its version. When retrieval gets worse in three months, you'll want to see whether extraction changed before you look at anything else.
03
canonical text before chunking
Extracted text has noise that should never reach the embedder: running headers and footers, page numbers, soft hyphens and words broken across lines. Strip it before chunking, in one deterministic function, so the same input always produces the same text:
The header and footer stripper looks only at the first and last few lines of each page and removes lines that appear on at least 60% of pages once digits are masked, so "Page 3 of 42" and "Page 4 of 42" count as the same line. That catches confidentiality notices, page counters and ERP export banners without touching body text that happens to repeat. Unicode normalization to NFC matters more than it looks for Czech and other accented languages, because the same "é" can arrive as one code point or two depending on the PDF producer, and the two forms hash and tokenize differently.
The hyphenation rule is a trade-off you should know about. Joining pay- at a line end with ment on the next line is right, but the same rule turns a compound like well- / known into wellknown. If your documents are full of hyphenated compounds, check the joined word against a dictionary before removing the hyphen. And if your layout tool labels headers, footers and headings itself, as Docling does, use its labels and keep the repetition rule as a fallback for the native-text path.
04
chunk to the embedder's real limit
Chunk size is usually set in round numbers, 350 or 512 tokens, without checking what the embedding model accepts. all-MiniLM-L6-v2 is a common default, and its model card says input longer than 256 word pieces is truncated, so with 350-token chunks the last quarter of every chunk is silently dropped at embedding time. It's still in the database and still shown to the LLM, but it can never be found by vector search. all-mpnet-base-v2 stops at 384, and hosted models have their own limits. Read the limit from the model's configuration, subtract the special tokens, and count with the model's own tokenizer.
There's a second trap in that tokenizer. The tokenizer.json shipped with all-MiniLM-L6-v2 has truncation and padding enabled at 128 tokens, so if you load it with the tokenizers library and count tokens without turning that off, every count tops out at 128 and every chunk looks like it fits. The symptom is easy to miss: while testing the code below, every chunk came out at exactly 128 tokens until truncation was switched off.
The chunker itself stays inside sections, packs whole paragraphs up to the budget, and only splits a paragraph at sentence boundaries, or as a last resort at a token offset, when it doesn't fit on its own:
Every chunk starts with its section heading, which gives both the embedder and the LLM context that a bare paragraph lacks, and the heading's tokens come out of the budget. The numbered-heading regex is a stand-in that works for contracts and policies; with a layout tool, use its heading labels instead. On a synthetic four-page contract with six sections, running headers, page footers and a word hyphenated across a line, the chunker produced 16 chunks with a maximum of 253 tokens against a budget of 254, and no header or footer text survived into the canonical paragraphs.
05
chunk IDs from content, not position
The chunk ID decides whether you can tell what changed between two ingestion runs. A common scheme hashes the document ID, page, chunk ordinal and text together. The ordinal is the problem: insert one paragraph near the start of a document, and every chunk after it gets a new ordinal and therefore a new ID, even though its text didn't change. A diff between runs then says the whole document changed, which is useless, and your pipeline re-embeds everything after the edit.
The code above derives the ID from the document ID and the normalized chunk text only, and keeps the ordinal as a separate field for ordering. An occurrence counter keeps two identical chunks in one document, such as a repeated boilerplate clause, from colliding. To see the difference, the test inserts a twelve-sentence paragraph into section 2 of the synthetic contract and diffs the chunk IDs of the two versions:
With content IDs, the diff shows exactly the local change: one chunk in section 2 was replaced by two, and the other fifteen kept their IDs, so their embeddings can be reused. With ordinals in the hash, fourteen of sixteen chunks look new. Because packing is greedy, an edit can still shift chunk boundaries until the end of its section, but sections stop the cascade, and in practice that's the difference between re-embedding a handful of chunks and re-embedding the document.
Store the ID with the page range, the section, the character offsets into the canonical text and the version of the chunking code. That's what a RAG answer needs to cite a passage precisely, and it's what the post on why RAG fails and how to fix it builds its provenance schema on.
06
a manifest and a gate that fails closed
Every ingestion run should write a manifest per document version: the hash of the raw file, the extraction engine and version, which pages went through OCR and at what DPI, the tokenizer and its exact revision, the chunk budget, a hash of the chunking code, per-page hashes of the canonical text and the list of chunk IDs. Here's one produced by the code above, trimmed:
With manifests in place, re-ingestion becomes a comparison, and the comparison can fail the job. If the raw file is byte-identical but a large share of chunk IDs changed, something in the pipeline changed, and that should stop publication until someone looks:
Add the same kind of check per batch for the OCR fallback rate, and per page for character counts: a page that produced 1,800 characters last week and 600 today is either a parser regression or a bad render, and either way you want to know before it reaches the index. The 15% threshold is a starting point; set it from what a normal re-run of your own corpus looks like. A replay command that reruns one document version with its historical manifest and prints the page-level differences turns most of these investigations into a few minutes of reading.
Pin everything that touches text: the extraction library, the OCR engine and its language data, the container image, the tokenizer revision and the embedding model. Changing any of them is a corpus migration, not a config tweak, because it changes what's in the index. Re-embed deliberately, compare retrieval on a fixed evaluation set, and keep the old vectors until the new ones have proven themselves, which is the process the post on rolling out a new embedding model in pgvector describes.
