Skip to content

Commit d5ef09b

Browse files
committed
TIKA-4745 -- efficiency improvements
1 parent 67ada35 commit d5ef09b

15 files changed

Lines changed: 236 additions & 127 deletions

File tree

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,19 @@
4040
public class EncodingDetectorContext {
4141

4242
private final List<Result> results = new ArrayList<>();
43+
private final EncodingProbeCache probeCache = new EncodingProbeCache();
4344
private String arbitrationInfo;
4445

46+
/**
47+
* Per-detection cache of the raw detection probe, shared across the detectors in
48+
* this chain so they don't each re-read the same leading bytes. It lives and dies
49+
* with this context (which is removed after detection), so it never leaks into
50+
* recursive/attachment parsing.
51+
*/
52+
public EncodingProbeCache getProbeCache() {
53+
return probeCache;
54+
}
55+
4556
/**
4657
* Record the ranked results from a child detector.
4758
*
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.tika.detect;
18+
19+
/**
20+
* Caches the raw encoding-detection probe (the leading bytes read for detection)
21+
* so that multiple detectors in a chain do not each re-read and re-tag-strip the
22+
* same bytes. For example a statistical detector and a downstream meta detector
23+
* that re-reads the bytes for arbitration can share one probe.
24+
* <p>
25+
* An instance is held by {@link EncodingDetectorContext}, so it inherits that
26+
* context's per-detection lifecycle: created fresh per detection and discarded
27+
* with the context immediately afterwards. That matters because a
28+
* {@link org.apache.tika.parser.ParseContext} flows on into recursive
29+
* (attachment/embedded) parsing — a probe must never outlive the single detection
30+
* it was read for.
31+
* <p>
32+
* Not thread-safe: a single detection runs its detectors sequentially on one
33+
* thread. The cache is keyed by the probe parameters — {@link #get} returns the
34+
* cached probe only when both {@code contentTarget} and {@code rawCap} match what
35+
* it was stored with, so a detector that wants a differently-sized probe
36+
* transparently reads (and caches) its own.
37+
* <p>
38+
* The cached array is shared read-only state; callers must not mutate it in place.
39+
*/
40+
public class EncodingProbeCache {
41+
42+
private byte[] probe;
43+
private int contentTarget = -1;
44+
private int rawCap = -1;
45+
46+
/**
47+
* @return the cached probe if one was stored with the same {@code contentTarget} and
48+
* {@code rawCap}; otherwise {@code null}
49+
*/
50+
public byte[] get(int contentTarget, int rawCap) {
51+
if (probe != null && this.contentTarget == contentTarget && this.rawCap == rawCap) {
52+
return probe;
53+
}
54+
return null;
55+
}
56+
57+
/**
58+
* Stores the probe bytes read with the given parameters.
59+
*/
60+
public void put(byte[] probe, int contentTarget, int rawCap) {
61+
this.probe = probe;
62+
this.contentTarget = contentTarget;
63+
this.rawCap = rawCap;
64+
}
65+
}

tika-encoding-detectors/tika-encoding-detector-html/src/main/java/org/apache/tika/parser/html/HtmlEncodingDetector.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,14 @@ public List<EncodingResult> detect(TikaInputStream tis, Metadata metadata,
162162
}
163163
tis.reset();
164164

165+
// findCharset only ever matches a meta tag (HTTP_META_PATTERN = "<\s*meta...").
166+
// If the probe has no such tag, the full ASCII decode + comment-stripping
167+
// regex below can only produce null — skip them. Byte-level, no allocation;
168+
// a strict necessary condition for any non-empty result.
169+
if (!containsMetaTag(buffer, n)) {
170+
return Collections.emptyList();
171+
}
172+
165173
String head = ASCII.decode(ByteBuffer.wrap(buffer, 0, n)).toString();
166174
String headNoComments = head.replaceAll("<!--.*?(-->|$)", " ");
167175
Charset charset = findCharset(headNoComments);
@@ -175,6 +183,39 @@ public List<EncodingResult> detect(TikaInputStream tis, Metadata metadata,
175183
EncodingResult.ResultType.DECLARATIVE));
176184
}
177185

186+
/**
187+
* Byte-level scan for an opening meta tag, mirroring the {@code <\s*meta} prefix of
188+
* {@link #HTTP_META_PATTERN} (ASCII, case-insensitive). Lets {@link #detect} skip the
189+
* full ASCII decode + comment-stripping regex on probes that cannot contain a meta
190+
* charset declaration. {@code <}, ASCII whitespace and {@code meta} are all ASCII, so a
191+
* raw-byte scan is equivalent to scanning the decoded head.
192+
*/
193+
private static boolean containsMetaTag(byte[] buf, int len) {
194+
for (int i = 0; i < len; i++) {
195+
if (buf[i] != '<') {
196+
continue;
197+
}
198+
int j = i + 1;
199+
while (j < len) {
200+
int c = buf[j] & 0xFF;
201+
if (c == ' ' || c == '\t' || c == '\n' || c == 0x0B || c == '\f'
202+
|| c == '\r') {
203+
j++;
204+
} else {
205+
break;
206+
}
207+
}
208+
if (j + 4 <= len
209+
&& (((buf[j] & 0xFF) | 0x20) == 'm')
210+
&& (((buf[j + 1] & 0xFF) | 0x20) == 'e')
211+
&& (((buf[j + 2] & 0xFF) | 0x20) == 't')
212+
&& (((buf[j + 3] & 0xFF) | 0x20) == 'a')) {
213+
return true;
214+
}
215+
}
216+
return false;
217+
}
218+
178219
//returns null if no charset was found
179220
private Charset findCharset(String s) {
180221

tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/AdaptiveProbe.java

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,20 @@ public static byte[] read(TikaInputStream tis, int contentTarget, int rawCap)
5555
throws IOException {
5656
tis.mark(rawCap);
5757
try {
58-
byte[] buf = new byte[rawCap];
59-
byte[] stripDst = new byte[rawCap];
58+
// Grow on demand rather than allocating (and zeroing) the full rawCap
59+
// (e.g. 512 KB) twice up front: the vast majority of probes are far
60+
// smaller. Bytes returned are identical to the eager-allocation version.
61+
int cap = Math.min(rawCap, contentTarget);
62+
byte[] buf = new byte[cap];
63+
byte[] stripDst = new byte[cap];
6064
int total = 0;
6165
while (total < rawCap) {
6266
int want = Math.min(rawCap - total, contentTarget);
67+
if (total + want > buf.length) {
68+
int newCap = Math.min(rawCap, Math.max(buf.length * 2, total + want));
69+
buf = Arrays.copyOf(buf, newCap);
70+
stripDst = Arrays.copyOf(stripDst, newCap);
71+
}
6372
int n = IOUtils.read(tis, buf, total, want);
6473
total += n;
6574
HtmlByteStripper.Result r =
@@ -72,7 +81,7 @@ public static byte[] read(TikaInputStream tis, int contentTarget, int rawCap)
7281
if (total == 0) {
7382
return new byte[0];
7483
}
75-
return total == rawCap ? buf : Arrays.copyOf(buf, total);
84+
return total == buf.length ? buf : Arrays.copyOf(buf, total);
7685
} finally {
7786
tis.reset();
7887
}

tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/MojibusterEncodingDetector.java

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929

3030
import org.apache.tika.config.TikaComponent;
3131
import org.apache.tika.detect.EncodingDetector;
32+
import org.apache.tika.detect.EncodingDetectorContext;
33+
import org.apache.tika.detect.EncodingProbeCache;
3234
import org.apache.tika.detect.EncodingResult;
3335
import org.apache.tika.detect.HighByteLetterStats;
3436
import org.apache.tika.io.TikaInputStream;
@@ -208,7 +210,7 @@ private static NaiveBayesBigramEncodingDetector loadFromClasspath() throws IOExc
208210
@Override
209211
public List<EncodingResult> detect(TikaInputStream tis, Metadata metadata,
210212
ParseContext parseContext) throws IOException {
211-
byte[] probe = readProbe(tis);
213+
byte[] probe = readProbe(tis, parseContext);
212214
return detect(probe, metadata);
213215
}
214216

@@ -749,7 +751,20 @@ private static boolean shouldTryStrip(String contentType) {
749751
return lower.contains("html") || lower.contains("xml");
750752
}
751753

752-
private static byte[] readProbe(TikaInputStream tis) throws IOException {
753-
return AdaptiveProbe.read(tis, PROBE_CONTENT_TARGET, PROBE_RAW_CAP);
754+
private static byte[] readProbe(TikaInputStream tis, ParseContext parseContext)
755+
throws IOException {
756+
EncodingDetectorContext context = parseContext.get(EncodingDetectorContext.class);
757+
EncodingProbeCache cache = context == null ? null : context.getProbeCache();
758+
if (cache != null) {
759+
byte[] cached = cache.get(PROBE_CONTENT_TARGET, PROBE_RAW_CAP);
760+
if (cached != null) {
761+
return cached;
762+
}
763+
}
764+
byte[] probe = AdaptiveProbe.read(tis, PROBE_CONTENT_TARGET, PROBE_RAW_CAP);
765+
if (cache != null) {
766+
cache.put(probe, PROBE_CONTENT_TARGET, PROBE_RAW_CAP);
767+
}
768+
return probe;
754769
}
755770
}

tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/NaiveBayesBigramEncodingDetector.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,9 +309,19 @@ public NaiveBayesBigramEncodingDetector(InputStream modelStream) throws IOExcept
309309
for (int bg = 0; bg < BIGRAM_SPACE; bg++) {
310310
logP8[bg * numClasses + c] = u;
311311
}
312-
// Overwrite with trained pairs.
312+
// Overwrite with trained pairs. Bigram ids are sorted ascending and
313+
// stored as varint deltas (LEB128) from the previous id.
314+
int bigram = 0;
313315
for (int i = 0; i < vocabSize; i++) {
314-
int bigram = dis.readUnsignedShort();
316+
int delta = 0;
317+
int shift = 0;
318+
int b;
319+
do {
320+
b = dis.readUnsignedByte();
321+
delta |= (b & 0x7F) << shift;
322+
shift += 7;
323+
} while ((b & 0x80) != 0);
324+
bigram += delta;
315325
byte q = dis.readByte();
316326
logP8[bigram * numClasses + c] = q;
317327
}

tika-langdetect/tika-langdetect-charsoup-core/src/main/java/org/apache/tika/langdetect/charsoup/CharSoupFeatureExtractor.java

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,9 +244,17 @@ public static String preprocess(String rawText) {
244244
* @return cleaned, NFC-normalized text
245245
*/
246246
public static String preprocessNoTruncate(String rawText) {
247-
// Strip URLs and emails
248-
String text = URL_REGEX.matcher(rawText).replaceAll(" ");
249-
text = MAIL_REGEX.matcher(text).replaceAll(" ");
247+
// Strip URLs and emails. Both regexes scan the entire input on every call;
248+
// skip each unless its required marker is present ("://" for URL_REGEX, "@"
249+
// for MAIL_REGEX). This is a no-op for the common (markerless) case — the
250+
// output is identical — but avoids a full-buffer regex scan + Matcher alloc.
251+
String text = rawText;
252+
if (text.indexOf("://") >= 0) {
253+
text = URL_REGEX.matcher(text).replaceAll(" ");
254+
}
255+
if (text.indexOf('@') >= 0) {
256+
text = MAIL_REGEX.matcher(text).replaceAll(" ");
257+
}
250258

251259
// NFC normalize
252260
if (!Normalizer.isNormalized(text, Normalizer.Form.NFC)) {

tika-ml/tika-ml-chardetect/src/main/java/org/apache/tika/ml/chardetect/tools/TrainNaiveBayesBigram.java

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -427,8 +427,8 @@ public static void main(String[] args) throws IOException {
427427
* float32 scale (per-class dequant)
428428
* byte unseenQ (int8 quantized unseen floor)
429429
* int32 vocabSize (number of trained pairs)
430-
* for each kept bigram:
431-
* uint16 bigramKey
430+
* bigram keys sorted ascending, each pair stored as:
431+
* varint deltaFromPrevKey (LEB128; first delta is the key itself)
432432
* byte logP8 (int8 quantized)
433433
*
434434
* <p>Sparse representation: only trained bigram pairs are stored;
@@ -497,11 +497,22 @@ private static void save(Path path, String[] labels,
497497
dos.writeByte(unseenQ[c]);
498498
float scale = perClassScale[c];
499499
dos.writeInt(logProbsPerClass[c].size());
500-
for (Map.Entry<Integer, Float> e : logProbsPerClass[c].entrySet()) {
501-
int q = Math.round(e.getValue() / scale);
500+
// Bigram ids sorted ascending, stored as varint (LEB128) deltas from
501+
// the previous id — most deltas fit in a single byte.
502+
int[] keys = logProbsPerClass[c].keySet().stream()
503+
.mapToInt(Integer::intValue).sorted().toArray();
504+
int prev = 0;
505+
for (int key : keys) {
506+
int q = Math.round(logProbsPerClass[c].get(key) / scale);
502507
if (q > 127) q = 127;
503508
if (q < -127) q = -127;
504-
dos.writeShort(e.getKey());
509+
int delta = key - prev;
510+
prev = key;
511+
while ((delta & ~0x7F) != 0) {
512+
dos.writeByte((delta & 0x7F) | 0x80);
513+
delta >>>= 7;
514+
}
515+
dos.writeByte(delta);
505516
dos.writeByte(q);
506517
}
507518
}

tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/BigramTables.java

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,11 @@
3636
* script. Codepoint → dense index is a binary search; index →
3737
* codepoint is direct array access. Typical sizes: ~7K-15K for HAN,
3838
* ~200-500 for most other scripts.
39-
* <li>{@code bigramKeys} / {@code bigramValues} — parallel arrays
40-
* implementing an open-addressed hash table with linear probing.
41-
* Each key is a 32-bit value {@code (idxA << 16) | idxB}; key {@code
42-
* -1} means "empty slot." Indices are bounded at 16 bits (65535),
43-
* which is comfortably above the largest per-script codepoint count
44-
* we observe.
39+
* <li>{@code bigramKeys} / {@code bigramValues} — parallel arrays of the
40+
* occupied entries only, sorted ascending by key for binary-search
41+
* lookup. Each key is a 32-bit value {@code (idxA << 16) | idxB}.
42+
* Indices are bounded at 16 bits (65535), comfortably above the
43+
* largest per-script codepoint count we observe.
4544
* <li>{@code unigramTable} — {@code byte[numCodepoints]}, quantized
4645
* unigram log-probabilities indexed by the same codepoint→index map.
4746
* <li>{@code bigramQuantMin/Max}, {@code unigramQuantMin/Max} —
@@ -56,10 +55,9 @@
5655
* independence sum.
5756
* </ul>
5857
*
59-
* <p>Membership semantics: no Bloom filter. The empty-slot sentinel is
60-
* the membership oracle — a pair is "seen" iff binary-search finds both
61-
* codepoints in the index AND a probe sequence hits a matching key before
62-
* an empty slot. Lookups are therefore exact.
58+
* <p>Membership semantics: no Bloom filter. A pair is "seen" iff
59+
* binary-search finds both codepoints in the index AND finds the packed
60+
* key in {@code bigramKeys}. Lookups are therefore exact.
6361
*
6462
* <p>Fields are package-private so the
6563
* {@link org.apache.tika.ml.junkdetect.tools.TrainJunkModel} trainer can

0 commit comments

Comments
 (0)