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:

  1. Extract A — run Tika config A over the corpus, writing one JSON per input file to extracts-A/.

  2. Extract B — run Tika config B over the same corpus, writing to extracts-B/.

  3. Compare + Report — run tika-eval to load both extract trees into a database and produce .xlsx reports 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), and plugins/ (file-system fetcher/emitter/iterator pipes plugin). Unzip somewhere and run from that directory; TikaAsyncCLI auto-discovers the plugins/ 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 its lib/. 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/-o form; 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 (sets totalTaskTimeoutMillis)

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 to reports in the current directory, not to anything derived from -d.

  • -z / --gzip — gzip the H2 db file to <db>.mv.db.gz after 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 -r to 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-256 field 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 numClients and timeoutMillis. 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 -d writes 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.

  1. One-time build: ./mvnw install -pl tika-app,tika-eval/tika-eval-app -am -DskipTests

  2. Unzip both distributions: unzip tika-app/target/tika-app-.zip and unzip tika-eval/tika-eval-app/target/tika-eval-app-.zip.

  3. Write tika-config-A.json (digester only) and tika-config-B.json (digester + the four-detector encoding-detectors array) per the examples above.

  4. 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
  5. 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
  6. 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-B

    Produces reports/ plus reports.tgz in the current directory, alongside cc-html-29k-A-vs-B.mv.db. Pass -rd cc-html-29k-A-vs-B-reports if 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;