Skip to main content

Wrapping Docling in a Reusable Text Extraction Pipeline

· 11 min read
Bogdan Varlamov
Bogdan Varlamov
Technologist

Docling already does most of what a batch text-extraction harness needs: it iterates over documents, swaps OCR backends, isolates per-document errors, and exports structured output. For the soviet.recipes project I wrap it in a thin pipeline so I can put different extraction approaches against each other on the same pages and trust the result. Docling does the extraction, but the wrapper prepares the book's curled and shadowed pages first and puts every engine behind one interface, whether it runs through Docling or bypasses it, so routing and scoring stay independent of which tool produced the text.

What Docling Already Provides

Docling is a document-processing harness in its own right, so before writing anything I mapped what it ships against what the project needs:

What I needWhat Docling already provides
Batch loop over many imagesconvert_all() returns an iterator that yields each result as its document finishes, so large collections stream without exhausting memory
Swappable OCR backendsBuilt-in selection across EasyOCR, Tesseract, RapidOCR, and ocrmac, plus a VLM pipeline and plugins that route page crops to a remote model
Per-item error handlingraises_on_error=False with a ConversionStatus and errors list per document
Structured outputExport to Markdown, JSON, HTML, text, and YAML
Quality signalInternal confidence measures during parsing and OCR

My Docling proof-of-concept already confirmed the extraction core reads Cyrillic text and page structure. However, based on that experience, I had a few additional thoughts.

  • Preprocessing. The proof-of-concept showed Docling with EasyOCR drops text where the page curves or angles away from the camera, and that loss can't be recovered downstream. Docling handles layout and reading order but gives me no tunable stage for dewarping, deskewing, denoising, or cropping. The phase 2 planning post flagged page-fold curvature, glare, shadow, and uneven contrast as the hard problems, all of them preprocessing concerns.
  • Engines Docling doesn't wrap. I want to compare against cloud OCR like Google Cloud Vision, Azure Document Intelligence, or Amazon Textract, or a VLM-OCR model that doesn't expose a compatible API.
  • Parallel throughput. Docling's batching runs across documents, and here each image is a one-page document, so there's no batch mode to reach through it. Running the pages in parallel to speed things up means either combining them into a single PDF to hand Docling or driving the conversions concurrently outside it, which falls to the wrapper.

Pipeline Architecture

The design is three stages, each isolated so a change in one doesn't ripple into the others. Preprocessing runs once per image and hands every engine the same prepared input. Extraction hides whatever tool produced the text behind a single call. Scoring reads the output directories and grades them on identical criteria.

A few principles hold across the stages:

  • Engine neutrality. The extract stage calls one method and doesn't know whether Docling or a cloud API answered.
  • Shared preprocessing. Every engine reads the same prepared images, so a quality gap reflects the engine, not the input.
  • Idempotency. Re-running skips images that already have output, so a crashed batch resumes without redoing work.
  • Observable progress. Plain logging with batch position for long runs.

Extract Stage

Every engine implements one interface, and Docling is just one implementation of it:

from abc import ABC, abstractmethod

class ExtractionEngine(ABC):
"""Contract for a single text-extraction engine."""

@abstractmethod
def extract_text(self, image_path: str) -> str:
"""Extract text from one image. Raises ExtractionError on failure."""
pass

@abstractmethod
def validate_config(self) -> bool:
"""Verify the engine is configured and ready."""
pass

Each engine is one implementation of that interface. DoclingEngine wraps a DocumentConverter, configured either with EasyOCR for traditional OCR or with Docling's VLM pipeline for a vision-language read of the page. LLMEngine bypasses Docling and sends each page straight to a local vision-language model. A commercial cloud OCR slots in as an APIEngine, and a PassthroughEngine returning dummy text drives the workflow in tests.

The batch processor discovers images (in my case, I also have photos of the book cover pages, which don't really have information we want to extract, so skipping these can be configured here), calls the engine, retries with backoff, and saves output:

class BatchProcessor:
"""Processes batches of images using an extraction engine."""

def __init__(self, engine, output_dir, max_retries=3, logger=None):
self.engine = engine
self.output_dir = Path(output_dir)
self.max_retries = max_retries
self.logger = logger or logging.getLogger(__name__)

def process_batch(self, image_dir: Path) -> BatchReport:
image_files = discover_images(image_dir, self.supported_extensions)
results = [self.process_single_image(p) for p in image_files]
...

def process_single_image(self, image_path: Path) -> ProcessingResult:
for attempt in range(1, self.max_retries + 1):
try:
text = self.engine.extract_text(str(image_path))
output_path = self._save_text(text, image_path)
return ProcessingResult(str(image_path), success=True, output_path=str(output_path), attempts=attempt)
except ExtractionError as e:
if attempt < self.max_retries:
time.sleep(2 ** (attempt - 1)) # exponential backoff
return ProcessingResult(str(image_path), success=False, error=str(e), attempts=self.max_retries)

I wrapped that loop in a CrewAI Flow:

from crewai.flow.flow import Flow, listen, start

class ImageBatchProcessorFlow(Flow[BatchProcessorState]):
@start()
def initialize_workflow(self):
# validate image_dir exists, engine_type is supported, output_dir is creatable
...

@listen(initialize_workflow)
def create_engine(self):
self.engine = EngineFactory.create_engine(self.state.engine_type, config)
self.engine.validate_config()

@listen(create_engine)
def discover_images(self):
self.state.total_images = len(discover_images(...))

@listen(discover_images)
def process_images(self):
report = BatchProcessor(self.engine, ...).process_batch(...)
self.state.successful, self.state.failed = report.successful, report.failed

@listen(process_images)
def generate_report(self) -> BatchReport:
...

I used CrewAI because I hadn't worked with it and wanted to practice its Flow model on something small. The five steps chain with @start() and @listen() and pass a typed Pydantic BatchProcessorState between them. The graph is a straight line here, so it leaves CrewAI's branching and multi-agent coordination untouched, which is exactly what the LLM-reasoning steps below will draw on.

Throughput and Concurrent LLM Calls

The Docling VLM run took about three minutes a page against a local llama-server, roughly a ten-hour pass over 224 pages. Docling drives that serially: each image is its own one-page document, so the engine calls convert() once per file and finishes it before starting the next. Docling's internal concurrency fans out across the pages within one document, and every document here has a single page, so it has nothing to spread.

The server on the other end isn't the constraint. llama-server keeps a pool of request slots and, with continuous batching, merges several in-flight requests into one forward pass so concurrent calls share the GPU instead of queueing; the --parallel (-np) setting controls how many run at once. Handing it one page at a time leaves most of those slots idle.

The engine slot makes closing that gap cheap to test. An LLMEngine that reads a batch of image files and fires their requests at the server concurrently would use the slots a serial Docling run leaves empty, and if the server decodes several pages together it could clear the full set well under the ten-hour mark. I haven't measured it, but dropping that engine in behind the same interface is a config change, so it's on the list to try.

Preprocess and Score

Preprocessing is a subflow in its own right, a chain of steps that each take an image and return a path to a prepared one:

from abc import ABC, abstractmethod
from pathlib import Path

class PreprocessStep(ABC):
"""A single image preparation step."""

@abstractmethod
def apply(self, image_path: Path, work_dir: Path) -> Path:
"""
Prepare an image and return the path to the result.

Steps that make no change may return image_path unchanged.
"""
pass

@abstractmethod
def get_step_name(self) -> str:
"""Return a human-readable step identifier."""
pass

Candidate steps for this book are dewarping page-fold curvature, deskewing, denoising, and cropping margins, all of which OpenCV covers without extra services. Chaining them as their own flow means preprocessing can run standalone to turn the raw scans into a prepared set once, which every later engine pass then reads instead of redoing the cleanup each time. Whether the cleanup helps at all is an open question: modern vision-language models often read curved and noisy pages unaided, and aggressive cleanup can strip detail an engine would have used. Running it as a separate flow turns that into an experiment variable, feeding the same engine raw and prepared images and letting the scores decide.

Scoring is eyeballing for now: I read a handful of pages from each engine side by side and judge which handled the text better. That won't scale to ranking engines across 224 pages, so an automated measure gets coded later. The scoring stage exists as a placeholder that holds the slot and the output layout, so the real implementation drops in without reshaping anything upstream.

Technology Choices

  • Python >= 3.11, managed with uv
  • Pydantic for typed configuration and workflow state
  • CrewAI Flows for step orchestration
  • Docling and a local vision-language model for the extraction engines
  • OpenCV for the preprocessing steps

Possible Future Directions

A few ideas keep tugging at me that go past the current OCR comparison. None are built yet. The pipeline leaves clean slots to plug them into when I get to one.

The nearest is running more models through the engine slot to see which reads this book best, newer VLM-OCR models like Unlimited-OCR among them. Each is image in, text out, so it drops into the same slot and the comparison runs unchanged.

A multi-LLM consensus step could send a page to more than one model and keep the text only when they agree, setting disagreements aside for a closer look.

The cookbook's full-page illustrations and artwork are worth pulling out and restoring in their own right. A flow could extract those images, clean them up, and hand it to a hosted image model like Gemini or Grok Imagine to reconstruct detail the scan lost.

Tool use is another fascinating direction. Instead of a fixed convert-and-save step, an engine could carry tools an LLM chooses to call: flag a page as image-only, crop a region, retry at higher resolution. Detect a problem in the image and call preprocessing tools at the model's discretion to denoise/sharpen/etc. The pipeline would then make decisions per page instead of running as a fixed sequence.

What's Next

The Docling and EasyOCR pass over all 224 pages confirmed the proof-of-concept's curvature data loss at scale, which is what motivated the other approaches. The next runs put the Docling VLM pipeline (with Qwen3-VL locally) and the pure-LLM engine over the same pages and score them head to head. A VLM reads text from context rather than fixed bounding boxes, so the open question is whether it recovers the curved and angled lines EasyOCR lost. If they still drop text on the worst pages, preprocessing becomes the next variable, dewarping and deskewing scored raw against prepared.

Related Posts

This work is part of the soviet.recipes project. See the phase 2 planning analysis for the experimental roadmap, and the soviet.recipes tag for related posts.