Optical Character Recognition (OCR) is inherently imperfect. Different OCR engines have different strengths: Tesseract excels at clean printed text, cloud-based APIs like Google Vision handle degraded scans better, and specialized models may be trained for particular fonts or scripts. No single engine is universally best. A fallback OCR reader pipeline—a composable chain of readers that fans out to multiple OCR backends and returns the first non-empty result—addresses this effectively.
The pipeline processes documents page by page. For each page, it sends the image to each registered OCR reader in priority order. As soon as one reader returns a non-empty text result, that result is accepted and subsequent readers are skipped. If all readers fail or return empty results for a page, the error is aggregated but the pipeline continues to the next page. This means extraction degrades gracefully—a document with one unreadable page out of fifty will still yield forty-nine pages of text rather than failing entirely.
This pattern is a specialized instance of the chain-of-responsibility design pattern, adapted for OCR where “success” means returning non-empty text and “failure” is defined as empty output rather than an error. The distinction matters because an OCR engine may process an image without errors but return empty text if it cannot recognize any characters.
Every OCR backend implements a common Reader interface that accepts a page image and returns extracted text. The interface is intentionally minimal: one method, one input, one output. This makes it trivial to add new OCR backends—wrap the vendor SDK in a struct that implements Reader and register it in the chain.
By defining a single ReadPage(ctx context.Context, img image.Image) (string, error) method, the pipeline can treat Tesseract, Google Vision, Amazon Textract, and any custom model identically. The caller never knows which backend produced the result.
Readers are registered in priority order. The first reader in the chain is tried first—this is typically the fastest or cheapest option (e.g., local Tesseract). If it returns empty text, the next reader is tried (e.g., Google Cloud Vision). This ordering lets you balance cost and latency: expensive cloud APIs are only invoked when cheaper options fail.
Unlike typical error handling where a nil error means success, the OCR pipeline defines success as non-empty output. An OCR engine can process an image without errors (no network failure, no parsing crash) but return an empty string because the image contains no recognizable text (a photograph, a blank page, or heavily degraded text). The pipeline treats empty output the same as an error for fallback purposes.
The pipeline aggregates errors across readers and pages rather than failing fast. A document that is 90% readable by the first reader and 10% readable only by the fallback reader will still produce a complete text extraction. Errors are logged for operator review but do not halt processing.
When multiple readers fail on the same page, their errors are collected into a multi-error value. This gives operators full visibility into which readers failed and why, enabling them to diagnose systemic issues (e.g., a cloud API quota exceeded) versus per-document issues (e.g., a corrupted image). Go’s errors.Join (or a similar multi-error utility) is used to combine errors without losing any.
Aggregating errors from all readers into a single multi-error means that callers can inspect individual failures with errors.As while still treating the combined result as a single error value. This is essential for logging and alerting on OCR pipeline health.
The fallback pipeline operates in two nested loops: an outer loop over document pages and an inner loop over registered readers.
// Reader extracts text from a page image.
type Reader interface {
ReadPage(ctx context.Context, img image.Image) (string, error)
}
// FallbackPipeline tries multiple readers per page, accepting the first
// non-empty result.
type FallbackPipeline struct {
readers []Reader
}
// NewFallbackPipeline creates a pipeline with readers in priority order.
func NewFallbackPipeline(readers ...Reader) *FallbackPipeline {
return &FallbackPipeline{readers: readers}
}
// PageResult holds the extracted text or errors for a single page.
type PageResult struct {
PageNum int
Text string
Err error
}
// ExtractDocument processes all pages through the fallback chain.
func (p *FallbackPipeline) ExtractDocument(ctx context.Context, pages []image.Image) []PageResult {
results := make([]PageResult, len(pages))
for i, page := range pages {
text, err := p.extractPage(ctx, page)
results[i] = PageResult{
PageNum: i + 1,
Text: text,
Err: err,
}
}
return results
}
func (p *FallbackPipeline) extractPage(ctx context.Context, img image.Image) (string, error) {
var errs []error
for _, reader := range p.readers {
text, err := reader.ReadPage(ctx, img)
if err != nil {
errs = append(errs, err)
continue
}
if strings.TrimSpace(text) != "" {
return text, nil // Success: non-empty result.
}
errs = append(errs, fmt.Errorf("reader returned empty text"))
}
return "", errors.Join(errs...)
}
A typical document processing pipeline uses the fallback OCR reader to extract text from scanned PDFs that lack an embedded text layer. The pipeline first attempts fast local OCR with Tesseract. For pages where Tesseract returns empty (usually due to low image quality or unusual fonts), the pipeline falls back to a cloud OCR service. This balances cost (most pages succeed locally) with coverage (difficult pages are handled by more powerful cloud models).
Some documents contain a mix of digital text pages and scanned pages. The pipeline handles this transparently: digital text pages are extracted by a text-layer reader (not OCR at all), while scanned pages fall through to the OCR chain. The text-layer reader is registered first in the chain since it is the fastest and most accurate.
By tracking which reader succeeded for each page, the pipeline provides a quality signal. Pages that required a fallback reader may have lower-quality text. This metadata can be used downstream to weight search results or flag documents that need manual review.
Each reader invocation is given a derived context with a per-reader timeout. This prevents a single slow reader (e.g., a cloud API experiencing high latency) from blocking the entire pipeline. If the timeout expires, the reader is treated as failed and the next one is tried.
func (p *FallbackPipeline) extractWithTimeout(
ctx context.Context,
reader Reader,
img image.Image,
timeout time.Duration,
) (string, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
resultCh := make(chan readerResult, 1)
go func() {
text, err := reader.ReadPage(ctx, img)
resultCh <- readerResult{text: text, err: err}
}()
select {
case result := <-resultCh:
return result.text, result.err
case <-ctx.Done():
return "", fmt.Errorf("reader timed out: %w", ctx.Err())
}
}
type readerResult struct {
text string
err error
}
Adding a new OCR backend is a matter of implementing the Reader interface and inserting it into the chain at the desired priority position. For example, to add Amazon Textract as a second-priority reader after Tesseract but before Google Vision:
pipeline := NewFallbackPipeline(
NewTesseractReader("/usr/bin/tesseract", "eng"),
NewTextractReader(textractClient),
NewGoogleVisionReader(visionClient),
)