Skip to content

perf: batch spaCy processing for PDF text - #4430

Open
ssingh231219 wants to merge 1 commit into
Unstructured-IO:mainfrom
ssingh231219:simransingh2312/batch-spacy-text-processing
Open

perf: batch spaCy processing for PDF text#4430
ssingh231219 wants to merge 1 commit into
Unstructured-IO:mainfrom
ssingh231219:simransingh2312/batch-spacy-text-processing

Conversation

@ssingh231219

@ssingh231219 ssingh231219 commented Aug 11, 2026

Copy link
Copy Markdown

Summary

Batch spaCy processing during PDFMiner-based PDF text classification while avoiding NLP work for elements that can be classified using inexpensive rules.

FAST PDF partitioning previously processed each extracted text block independently. A block could invoke spaCy multiple times through sentence tokenization and POS tagging.

This change:

  1. Classifies conclusive non-NLP elements first.
  2. Sends only unresolved text blocks through nlp.pipe().
  3. Reuses the resulting spaCy Doc objects across tokenizer helpers.

Classification results, element order, coordinates, metadata, and links remain unchanged.

Key Changes

  • Add batch_process_texts() for reusable spaCy Doc objects.
  • Process PDFMiner text using page-local, bounded batches.
  • Pre-classify headers, footers, lists, addresses, emails, and numeric text without spaCy.
  • Send only NLP-dependent elements through nlp.pipe().
  • Deduplicate text within each spaCy batch.
  • Preserve lowercase POS processing for uppercase text.
  • Reuse preprocessed documents for sentence, word, and POS tokenization.
  • Preserve original PDFMiner element order when attaching metadata.
  • Add output-parity regression tests and a reproducible workload-matrix benchmark.

Performance Benchmark

Model-warm, cache-cleared classification benchmark using five iterations, batch size 256, and page-like contexts of 64 elements:

1,000 Elements

Workload Sequential Median Selectively Batched Median Result
NLP-heavy 5.153 s 0.528 s 9.75× faster
50/50 mixed 2.555 s 0.270 s 9.47× faster
All-cheap 0.00528 s 0.00525 s Effectively equal

Sequential and selectively batched modes produced identical category/text fingerprints for every workload and corpus size.

Reproduce the complete matrix with:

uv run --no-sync python scripts/performance/benchmark_text_classification.py \
  --workload all \
  --counts 1,4,16,40,100,1000 \
  --context-size 64 \
  --batch-size 256 \
  --iterations 5
### Local Real PDF Validation

A model-warm, cache-cleared FAST benchmark on a 24-page PDF produced:

Version Median Elements
origin/main 2.279 s 201
This branch 1.496 s 201
Improvement 34.4% faster Identical

The page/type/text/order fingerprint was identical.


Compatibility

  • Public element_from_text() behavior remains unchanged.
  • Numeric text now takes an explicit fast path to the same Text result without invoking NLP.
  • No multiprocessing is enabled because its startup and IPC overhead made page-sized batches slower.

Verification & Testing

  • Tokenizer and text tests: 59 passed
  • FAST PDF tests: 47 passed
  • Total relevant tests: 106 passed
  • Ruff formatting and lint checks: passed
  • Synthetic category/text fingerprint parity: passed
  • FAST PDF output regression tests: passed
  • Real-PDF page/type/text/order fingerprint parity: passed

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 5 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="unstructured/nlp/tokenize.py">

<violation number="1" location="unstructured/nlp/tokenize.py:196">
P2: A text-dense page can now hold every parsed spaCy Doc for all unique text blocks in memory simultaneously for the whole page. Before this change each `_process` call built one Doc that was consumed and discarded, so peak memory was roughly a single Doc; now the `docs` dict accumulates all of them until the context exits. The `batch_size` argument only chunks `nlp.pipe` internally and does not bound retained memory, so the PR's 'page-bounded / safe for large PDFs' memory goal isn't actually enforced by this implementation. Consider retaining and releasing Docs in chunks (still reusing them across the three tokenizer steps per chunk) so peak memory remains bounded for dense pages, and document that `batch_size` controls pipeline chunking, not per-page retention.</violation>
</file>

Shadow auto-approve: would not auto-approve because issues were found.

Re-trigger cubic

Comment thread scripts/performance/benchmark_text_classification.py

nlp = _get_nlp()
prepared_inputs = tuple(_prepare_text(text, nlp) for text in pipeline_inputs)
docs = dict(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A text-dense page can now hold every parsed spaCy Doc for all unique text blocks in memory simultaneously for the whole page. Before this change each _process call built one Doc that was consumed and discarded, so peak memory was roughly a single Doc; now the docs dict accumulates all of them until the context exits. The batch_size argument only chunks nlp.pipe internally and does not bound retained memory, so the PR's 'page-bounded / safe for large PDFs' memory goal isn't actually enforced by this implementation. Consider retaining and releasing Docs in chunks (still reusing them across the three tokenizer steps per chunk) so peak memory remains bounded for dense pages, and document that batch_size controls pipeline chunking, not per-page retention.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At unstructured/nlp/tokenize.py, line 196:

<comment>A text-dense page can now hold every parsed spaCy Doc for all unique text blocks in memory simultaneously for the whole page. Before this change each `_process` call built one Doc that was consumed and discarded, so peak memory was roughly a single Doc; now the `docs` dict accumulates all of them until the context exits. The `batch_size` argument only chunks `nlp.pipe` internally and does not bound retained memory, so the PR's 'page-bounded / safe for large PDFs' memory goal isn't actually enforced by this implementation. Consider retaining and releasing Docs in chunks (still reusing them across the three tokenizer steps per chunk) so peak memory remains bounded for dense pages, and document that `batch_size` controls pipeline chunking, not per-page retention.</comment>

<file context>
@@ -162,9 +168,55 @@ def _process(text: str) -> spacy.tokens.Doc:
+
+    nlp = _get_nlp()
+    prepared_inputs = tuple(_prepare_text(text, nlp) for text in pipeline_inputs)
+    docs = dict(
+        zip(
+            pipeline_inputs,
</file context>

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, good catch. PDFMiner records are now processed in chunks of BATCH_SIZE, with a separate batch_process_texts() context per chunk, so the documents are released between chunks.

@ssingh231219
ssingh231219 force-pushed the simransingh2312/batch-spacy-text-processing branch 2 times, most recently from e862c41 to 83541c7 Compare August 11, 2026 13:38
@ssingh231219
ssingh231219 force-pushed the simransingh2312/batch-spacy-text-processing branch from 8290373 to 12aa311 Compare August 14, 2026 08:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant