tika-eval & Extraction Quality

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Baseline date: 2026-10-13. This page was written against Apache Tika 4.1.0 (the latest release on Maven Central on that date) with the 3.3.x differences noted, Java 21 and later for the examples (Tika 4.x needs Java 17), Spring Boot 4.x on Spring Framework 7 and the Docker image apache/tika:4.1.0. Every other page of this section tells you how to get text out of a document. This one answers the question that follows: is the text any good, and did my last change make it better or worse? Tika’s answer is tika-eval, the project’s own toolkit for profiling one extraction run and comparing two. The Tika committers use it for regression testing before each release; you can use it before you upgrade Tika, change an encoding-detector chain, switch on OCR or replace a parser.

The page explains why extraction quality has to be measured, the artifacts that make up tika-eval in 4.1.0, the Profile and Compare tools with every metric they record, the official regression recipe applied to the Bookshelf corpus, a real 3.3.2 to 4.1.0 upgrade comparison, querying the H2 database, the crash ledger, text-quality scores at parse time with TikaEvalMetadataFilter, the new structure-comparison tool and the pitfalls.

Related pages: Tika App (Command Line & GUI) (which writes the extracts), Tika Pipes (the reporters behind the crash ledger), Character Encoding Detection (the JunkDetector behind languageness), Language Detection & Translation (CharSoup) and What’s Changed & Migration (what an upgrade changes).

How this page was checked: the tika-eval-app-4.1.0.zip distribution was downloaded from downloads.apache.org (SHA-512 verified), the tika-app-4.1.0.zip and the 3.3.2 runnable tika-app-3.3.2.jar were used to write extracts, and every Profile, Compare, Report, H2 query, custom report, ledger join and structure comparison shown below was run on macOS with Java 25 against a small Bookshelf corpus. The option names were read from the tika-eval-app 4.1.0 and 3.3.2 source jars on Maven Central, and the metric definitions from the tika-eval-core 4.1.0 source jar. The section What was verified and what was not lists what could not be checked.

Why measure extraction quality

Extraction rarely fails loudly. A parser that throws is easy to see; the expensive failures are the quiet ones:

  • Mojibake. A legacy file decoded with the wrong charset still produces text, so the index accepts it, but Catálogo became Cat�logo or Catálogo and nobody finds the document by its words.

  • Empty or missing text. A scanned PDF without OCR, an encrypted file, a parser that skips a part: zero tokens and no exception.

  • Junk text. OCR of a poor scan, a PDF with broken font mappings, or binary noise decoded as text.

  • Lost attachments. A container whose embedded documents stop being extracted after a change.

  • New exceptions, timeouts and out-of-memory crashes that only appear on a few files in a large corpus.

  • Detection drift. Files that changed media type or charset between two versions.

There is usually no ground truth: nobody has a hand-checked transcript of every document in a corpus of a million files. tika-eval therefore measures proxies that do not need one. The main proxy is the share of common words: real text in a language contains many of that language’s most frequent words, while mojibake and junk do not. Others are the rate of U+FFFD replacement characters, a learned text-quality score, the detected language, the amount of text per page and the exceptions recorded during the parse. Compared between two runs over the same files, these proxies become a regression test.

The Tika wiki lists the questions tika-eval was built for: comparing two different tools, two versions of the same tool ("should we upgrade?"), two runs with different settings (for example OCR at 200 against 300 DPI), and a tool against a gold standard. The project runs it against about three million files as part of its regression testing; the wiki also states that the design does not scale to hundreds of millions of files.

Source: TikaEval, Tika wiki (written in the 1.x to 2.x era, last updated 2022) and Apache Tika 4.1.x, Regression evaluation with tika-app + tika-eval.

The tika-eval artifacts in 4.1.0

Artifact Where What it is

tika-eval

Maven Central (pom only)

The parent POM of the modules below. In 1.x (1.15 to 1.28.5) tika-eval was itself a jar.

tika-eval-core

Maven Central (jar)

The metrics library: tokenization, common-token lists for 204 languages (as Bloom filters), CharSoup language id, the JunkDetector languageness score, token contrast (dice, overlap) and TikaEvalMetadataFilter. Bundled in the tika-app 4.1.0 distribution.

tika-eval-app

Distribution zip tika-eval-app-4.1.0.zip on the Apache download site; a thin jar on Maven Central

The command-line tools Profile, Compare, Report and StartDB, an H2 database (h2-2.5.250.jar in lib/) and Apache POI for the .xlsx reports.

tika-eval-structure

Maven Central only (jar with a lib/ class path), new in 4.1.0

A standalone comparison of block order, segmentation and word boundaries between two sets of XHTML extracts.

Like tika-app and tika-server-standard, tika-eval-app 4.x is a thin launcher: the jar on Maven Central has a manifest class path that points at lib/ and fails on its own. Download the zip, check it and unpack it into a new directory (the zip has no top-level folder):

curl -O https://downloads.apache.org/tika/4.1.0/tika-eval-app-4.1.0.zip
curl -O https://downloads.apache.org/tika/4.1.0/tika-eval-app-4.1.0.zip.sha512
shasum -a 512 tika-eval-app-4.1.0.zip    # compare with the .sha512 file
unzip -q tika-eval-app-4.1.0.zip -d tika-eval
cd tika-eval
java -jar tika-eval-app-4.1.0.jar

Output:

Must specify one of the following tools in the first parameter:
Profile
Compare
Report
StartDB

The zip (41 MB) holds the launcher jar, LICENSE, NOTICE and 43 jars in lib/, among them tika-core, tika-eval-core, tika-langdetect-charsoup, tika-ml-junkdetect, tika-encoding-detector-mojibuster, tika-pipes-core, tika-pipes-file-system (the directory crawler), tika-serialization, h2 and poi-ooxml. It does not contain parsers: tika-eval never parses your documents. It reads the extracts another tool wrote. The 3.x line ships tika-eval-app-3.3.2.jar as a runnable fat jar instead.

Source: Apache Tika, Download and Maven Central, org.apache.tika (artifact listings checked on 2026-10-13).

The evaluation flow

Every tika-eval run has the same three stages: write extracts, load them into a database, write reports from the database. Profile loads one set of extracts; Compare loads two sets of extracts of the same corpus and pairs them file by file.

flowchart LR corpus[("Corpus: the original files")] corpus --> exA["Extract A: tika-app, config or version A"] corpus --> exB["Extract B: tika-app, config or version B"] exA --> jsonA[("extracts-A: one JSON per file")] exB --> jsonB[("extracts-B: one JSON per file")] jsonA --> profile["tika-eval Profile"] jsonA --> compare["tika-eval Compare"] jsonB --> compare profile --> db[("H2 database: profiles, contents, exceptions")] compare --> db db --> report["tika-eval Report"] report --> xlsx["reports/*.xlsx"] report --> md["reports/summary.md"] db --> sql["Your own SQL, custom report files"]

Two consequences of this design matter in practice. First, tika-eval evaluates any tool that can write the extract format, not only Tika: a directory of UTF-8 .txt files from another extractor can be one side of a comparison. Second, the database survives the run (when you name it with -d), so you can write new reports or run your own queries long after the extraction finished.

Writing the extracts

The extract format

tika-eval reads, for each original file, an extract with the same relative path and the suffix .json (the list of metadata objects that RecursiveParserWrapper, /rmeta and tika-app -J produce, one object per document with the text under tk:content) or .txt (plain UTF-8 text). Files can be at any depth; the crawler walks the tree. The JSON form is far more useful: it carries the media type, the parse exceptions, the detected encoding, the embedded documents and the parse time.

The fields tika-eval reads from a JSON extract include tk:content, Content-Type, tk:parsed-by, tk:embedded-depth, tk:embedded-resource-path, tk:parse-time-millis, tk:detected-encoding, tk:encoding-detector, the exception keys and tk:digest:SHA-256 (or MD5). Extracts written by 3.x or by a 4.0 pre-release use the old X-TIKA: and camelCase spellings; the 4.1.0 reader normalizes X-TIKA:content, X-TIKA:embedded_depth, X-TIKA:parse_time_millis, X-TIKA:detectedEncoding, X-TIKA:digest:<alg> and the other legacy keys to their tk: names before computing anything, so a 3.x run and a 4.x run can be compared directly. See Metadata for the full key mapping.

Extracts with tika-app 4.1.0

In 4.x, tika-app -i <in> -o <out> runs Tika Pipes: it forks parser JVMs, writes one <file>.json per input and survives crashes. The regression recipe needs a digester in both configurations, because tika-eval aligns embedded documents across runs by their digest:

{
  "parse-context": {
    "commons-digester-factory": {
      "digests": [{ "algorithm": "SHA256" }],
      "skipContainerDocumentDigest": false
    }
  }
}
cd tika-app            # the unpacked tika-app-4.1.0.zip
java -jar tika-app-4.1.0.jar -i ~/bookshelf/corpus -o ~/bookshelf/extracts-A \
    -c ~/bookshelf/configs/tika-config-A.json -n 2

Last line of the output for the eight Bookshelf files:

INFO  [main] 09:51:28,372 tika.pipes Successfully finished processing 8 files in 10276 ms

The extracts carry tk:digest:SHA-256 for every document (the config value is the enum name SHA256; the emitted key uses the JCA spelling SHA-256). In pipes mode the default content handler of 4.1.0 is Markdown (tk:content-handler-type is MARKDOWN); pass --handler t for plain text or --handler x for XHTML. Use the same handler on both sides of a comparison, or every token that the Markdown markup adds (#, |) shows up as a difference.

Extracts with tika-app 3.3.x

The 3.x batch mode writes the same layout:

# 3.x only
java -jar tika-app-3.3.2.jar -J -t -i ~/bookshelf/corpus -o ~/bookshelf/extracts-332

-J selects the recursive JSON, -t plain text. In the run for this page the 3.3.2 batch mode rejected --digest=sha256 with UnrecognizedOptionException; it wrote X-TIKA:digest:MD5 on its own, which tika-eval uses as the fallback digest.

Source: TikaEval, Tika wiki, Quick Start Usage (a 3.x-era page; the tika-eval-X.Y.jar commands on it no longer match 4.x, see the CLI reference).

The Bookshelf corpus used on this page

The examples run on eight original files in ~/bookshelf/corpus: catalogue.pdf (one page), manuscript.docx, publisher-email.eml (with samples.zip, a docx, a PDF, a PNG and a short cover-note.txt inside), renamed-invoice.png (a PDF with a misleading name), legacy-catalogue.txt (431 bytes of Spanish in windows-1252, with accents and two € signs), reseñas-fr.txt (French, UTF-8), reviews-es.html (Spanish, UTF-8 with a <meta charset>) and empty.bin (zero bytes). This page uses its own, longer variant of legacy-catalogue.txt (431 bytes); the Character Encoding Detection page uses a 234-byte one and Testing Tika Integrations a 151-byte fixture, so their numbers are not comparable with the ones here. The corpus is tiny on purpose, so every number can be checked by hand. Its counts are real, but its parse times are noise; see Memory, performance and scaling.

Profile: one extraction run

Profile answers "what did this run produce?". It needs the extracts directory (-e) and a database name (-d); the original files (-i) are optional but recommended, because with them tika-eval also records the files that produced no extract at all.

cd tika-eval
java -jar tika-eval-app-4.1.0.jar Profile \
    -i ~/bookshelf/corpus -e ~/bookshelf/extracts-A -d bookshelf-profile -n 2
java -jar tika-eval-app-4.1.0.jar Report -d bookshelf-profile -rd profile-reports

Profile creates bookshelf-profile.mv.db in the current directory and logs:

INFO  ... org.apache.tika.eval.app.StatusReporter There are 0 file processors still active.
INFO  ... org.apache.tika.eval.app.StatusReporter The crawler has enqueued 8 files.
INFO  ... org.apache.tika.eval.app.StatusReporter Completed successfully.

Report then writes 16 workbooks:

Directory Workbooks

mimes/

all_mimes.xlsx, container_mimes.xlsx, embedded_mimes.xlsx: counts per media type.

content/

common_tokens_by_lang.xlsx, common_tokens_div_alphabetic_exclude_media_and_zips.xlsx, detected_langs.xlsx, num_tokens_by_detected_langs.xlsx, tokens_per_page_in_container_files.xlsx.

exceptions/

exceptions_by_type.xlsx, exceptions_by_type_embedded.xlsx, exceptions_by_mime_by_type.xlsx, stack_traces_by_mime.xlsx, stack_traces_details.xlsx, crash_status_extract_present.xlsx, extract_exceptions_by_pipes_status.xlsx, extract_exceptions_pipes_details.xlsx.

Each workbook has the result on its first sheet and, because the report definitions set includeSql="true", the SQL that produced it on a second sheet: a good way to learn the schema. A profile database gets no summary.md; the log says Not a comparison database; skipping markdown summary.

The per-document numbers behind those workbooks, read straight from the database (the query is in Querying the H2 database):

FILE_PATH            | IS_EMBEDDED | MIME_STRING                          | NUM_TOKENS | NUM_COMMON_TOKENS | OOV   | LNG  | LANG_ID_1
catalogue.pdf        | FALSE       | application/pdf                      | 10         | 9                 | 0.1   | 2.37 | eng
empty.bin            | FALSE       | application/octet-stream             | null       | null              | null  | null | null
legacy-catalogue.txt | FALSE       | text/plain; charset=windows-1252     | 67         | 44                | 0.323 | 2.19 | spa
manuscript.docx      | FALSE       | application/vnd.openxmlformats-...   | 11         | 10                | 0.091 | 2.2  | eng
publisher-email.eml  | FALSE       | message/rfc822                       | 15         | 14                | 0.067 | 2.39 | eng
publisher-email.eml  | TRUE        | image/png                            | null       | null              | null  | null | null
publisher-email.eml  | TRUE        | application/vnd.openxmlformats-...   | 20         | 14                | 0.222 | 1.68 | eng
publisher-email.eml  | TRUE        | application/pdf                      | 10         | 9                 | 0.1   | 2.37 | eng
publisher-email.eml  | TRUE        | text/plain; charset=windows-1252     | 19         | 17                | 0.105 | 2.41 | eng
publisher-email.eml  | TRUE        | application/zip                      | 9          | 7                 | 0.222 | 1.03 | eng
renamed-invoice.png  | FALSE       | application/pdf                      | 10         | 9                 | 0.1   | 2.37 | eng
reseñas-fr.txt       | FALSE       | text/plain; charset=UTF-8            | 48         | 35                | 0.271 | 2.17 | fra
reviews-es.html      | FALSE       | text/html; charset=UTF-8             | 40         | 30                | 0.25  | 2.33 | spa

(The long Office media type is shortened here.) Thirteen documents from eight files: the e-mail contributes its five embedded documents as their own rows, each with its own metrics. empty.bin has a profile row but no content row, and an exception row with type RUNTIME (the ZeroByteFileException the parse recorded). Language ids are ISO 639-3 codes from CharSoup: spa, fra, eng.

Source: TikaEval, Tika wiki, Single Output from One Tool (Profile); command forms from the ExtractProfileRunner options in the tika-eval-app 4.1.0 source jar.

The metrics

tika-eval computes the same text profile for every document in Profile and for each side in Compare, then adds the pairwise contrast in Compare. The column names are those of the contents, profiles and content_comparisons tables (contents_a and contents_b in a comparison database).

Text profile (table contents)

Column Meaning

num_tokens, num_unique_tokens

Tokens after the tika-eval analyzer (lower-casing, ICU folding of accents, so Catálogo and catalogo are the same token) and how many distinct ones.

num_alphabetic_tokens, num_unique_alphabetic_tokens

Tokens with at least one alphabetic or ideographic character; numbers and punctuation do not count.

num_common_tokens, num_unique_common_tokens, common_tokens_lang

Alphabetic tokens found in the common-token list of the detected language (common_tokens_lang). The lists ship in tika-eval-core as Bloom filters for 204 languages; a language without a list falls back to English.

oov

Out-of-vocabulary rate, \( 1 - \frac{\text{common tokens}}{\text{alphabetic tokens}} \). Lower is better. Real prose sits roughly between 0.1 and 0.4 on this corpus; junk tends towards 1.

languageness

The JunkDetector z-score of the text (from tika-ml-junkdetect): positive means "looks like clean text in its script", 0 is the threshold the model was fitted to, negative means mojibake or garbage. -99 is the sentinel for "could not score" (an unknown script, or no model). In 4.x this replaced an older, GLM-based languageness measure (according to a source comment, TIKA-4720).

lang_id_1, lang_id_prob_1, lang_id_2, lang_id_prob_2

The two most likely languages and their scores, from the CharSoup detector in 4.x (OpenNLP in 3.x).

num_replacement, num_non_ascii

Count of U+FFFD replacement characters and of non-ASCII characters. Their ratio is the cleanest decode-failure signal.

content_length, content_truncated_at_max_len

Characters of text, and whether the text was cut at the configured maximum before the statistics were computed.

token_entropy_rate, token_length_sum, token_length_mean, token_length_std_dev

Token-distribution statistics. Very short mean token lengths or a very low entropy rate point at broken text (single letters, repeated characters).

top_n_tokens, unicode_char_blocks

The most frequent tokens with their counts, and the Unicode blocks the text uses with their counts.

The profiles table adds one row per document with file_name, mime_id, length, is_embedded, embedded_depth, embedded_file_path, attachment_type, elapsed_time_millis, num_attachments, num_metadata_values, num_pages, num_ocr_pages and has_content. The encodings table records detected_encoding, encoding_detector and the declared_metadata charset.

Contrast between A and B (table content_comparisons)

Compare tokenizes both texts of a pair and computes:



Both are 1.0 for identical token sets and fall towards 0 as the texts diverge. Dice looks at the vocabulary, overlap at the counts, so a document whose text is duplicated in B keeps a dice of 1.0 but its overlap drops. The table also stores the top ten tokens unique to each side (top_10_unique_token_diffs_a, _b) and the top ten tokens that are more frequent on each side (top_10_more_in_a, _b); these lists are often the fastest way to see what changed.

Exceptions and failures

Reference table Values

ref_parse_exception_types

RUNTIME, ENCRYPTION, ACCESS_PERMISSION, UNSUPPORTED_VERSION: the category of an exception recorded in the extract.

ref_parse_error_types

OOM, TIMEOUT: catastrophic failures of the parse.

ref_extract_exception_types

NO_EXTRACT_FILE, ZERO_BYTE_EXTRACT_FILE, IO_EXCEPTION, EXTRACT_PARSE_EXCEPTION, EXTRACT_FILE_TOO_SHORT, EXTRACT_FILE_TOO_LONG, INCORRECT_EXTRACT_FILE_SUFFIX: problems reading the extract itself, before any text statistic.

The stack traces are stored twice: as recorded (orig_stack_trace) and normalized for grouping (sort_stack_trace), which is what lets the reports count "the same bug" across thousands of files.

Source: the TokenContraster, CommonTokenResult and TikaEvalMetadataFilter classes and the common_tokens_bloom resources in the tika-eval-core 4.1.0 source jar; table and column names read from a 4.1.0 database with information_schema.columns; TikaEval, Tika wiki, Evaluating Success via Common Words.

Compare: regression between two configurations

This is the official worked example of the 4.1.x documentation, applied to the Bookshelf corpus. The documentation compares the default encoding-detector chain against an explicit chain over 29,000 HTML pages from Common Crawl. Here the variant is deliberately bad, so that the reports have something to find: configuration B replaces the detector chain with a BOM check and an override that always says UTF-8. The two configurations differ in exactly one thing, the field under test; the digester is identical.

tika-config-A.json is the digester-only file shown above. tika-config-B.json:

{
  "encoding-detectors": [
    { "bom-detector": {} },
    { "override-encoding-detector": { "charset": "UTF-8" } }
  ],
  "parse-context": {
    "commons-digester-factory": {
      "digests": [{ "algorithm": "SHA256" }],
      "skipContainerDocumentDigest": false
    }
  }
}

An explicit encoding-detectors array replaces the SPI-discovered chain completely; see Character Encoding Detection for the detector names.

cd tika-app
java -jar tika-app-4.1.0.jar -i ~/bookshelf/corpus -o ~/bookshelf/extracts-A -c ~/bookshelf/configs/tika-config-A.json -n 2
java -jar tika-app-4.1.0.jar -i ~/bookshelf/corpus -o ~/bookshelf/extracts-B -c ~/bookshelf/configs/tika-config-B.json -n 2

cd ../tika-eval
java -jar tika-eval-app-4.1.0.jar -n 2 -r -d bookshelf-A-vs-B \
    -i ~/bookshelf/corpus -a ~/bookshelf/extracts-A -b ~/bookshelf/extracts-B

The Compare keyword is optional: with both -a and -b the CLI infers it. -r runs Report right after Compare and packs the reports directory into reports.tgz; -d keeps the database as bookshelf-A-vs-B.mv.db (94 KB here). The whole comparison took 11 seconds of wall time, most of it JVM start-up and the 67 report queries.

The quickest overview is reports/summary.md, new in 4.x. Extracts from the run for this page (empty sections and the long paths of the provenance tables removed):

## Overview

- **Total containers**: 8
- **Total files (A)**: 13
- **Total files (B)**: 13
- **Exceptions (A)**: 1
- **Exceptions (B)**: 1

### Top 20 Individual OOV Increases

| FILE | MIME_A | OOV_A | OOV_B | OOV_DELTA | LANG_A | LANG_B | FFFD_A | FFFD_B | LANG_ID_A | LANG_ID_B |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| legacy-catalogue.txt | text/plain; charset=windows-1252 | 0.3231 | 0.5641 | 0.241 | 2.19 | 0.96 | 0 | 16 | spa | spa |

## Token Count Changes by Mime Type

| MIME_A | MIME_B | TOKENS_A | TOKENS_B | PCT_CHANGE | COMMON_A | COMMON_B |
| --- | --- | --- | --- | --- | --- | --- |
| text/plain; charset=windows-1252 | text/plain; charset=UTF-8 | 86 | 99 | 15.1 | 61 | 51 |
| application/pdf | application/pdf | 30 | 30 | 0.0 | 27 | 27 |

## Mime Type Changes (A -> B)

| CHANGE | COUNT |
| --- | --- |
| text/plain; charset=windows-1252 -> text/plain; charset=UTF-8 | 2 |

## Top 20 Content Regressions (lowest dice)

| FILE | MIME_A | DICE | OVERLAP | ONLY_IN_A | ONLY_IN_B |
| --- | --- | --- | --- | --- | --- |
| legacy-catalogue.txt | text/plain; charset=windows-1252 | 0.6607 | 0.7211 | catalogo: 1 \| edicion: 1 \| electronico: 1 \| ... | a: 3 \| env: 2 \| an: 1 \| ... |

Every signal points at the same file, and each one tells a different part of the story:

  • U+FFFD. B decoded the 16 non-ASCII bytes of legacy-catalogue.txt as UTF-8 and got 16 replacement characters (FFFD_B); A had none.

  • OOV and common tokens. The broken words (Cat�logo splits into cat and logo) are not common Spanish words, so OOV rose from 0.32 to 0.56 and its common tokens fell from 44 to 34, while its raw token count rose from 67 to 80 (about 19 %) because every broken word became two tokens. (The 61 to 51 and 15 % figures in the summary are totals for the media type, which also include the unchanged cover-note.txt.) More tokens is not better text.

  • Languageness fell from 2.19 to 0.96. It stayed positive: most of the file is still clean ASCII Spanish.

  • Language id stayed spa: the detector is robust to a few broken words, so a stable language is not proof of a clean decode.

  • Dice and the token lists show the cause in words: catalogo, edicion, electronico only in A; fragments such as env, contempor and electr only in B.

  • Media type. Two documents changed from charset=windows-1252 to charset=UTF-8: the file itself and the embedded cover-note.txt in the e-mail. The second one is pure ASCII, so its text is identical (dice 1.0). A charset flip is only a regression when the text changed with it, which is why the charset/ reports join the flips to the text metrics.

The same summary also lists a parse-time table. Ignore it on a corpus this small; see Memory, performance and scaling.

The comparison reports

A comparison database gets 67 workbooks plus summary.md in 4.1.0:

Directory What to look at first

mimes/

mime_diffs_A_to_B.xlsx (counts per A type to B type transition) and mime_diffs_A_to_B_details.xlsx (one row per changed file); also all, container and embedded media types for each side.

charset/

charset_coverage.xlsx (the A to B flips), encoding_diffs_A_to_B.xlsx and _details, the detected-encoding and detector distributions per side, declared_vs_detected_A.xlsx and _B, detector_quality_B.xlsx and sbcs_to_cjk_flips_A_to_B.xlsx.

content/

content_diffs_no_exceptions.xlsx, content_diffs_with_exceptions.xlsx, common_token_comparisons_by_mime.xlsx, dice_quintiles.xlsx, overlap_quintiles.xlsx, page_count_diffs.xlsx.

exceptions/

new_exceptions_in_B_by_mime.xlsx, new_exceptions_in_B_details.xlsx, fixed_exceptions_in_B_by_mime.xlsx, new_catastrophic_exceptions_in_b.xlsx, fixed_catastrophic_exceptions_in_b.xlsx, text_lost_from_A_caused_by_new_exceptions_in_B.xlsx, stack traces per side and the crash-ledger reports.

attachments/

Embedded documents and containers missing on either side, attachment-count differences with and without exceptions, and attachment_name_diffs.xlsx.

metadata/

metadata_value_count_diffs.xlsx: documents whose number of metadata values changed.

parse_times/

parse_time_millis_by_mime_compared.xlsx and parse_time_millis_details.xlsx.

The 4.1.x documentation also lists an md5/ directory; the 4.1.0 run for this page produced none, so do not rely on it. Report -d <db> -rd <dir> re-runs the reports on an existing database without repeating Compare. Report overwrites an existing reports directory without warning.

Source: Regression evaluation, Step 5: read the reports and the report definitions comparison-reports.xml and profile-reports.xml in the tika-eval-app 4.1.0 jar.

Before you upgrade Tika: 3.3.2 against 4.1.0

The question tika-eval was first built for is "should we upgrade?". The recipe is the same as above, with the version as the variable instead of the configuration. Use the same corpus, the same output form (plain text on both sides here, since 3.x has no Markdown handler) and, where possible, the same digests and timeouts.

# A: the version you run today (3.x batch mode)
java -jar tika-app-3.3.2.jar -J -t -i ~/bookshelf/corpus -o ~/bookshelf/extracts-332

# B: the candidate (4.x pipes mode), plain text to match A
cd tika-app
java -jar tika-app-4.1.0.jar -i ~/bookshelf/corpus -o ~/bookshelf/extracts-410 \
    -c ~/bookshelf/configs/tika-config-A.json --handler t -n 2

cd ../tika-eval
java -jar tika-eval-app-4.1.0.jar Compare -n 2 -d tika-332-vs-410 -r -rd upgrade-reports \
    -i ~/bookshelf/corpus -a ~/bookshelf/extracts-332 -b ~/bookshelf/extracts-410

Thanks to the legacy-key normalization, the 3.3.2 extracts (X-TIKA:content, X-TIKA:digest:MD5) and the 4.1.0 extracts (tk:content, tk:digest:SHA-256) were paired without any preprocessing. What the comparison found:

## Mime Type Changes (A -> B)

| CHANGE | COUNT |
| --- | --- |
| text/plain; charset=ISO-8859-1 -> text/plain; charset=windows-1252 | 2 |

A query for the two flipped documents (the SQL is in the next section) shows that the change is a fix:

FILE_NAME            | FFFD_A | FFFD_B | OOV_A | OOV_B | LANG_A | LANG_B | ENC_A      | ENC_B
legacy-catalogue.txt | 2      | 0      | 0.323 | 0.323 | 2.0    | 2.14   | ISO-8859-1 | windows-1252
cover-note.txt       | 0      | 0      | 0.105 | 0.105 | 2.21   | 2.14   | ISO-8859-1 | windows-1252

3.3.2 decoded legacy-catalogue.txt as ISO-8859-1. The decoder itself does not fail: it maps byte 0x80 of each € sign to the C1 control character U+0080. Tika’s XHTMLContentHandler treats U+007F to U+009F as invalid in XHTML and writes U+FFFD in their place, so the two € signs came out as replacement characters (checked by decoding the file with AutoDetectReader, which returns U+0080, and with tika-app-3.3.2.jar -t, which prints U+FFFD). 4.1.0 detected windows-1252 and decoded them correctly. Note what did not move: OOV, the token counts and the dice coefficient (1.0) are identical, because € is not a word token. Token metrics are blind to symbols, currency signs and punctuation; the replacement-character count and the charset reports are not. Read them together.

Three more observations from the same run, each of them a lesson for real upgrades:

  • Metadata value counts went up for every document (for example 47 to 57 for catalogue.pdf), because 4.x writes more tk: keys (tk:encoding-detection-trace, tk:content-type-magic-detected and others). The metadata_value_count_diffs.xlsx report flags all of them; it is a key-set change, not a content change. Check the keys your application reads against the mapping table on the Metadata page.

  • All 13 documents paired, with no attachments missing on either side: the embedded-document handling of the e-mail is unchanged.

  • Parse times differed by factors of two to twenty in both directions. This is a single run of eight tiny files on two very different execution models (3.x in-process batch, 4.x forked pipes with cold JVMs), and it means nothing.

A practical pre-upgrade checklist:

  1. Build a corpus that represents production: every media type you accept, the legacy charsets you receive, scans, password-protected files, the largest files, and the files that caused incidents before. A few thousand files are enough for most decisions; tika-eval comfortably handles far more.

  2. Extract with the current version and the candidate using the same output form, digests, timeouts and fork count.

  3. Run Compare with -d (keep the database) and -r.

  4. Read, in order: new catastrophic exceptions and crashes, content lost, new exceptions by media type, media-type and charset flips, OOV and languageness decreases, dice quintiles, attachments missing.

  5. For every surprise, open the two extracts side by side and decide: regression, fix or harmless change.

  6. Keep the reports and the database next to the upgrade ticket, and repeat the run on the next release.

Source: TikaEval, Tika wiki, Background (the upgrade use case) and the ExtractReader legacy-key table in the tika-eval-app 4.1.0 source jar. See What’s Changed & Migration for what else to check when you move from 3.x to 4.x.

Querying the H2 database

The reports are pre-canned views. For anything else, query the database directly. The rules, all confirmed in the runs for this page:

  • The database has no user name and no password. Pass empty credentials; -user sa fails with "Wrong user name or password".

  • Leave the .mv.db suffix out of the JDBC URL: jdbc:h2:./bookshelf-A-vs-B.

  • Append ;IFEXISTS=TRUE (a typo then fails instead of creating a new, empty database) and ;ACCESS_MODE_DATA=r for a read-only open.

  • H2 is single-writer: query only after Profile, Compare or Report has finished.

  • Use the h2-2.5.250.jar shipped in the lib/ directory of tika-eval-app, so the file format matches.

  • Pass SQL to -sql as a single line; a multi-line argument leaves the shell waiting on standard input.

H2=lib/h2-2.5.250.jar
DB='jdbc:h2:./bookshelf-A-vs-B;IFEXISTS=TRUE;ACCESS_MODE_DATA=r'
java -cp "$H2" org.h2.tools.Shell -url "$DB" -user '' -password '' -sql "SHOW TABLES"

A comparison database has 29 tables: containers, profiles_a/_b, contents_a/_b, content_comparisons, encodings_a/_b, exceptions_a/_b, exceptions_compared, extract_exceptions_a/_b, emb_path_a/_b, tags_a/_b, mimes, pair_names, parse_time_compared, token_counts_compared, pipes_class_a/_b, run_info/_a/_b and the three reference tables. A profile database has the same tables without the suffixes (14 in all). A and B are paired by id: the same id is the same document in both runs, and every join between the two sides uses it.

The OOV direction count from the official documentation, on the encoding comparison:

SELECT SUM(CASE WHEN cb.oov < ca.oov THEN 1 ELSE 0 END) AS oov_better,
       SUM(CASE WHEN cb.oov > ca.oov THEN 1 ELSE 0 END) AS oov_worse
FROM contents_a ca JOIN contents_b cb ON ca.id = cb.id;
OOV_BETTER | OOV_WORSE
0          | 1

The documents whose text changed, or that gained replacement characters, with all the signals side by side:

SELECT pa.file_name, ROUND(ca.oov,3) oov_a, ROUND(cb.oov,3) oov_b,
       ROUND(ca.languageness,2) lang_a, ROUND(cb.languageness,2) lang_b,
       ca.num_replacement fffd_a, cb.num_replacement fffd_b, cb.num_non_ascii,
       ROUND(cc.dice_coefficient,3) dice
FROM profiles_a pa
JOIN contents_a ca ON ca.id = pa.id
JOIN contents_b cb ON cb.id = pa.id
JOIN content_comparisons cc ON cc.id = pa.id
WHERE cc.dice_coefficient < 1.0 OR cb.num_replacement > 0;
FILE_NAME            | OOV_A | OOV_B | LANG_A | LANG_B | FFFD_A | FFFD_B | NUM_NON_ASCII | DICE
legacy-catalogue.txt | 0.323 | 0.564 | 2.19   | 0.96   | 0      | 16     | 16            | 0.661

Sixteen replacement characters out of sixteen non-ASCII characters: a 100 % decode-failure rate for the non-ASCII part. Divide by num_non_ascii, not by the content length, which dilutes the rate to almost zero on mostly-ASCII text.

The charset flips with the detector that made each decision:

SELECT pa.file_name, ea.detected_encoding enc_a, ea.encoding_detector det_a,
       eb.detected_encoding enc_b, eb.encoding_detector det_b
FROM profiles_a pa
JOIN encodings_a ea ON ea.id = pa.id
JOIN encodings_b eb ON eb.id = pa.id
WHERE ea.detected_encoding <> eb.detected_encoding;
FILE_NAME            | ENC_A        | DET_A                      | ENC_B | DET_B
legacy-catalogue.txt | windows-1252 | MojibusterEncodingDetector | UTF-8 | OverrideEncodingDetector
cover-note.txt       | windows-1252 | MojibusterEncodingDetector | UTF-8 | OverrideEncodingDetector

The common-token headline ("did B recover more real text?"):

SELECT SUM(ca.num_common_tokens) AS common_a, SUM(cb.num_common_tokens) AS common_b,
       SUM(cb.num_common_tokens) - SUM(ca.num_common_tokens) AS delta
FROM contents_a ca JOIN contents_b cb ON ca.id = cb.id;
COMMON_A | COMMON_B | DELTA
198      | 188      | -10

And, on the profile database, the per-document listing shown in the Profile section:

SELECT c.file_path, p.is_embedded, m.mime_string, ct.num_tokens, ct.num_common_tokens,
       ROUND(ct.oov,3) oov, ROUND(ct.languageness,2) lng, ct.lang_id_1
FROM profiles p
JOIN containers c ON p.container_id = c.container_id
JOIN mimes m ON p.mime_id = m.mime_id
LEFT JOIN contents ct ON ct.id = p.id
ORDER BY c.file_path, p.id;

The documentation adds a caution worth repeating: OOV is one signal, not the verdict. A language-id shift (for example a CJK text that B decodes correctly for the first time) can raise OOV although B is right, and a wrong decode that fragments words into short common-looking tokens can lower it. Languageness has the opposite blind spots. When the two disagree on a file, look at the file.

StartDB starts the H2 web console (org.h2.tools.Console -web, by default on http://localhost:8082) for browsing a database in a browser; enter the JDBC URL with the full path and empty credentials. It runs until you stop it.

Custom reports

A report file is an XML list of SQL queries, each written to its own workbook; the built-in profile-reports.xml and comparison-reports.xml inside the tika-eval-app jar are the best examples (with optional <before> and <after> SQL blocks that build and drop helper tables). A Bookshelf report that lists suspicious decodings in B:

<?xml version="1.0" encoding="UTF-8"?>
<reports>
  <report reportName="Suspect decodings in B"
          reportFilename="bookshelf/suspect_decodings_b.xlsx"
          format="xlsx"
          includeSql="true">
    <sql>
      select pa.file_name, ea.detected_encoding enc_a, eb.detected_encoding enc_b,
             cb.num_replacement fffd_b, ca.oov oov_a, cb.oov oov_b,
             ca.languageness lang_a, cb.languageness lang_b
      from profiles_a pa
      join contents_a ca on ca.id = pa.id
      join contents_b cb on cb.id = pa.id
      left join encodings_a ea on ea.id = pa.id
      left join encodings_b eb on eb.id = pa.id
      where cb.num_replacement > ca.num_replacement
         or cb.languageness &lt; ca.languageness - 0.5
      order by cb.num_replacement desc
    </sql>
  </report>
</reports>
java -jar tika-eval-app-4.1.0.jar Report -d bookshelf-A-vs-B -rf bookshelf-reports.xml -rd custom-reports

The run wrote custom-reports/bookshelf/suspect_decodings_b.xlsx with one row (legacy-catalogue.txt, windows-1252, UTF-8, 16, 0.32, 0.56, 2.19, 0.96) and the SQL on its second sheet. Escape < as < inside the XML. On a comparison database Report writes summary.md as well, whichever report file you pass.

The crash ledger and run provenance

A crash (OOM, TIMEOUT, UNSPECIFIED_CRASH) leaves no extract, so on its own tika-eval reports it as NO_EXTRACT_FILE, the same as a file that never ran. Since 4.1.0 two inputs close the gap:

  • the per-document JSONL that the file-system-jsonl-reporter of Tika Pipes writes during the batch (-pr on Profile, -pa and -pb on Compare);

  • a run-info.json that describes what ran (-ri on Profile, -ra and -rb on Compare).

When these flags are absent, tika-eval looks in <extracts>/.run-info/ for crashes-*.jsonl and run-info-*.json (it refuses to start if that directory holds more than one of either, since that means two runs wrote into the same extracts directory). The .run-info directory is skipped by the crawler and excluded from the extracts fingerprint. A reporter block that writes the ledger where tika-eval looks for it, with successes left out:

{
  "pipes-reporters": {
    "file-system-jsonl-reporter": {
      "path": "/home/bookshelf/extracts-L/.run-info/crashes-bookshelf.jsonl",
      "onExists": "REPLACE",
      "excludes": ["PARSE_SUCCESS"]
    }
  },
  "parse-context": {
    "commons-digester-factory": {
      "digests": [{ "algorithm": "SHA256" }],
      "skipContainerDocumentDigest": false
    },
    "timeout-limits": { "totalTaskTimeoutMillis": 60000, "progressTimeoutMillis": 30000 }
  }
}

On the Bookshelf corpus nothing crashed, so the ledger held a single line, for the zero-byte file:

{"id":"empty.bin","status":"PARSE_SUCCESS_WITH_EXCEPTION","category":"SUCCESS","message":null,"elapsedMs":10116,"timestamp":"2026-10-13T07:59:23.292358Z"}

Profile found and joined it without any flag (loaded 1 pipes report rows (0 pipeline error lines) in the log), and the result lands in containers.pipes_status and pipes_message (_a and _b on Compare), and in run_info as pipes_report.rows = 1 and pipes_report.joined = 1. A joined of zero with a non-empty ledger means the wrong ledger, or a crawl without -i, which never sees the files that crashed. The reports classify each container:

Classification Meaning

CRASH

OOM, TIMEOUT or UNSPECIFIED_CRASH recorded. In shared-server mode a crash takes every in-flight request with it, so retry a CRASH once, alone, before you blame the file.

any other status

Passed through as recorded (FETCH_EXCEPTION, PAYLOAD_LIMIT_EXCEEDED and so on).

NO_PIPES_RECORD

A ledger was supplied but has no line for this file: the batch recorded no failure for it.

BATCH_WITHOUT_LEDGER

A run-info was supplied but the batch wrote no ledger.

NO_PIPES_REPORT_SUPPLIED

Neither was given.

The run_info tables also record, without being asked, the tika-eval version, the SHA-256 of its jar, Java, host, user, the arguments, the effective EvalConfig, start and end times, and for each extracts directory the file count and a fingerprint (SHA-256 over the sorted names and sizes). summary.md opens with them, so a report always says what produced it. Password-like values in the JDBC string, arguments and configuration are masked, since these tables travel with the reports.

The Tika source tree has a helper, .skills/devs/tika-eval-compare/run-batch.sh, that runs tika-app and writes both files (run-info-<run.id>.json with the SHA-256 of every jar and of the configuration, and the ledger). It is not part of the distribution zips and was not run for this page. If a run-info.json carries run.id, the ledger must be named crashes-<run.id>.jsonl or tika-eval refuses to start.

Text-quality scores at parse time: TikaEvalMetadataFilter

You do not need the whole tika-eval-app to use its metrics. TikaEvalMetadataFilter (package org.apache.tika.eval.core.metadata, artifact tika-eval-core) is a metadata filter: it reads tk:content from a Metadata object and writes the scores next to it. That turns quality into a per-document field your pipeline can act on before the text reaches an index or an embedding model.

Key Type Meaning

tika-eval:numTokens

integer

All tokens.

tika-eval:numUniqueTokens

integer

Distinct tokens.

tika-eval:numAlphaTokens

integer

Tokens with a letter or ideograph.

tika-eval:numUniqueAlphaTokens

integer

Distinct alphabetic tokens.

tika-eval:numCommonTokens

integer

Alphabetic tokens in the common-token list of the detected language.

tika-eval:oov

real

Out-of-vocabulary rate; -1.0 when there are no alphabetic tokens.

tika-eval:lang, tika-eval:langConfidence

text, real

Top language and its raw score from CharSoup.

tika-eval:languageness

real

The JunkDetector z-score; -99.0 when the text could not be scored. New in 4.x.

The filter does nothing when tk:content is blank. The 3.x filter writes the same keys except languageness, and uses OpenNLP for the language.

From Java

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.apache.tika</groupId>
      <artifactId>tika-bom</artifactId>
      <version>4.1.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>
<dependencies>
  <dependency>
    <groupId>org.apache.tika</groupId>
    <artifactId>tika-parsers-standard-package</artifactId>
    <type>pom</type>
  </dependency>
  <dependency>
    <groupId>org.apache.tika</groupId>
    <artifactId>tika-eval-core</artifactId>
  </dependency>
</dependencies>
dependencies {
    implementation(platform("org.apache.tika:tika-bom:4.1.0"))
    implementation("org.apache.tika:tika-parsers-standard-package")
    implementation("org.apache.tika:tika-eval-core")
}

A quality gate for the Bookshelf intake: parse each file with RecursiveParserWrapper, score the container text and flag the documents a human should look at. The thresholds are illustrations chosen for this corpus; set yours from a Profile of your own documents.

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.stream.Stream;

import org.apache.tika.eval.core.metadata.TikaEvalMetadataFilter;
import org.apache.tika.exception.TikaException;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.parser.AutoDetectParser;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.RecursiveParserWrapper;
import org.apache.tika.sax.BasicContentHandlerFactory;
import org.apache.tika.sax.RecursiveParserWrapperHandler;
import org.xml.sax.SAXException;

public class QualityGate {

    // Thresholds for this corpus; tune them on your own documents.
    private static final double MAX_OOV = 0.50;
    private static final double MIN_LANGUAGENESS = 1.0;

    public static void main(String[] args) throws IOException {
        RecursiveParserWrapper wrapper = new RecursiveParserWrapper(new AutoDetectParser());
        TikaEvalMetadataFilter quality = new TikaEvalMetadataFilter();

        try (Stream<Path> files = Files.list(Path.of(args[0]))) {
            for (Path file : files.sorted().toList()) {
                RecursiveParserWrapperHandler handler = new RecursiveParserWrapperHandler(
                        new BasicContentHandlerFactory(BasicContentHandlerFactory.HANDLER_TYPE.TEXT, -1));
                try (TikaInputStream tis = TikaInputStream.get(file)) {
                    wrapper.parse(tis, handler, new Metadata(), new ParseContext());
                } catch (SAXException | TikaException e) {
                    // RecursiveParserWrapper records the exception in the container metadata
                }
                List<Metadata> list = handler.getMetadataList();
                Metadata container = list.get(0);
                quality.filter(container);   // reads tk:content, writes tika-eval:* keys
                report(file.getFileName().toString(), container);
            }
        }
    }

    private static void report(String name, Metadata m) {
        Double oov = m.getDouble(TikaEvalMetadataFilter.OUT_OF_VOCABULARY);
        Double languageness = m.getDouble(TikaEvalMetadataFilter.LANGUAGENESS);
        String verdict;
        if (oov == null) {
            verdict = "NO_TEXT";
        } else if (oov > MAX_OOV || languageness < MIN_LANGUAGENESS) {
            verdict = "REVIEW";
        } else {
            verdict = "OK";
        }
        System.out.printf(Locale.ROOT, "%-29s tokens=%-4s common=%-4s oov=%-6s lang-ness=%-6s lang=%-4s %s%n",
                name,
                orDash(m.get(TikaEvalMetadataFilter.NUM_TOKENS)),
                orDash(m.get(TikaEvalMetadataFilter.NUM_COMMON_TOKENS)),
                fmt(oov), fmt(languageness),
                orDash(m.get(TikaEvalMetadataFilter.LANGUAGE)),
                verdict);
    }

    private static String orDash(String s) {
        return s == null ? "-" : s;
    }

    private static String fmt(Double d) {
        return d == null ? "-" : String.format(Locale.ROOT, "%.3f", d);
    }
}

For this run the corpus directory also held two deliberately bad files: double-encoded-catalogue.txt (the catalogue encoded to UTF-8, wrongly read as windows-1252 and saved again, the classic Catálogo mojibake) and scanned-loan-form-ocr.txt (simulated junk OCR output). Output on Tika 4.1.0:

catalogue.pdf                 tokens=10   common=9    oov=0.100  lang-ness=1.989  lang=eng  OK
double-encoded-catalogue.txt  tokens=84   common=35   oov=0.563  lang-ness=0.793  lang=spa  REVIEW
empty.bin                     tokens=-    common=-    oov=-      lang-ness=-      lang=-    NO_TEXT
legacy-catalogue.txt          tokens=67   common=44   oov=0.323  lang-ness=2.157  lang=spa  OK
manuscript.docx               tokens=11   common=10   oov=0.091  lang-ness=2.087  lang=eng  OK
publisher-email.eml           tokens=15   common=14   oov=0.067  lang-ness=2.176  lang=eng  OK
renamed-invoice.png           tokens=10   common=9    oov=0.100  lang-ness=1.989  lang=eng  OK
reseñas-fr.txt                tokens=48   common=35   oov=0.271  lang-ness=2.122  lang=fra  OK
reviews-es.html               tokens=40   common=30   oov=0.250  lang-ness=2.303  lang=spa  OK
scanned-loan-form-ocr.txt     tokens=108  common=0    oov=1.000  lang-ness=-1.713 lang=diq  REVIEW

The junk OCR is unmistakable: no common tokens at all, OOV 1.0, a negative languageness and a nonsense language (diq, Dimli). The double-encoded file is subtler: its languageness stays positive (0.79), and only the OOV jump from 0.32 for the correct decoding to 0.56 catches it, with this threshold. That is the general lesson: no single score is a reliable gate on its own, and thresholds must come from your data. The scores are not identical to those of the tika-eval-app runs above (languageness 2.157 here, 2.19 in the Markdown Profile and 2.14 in the plain-text upgrade run for legacy-catalogue.txt; 1.989 against 2.37 for catalogue.pdf): the extracted text and the scoring path differ between the two tools, so compare scores only within one tool and one output form. The example scores only the container; for the e-mail, call filter on every element of the list to score each attachment.

TikaEvalMetadataFilter is thread-safe in practice for this use (its calculators and the JunkDetector are static and shared); create one instance and reuse it.

Source: TikaEvalMetadataFilter, Javadoc 4.1.0, Apache Tika 4.1.x, Metadata Filters and TikaEval, Tika wiki (which points to the filter for "many of these statistics at parse time").

From tika-config.json: not in 4.1.0

The 4.1.x documentation lists the filter as tika-eval-metadata-filter for the metadata-filters array, and the tika-app 4.1.0 distribution bundles tika-eval-core. In the run for this page this configuration failed at start-up:

{
  "metadata-filters": [
    { "tika-eval-metadata-filter": {} }
  ]
}
Exception in thread "main" org.apache.tika.exception.TikaConfigException: Failed to load MetadataFilter: tika-eval-metadata-filter
...
Caused by: java.lang.ClassNotFoundException: Component 'tika-eval-metadata-filter' is not registered. ...

The cause is in the 4.1.0 jar: tika-eval-core-4.1.0.jar has no META-INF/tika/metadata-filters.idx component index (the class carries the @TikaComponent annotation, but the index was not generated), while, for comparison, tika-langdetect-charsoup-4.1.0.jar registers charsoup-metadata-filter that way. The 4.x loader refuses class names that are not registered, so there is no workaround in JSON. Until a later release fixes the index, apply the filter in Java as above, or run Profile on the extracts. Re-test this when you upgrade.

In 3.x the filter is configured by class name in tika-config.xml:

<!-- 3.x only -->
<properties>
  <metadataFilters>
    <metadataFilter class="org.apache.tika.eval.core.metadata.TikaEvalMetadataFilter"/>
  </metadataFilters>
</properties>

The 3.x XML form was taken from the 3.x configuration conventions and was not run for this page.

Using the scores downstream

  • Search. Index tika-eval:oov and tika-eval:languageness as numeric fields, so you can find and re-process junk later, and keep junk out of the default result set. See Search Indexing with Lucene, Solr & Elasticsearch.

  • RAG. Gate chunks before embedding them: junk text wastes tokens and poisons retrieval. See RAG & LLM Ingestion.

  • OCR. A scanned PDF with a negative languageness is a candidate for re-OCR at a higher DPI or with another engine; see OCR with Tesseract.

  • Charsets. For a direct comparison of candidate decodings, use JunkDetector.compare as shown on Character Encoding Detection, which also documents the score’s limits between close Latin charsets.

  • Tests. Turn a small Profile or the filter into an assertion in CI; see Testing Tika Integrations.

Structure comparison: tika-eval-structure

Token metrics cannot see order. A parser change that reorders the columns of a PDF, splits a paragraph or glues two words keeps dice near 1.0. tika-eval-structure (new in 4.1.0, Maven Central only) compares two directories of XHTML extracts block by block: it matches paragraphs, headings, list items and cells one to one by token overlap and reports Kendall tau and adjacent-pair agreement for the order, splits and merges, and words glued on one side and split on the other. Its summary groups files by a metadata key of A (-g, default pdf:producer).

The jar expects its dependencies in lib/ next to it; the lib/ directory of tika-eval-app 4.1.0 contains all of them (tika-core, tika-serialization, commons-cli, Jackson, log4j), which is how it was run here:

cd tika-app
java -jar tika-app-4.1.0.jar -i ~/bookshelf/corpus -o ~/bookshelf/xhtml-A -c ~/bookshelf/configs/tika-config-A.json --handler x -n 2
java -jar tika-app-4.1.0.jar -i ~/bookshelf/corpus -o ~/bookshelf/xhtml-B -c ~/bookshelf/configs/tika-config-B.json --handler x -n 2

cd ../tika-eval      # tika-eval-structure-4.1.0.jar copied here, next to lib/
java -jar tika-eval-structure-4.1.0.jar -a ~/bookshelf/xhtml-A -b ~/bookshelf/xhtml-B -o structure-report
# Structure: xhtml-A (A) vs xhtml-B (B)

Files: 7; scored for order (matched token share >= 0.500): 7
...
Missing in B: 0; unreadable or not XHTML: 1

As expected for a charset-only change, every file kept a tau of 1.000. The unreadable file is empty.bin, whose extract has no XHTML. The options are -a, -b, -o, -g, -d (the least token dice for two blocks to match, default 0.5) and -s (a suffix to drop from B names, for comparing against labels made from a source document). Use the tool when the variable under test is a PDF parser, layout or reading-order setting; see PDF Parsing.

Source: Regression evaluation, Structure: order, segmentation and word boundaries and the --help output of tika-eval-structure-4.1.0.jar.

CLI reference (tika-eval-app 4.1.0)

The first argument names the tool. When it is a flag instead, the tool is inferred: -a with -b means Compare, -e means Profile, -d alone means Report.

Tool Purpose

Profile

Load one extracts directory into a database.

Compare

Load two extracts directories of the same corpus and pair them.

Report

Write the .xlsx reports (and summary.md for a comparison) from a database.

StartDB

Start the H2 web console.

Option Tools Meaning

-e, --extracts

Profile

The extracts directory (required).

-a, --extractsA; -b, --extractsB

Compare

The two extracts directories (required).

-i, --inputDir

Profile, Compare

The original files. Without it the extracts directory is crawled, and files without an extract are invisible.

-d, --db

all

H2 database name or path, without .mv.db; a value starting with jdbc: is used as a JDBC URL. Required for Profile; on Compare without -d a temporary database is used and deleted on exit.

-c, --config

Profile, Compare

tika-eval JSON configuration (see below).

-n, --numWorkers

Profile, Compare

Worker threads (default 4).

-m, --maxExtractLength

Profile, Compare

Skip extracts longer than this many bytes (default 100,000,000).

-r, --report

Compare

Run Report afterwards and pack the reports into <reportsDir>.tgz.

-rd, --reportsDir

Compare, Report

Reports directory (default reports in the current directory, not derived from -d).

-z, --gzip

Compare

Gzip the database to <db>.mv.db.gz for transfer; needs -d.

-pr, --pipesReport; -pa, -pb

Profile; Compare

Crash ledger JSONL.

-ri, --runInfo; -ra, -rb

Profile; Compare

Run-info JSON.

-rf, --reportsFile

Report

XML file of custom reports.

-jdbc, -jdbcdriver, -tablePrefix

Report

Expert options for another database.

The -c file maps onto EvalConfig. Its keys are minExtractLength (default 0), maxExtractLength (100,000,000), numWorkers (4) and jdbcDriverClass; the class also declares jdbcString, forceDrop, maxFilesToAdd, maxTokens and maxContentLength, but in the 4.1.0 source nothing reads them. Command-line -n and -m override the file:

{ "minExtractLength": 10, "maxExtractLength": 20000000, "numWorkers": 2 }

The run with this file recorded EvalConfig{minExtractLength=10, maxExtractLength=20000000, ..., numThreads=2, ...} in run_info.

Options of the 3.x tools that no longer exist in 4.1.0: -drop, -alterExtract (as_is, first_only, concatenate_content), -minExtractLength as a flag, -maxTokens, -maxContentLength, -maxContentLengthForLangId, -defaultLangCode, -bc and -numConsumers (tika-batch), -tablePrefixA/-tablePrefixB on Compare, and the FileProfile tool. See Pitfalls for what happens if you still type them.

Source: the option definitions of TikaEvalCLI, ExtractProfileRunner, ExtractComparerRunner and ResultsReporter in the tika-eval-app 4.1.0 and 3.3.2 source jars on Maven Central; the Report usage text printed by the 4.1.0 jar.

Memory, performance and scaling

  • Cost profile. tika-eval does not parse documents; its cost is tokenizing each extract (twice in Compare), language id, the junk score and database writes. On the eight-file corpus Compare took 11 seconds of wall time, almost all of it start-up and the 67 report queries. The 4.1.x documentation reports 2 to 5 minutes for Compare over 29,000 HTML extracts on an 8-fork workstation, and 45 to 60 seconds for each extraction.

  • Memory. Each worker holds one extract in memory. -m/maxExtractLength (100 MB of JSON by default) is the guard; lower it on a small heap and pass -Xmx to the java command for large corpora. Oversized extracts are recorded as EXTRACT_FILE_TOO_LONG, not silently dropped.

  • Database. H2 is embedded and single-writer. The wiki’s guidance (millions of files fine, hundreds of millions not) still describes the design. Report also accepts -jdbc for another database, and the jar contains a comparison-reports-pg.xml for PostgreSQL; that path was not tested here.

  • Parse times are not benchmarks. parse_times/ compares the tk:parse-time-millis values the extraction recorded. They mix JVM warm-up, fork start-up and machine load; only trust differences that are large, consistent and measured on a big corpus with the same numClients. Use Performance & Scaling for real throughput work.

  • Same settings on both sides. Different fork counts or timeouts change which files time out, and every such file shows up as an exception difference.

Pitfalls and security

  • Old flags parse into something else. The wiki and 3.x scripts use -drop. In 4.1.0 there is no such option, but Apache Commons CLI reads -drop as -d rop: a run of Compare -drop -extractsA ... -extractsB ... succeeded and silently wrote a database called rop.mv.db. The single-dash long names -extractsA and -extractsB still work.

  • Existing tables stop the run. Profile and Compare refuse a database that already has their tables (Table "PROFILES_A" already exists). Use a new -d name for every run, or delete the old .mv.db file yourself.

  • No -d on Compare means no database afterwards. Without -d the temporary database is deleted on exit; without -r as well, the run leaves nothing.

  • Digest mismatch. If A digests and B does not (or with another algorithm), embedded documents are aligned by name and the attachment reports fill with false differences.

  • Different content handlers. Markdown on one side and text on the other shows up as a content regression everywhere.

  • Without -i crashes are invisible. A file that crashed has no extract; only a crawl of the original files, plus the ledger, reports it as a crash.

  • More tokens is not better. Mojibake splits words and raises token counts; compare common tokens and OOV, not raw counts.

  • Token metrics miss symbols. The 3.3.2 to 4.1.0 euro-sign fix left every token metric unchanged; only U+FFFD counts and the charset reports saw it.

  • Short texts are noisy. OOV, languageness and language id are unreliable on a title or a two-word cell. Filter on num_alphabetic_tokens before you draw conclusions, and treat -99 languageness and -1 OOV as "not scored", not as scores.

  • The tika-config.json filter name does not load in 4.1.0; see above.

  • Reports are overwritten. Report writes into an existing reports directory without warning.

  • The database and reports contain document text. top_n_tokens, the token-difference lists, file names, paths and stack traces are all in the .mv.db file and the workbooks, and run_info records host, user and paths. Treat them with the same care as the corpus: do not attach a comparison of confidential documents to a public issue. Run tika-eval on copies of sensitive corpora, as the documentation advises when investigating crashes. See Security.

  • The H2 console listens on a port. StartDB serves a web console that can open any database file the user can read; run it only on a trusted machine and stop it after use.

From the book to 4.x

Version tika-eval

1.0 (Tika in Action, 2011)

No tika-eval. The book predates it, so nothing in it covers quality measurement.

1.15 to 1.28.5

tika-eval added as a single jar with Profile, Compare, Report and StartDB, built on tika-batch, Lucene analyzers and a "common words" list per language; options as on the wiki page (-drop, -alterExtract, -db).

2.x and 3.x

Split into tika-eval-core (metrics, TikaEvalMetadataFilter) and tika-eval-app (tools, runnable jar); FileProfile tool added; language id with OpenNLP; filter configured by class name in XML.

4.0 and 4.1 (checked on 4.1.0)

tika-eval-app rebuilt on the Tika Pipes file-system iterator instead of tika-batch and shipped as a zip with lib/; CharSoup language id; languageness from JunkDetector; replacement-character and charset reports; summary.md; run_info provenance and the crash-ledger join; tool inference from flags; -r and -z; legacy-key normalization for 3.x extracts; tika-eval-structure (4.1.0). The 3.x tuning flags and FileProfile are gone.

The 4.0.0 release was not inspected separately; the "4.0 and 4.1" row describes 4.1.0.

What was verified and what was not

Verified on 2026-10-13 by running it:

  • the tika-eval-app-4.1.0.zip contents, its SHA-512, the tool list, the Report usage text and the 4.1.0 option names (also read from the source jar);

  • Profile, Compare with -r and -d, Report with the default and a custom report file, against extracts from tika-app 4.1.0 (Markdown, text and XHTML) and from tika-app 3.3.2 (-J -t); every output, count and query result on this page comes from those runs;

  • the H2 rules (empty credentials, IFEXISTS, read-only mode, single-line -sql), the table and column names;

  • the crash-ledger auto-discovery and join with a file-system-jsonl-reporter ledger (with no real crash in the corpus);

  • the -drop misparse, the "table already exists" failure and the EvalConfig JSON file;

  • TikaEvalMetadataFilter from Java, and the failure of tika-eval-metadata-filter in tika-config.json on 4.1.0;

  • tika-eval-structure-4.1.0.jar run with the lib/ of tika-eval-app.

Not verified:

  • behaviour with a real crash (OOM, TIMEOUT) in the ledger, the CRASH classification and the run-batch.sh helper;

  • -z, -jdbc with PostgreSQL or another database, and StartDB beyond confirming that it starts and blocks;

  • the 29,000-file timings, which are quoted from the documentation;

  • the 3.x XML configuration of the metadata filter, and the 1.x and 2.x details in the history table, which come from the wiki and the 3.3.2 source;

  • any 4.0.0-specific behaviour.