4141 * trained on Wikipedia (primary corpus) with MADLAD supplements for thin languages.
4242 * <p>
4343 * Text is buffered via {@link #addText(char[], int, int)} up to
44- * {@link CharSoupFeatureExtractor#MAX_TEXT_LENGTH} characters. At {@link #detectAll()} time,
45- * the buffer is evaluated in independent {@value #CHUNK_SIZE}-character chunks.
46- * Each chunk runs the full preprocessing pipeline (truncate → strip URLs/emails →
47- * NFC normalize → extract bigram features → score via raw logits). If the first
48- * chunk produces high entropy (indicating junk, code, or non-language content),
49- * the next chunk is tried. The result from the chunk with the lowest entropy
50- * is returned. This avoids polluting the language signal with leading junk while
51- * keeping the implementation simple and predictable.
44+ * {@link CharSoupFeatureExtractor#MAX_TEXT_LENGTH} characters (configurable
45+ * via {@link #setMaxLength(int)}). At {@link #detectAll()} the entire buffer
46+ * is fed through the full preprocessing pipeline (strip URLs/emails →
47+ * NFC normalize → extract bigram features) and scored once. The verdict
48+ * is the argmax of the (group-collapsed) logits over the whole input.
5249 * </p>
5350 * <p>
5451 * Inference uses raw logits throughout — no softmax distribution is ever computed.
55- * Confidence is based on the <em>margin</em> between the top two logits after
56- * confusable-group collapsing: {@code sigmoid(top_logit − second_logit)}.
57- * This is invariant to the number of classes and provides a stable confidence
58- * signal from short snippets up to full documents. Per-class {@code rawScore}
59- * is {@code sigmoid(logit_c − best_competitor_logit)}: the winner gets a value
60- * above 0.5, all others below.
52+ * Confidence is based on the <em>margin</em> between the top two logits after
53+ * confusable-group collapsing: {@code sigmoid(top_logit − second_logit)}.
54+ * This is invariant to the number of classes and provides a stable confidence
55+ * signal from short snippets up to full documents. Per-class {@code rawScore}
56+ * is {@code sigmoid(logit_c − best_competitor_logit)}: the winner gets a value
57+ * above 0.5, all others below.
6158 * </p>
6259 */
6360@ TikaComponent (name = "charsoup-language-detector" )
@@ -73,36 +70,13 @@ public class CharSoupLanguageDetector extends LanguageDetector implements SelfCo
7370 private static final String MODEL_RESOURCE =
7471 "/org/apache/tika/langdetect/charsoup/langdetect-20260320.bin" ;
7572
76- /**
77- * Size (in chars) of each independent chunk evaluated during detection.
78- * If the first chunk yields high entropy (junk/code), the next chunk
79- * is tried, and so on, until a confident result is found or the buffer
80- * is exhausted. Each chunk is preprocessed and evaluated independently
81- * so that junk in one chunk does not pollute the signal in the next.
82- */
83- private static final int CHUNK_SIZE = 5_000 ;
84-
8573 /**
8674 * Buffer length at which {@link #hasEnoughText()} returns true.
87- * One chunk is more than sufficient for reliable language detection;
88- * this is set to two chunks so the detector has a fallback if the
89- * first chunk is junk.
90- */
91- private static final int ENOUGH_TEXT_LENGTH = CHUNK_SIZE * 2 ;
92-
93- /**
94- * Maximum entropy (in bits) for a chunk to be considered "confident
95- * enough" to return. If a chunk's collapsed-distribution entropy
96- * exceeds this threshold, the detector moves on to the next chunk.
97- * <p>
98- * Typical values:
99- * <ul>
100- * <li>< 1.0 — clean, single-language text</li>
101- * <li>1.0–3.0 — confusable language or short text</li>
102- * <li>> 3.5 — likely junk (code, OCR garbage, binary, etc.)</li>
103- * </ul>
75+ * 10,000 characters is comfortably above the saturation point where
76+ * the bigram-NB model has full discriminative signal on typical
77+ * prose; streaming callers can stop feeding once this is reached.
10478 */
105- private static final float ENTROPY_THRESHOLD = 3.5f ;
79+ private static final int ENOUGH_TEXT_LENGTH = 10_000 ;
10680
10781 /**
10882 * Confusable language groups — languages within the same group are nearly
@@ -721,40 +695,27 @@ public boolean hasEnoughText() {
721695 @ Override
722696 public List <LanguageResult > detectAll () {
723697 String text = buffer .toString ();
724- if (text .isEmpty ()) {
698+ // Cheap empty/whitespace-only short-circuit so callers see the
699+ // explicit NULL result instead of model-bias logits computed from
700+ // an empty feature vector.
701+ if (text .isBlank ()) {
725702 lastEntropy = Float .NaN ;
726703 return Collections .singletonList (LanguageResult .NULL );
727704 }
728705
729- int len = text .length ();
730- float [] bestLogits = null ;
731- float bestEntropy = Float .MAX_VALUE ;
732- String bestChunk = null ;
706+ // Single full-buffer extraction. The feature extractor is
707+ // whitespace-invariant (only letter-letter / sentinel-letter /
708+ // letter-sentinel bigrams are emitted) and bounded internally
709+ // at MAX_TEXT_LENGTH; the caller's buffer is already bounded
710+ // by setMaxLength(...) so the work here is linear in
711+ // min(buffer.length, MAX_TEXT_LENGTH).
733712 int [] features = new int [extractor .getNumBuckets ()];
734-
735- for (int start = 0 ; start < len ; start += CHUNK_SIZE ) {
736- int end = Math .min (start + CHUNK_SIZE , len );
737- String chunk = text .substring (start , end );
738-
739- extractor .extractAndCount (chunk , features );
740- float [] logits = model .predictLogits (features );
741- logits = applyScriptGate (logits , chunk , classScript );
742- float [] collapsed = collapseGroups (logits , groupIndices );
743-
744- float entropy = entropyFromLogits (collapsed );
745-
746- if (entropy < bestEntropy ) {
747- bestEntropy = entropy ;
748- bestLogits = collapsed ;
749- bestChunk = chunk ;
750- }
751-
752- if (entropy < ENTROPY_THRESHOLD ) {
753- break ;
754- }
755- }
756-
757- return buildResults (bestLogits , bestEntropy );
713+ extractor .extractAndCount (text , features );
714+ float [] logits = model .predictLogits (features );
715+ logits = applyScriptGate (logits , text , classScript );
716+ float [] collapsed = collapseGroups (logits , groupIndices );
717+ float entropy = entropyFromLogits (collapsed );
718+ return buildResults (collapsed , entropy );
758719 }
759720
760721 /**
0 commit comments