- Published on
Building a Fallback-First Document Extraction Pipeline with Tika, POI, and OCR
- Authors
A document extraction pipeline usually starts with a deceptively small requirement: turn a file into text so the text can be indexed.
That requirement stopped being small when the input expanded beyond one or two predictable formats. I worked on a search indexing system that had to accept PDFs, HWP documents, Microsoft Office files, and images. The existing extractor handled a useful subset, but adding formats one by one would have tied the whole indexing flow to parser-specific branches.
I introduced Apache Tika as a broad detection and parsing layer, kept the existing extractor as a compatibility fallback, used Apache POI's event model where large spreadsheets needed a specialized path, and connected Tesseract for image text. The important design was not the list of libraries. It was deciding which extractor should run, when a failure should fall back, and how to prove that “parsed successfully” meant usable search text.
The preserved implementation notes include the routing and fallback code, but not enough benchmark methodology to publish the old success rates or throughput figures responsibly. Hardware, parser versions, warm-up state, retry rules, and the exact definition of success are missing. I therefore use no historical performance numbers here.
The Failure Boundary Was the Product
The first version of the problem sounded like this:
file -> parser -> text
The operational version was closer to this:
file
-> validate size and readability
-> detect media type from content
-> choose general or specialized extractor
-> extract text and metadata
-> classify the outcome
-> fall back only when policy allows
-> persist diagnostics
-> send usable text to the search index
Every arrow can fail differently. A file extension can be wrong. A parser can advertise support and still reject a damaged document. A scanned PDF can parse without an exception but yield almost no text. OCR can time out. A large workbook can exhaust the heap before the first row reaches the indexer.
Treating all of those outcomes as an empty string made the pipeline easy to call and hard to operate.
Route by Detected Content, Not Only by Extension
The preserved implementation created a shared Tika configuration, a detector, and an auto-detecting parser. Before extraction, it opened the file with TikaInputStream, detected its media type, and checked whether the configured parser set supported that type.
A simplified reconstruction looks like this:
final class TikaCapability {
private final Parser parser;
private final Detector detector;
TikaCapability(TikaConfig config) {
this.parser = new AutoDetectParser(config);
this.detector = new DefaultDetector(config.getMimeRepository());
}
MediaType detect(Path path) throws IOException {
try (TikaInputStream input = TikaInputStream.get(path)) {
return detector.detect(input, new Metadata());
}
}
boolean supports(MediaType mediaType) {
return parser
.getSupportedTypes(new ParseContext())
.contains(mediaType);
}
}
The extension can remain useful metadata, but it should not be the only routing fact. A renamed executable, a mislabeled PDF, and an OOXML container all demonstrate why content-aware detection matters.
Detection is still evidence rather than proof. It answers “what does this stream appear to be?” A successful extraction must answer a different question: “did the chosen parser produce the text and metadata this indexing policy requires?”
Apache Tika's AutoDetectParser combines detection with parser selection, so a smaller application can call it directly. I kept the capability check outside the extraction call because the system already had another extractor worth preserving. The explicit boundary made the migration gradual instead of all-or-nothing.
Keep a Compatibility Extractor During Migration
The routing rule in the old entry point was deliberately conservative:
- detect whether the configured Tika parser set supports the file;
- use Tika when it does;
- otherwise use the existing extractor;
- if Tika throws during extraction, retry once through the existing extractor;
- propagate the error if the fallback also fails.
The same rule can be expressed without exposing the original classes:
final class RoutingExtractor implements TextExtractor {
private final TikaCapability tikaCapability;
private final TextExtractor tikaExtractor;
private final TextExtractor compatibilityExtractor;
@Override
public ExtractedDocument extract(Path path) throws ExtractionException {
final MediaType mediaType;
try {
mediaType = tikaCapability.detect(path);
} catch (IOException detectionFailure) {
return compatibilityExtractor.extract(path)
.withWarning("content detection failed");
}
if (!tikaCapability.supports(mediaType)) {
return compatibilityExtractor.extract(path)
.withDetectedType(mediaType.toString());
}
try {
return tikaExtractor.extract(path)
.withDetectedType(mediaType.toString());
} catch (RecoverableExtractionException tikaFailure) {
return compatibilityExtractor.extract(path)
.withDetectedType(mediaType.toString())
.withWarning("primary extractor failed");
}
}
}
This is a generic reconstruction of the control flow, not private production source. The significant decision is that fallback is bounded:
- it is tried once, not recursively;
- it does not hide the primary failure;
- it records which extractor produced the final result;
- it distinguishes a recoverable parse failure from a policy rejection or infrastructure failure.
Falling back on every exception is too broad. An unreadable path, exhausted disk, cancelled job, or global resource limit should not automatically trigger another expensive parser. Classify errors before deciding that a second attempt is safe.
There is also a subtler case: the parser returns normally but the output is unusable. Empty text can mean a blank document, an image-only document, an encrypted file, an unsupported embedded object, or a parser defect. The pipeline should record that outcome rather than silently calling it success.
Give Every Extractor the Same Result Contract
The original public entry point returned a String, which made it easy to substitute the fallback implementation. It also discarded the evidence needed to diagnose extraction quality.
A stronger boundary keeps the text while making the route observable:
record ExtractedDocument(
String text,
String detectedMediaType,
String extractor,
ExtractionStatus status,
List<String> warnings
) {
enum ExtractionStatus {
EXTRACTED,
EMPTY,
UNSUPPORTED,
ENCRYPTED,
TOO_LARGE,
FAILED
}
}
The search-indexing worker can accept only EXTRACTED documents, while operations can count EMPTY, UNSUPPORTED, and FAILED separately. That is much more useful than one aggregate “success rate.”
It also prevents fallback from erasing provenance. If the compatibility extractor succeeds after Tika fails, the final status is usable, but the primary failure still belongs in metrics and sampled diagnostics.
Use POI When Spreadsheet Semantics or Size Justify It
A general parser is valuable because it provides one interface across many formats. It is not automatically the best interface for every workload.
In a related large-Excel flow, loading a complete workbook object was a heap risk. I used Apache POI's XSSF event model to read OOXML worksheets as SAX events. Rows could be handled incrementally instead of retaining the whole workbook.
The essential shape is:
try (OPCPackage pkg = OPCPackage.open(file.toFile(), PackageAccess.READ)) {
XSSFReader reader = new XSSFReader(pkg);
SharedStrings sharedStrings = reader.getSharedStringsTable();
StylesTable styles = reader.getStylesTable();
XSSFReader.SheetIterator sheets =
(XSSFReader.SheetIterator) reader.getSheetsData();
while (sheets.hasNext()) {
try (InputStream sheet = sheets.next()) {
XMLReader xmlReader = XMLHelper.newXMLReader();
xmlReader.setContentHandler(
new XSSFSheetXMLHandler(
styles,
null,
sharedStrings,
rowHandler,
new DataFormatter(),
false
)
);
xmlReader.parse(new InputSource(sheet));
}
}
}
rowHandler receives row and cell events and can append normalized text, emit structured records, or write batches downstream. The exact constructor signatures should be checked against the POI version used by the application.
This path costs more implementation effort than Tika's general text extraction. It becomes worthwhile when at least one of these is true:
- workbook size makes the full user model unsafe;
- row and cell boundaries matter to the index schema;
- formulas, formatted values, or sheet names need explicit policy;
- the same stream feeds validated database records as well as search text;
- per-row progress and failure reporting are required.
For large workbook generation, POI's SXSSFWorkbook solves the opposite direction with a sliding row window and temporary files. That does not make random access free: rows flushed outside the window are no longer available, and temporary-file lifecycle becomes an operational concern.
The lesson is not “POI is better than Tika.” Tika is the broad front door. POI earns a specialized route when spreadsheet structure or memory behavior is part of the requirement.
Keep OCR Out of the Default Hot Path
The old notes show Tesseract integration, and one preserved Tika configuration explicitly excluded TesseractOCRParser from the default parser set. The final OCR trigger is not preserved, so I will not invent one. The boundary itself is still valuable: OCR was not intended to run indiscriminately for every document.
OCR is qualitatively different from ordinary text extraction:
- it launches CPU-intensive recognition work;
- language data must be installed and selected;
- page segmentation affects results;
- image preprocessing can help or harm;
- a timeout is a normal control, not an exceptional afterthought;
- a text-bearing PDF may produce duplicated or noisier output if OCR is forced.
A defensible policy starts with ordinary parsing and invokes OCR only for a named class of inputs, such as image media or documents that a reviewed rule classifies as image-only. Do not publish a universal “minimum character” threshold without testing it against the real corpus; short forms and title pages can be valid documents.
Current Tika configuration supports a Tesseract parser with explicit language and timeout settings while retaining the default parser set:
{
"parsers": [
{
"tesseract-ocr-parser": {
"language": "eng",
"timeoutMillis": 120000
}
},
{
"default-parser": {}
}
]
}
The Tesseract executable and the selected traineddata must exist in the runtime environment. A production image should verify them during startup or deployment rather than discovering the missing binary on the first scanned document.
For untrusted documents, process isolation is more important than a convenient in-process API. Current Tika guidance warns that direct parser calls run on the application's thread and heap and can be driven into excessive CPU, memory use, or crashes. I would isolate parsing from critical application infrastructure and apply file-size, time, and process limits at that boundary.
Verification Needs a Corpus, Not One Sample per Extension
An extension checklist proves very little. Two PDFs can exercise completely different paths: one has a text layer, another is scanned, another is encrypted, and another contains damaged embedded objects.
I would build a small version-controlled fixture set for unit and integration tests, plus a larger non-public corpus for load and quality evaluation. The matrix should include:
| Dimension | Cases |
|---|---|
| Identity | correct extension, wrong extension, no extension |
| Content | plain text, tables, slides, formulas, images, embedded files |
| Protection | encrypted, password-protected, permission denied |
| Integrity | valid, truncated, malformed container |
| Scale | tiny file, large workbook, many sheets, large image |
| OCR | clean scan, rotated page, low contrast, unsupported language |
| Routing | Tika success, Tika failure with fallback success, both fail |
Each fixture needs an expected outcome, not only an expected lack of exceptions:
assertThat(result.status()).isEqualTo(EXTRACTED);
assertThat(result.extractor()).isEqualTo("tika");
assertThat(result.detectedMediaType()).isEqualTo("application/pdf");
assertThat(result.text()).contains("known marker text");
For a fallback case, assert both the final extractor and the warning that explains why the primary path was abandoned. For OCR, compare normalized expected tokens and capture the language model and page-segmentation configuration used for the run.
Performance reports should state:
- parser, Java, POI, Tika, and Tesseract versions;
- CPU, memory limit, storage, and process-isolation model;
- corpus composition by format and file-size distribution;
- cold versus warm runs and the number of repetitions;
- concurrency and queue depth;
- whether retries and OCR time are included;
- the exact success predicate.
Those fields were not preserved with the historical table, which is why the old percentages and throughput values do not appear in this article.
The Trade-Offs I Would Accept Again
This layered design gave the system a wider format surface without forcing a risky parser replacement.
It also introduced costs:
- two extractors can disagree about text order and metadata;
- a fallback doubles work on some failures;
- parser configuration becomes deployable application behavior;
- native OCR dependencies complicate packaging;
- specialized POI code requires more maintenance than a universal interface;
- output-quality validation becomes a product decision;
- untrusted files require isolation and strict resource limits.
I would use this design when a search or content-analysis system receives heterogeneous documents, must migrate from an existing extractor gradually, and needs per-format observability.
I would simplify it when the input set is small and controlled. If every file is a known DOCX template, a direct format-specific parser with strong validation may be easier to test and operate. If exact layout fidelity is the goal, plain-text extraction may be the wrong abstraction entirely.
The broader lesson is that multi-format extraction is not solved by adding a universal parser dependency. Reliability comes from the routing policy around the parser: detect content, keep special cases deliberate, preserve a bounded fallback, record the extractor that won, and test the documents that fail differently—not merely the extensions that look different.
Related reading
Official references
- Apache Tika overview
- Apache Tika Java API and
AutoDetectParser - Apache Tika security and process-isolation guidance
- Apache Tika
TesseractOCRParserconfiguration - Apache Tika supported formats
- Apache POI spreadsheet and streaming APIs
- Apache POI
XSSFSheetXMLHandlerAPI - Tesseract installation and language data
- Tesseract command-line usage