Skip to content

Commit 29f8028

Browse files
committed
fix(ingest): strip control bytes and fold ligatures in extracted PDF text
1 parent a115d41 commit 29f8028

4 files changed

Lines changed: 81 additions & 6 deletions

File tree

app/ingest/parse.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import io
1818
import logging
1919
import re
20+
import unicodedata
2021
from dataclasses import dataclass, field
2122

2223
from app.config import settings
@@ -38,6 +39,30 @@
3839
# large that the request cost doubles for no accuracy gain.
3940
RENDER_DPI = 150
4041

42+
# Control characters Postgres will not store in a text column, minus the
43+
# whitespace we want to keep. A single stray NUL in a PDF's text stream aborts
44+
# the whole insert with
45+
# invalid byte sequence for encoding "UTF8": 0x00
46+
# and takes the entire document's ingestion down with it. Five of the thirteen
47+
# papers in the benchmark corpus contain at least one.
48+
_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
49+
50+
51+
def clean_extracted_text(text: str) -> str:
52+
"""Make PDF text storable and searchable.
53+
54+
Two problems, both invisible until they bite:
55+
56+
* **NUL and control bytes** — Postgres rejects them outright.
57+
* **Typographic ligatures** — PDF text streams encode "fi" as U+FB01 and
58+
"ff" as U+FB00, so "inverted file" extracts as "inverted file" and
59+
"effectiveness" as "effectiveness". Those are *different strings*: a
60+
keyword search for "file" misses them, and the tokens an embedding model
61+
sees are not the ones the reader typed. NFKC folds them back, along with
62+
other compatibility forms such as non-breaking spaces.
63+
"""
64+
return _CONTROL_CHARS.sub("", unicodedata.normalize("NFKC", text))
65+
4166

4267
@dataclass
4368
class Heading:
@@ -80,7 +105,7 @@ def _extract_headings_native(page) -> tuple[str, list[Heading]]:
80105
try:
81106
blocks = page.get_text("dict")["blocks"]
82107
except Exception: # pragma: no cover - malformed PDFs
83-
return page.get_text("text"), []
108+
return clean_extracted_text(page.get_text("text")), []
84109

85110
lines: list[tuple[str, float, bool]] = []
86111
sizes: list[float] = []
@@ -98,7 +123,7 @@ def _extract_headings_native(page) -> tuple[str, list[Heading]]:
98123
sizes.append(size)
99124

100125
if not lines:
101-
return page.get_text("text"), []
126+
return clean_extracted_text(page.get_text("text")), []
102127

103128
sorted_sizes = sorted(sizes)
104129
body_size = sorted_sizes[len(sorted_sizes) // 2] # median == body text
@@ -109,7 +134,8 @@ def _extract_headings_native(page) -> tuple[str, list[Heading]]:
109134
out_parts: list[str] = []
110135
headings: list[Heading] = []
111136
offset = 0
112-
for text, size, bold in lines:
137+
for raw_text, size, bold in lines:
138+
text = clean_extracted_text(raw_text)
113139
level = level_of.get(round(size, 1))
114140
# A short, bold, title-cased line is a heading even at body size —
115141
# common in filings and specs where headings aren't set larger.
@@ -178,7 +204,7 @@ async def parse_page_with_vision(data: bytes, page_number: int, meter: UsageMete
178204
cached_input_tokens=result.cached_input_tokens,
179205
label="vision_ocr",
180206
)
181-
text = (result.text or "").strip()
207+
text = clean_extracted_text(result.text or "").strip()
182208
if text == "[BLANK PAGE]":
183209
text = ""
184210
return ParsedPage(

app/ingest/pipeline.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -567,12 +567,29 @@ async def ingest_to_completion(
567567
Used by the CLI, the eval corpus loader, and local development — anywhere a
568568
caller can afford to block. On serverless the client polls instead.
569569
"""
570+
# Counters on TickResult are per-tick. Returning the last one reports the
571+
# terminal INDEXING->READY step, which did no work: a multi-tick document
572+
# would log "ready — 0 pages, 0 chunks" despite ingesting perfectly.
573+
totals = {"pages": 0, "written": 0, "embedded": 0, "dupes": 0, "cost": 0.0}
570574
result: TickResult | None = None
575+
571576
for _ in range(max_ticks):
572577
result = await ingest_tick(session, document_id, config)
573578
await session.commit()
579+
totals["pages"] += result.pages_parsed
580+
totals["written"] += result.chunks_written
581+
totals["embedded"] += result.chunks_embedded
582+
totals["dupes"] += result.duplicates_skipped
583+
totals["cost"] += result.cost_usd
574584
if result.done:
575-
return result
585+
break
586+
576587
if result is None:
577588
raise IngestError("no ticks executed")
589+
590+
result.pages_parsed = totals["pages"]
591+
result.chunks_written = totals["written"]
592+
result.chunks_embedded = totals["embedded"]
593+
result.duplicates_skipped = totals["dupes"]
594+
result.cost_usd = totals["cost"]
578595
return result

evals/golden_set.jsonl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
{"id":"q01","question":"What are the two RAG model variants introduced in the RAG paper?","expected":"RAG-Sequence, which uses the same retrieved document to generate the whole sequence, and RAG-Token, which can use a different document for each generated token.","expected_chunks":["rag.pdf#~RAG-Sequence","rag.pdf#~RAG-Token"],"category":"factual","difficulty":"easy"}
2020
{"id":"q02","question":"Which generator model does the RAG paper use?","expected":"BART-large, a pre-trained sequence-to-sequence transformer, is used as the parametric generator.","expected_chunks":["rag.pdf#~BART"],"category":"factual","difficulty":"easy"}
2121
{"id":"q03","question":"What does the parameter M control in the HNSW index?","expected":"M is the number of established connections (neighbours) per element per layer — the graph's out-degree, which trades index size and build time against search quality.","expected_chunks":["hnsw.pdf#~number of established connections"],"category":"factual","difficulty":"medium"}
22-
{"id":"q04","question":"What does ef_construction control when building an HNSW index?","expected":"It is the size of the dynamic candidate list used during index construction; larger values produce a higher-quality graph at the cost of longer build time.","expected_chunks":["hnsw.pdf#~ef_construction"],"category":"factual","difficulty":"medium"}
22+
{"id":"q04","question":"What does ef_construction control when building an HNSW index?","expected":"It is the size of the dynamic candidate list used during index construction; larger values produce a higher-quality graph at the cost of longer build time.","expected_chunks":["hnsw.pdf#~efconstruction"],"category":"factual","difficulty":"medium"}
2323
{"id":"q05","question":"Which pooling strategy did Sentence-BERT find worked best?","expected":"Mean pooling over the token output vectors (the MEAN strategy) outperformed both the CLS token and max pooling.","expected_chunks":["sentence-bert.pdf#~pooling"],"category":"factual","difficulty":"medium"}
2424
{"id":"q06","question":"What is the central finding of the Lost in the Middle paper?","expected":"Model performance is highest when the relevant information appears at the very beginning or the very end of the input context and degrades substantially when it is in the middle, producing a U-shaped performance curve.","expected_chunks":["lost-in-the-middle.pdf#~middle"],"category":"factual","difficulty":"easy"}
2525
{"id":"q07","question":"Which metrics does the RAGAS framework propose?","expected":"Faithfulness, answer relevance, and context relevance — evaluated without human-annotated ground-truth references.","expected_chunks":["ragas.pdf#~faithfulness"],"category":"factual","difficulty":"easy"}

tests/test_chunking.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,35 @@ def test_configs_a_and_b_land_in_different_spaces():
172172
assert space_for(get_config("B")) == space_for(get_config("C")) == space_for(get_config("D"))
173173
# E uses a different embedder and therefore a different vector dimension.
174174
assert space_for(get_config("E")) != space_for(get_config("D"))
175+
176+
177+
# --- extracted-text cleaning ------------------------------------------------
178+
179+
180+
def test_nul_bytes_are_stripped():
181+
"""Postgres rejects NUL in a text column outright, aborting the insert with
182+
`invalid byte sequence for encoding "UTF8": 0x00` — which takes the whole
183+
document's ingestion down. Five of the thirteen benchmark PDFs contain one.
184+
"""
185+
from app.ingest.parse import clean_extracted_text
186+
187+
assert clean_extracted_text("before\x00after") == "beforeafter"
188+
assert "\x00" not in clean_extracted_text("a\x00b\x01c\x1fd\x7fe")
189+
190+
191+
def test_whitespace_is_preserved():
192+
from app.ingest.parse import clean_extracted_text
193+
194+
assert clean_extracted_text("a\nb\tc\rd") == "a\nb\tc\rd"
195+
196+
197+
def test_ligatures_are_folded_to_ascii():
198+
"""PDF text streams encode "fi" as U+FB01 and "ff" as U+FB00, so a keyword
199+
search for "file" misses "inverted file" entirely and the embedding model
200+
sees tokens the reader never typed."""
201+
from app.ingest.parse import clean_extracted_text
202+
203+
assert clean_extracted_text("inverted file") == "inverted file"
204+
assert clean_extracted_text("effectiveness") == "effectiveness"
205+
assert clean_extracted_text("difficult") == "difficult"
206+
assert clean_extracted_text("flow") == "flow"

0 commit comments

Comments
 (0)