DEV Community

KolmoPDF
KolmoPDF

Posted on

Why Broken Hyphenation and Ligatures Corrupt Technical Embeddings (and How to Normalize PDF Text Streams)

This article is part of the **PDF Parsing for RAG* series. In Part 1, we resolved multi-column reading order. In Part 2, we reconstructed borderless tables. In Part 3, we serialized mathematical formulas into LaTeX blocks. In Part 4, we bound floating captions and footnotes. Today we address broken hyphenation, font ligatures, and text stream normalization.*

When a retrieval-augmented generation (RAG) system fails to retrieve a technical paragraph, engineers usually adjust chunk sizes, tune top-k parameters, or switch embedding models. Yet in many PDF ingestion pipelines, the primary failure occurs before the chunker runs: the raw text stream corrupts key terminology at the character level.

A search query for "document classification" fails to match a chunk containing the exact definition. When inspecting the vector space, the cosine similarity between the query and the chunk is surprisingly low (often below 0.55 instead of above 0.82). The issue is not semantic distance. In the original PDF, the word appeared at the end of a justified column as classifi- followed by cation on the next line. Naive text extractors emit classifi- cation, which byte-pair encoding (BPE) tokenizers shatter into disconnected subwords.

The typography of broken text streams

PDF documents do not store semantic paragraphs. A PDF is a set of display instructions specifying font subsets and glyph placements on a two-dimensional canvas. Two typographic optimizations common in technical publishing routinely corrupt downstream NLP pipelines:

1. Line-break hyphenation

Typesetting engines like LaTeX and InDesign justify columns by splitting long words across line boundaries. When a word is divided, the engine inserts an ASCII hyphen (0x2D) or a soft hyphen (0xAD) and pushes the remaining syllables to the subsequent line.

If an extractor preserves the line break literally, the text becomes hyper- parameter. If the extractor strips line breaks and inserts spaces, the text becomes hyper- parameter. When tokenized by models such as OpenAI text-embedding-3-large (cl100k_base) or BGE-large, the split word yields ["hyper", "-", "parameter"] instead of the canonical ["hyperparameter"]. The vector representation shifts away from the semantic center of the intended concept.

Removing all hyphens unconditionally creates an opposite failure. True hyphenated compounds like state-of-the-art, trade-off, cost-effective, and zero-shot turn into stateoftheart and zeroshot. These concatenated strings do not exist in the tokenizer vocabulary and decompose into arbitrary character chunks.

2. Typographic ligatures and custom glyph mapping

To improve readability in print, typesetting systems merge adjacent letter pairs into a single combined glyph. The most common pairs are fi, fl, ff, ffi, and ffl.

In digital PDFs, ligatures introduce two failure modes:

  • Standard Unicode ligatures: The engine encodes fi as the single Unicode point \uFB01 (LATIN SMALL LIGATURE FI). While modern LLMs sometimes recognize these code points, many dense embedding models and BM25 tokenizers treat \uFB01 as an out-of-vocabulary character or decompose it into raw UTF-8 bytes. The word definition (de\uFB01nition) fails exact-match token lookups.
  • Font subsetting and missing CMaps: When a PDF generator embeds a custom font without a valid ToUnicode mapping table, ligatures are assigned arbitrary glyph indices or mapped to Private Use Area (PUA) code points (\uE000 to \uF8FF). In these documents, configuration extracts as con\uE002guration or con guration.

3. Invisible control characters and soft hyphens

Soft hyphens (\xad) mark discretionary break points. In some PDF text streams, soft hyphens remain embedded within words even when the word did not break across lines ("iden\xadti\xfier"). Zero-width spaces (\u200B) and non-breaking spaces (\u00A0) also contaminate token sequences, preventing dense retrievers from matching plain text queries.

Tokenization and retrieval impact

The following table demonstrates how character-level corruption alters subword token counts and cosine retrieval scores against the clean query "efficient document classification":

Input Text Representation Subword Tokens (cl100k_base) Token Count Cosine Similarity vs Query
efficient document classification (Clean) ['efficient', ' document', ' classification'] 3 0.884
efficient document classifi- cation (Broken hyphen) ['efficient', ' document', ' class', 'ifi', '-', ' cation'] 6 0.612
efficient document classification (Unicode ligature \uFB01) ['efficient', ' document', ' classi', 'fi', 'cation'] 5 0.731
efficient document classi\uE001cation (PUA glyph) ['efficient', ' document', ' classi', '<unk>', 'cation'] 5 0.548

The broken hyphen alone reduces retrieval similarity by 0.272, pushing a relevant chunk below standard similarity thresholds.

Building a text normalization pipeline

A resilient PDF ingestion pipeline must normalize text streams before chunking. The process requires three sequential steps:

  1. Normalize Unicode forms via NFKC to expand standard ligatures.
  2. Resolve known Private Use Area font glitches.
  3. Reconnect hyphenated words across line breaks using a lexical heuristic.

Here is a Python implementation designed for PDF text streams:

import re
import unicodedata

# Common Private Use Area (PUA) ligature mappings found in PDF fonts
PUA_LIGATURE_MAP = {
    "\uE000": "fi",
    "\uE001": "fl",
    "\uE002": "ff",
    "\uE003": "ffi",
    "\uE004": "ffl",
    "\uF001": "fi",
    "\uF002": "fl",
}

# Common technical compounds that must retain their internal hyphen
TRUE_COMPOUNDS = {
    "state-of-the-art", "cost-effective", "trade-off", "trade-offs",
    "zero-shot", "few-shot", "out-of-domain", "pre-trained",
    "end-to-end", "cross-validation", "fine-tuning", "multi-modal"
}

def clean_invisible_glyphs(text: str) -> str:
    """Removes soft hyphens, zero-width spaces, and control characters."""
    text = text.replace("\xad", "")       # Soft hyphen
    text = text.replace("\u200b", "")     # Zero-width space
    text = text.replace("\ufeff", "")     # Byte order mark
    text = text.replace("\u00a0", " ")    # Non-breaking space
    return text

def expand_ligatures(text: str) -> str:
    """Expands standard Unicode ligatures and resolves known PUA glyphs."""
    # 1. Replace known PUA codes
    for pua_char, replacement in PUA_LIGATURE_MAP.items():
        if pua_char in text:
            text = text.replace(pua_char, replacement)

    # 2. Apply Unicode NFKC normalization (decomposes standard ligatures like \uFB01 to 'fi')
    text = unicodedata.normalize("NFKC", text)
    return text

def dehyphenate_text(text: str) -> str:
    """
    Reconstructs words broken by line-end hyphenation while
    preserving intentional compound words.
    """
    # Regex matches: word-fragment + hyphen + optional whitespace/newline + word-fragment
    pattern = re.compile(r"(\b[A-Za-z]+)-\s*\n\s*([A-Za-z]+\b)")

    def replace_hyphen(match):
        part1 = match.group(1)
        part2 = match.group(2)
        combined_lower = f"{part1}{part2}".lower()
        hyphenated_lower = f"{part1}-{part2}".lower()

        # Check if the hyphenated version is an intentional technical compound
        if hyphenated_lower in TRUE_COMPOUNDS:
            return f"{part1}-{part2}"

        # If the second part starts with an uppercase letter, keep the hyphen (e.g. Franco-Prussian)
        if part2[0].isupper():
            return f"{part1}-{part2}"

        # Default action: merge into a single continuous word
        return f"{part1}{part2}"

    return pattern.sub(replace_hyphen, text)

def normalize_pdf_stream(raw_text: str) -> str:
    """Full normalization pipeline for raw PDF extracted text."""
    text = clean_invisible_glyphs(raw_text)
    text = expand_ligatures(text)
    text = dehyphenate_text(text)
    # Collapse irregular internal whitespace while preserving paragraph breaks
    text = re.sub(r"[ \t]+", " ", text)
    return text
Enter fullscreen mode Exit fullscreen mode

Normalization audit checklist

Before sending extracted PDF text to your chunking and embedding workers, verify these four checkpoints:

  1. Ligature expansion: Run a scan for characters in the Unicode range \uFB00 to \uFB4F and the PUA range \uE000 to \uF8FF. The count should be zero after normalization.
  2. Line-end hyphen count: Count hyphens preceding newlines. In standard text, nearly all line-break hyphens should be resolved into continuous words or explicit compounds.
  3. Compound word preservation: Test specific benchmark words like state-of-the-art and trade-off against your dehyphenator to confirm they retain their hyphens.
  4. Vocabulary check: Run a sample of 1,000 extracted words against your tokenizer or an English dictionary. Words flagged as unknown tokens often expose unmapped font encoding tables in the source document.

Top comments (0)