Skip to content

Commit 4ce5c70

Browse files
authored
TIKA-4745 - charset/junk/tika-eval improvements (#2861)
1 parent ce700b6 commit 4ce5c70

30 files changed

Lines changed: 1842 additions & 118 deletions

File tree

.skills/tika-eval-compare.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,16 @@ directory, plus a `summary.md` with key metrics:
120120
| Exception count | ≤ A | > A |
121121
| Total files (B) vs (A) | equal or higher | lower — missing embedded docs |
122122

123+
### Encoding-detection evals
124+
125+
For charset/encoding-detector changes, the summary reports don't cover it — query
126+
the db directly (see the **tika-eval-h2-query** skill). The detected encoding is in
127+
the `ENCODINGS_A`/`ENCODINGS_B` tables (`DETECTED_ENCODING`, `ENCODING_DETECTOR`,
128+
`DECLARED_METADATA`), **not** `PROFILES`. Key signals: per-encoding counts (e.g. CJK
129+
total), A→B flips by direction, and OOV on the flipped files (a flip that *worsens*
130+
OOV is a regression; one that *improves* it is a fix). Pair on `ID`; map back to the
131+
source file via `PROFILES_*.FILE_NAME` (the content hash).
132+
123133
### CRITICAL: Review Checklist
124134

125135
The purpose of tika-eval is to find regressions BEFORE a release. After

.skills/tika-eval-encoding-regression.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,41 @@ WHERE <enc_a/enc_b filter as above>
123123
ORDER BY delta ASC LIMIT 15;
124124
```
125125

126+
## Reading the signals — OOV, languageness, and FFFD together
127+
128+
No single signal is authoritative. Use `oov` as a **secondary** signal alongside
129+
`languageness` (the junk-model coherence z-score) and the U+FFFD rate — each is
130+
right where the others are blind, so cross-check rather than ranking on any one.
131+
(Established 2026-06-03: a 40-file OOV-"worse" set was mostly metric artifacts
132+
once languageness/FFFD were brought in — only ~6 were real. But OOV is also the
133+
*correct* signal where languageness is blind, so neither dominates.)
134+
135+
- **OOV can mislead** when langid shifts — a CJK/UTF-8 recovery in B is scored
136+
against a different vocab → higher OOV though B is right — or when a wrong
137+
decode fragments words into more short common tokens (→ higher count for the
138+
WORSE decode). A common-token delta is a signal, not proof.
139+
- **languageness can mislead** on SBCS↔SBCS cross-script mojibake — Greek decoded
140+
as KOI8-R is "coherent" Cyrillic, so `languageness` stays flat while `oov`
141+
correctly flags it. Conversely languageness catches OOV's CJK/script-recovery
142+
blind spot. Each covers the other's blind spot.
143+
- **FFFD rate** flags decode failures (illegal bytes): `num_replacement /
144+
num_non_ascii` (un-diluted; `/ content_length` dilutes to ~0 on ASCII-heavy
145+
docs). Tika strips C0 controls at extraction, so legal-but-wrong (C1) mojibake
146+
does not surface here — that signal belongs in the detector chain, not the eval.
147+
- **In practice:** when the signals agree, high confidence; when they disagree
148+
(OOV-worse but languageness-better, or vice versa), that file needs a look —
149+
the disagreement points you at WHICH files to inspect, it does not by itself
150+
declare OOV or languageness "wrong." Split OOV-worse by languageness direction
151+
(query in `tika-eval-regression.adoc`).
152+
153+
### Isolate a change against the PRIOR run, not just 3.x
154+
155+
To see what one chain change actually did, Compare the new run against the
156+
*previous* 4.x run (B-new vs B-prior), not only vs 3.x. The diff should be
157+
*surgical* — e.g. the within-Latin letter gate moved exactly 6 files
158+
(IBM850 / x-MacRoman → windows-1252) vs the prior run and nothing else. A
159+
bigger-than-expected diff means the change fired more broadly than intended.
160+
126161
## Per-file detector attribution (`X-TIKA:encodingDetectionTrace`)
127162

128163
Every JSON extract from a chain with multiple detectors carries

.skills/tika-eval-h2-query.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ it then waits on stdin and appears to hang).
4040
|---|---|
4141
| `PROFILES_A` / `PROFILES_B` | one row per extracted file: `FILE_NAME`, `MD5`, `MIME_ID`, `CONTAINER_ID`, `EMBEDDED_FILE_PATH`, `LENGTH`, `NUM_PAGES`, … (A = "before"/-a, B = "after"/-b) |
4242
| `CONTENTS_A` / `CONTENTS_B` | text profile per file (join on `ID`): `OOV`, `LANGUAGENESS`, `NUM_TOKENS`, `NUM_COMMON_TOKENS`, `LANG_ID_1`/`LANG_ID_PROB_1`, `TOKEN_ENTROPY_RATE`, … |
43+
| `ENCODINGS_A` / `ENCODINGS_B` | detected-encoding per file (join on `ID`): `DETECTED_ENCODING`, `ENCODING_DETECTOR`, `DECLARED_METADATA`. **`DETECTED_ENCODING` lives HERE, not on `PROFILES` — moved out in the encodings-table refactor; querying `PROFILES_*.DETECTED_ENCODING` now errors "Column not found".** A file with no detected encoding has no row. |
4344
| `CONTENT_COMPARISONS` | per-file A↔B comparison (`ID`): `DICE_COEFFICIENT`, `OVERLAP`, top token diffs |
4445
| `MIMES` | `MIME_ID``MIME_STRING` |
4546
| `CONTAINERS` | container id → input file path |
@@ -86,6 +87,56 @@ FROM CONTENTS_A ca JOIN CONTENTS_B cb ON ca.ID = cb.ID;
8687
To bring in mime/path, join `PROFILES_A pa ON pa.ID = ca.ID` (and `pb`/`cc`
8788
likewise on the same `id`) — all on `id`.
8889

90+
Detected-encoding queries — `DETECTED_ENCODING` is on `ENCODINGS_A`/`ENCODINGS_B`
91+
(join on `ID`), NOT `PROFILES`. CJK count in B (LOWER() — `REGEXP` is case-sensitive,
92+
see below):
93+
94+
```sql
95+
SELECT COUNT(*) FROM ENCODINGS_B
96+
WHERE LOWER(DETECTED_ENCODING) REGEXP 'gb|big5|euc|shift|jis|2022|949';
97+
```
98+
99+
Encoding flips A→B by direction (what changed between runs):
100+
101+
```sql
102+
SELECT ea.DETECTED_ENCODING a_enc, eb.DETECTED_ENCODING b_enc, COUNT(*) n
103+
FROM ENCODINGS_A ea JOIN ENCODINGS_B eb ON ea.ID = eb.ID
104+
WHERE ea.DETECTED_ENCODING <> eb.DETECTED_ENCODING
105+
GROUP BY a_enc, b_enc ORDER BY n DESC;
106+
```
107+
108+
Map a flipped file back to its source file — `PROFILES_*.FILE_NAME` is the content
109+
hash (the input file is `<corpus>/<first-2-hex>/<FILE_NAME>`); join `CONTENTS` for OOV:
110+
111+
```sql
112+
SELECT pb.FILE_NAME, ea.DETECTED_ENCODING a_enc, eb.DETECTED_ENCODING b_enc,
113+
ca.OOV oov_a, cb.OOV oov_b
114+
FROM ENCODINGS_A ea JOIN ENCODINGS_B eb ON ea.ID = eb.ID
115+
JOIN PROFILES_B pb ON ea.ID = pb.ID
116+
JOIN CONTENTS_A ca ON ea.ID = ca.ID JOIN CONTENTS_B cb ON ea.ID = cb.ID
117+
WHERE LOWER(eb.DETECTED_ENCODING) REGEXP 'gb|big5|euc|shift|jis|2022|949'
118+
AND NOT (LOWER(ea.DETECTED_ENCODING) REGEXP 'gb|big5|euc|shift|jis|2022|949');
119+
```
120+
121+
## Gotcha: `REGEXP` is case-sensitive (silent wrong results)
122+
123+
H2's `REGEXP` operator is **case-sensitive**, so `DETECTED_ENCODING REGEXP
124+
'big5|gb|euc'` does **not** match `Big5-HKSCS` or `GB18030` — and it fails
125+
*silently*, quietly dropping/keeping the wrong rows instead of erroring. Always
126+
either lowercase the column or use the inline case-insensitive flag:
127+
128+
```sql
129+
-- right:
130+
WHERE LOWER(DETECTED_ENCODING) REGEXP 'big5|gb|euc|shift|jis|2022|949'
131+
-- or:
132+
WHERE DETECTED_ENCODING REGEXP '(?i)big5|gb|euc|shift|jis|2022|949'
133+
-- wrong (misses Big5-HKSCS, GB18030, Shift_JIS, ...):
134+
WHERE DETECTED_ENCODING REGEXP 'big5|gb|euc|shift|jis|2022|949'
135+
```
136+
137+
(`DETECTED_ENCODING` is on `ENCODINGS_A`/`ENCODINGS_B` — join to `PROFILES`/`CONTENTS`
138+
on `ID` — populated from `X-TIKA:detectedEncoding`.)
139+
89140
## Tip
90141

91142
For a quick interactive session, drop `-sql` and you get an H2 prompt; `SHOW

docs/modules/ROOT/pages/advanced/charset-detection-design.adoc

Lines changed: 82 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ results are collected into an `EncodingDetectorContext` on the
5353
| `MojibusterEncodingDetector`
5454
| `tika-encoding-detector-mojibuster`
5555
| Structural UTF-32 and UTF-16 detection, UTF-8 grammar gate, HTML
56-
attribute-aware stripping, then a 33-class byte-bigram NB
56+
attribute-aware stripping, then a 34-class byte-bigram NB
5757
classifier. STRUCTURAL for structural hits; STATISTICAL for NB
5858
predictions. See <<nb-pipeline>>.
5959

@@ -135,6 +135,21 @@ sequences. Three outcomes:
135135
* `AMBIGUOUS` — no complete multi-byte sequence (pure ASCII, or only
136136
a truncated lead at probe-end). No emission.
137137

138+
=== ISO-2022-JP/KR/CN structural detection (pure-ASCII branch)
139+
140+
ISO-2022 encodings are 7-bit and escape-based (`ESC $ B`, `ESC $ ) C`, …),
141+
so they carry no high bytes and are invisible to the byte-bigram
142+
classifier; without a structural check a real ISO-2022-JP page would fall
143+
through to the windows-1252 default and decode to gibberish. On a
144+
pure-ASCII probe — the only place ISO-2022 can occur — the pipeline scans
145+
for the ISO-2022 designation escape and, if found, *verifies* by decoding:
146+
the result must contain real CJK at a near-zero replacement rate. The
147+
verify rejects a stray `ESC $` in ordinary ASCII (which yields no CJK).
148+
On success an ISO-2022-JP/KR/CN STRUCTURAL candidate is emitted. High-byte
149+
binary that happens to contain an escape sequence never reaches this
150+
check — it fails the pure-ASCII gate and takes the normal NB path, so it
151+
cannot trigger a false ISO-2022 detection.
152+
138153
=== Layer 4 — HTML stripping (content-type aware)
139154

140155
When the probe looks like HTML/XML (explicit content-type or unknown),
@@ -155,10 +170,10 @@ just content bytes for NB feature extraction. Optimizations:
155170

156171
=== Layer 5 — Naive Bayes byte-bigram classifier
157172

158-
33 classes: CJK multibyte (Big5-HKSCS, EUC-JP, GB18030, Shift_JIS,
173+
34 classes: CJK multibyte (Big5-HKSCS, EUC-JP, GB18030, Shift_JIS,
159174
x-EUC-TW, x-windows-949), EBCDIC family (IBM420/424-ltr/rtl, IBM500,
160175
IBM1047), DOS OEM (IBM850/852/855/866), Cyrillic (KOI8-R, KOI8-U),
161-
Windows single-byte (1250-1258, 874), ISO-8859-3/16, Mac (x-MacRoman,
176+
Windows single-byte (1250-1258, 874), ISO-8859-2/3/16, Mac (x-MacRoman,
162177
x-mac-cyrillic), and UTF-8.
163178

164179
Features are **stride-1 byte bigrams** — for probe bytes `b[0..N]`,
@@ -212,9 +227,10 @@ every probe length we've measured.
212227

213228
* **Empty / near-empty probes (< 2 bytes)** → windows-1252 @ 0.1
214229
confidence. WHATWG default; never returns empty result.
215-
* **Pure ASCII probes** (no bytes ≥ 0x80, no nulls) → windows-1252.
216-
Bigram NB cannot discriminate Latin code pages on pure-ASCII
217-
content; return the HTML5-canonical answer directly.
230+
* **Pure ASCII probes** (no bytes ≥ 0x80, no nulls) → ISO-2022 structural
231+
detection first (see above); otherwise windows-1252. Bigram NB cannot
232+
discriminate Latin code pages on pure-ASCII content; return the
233+
HTML5-canonical answer directly.
218234
* **Latin-sibling → windows-1252 rewrite** — on low-evidence probes
219235
(< 5 high bytes), if the top NB candidate is a non-1252 member of
220236
the Latin family and the probe decodes byte-identically under
@@ -223,6 +239,34 @@ every probe length we've measured.
223239
threshold are not emitted into the pool. Prevents JunkFilter from
224240
scoring weak coincidence picks against NB's confident top.
225241

242+
==== CJK decode-failure veto (`CjkDecodeValidator`)
243+
244+
A legacy multi-byte CJK class (GB18030, Big5-HKSCS, Shift_JIS, EUC-JP,
245+
x-windows-949, x-EUC-TW) that NB picks on Latin/Cyrillic/garbage bytes is
246+
*false-CJK*: those bytes don't validate under the charset, so decoding
247+
produces many malformed/unmappable events, whereas real CJK decodes
248+
cleanly. After NB, each legacy-CJK candidate is decoded under its vendor
249+
superset (`CharsetSupersets`) and its failure rate measured as
250+
`failures / high-bytes`; above ~2.5% the candidate is dropped — and if it
251+
was NB's only pick, the pool empties and windows-1252 wins. Two
252+
corrections make the rate trustworthy:
253+
254+
* **Decode under the vendor superset, not the strict base** — real
255+
vendor-extension chars (NEC/IBM for Shift_JIS/EUC-JP, HKSCS for Big5)
256+
would otherwise count as failures and penalize genuine CJK.
257+
* **Discount embedded UTF-8** — mixed-encoding pages (legacy CJK body +
258+
UTF-8 widgets) would otherwise read as 2–9.5% failure. The validator
259+
walks the bytes and *skips* positions that begin a valid UTF-8 sequence
260+
(it does NOT physically strip them — that would misalign a pure
261+
legacy-CJK stream and manufacture failures), decoding the legacy charset
262+
in place elsewhere. Post-discount, real CJK (pure or mixed) is ≤1.6%
263+
while genuine false-CJK stays ≥5.3%, so ~2.5% separates them.
264+
265+
This veto catches *structurally-illegal* false-CJK only. The
266+
*legal-but-wrong* class — Latin/Cyrillic bytes that form a *valid* CJK
267+
decode at ~0 failure — is the typicality layer's job (<<junk-filter>>),
268+
not this veto's.
269+
226270
[[junk-filter]]
227271
== JunkFilterEncodingDetector — text-quality arbitration
228272

@@ -261,6 +305,36 @@ For plain first-match-wins, omit JunkFilter (see <<opting-out-of-arbitration>>).
261305
. **Pairwise tournament** — first candidate seeds champion; each
262306
challenger compared via `JunkDetector.compare`; higher z-score wins.
263307

308+
=== Post-tournament demote gates
309+
310+
Two demote-only refinements run after the champion is chosen. Each fires only
311+
to *demote* the champion across one boundary the whole-text z-score reads
312+
poorly under COMMON-dilution; neither can promote, so they cannot cost a
313+
confident detection.
314+
315+
* **CJK family gate** — the whole-text z coin-flips on the CJK/non-CJK boundary
316+
when markup and digits decode identically and swamp the few discriminating
317+
high bytes. A script-letter "diff" z — scored over only the `>= 0x80`
318+
letters/ideographs, where candidates actually differ — reads that boundary
319+
cleanly. If the champion is CJK and the best non-CJK diff-z beats the best
320+
CJK diff-z by `FAMILY_DIFF_MARGIN` (2.0), demote to the best non-CJK
321+
candidate. The reverse (promote to CJK) regressed at scale and is
322+
unnecessary — genuine CJK is `<meta>`-declared upstream.
323+
324+
* **Within-Latin letter gate** — among single-byte Latin siblings the z also
325+
coin-flips, occasionally promoting a DOS-OEM / Mac charset (IBM850,
326+
x-MacRoman) whose high bytes decode to box-drawing / symbols over the
327+
windows-1252 truth. Cased-letter count reads this where typicality cannot:
328+
if the champion is a Latin SBCS, a windows-1252 candidate is present, the
329+
probe is high-byte-dense, and windows-1252 decodes clearly more cased
330+
high-byte letters (by a margin), demote to windows-1252. Directional — a
331+
genuine Central-European / DOS document has *more* letters under its true
332+
charset, so the gate stays silent. Latin-scoped, so it never crosses the
333+
CJK boundary (the family gate's job) or touches a non-Latin SBCS, whose
334+
Cyrillic/Greek cased letters would pollute the count. Shares the
335+
`HighByteLetterStats` letter counter with Mojibuster's Western-Latin sibling
336+
fallback.
337+
264338
=== JunkDetector scoring
265339

266340
`JunkDetector` partitions decoded text into maximal Unicode-script runs
@@ -485,7 +559,7 @@ can't encode typographic characters).
485559
value, vocabulary size, and each trained bigram as
486560
`(uint16 bigram, int8 logP)` pairs.
487561

488-
Files for the shipped 33-class model are ~1 MB on disk. Loader
562+
Files for the shipped 34-class model are ~1 MB on disk. Loader
489563
materializes a dense `logP8[65 536 × numClasses]` array filled with
490564
per-class unseen floors, overwritten by trained pairs. Working-set
491565
memory: ~2 MB.
@@ -498,7 +572,7 @@ with feature hashing. The move to NB was driven by:
498572
* **Speed**: direct bigram indexing removes the hash + bucket-lookup
499573
cost. Inner loop is `score[c] += logP[b × numClasses + c] × idf[b]`
500574
with no branching (zero-IDF bigrams are skipped before the class
501-
loop). Measured ~15 µs on a full 1 KB probe for 33 classes.
575+
loop). Measured ~15 µs on a full 1 KB probe for 34 classes.
502576
* **Memory layout**: bigram-major byte arrays fit in L3 cache for the
503577
full table. Sequential access through the hot loop is cache-line
504578
efficient.

docs/modules/ROOT/pages/advanced/integration-testing/tika-eval-regression.adoc

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,8 @@ waiting on stdin).
339339

340340
Key tables: `profiles_a`/`profiles_b` (one row per extracted file: `file_name`,
341341
`mime_id`, `length`, …), `contents_a`/`contents_b` (text profile: `oov`,
342-
`languageness`, `num_tokens`, `lang_id_1`, …), `content_comparisons`
342+
`languageness`, `num_tokens`, `lang_id_1`, `num_replacement` (U+FFFD count),
343+
`num_non_ascii`, …), `content_comparisons`
343344
(`dice_coefficient`, `overlap`), `mimes`, `containers`. *A and B are paired by
344345
`id`* — the same row `id` is the same file in both runs (this is how the built-in
345346
reports join: `join profiles_b pb on pa.id = pb.id`). Always join on `id`.
@@ -351,6 +352,24 @@ SELECT SUM(CASE WHEN cb.oov < ca.oov THEN 1 ELSE 0 END) AS oov_better,
351352
SUM(CASE WHEN cb.oov > ca.oov THEN 1 ELSE 0 END) AS oov_worse
352353
FROM contents_a ca JOIN contents_b cb ON ca.id = cb.id;
353354
355+
-- NOTE: OOV is one signal, not the verdict -- read it with languageness and the
356+
-- FFFD rate (use OOV as a secondary signal). OOV can mislead (a langid shift,
357+
-- e.g. a CJK decode recovered in B, inflates oov_worse even when B is correct; a
358+
-- wrong decode that fragments words can LOWER OOV), and languageness can mislead
359+
-- on SBCS-cross-script mojibake -- each is right where the other is blind. When
360+
-- OOV-worse and languageness disagree, that file needs a look (split below):
361+
SELECT SUM(CASE WHEN cb.languageness > ca.languageness + 0.2 THEN 1 ELSE 0 END) AS lang_better_oov_lied,
362+
SUM(CASE WHEN cb.languageness < ca.languageness - 0.2 THEN 1 ELSE 0 END) AS lang_worse_real_candidate
363+
FROM contents_a ca JOIN contents_b cb ON ca.id = cb.id
364+
WHERE cb.oov > ca.oov + 0.02 AND ca.languageness > -90 AND cb.languageness > -90;
365+
366+
-- FFFD decode-failure rate, un-diluted (over non-ASCII chars, NOT total length,
367+
-- which dilutes to ~0 on ASCII-dominated docs)
368+
SELECT ROUND(100.0 * cb.num_replacement / NULLIF(cb.num_non_ascii, 0), 1) AS fffd_pct,
369+
cb.num_replacement, cb.num_non_ascii
370+
FROM contents_b cb WHERE cb.num_replacement > 0
371+
ORDER BY cb.num_replacement DESC FETCH FIRST 20 ROWS ONLY;
372+
354373
-- net common-tokens A vs B (headline "more real text recovered" metric)
355374
SELECT SUM(ca.num_common_tokens) AS common_a,
356375
SUM(cb.num_common_tokens) AS common_b,

tika-core/src/main/java/org/apache/tika/detect/CharsetSupersets.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
* <li>GB2312 → GB18030 (GB18030 is a strict superset of both GB2312 and GBK)</li>
4343
* <li>GBK → GB18030 (GB18030 is a strict superset; enables 4-byte extension sequences)</li>
4444
* <li>Shift_JIS → windows-31j (MS932 is a strict superset with NEC/IBM extensions)</li>
45+
* <li>EUC-JP → x-eucJP-Open (EUC packing of the NEC/IBM vendor extensions)</li>
4546
* </ul>
4647
*/
4748
public final class CharsetSupersets {
@@ -59,6 +60,7 @@ public final class CharsetSupersets {
5960
m.put("GB2312", "GB18030");
6061
m.put("GBK", "GB18030");
6162
m.put("Shift_JIS", "windows-31j");
63+
m.put("EUC-JP", "x-eucJP-Open");
6264
SUPERSET_MAP = Collections.unmodifiableMap(m);
6365
}
6466

0 commit comments

Comments
 (0)