Skip to main content

Wrapping Docling in a Reusable Text Extraction Pipeline

· 12 min read
Bogdan Varlamov
Bogdan Varlamov
Technologist

Docling already does most of what a batch text-extraction harness does: it iterates over documents, swaps OCR backends, handles per-document errors, and exports structured output. So the code worth writing for the soviet.recipes project is a thin pipeline around Docling that adds the three things Docling leaves to me: image preprocessing before extraction, a neutral slot for engines Docling does not wrap, and consistent scoring across every approach I test. So far I've built the engine slot and the batch loop that drives it; preprocessing and scoring are designed but not written yet.

Why Not Just Use Docling On Its Own?

This is the first question to answer, because Docling is itself a document-processing harness, not a bare OCR call. Before building anything, I checked what it already provides against what I thought I needed:

What I needWhat Docling already provides
Batch loop over many imagesconvert_all() returns an iterator that yields results as they finish, 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

That table covers the bulk of the original design I had sketched. Rebuilding it would duplicate a maintained framework. My Docling proof-of-concept already showed the extraction core works for Cyrillic text and page structure, so the value is in the stages Docling leaves to me, not in reimplementing what it already does.

Three gaps remain, and they are the reason to write any code at all:

  • Preprocessing. Docling handles layout and reading order. The proof-of-concept showed it drops text where the page curves or angles away from the camera, and that loss is unrecoverable downstream, so Docling with EasyOCR is not sufficient on its own. It does not give me a tunable stage for dewarping, deskewing, denoising, or cropping tuned to this specific book. The phase 2 planning post listed curved text from page folds, glare, shadow, and uneven contrast as the hard problems, and those are preprocessing concerns.
  • Engines Docling does not wrap. If I want to compare against cloud OCR such as Google Cloud Vision, Azure Document Intelligence, or Amazon Textract, or a newer VLM-OCR model without a Docling plugin, I need a slot that treats those as first-class engines.
  • Scoring across all of them. To rank Docling against a non-Docling approach fairly, I need one scoring layer that grades every engine on the same terms, independent of which tool produced the text.

So the pipeline wraps Docling rather than replacing it. Docling owns extraction. I own the stages around it.

The Pipeline: Preprocess, Extract, Score

The design has three stages. Preprocessing runs once per image and feeds every engine the same prepared input. Extraction treats Docling's whole pipeline as one plug and a raw Textract call as another. Scoring reads the output directories and grades them on identical criteria.

Of the three stages, only Extract is built so far. Preprocess and Score are the design for what comes next, laid out below so the shape of the pipeline is clear before I write that code.

Design principles carried through the stages:

  • Idempotency (planned). Re-running should skip images that already have output, so a crashed batch resumes without redoing work. The current implementation overwrites existing outputs on every run instead; skip-if-exists is a small addition on top of the batch processor, not something built yet.
  • Shared preprocessing. Every engine reads the same prepared images, so a quality difference reflects the engine, not the input.
  • Engine neutrality. The extract stage does not know whether it is calling Docling or an API. It calls one method.
  • Observable progress. Plain logging with position in the batch for long runs.

Extract Stage

Every engine implements one small interface. Docling is one implementation of it, sitting alongside engines Docling does not wrap:

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

DoclingEngine wraps a DocumentConverter configured with EasyOCR for Russian and English. LLMEngine and APIEngine implement the same interface for a local vision model and a commercial API respectively, but both are stubs right now, they raise NotImplementedError until I build out that engine. A PassthroughEngine that returns dummy text exists purely to exercise the workflow in tests without calling a real engine. The batch processor treats every engine identically, which is what will let me score Docling against a non-Docling tool on equal footing once a second engine is real.

Batch Processor and Workflow

The batch processor iterates images, calls the engine, retries on failure, and saves output. This is the part that actually runs today:

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)

That part is a plain, easy to follow sequence: discover images, loop, retry with backoff, save. I built it as 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:
...

The batch loop is simple enough that a plain function would do the same job with less code. I picked CrewAI Flows here specifically because the workflow was simple: a five-step linear pipeline is small enough to learn the framework's step model, @start()/@listen() chaining and a typed Pydantic BatchProcessorState passed between steps, without the framework's own complexity competing with the problem I was solving. For this pipeline the tradeoff is real: each step reads and writes self.state instead of passing return values, and the step graph is a straight line, so none of CrewAI's branching or multi-agent coordination gets exercised yet. Those features are the actual reason to reach for Flows, and they'll matter more once the API and LLM engines need agent-style tool use or branching logic. For now this was practice with the framework on a problem simple enough to keep the plain-Python version in mind as a comparison.

Swapping engines is a one-line change once more than one is implemented:

config = BatchProcessorConfig(image_dir=images, output_dir="output/docling", engine_type="docling", engine_config=DoclingConfig())
# Same shape, different engine_type and engine_config, once LLMEngine or APIEngine are implemented

Preprocess and Score Stages (Not Yet Built)

Preprocessing and scoring are still design, not code. This is what I'm building next, not what exists today.

Preprocessing is the stage that justifies owning a pipeline outside Docling. The plan is a chain of steps, each taking an image and returning a path to a prepared image:

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 to correct page-fold curvature, deskewing, denoising, and cropping margins. OpenCV covers all of them without extra services.

Whether preprocessing helps is an open question, not an assumption. Modern vision-language models often read curved and noisy pages without help, and aggressive cleanup can remove detail an engine would have used. So preprocessing is also an experiment variable: run the same engine on raw images and on prepared images, and let the scores decide. Keeping preprocessing as its own stage is what makes that comparison possible.

Docling reports its own confidence, but that number does not compare across engines that measure quality differently. To rank approaches I plan to grade a sample of each engine's output on the same three criteria and store the results as CSV:

from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional
import csv, random

@dataclass
class ValidationScore:
image_name: str
engine_name: str
accuracy: int # 1-5: character-level correctness
completeness: int # 1-5: content coverage
structure: int # 1-5: layout preservation
notes: str = ""

class Scorer:
"""Standardized quality assessment across engines."""

def __init__(self, output_dir: Path, validation_dir: Path):
self.output_dir = Path(output_dir)
self.validation_dir = Path(validation_dir)
self.validation_dir.mkdir(parents=True, exist_ok=True)

def select_sample(self, engine_name: str, sample_size: int = 20, seed: Optional[int] = None) -> List[Path]:
files = sorted((self.output_dir / engine_name).glob("*.txt"))
if seed is not None:
random.seed(seed)
return random.sample(files, min(sample_size, len(files)))

def record_scores(self, scores: List[ValidationScore], engine_name: str):
out = self.validation_dir / f"{engine_name}_scores.csv"
with out.open("w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["image_name", "accuracy", "completeness", "structure", "notes"])
for s in scores:
writer.writerow([s.image_name, s.accuracy, s.completeness, s.structure, s.notes])

def compare_engines(self, engine_names: List[str]) -> dict:
results = {}
for name in engine_names:
score_file = self.validation_dir / f"{name}_scores.csv"
if not score_file.exists():
continue
with score_file.open("r", encoding="utf-8") as f:
scores = list(csv.DictReader(f))
if not scores:
continue
results[name] = {
"accuracy": sum(int(s["accuracy"]) for s in scores) / len(scores),
"completeness": sum(int(s["completeness"]) for s in scores) / len(scores),
"structure": sum(int(s["structure"]) for s in scores) / len(scores),
"sample_size": len(scores),
}
return results

The review loop stays small: process 5 to 10 images with a new engine, grade the sample by hand, record the scores, and compare against earlier engines before committing to a full 224-image run.

Directory Layout (Planned)

Once preprocess and score exist, the plan is to keep prepared images and per-engine output side by side for review:

project_root/
├── images/ # 224 source images
├── work/ # preprocessed images
├── output/
│ ├── docling/ # one .txt per image
│ ├── textract/
│ └── docling_raw/ # same engine, no preprocessing
└── validation/
├── docling_scores.csv
├── textract_scores.csv
└── docling_raw_scores.csv

Today the codebase only has output/docling_extraction/, since Docling is the only working engine and there's no scoring yet.

Technology Choices

  • Python >= 3.11, managed with uv
  • Pydantic for typed configuration and workflow state
  • CrewAI Flows for step orchestration, more on why below
  • Docling for the working engine, with per-engine SDKs to follow as LLMEngine and APIEngine get implemented
  • OpenCV for the preprocessing steps, once that stage is built
When to reach for an orchestration platform

For a batch loop this size, a plain Python script would have been enough on its own. If a project grows scheduling, distributed runs, or dashboards, Prefect and LangGraph cover that ground with pre-built retry and monitoring. Apache Airflow fits scheduled enterprise pipelines and would be more than this project needs.

What's Next

The Docling and EasyOCR run across all 224 pages is already done, and it confirmed the curvature data loss from the proof-of-concept at scale. The next experiment swaps the engine rather than repeating that OCR batch: run the pipeline with Docling's VLM pipeline using Qwen3-VL locally. A VLM reads text from context instead of fixed bounding boxes, so it should tolerate the curved and angled pages better. If it still drops text on the worst pages, that is the trigger to build out the preprocessing steps, dewarping and deskewing first, and score raw against prepared images.

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.