Regression evaluation with tika-app + tika-eval
A step-by-step recipe for comparing two Tika configurations against the same corpus. Typical uses:
-
Verify a code change or dependency bump didn’t regress extraction.
-
Compare two parser/detector configurations (e.g. different encoding-detector chains) head-to-head.
-
Generate signed-off reports for a release.
Concept
The flow is three stages:
-
Extract A — run Tika config
Aover the corpus, writing one JSON per input file toextracts-A/. -
Extract B — run Tika config
Bover the same corpus, writing toextracts-B/. -
Compare + Report — run
tika-evalto load both extract trees into a database and produce.xlsxreports highlighting differences (MIME, content, exceptions, embedded documents, metadata).
Both extracts include SHA-256 digests so tika-eval can align
embedded documents across runs even when filenames differ.
Prerequisites
# Build tika-app and tika-eval (in the tika source tree)
./mvnw install -pl tika-app,tika-eval/tika-eval-app -am -DskipTests \
-Dmaven.repo.local=$(pwd)/.local_m2_repo
This produces two distribution zips:
-
tika-app/target/tika-app-4.1.0-SNAPSHOT.zip— contains the tika-app jar,lib/(dependencies), andplugins/(file-system fetcher/emitter/iterator pipes plugin). Unzip somewhere and run from that directory;TikaAsyncCLIauto-discovers theplugins/sibling, so no extra flag is needed. -
tika-eval/tika-eval-app/target/tika-eval-app-4.1.0-SNAPSHOT.zip— contains the tika-eval-app jar and itslib/. Unzip and run from there.
mkdir -p <workdir>/tika-runtime && cd <workdir>/tika-runtime
unzip -q tika-app/target/tika-app-4.1.0-SNAPSHOT.zip -d tika-app
unzip -q tika-eval/tika-eval-app/target/tika-eval-app-4.1.0-SNAPSHOT.zip -d tika-eval
Running the bare tika-app-.jar or tika-eval-app-.jar (without
the unzipped lib/ siblings) will fail at runtime with
ClassNotFoundException — the jars are intentionally not fat jars.
Step 1: prepare the corpus
Put the input files somewhere on disk. For this example we’ll use
<corpus>/. Tika’s pipes iterator walks the tree recursively;
files at any depth are processed.
Step 2: write the two extraction configs
When you pass -i <input> and -o <output> on the command line,
tika-app auto-builds the fetcher / emitter / pipes-iterator for the
file system. The config only needs the things you want to OVERRIDE
from defaults — typically just the digester and (for this kind of
A/B test) the field under test.
The two configs should differ in exactly one thing — the field under test. Everything else (digester, parsers, fork count) should match.
Config A (baseline)
tika-config-A.json:
{
"parse-context": {
"commons-digester-factory": {
"digests": [{ "algorithm": "SHA256" }],
"skipContainerDocumentDigest": false
}
}
}
The parse-context > commons-digester-factory block makes every
extracted record carry a tk:digest:SHA-256 metadata field —
required by tika-eval for embedded-document alignment across runs.
(The config value is the enum name SHA256; the emitted key uses the
JCA spelling SHA-256.)
Config B (variant under test)
Copy config A, add the field you’re testing. Example — test a different encoding-detector chain:
{
"encoding-detectors": [
{ "bom-detector": {} },
{ "html-encoding-detector": {} },
{ "mojibuster-encoding-detector": {} },
{ "junk-filter-encoding-detector": {} }
],
"parse-context": {
"commons-digester-factory": {
"digests": [{ "algorithm": "SHA256" }],
"skipContainerDocumentDigest": false
}
}
}
When an explicit encoding-detectors array is present, TikaLoader
uses it verbatim instead of the SPI-discovered default chain. Other
encoding detectors on the classpath are excluded.
If you need to override anything else (parsers, plugin-roots, content handler factory), put it under additional top-level keys. Anything not specified takes its default.
Step 3: run extractions
cd <workdir>/tika-runtime/tika-app
# Extract A — baseline
java -jar tika-app-4.1.0-SNAPSHOT.jar \
-i <corpus> \
-o <workdir>/extracts-A \
-c <workdir>/tika-config-A.json \
-n 8
# Extract B — variant
java -jar tika-app-4.1.0-SNAPSHOT.jar \
-i <corpus> \
-o <workdir>/extracts-B \
-c <workdir>/tika-config-B.json \
-n 8
Flags:
-
-i/--inputDir— input directory (recursive walk) -
-o/--outputDir— output directory for per-file JSON -
-c/--config— optional config overrides -
-n/--numClients— number of forked JVMs (default 2 for the-i/-oform; 1 for the bare<inputDir> <outputDir>form) -
-p/--pluginsDir— explicit plugins directory (omit when running from the unzipped distribution — auto-discovery handles it) -
-T/--timeoutMillis— total task timeout per file (setstotalTaskTimeoutMillis)
Each run forks JVMs and produces one .json per input
file under the output directory. Run them sequentially or in
parallel — they’re independent.
Progress is logged to stderr. On a typical workstation, extraction throughput is in the hundreds-of-files-per-minute range, depending on parser cost and fork count.
Step 4: compare and report
cd <workdir>/tika-runtime/tika-eval
java -jar tika-eval-app-4.1.0-SNAPSHOT.jar \
-n 8 -r -d my-comparison \
-a <workdir>/extracts-A \
-b <workdir>/extracts-B
The Compare subcommand keyword is optional — the CLI infers it from
the -a / -b flags. The -r flag both runs the Report stage and
creates a .tgz archive of the resulting reports directory
(<reportsDir>.tgz) for easy archiving.
Options:
-
-a/--extractsA— A’s extract directory (required) -
-b/--extractsB— B’s extract directory (required) -
-i/--inputDir— original binary input directory (optional, lets tika-eval pair extracts to source files even if A or B failed on some) -
-d/--db— H2 database name/path. A short label is fine — tika-eval will create{label}.mv.db. Persist the db if you want to re-run Report later. -
-r/--report— automatically run the Report step after Compare, and tgz the reports directory to<reportsDir>.tgz. -
-rd/--reportsDir— reports directory; defaults toreportsin the current directory, not to anything derived from-d. -
-z/--gzip— gzip the H2 db file to<db>.mv.db.gzafter Compare so it can be transferred. Requires-d(no-op with a warning for a temp db or a non-file jdbc connection (e.g.jdbc:h2:mem:, tcp)). Combine with-rto package both the reports and the db. -
-n/--numWorkers— comparison worker count. -
-c/--config— optional tika-eval JSON config.
Step 5: read the reports
The reports directory contains subdirectories:
-
mimes/— MIME type comparison. Headline files:-
mime_diffs_A_to_B.xlsx— summary counts per (A type, B type) transition -
mime_diffs_A_to_B_details.xlsx— one row per file that changed
-
-
charset/— detected-encoding coverage, distribution, and A→B flips -
content/— token-count and content comparison -
exceptions/— new/changed exceptions in B vs A -
attachments/— embedded-document comparison -
metadata/— metadata comparison -
md5/,parse_times/— digest and timing comparisons
Open the .xlsx files directly, or query the H2 database for custom counts and
joins (see Querying the H2 database directly).
Tips
-
Keep the digester identical between A and B. tika-eval uses the
tk:digest:SHA-256field on embedded documents to align records across the two extracts. If A digests and B doesn’t (or different algorithms), the embedded-doc alignment falls back to filename and produces false-positive diffs. -
Use the same
numClientsandtimeoutMillis. Different parallelism can produce different timeout behavior that shows up as spurious exception diffs. -
Run on copies of the binaries when investigating crashes. Some parsers may corrupt input files in rare cases; keep a known-good source tree separate from working copies.
-
Persist the H2 database (
-d <workdir>/eval.db) if you want to re-run the Report step or query it directly with the H2 console. Omitting-dwrites to a temp file that’s cleaned up on exit. -
Re-run only Report: if you already have the database, skip Compare and run the Report tool directly:
cd <workdir>/tika-runtime/tika-eval java -jar tika-eval-app-4.1.0-SNAPSHOT.jar Report \ -d <workdir>/eval.db -rd <workdir>/reports/
Worked example: encoding-detector chain comparison
Compare the SPI default detector chain (A) against an explicit
[BOM, HTML, Mojibuster, JunkFilter] chain (B) over a 29 000-file
Common Crawl HTML sample at <corpus>/cc-html-sample.
-
One-time build:
./mvnw install -pl tika-app,tika-eval/tika-eval-app -am -DskipTests -
Unzip both distributions:
unzip tika-app/target/tika-app-.zipandunzip tika-eval/tika-eval-app/target/tika-eval-app-.zip. -
Write
tika-config-A.json(digester only) andtika-config-B.json(digester + the four-detectorencoding-detectorsarray) per the examples above. -
Extract A:
cd <workdir>/tika-runtime/tika-app java -jar tika-app-4.1.0-SNAPSHOT.jar \ -i <corpus>/cc-html-sample \ -o <workdir>/extracts/cc-html-sample-A \ -c <workdir>/configs/tika-config-A.json -n 8 -
Extract B (same form, different config + output):
java -jar tika-app-4.1.0-SNAPSHOT.jar \ -i <corpus>/cc-html-sample \ -o <workdir>/extracts/cc-html-sample-B \ -c <workdir>/configs/tika-config-B.json -n 8 -
Compare + report:
cd <workdir>/tika-runtime/tika-eval java -jar tika-eval-app-4.1.0-SNAPSHOT.jar \ -n 8 -r -d cc-html-29k-A-vs-B \ -a <workdir>/extracts/cc-html-sample-A \ -b <workdir>/extracts/cc-html-sample-BProduces
reports/plusreports.tgzin the current directory, alongsidecc-html-29k-A-vs-B.mv.db. Pass-rd cc-html-29k-A-vs-B-reportsif you want the reports directory named after the run.
For a 29 K-file HTML sample on a typical workstation (8 forks,
i9-ish CPU): each extraction completes in roughly 45-60 seconds; the
Compare step finishes in 2-5 minutes depending on extract size.
. Open reports/mimes/mime_diffs_A_to_B.xlsx to see the headline
MIME-detection differences. The encoding-detector chain change shows up
in reports/charset/ — charset_coverage.xlsx counts the A→B flips, and
the distribution reports break down tk:detected-encoding per run. It also
shows up in mimes/mime_diffs_A_to_B_details.xlsx, because the recorded
MIME string carries the charset= parameter.
Keep the writeup next to the reports directory it describes, so the numbers and the narrative travel together.
Querying the H2 database directly
tika-eval stores everything in an H2 database — the -d <name> passed to
Compare produces <name>.mv.db. The generated reports surface only pre-canned
views; for custom counts and joins, connect to the H2 file and run SQL.
The database is created with no username and no password
(H2Util opens it with DriverManager.getConnection(url) and no credentials),
so -user sa fails with "Wrong user name or password" — use
empty credentials. Append ;IFEXISTS=TRUE (so a typo opens nothing rather than
creating a new empty db) and ;ACCESS_MODE_DATA=r for a read-only open. H2 is
single-writer: query only after the Compare/Profile/Report run has released
the file lock. Use the h2-*.jar shipped with tika-eval-app.
H2=path/to/h2-x.y.z.jar
# note: omit the .mv.db suffix in the URL
DB='jdbc:h2:path/to/eval-db;IFEXISTS=TRUE;ACCESS_MODE_DATA=r'
java -cp "$H2" org.h2.tools.Shell -url "$DB" -user '' -password '' \
-sql "SHOW TABLES"
Pass SQL to -sql as a single line (a multi-line argument leaves the Shell
waiting on stdin).
Key tables: profiles_a/profiles_b (one row per extracted file: file_name,
mime_id, length, …), contents_a/contents_b (text profile: oov,
languageness, num_tokens, lang_id_1, num_replacement (U+FFFD count),
num_non_ascii, …), content_comparisons
(dice_coefficient, overlap), mimes, containers. A and B are paired by
id — the same row id is the same file in both runs (this is how the built-in
reports join: join profiles_b pb on pa.id = pb.id). Always join on id.
-- OOV / languageness: how many files improved vs regressed in B (fast PK join)
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;
-- NOTE: OOV is one signal, not the verdict -- read it with languageness and the
-- FFFD rate (use OOV as a secondary signal). OOV can mislead (a langid shift,
-- e.g. a CJK decode recovered in B, inflates oov_worse even when B is correct; a
-- wrong decode that fragments words can LOWER OOV), and languageness can mislead
-- on SBCS-cross-script mojibake -- each is right where the other is blind. When
-- OOV-worse and languageness disagree, that file needs a look (split below):
SELECT SUM(CASE WHEN cb.languageness > ca.languageness + 0.2 THEN 1 ELSE 0 END) AS lang_better_oov_lied,
SUM(CASE WHEN cb.languageness < ca.languageness - 0.2 THEN 1 ELSE 0 END) AS lang_worse_real_candidate
FROM contents_a ca JOIN contents_b cb ON ca.id = cb.id
WHERE cb.oov > ca.oov + 0.02 AND ca.languageness > -90 AND cb.languageness > -90;
-- FFFD decode-failure rate, un-diluted (over non-ASCII chars, NOT total length,
-- which dilutes to ~0 on ASCII-dominated docs)
SELECT ROUND(100.0 * cb.num_replacement / NULLIF(cb.num_non_ascii, 0), 1) AS fffd_pct,
cb.num_replacement, cb.num_non_ascii
FROM contents_b cb WHERE cb.num_replacement > 0
ORDER BY cb.num_replacement DESC FETCH FIRST 20 ROWS ONLY;
-- net common-tokens A vs B (headline "more real text recovered" metric)
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;
-- detected charset distribution in the B run
SELECT m.mime_string, COUNT(*) n
FROM profiles_b p JOIN mimes m ON p.mime_id = m.mime_id
GROUP BY m.mime_string ORDER BY n DESC;