Skip to content

Commit 2b4258e

Browse files
committed
OPENNLP-1883: Route TermAnalyzer factory-based stemming through CachingStemmer
Builder.stem(StemmerFactory) now wraps the factory in a CachingStemmer (default capacity), with a stem(StemmerFactory, int) overload for explicit cache sizing, and NormalizationProfile.matchingAnalyzer() builds through the factory again so profile analyzers get the per-thread stem cache for free. Analyzer results are unchanged; repeated words resolve from the cache instead of being re-stemmed.
1 parent 52e447e commit 2b4258e

3 files changed

Lines changed: 49 additions & 12 deletions

File tree

opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NormalizationProfile.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ public Stemmer newStemmer() {
8080
/**
8181
* Returns a matching analyzer for this language: NFC, case folding, the language's
8282
* {@linkplain #accentFold() diacritic fold} when it has one, then stemming. The returned
83-
* analyzer is thread-safe: it stems through a thread-safe {@link SnowballStemmer}.
83+
* analyzer is thread-safe, and repeated words resolve from a bounded per-thread stem cache
84+
* instead of being re-stemmed (natural text is Zipf-distributed, so most tokens are cache
85+
* hits).
8486
*
8587
* @return the analyzer.
8688
*/
@@ -89,6 +91,6 @@ public TermAnalyzer matchingAnalyzer() {
8991
if (accentFold != null) {
9092
builder.transform(Dimension.ACCENT_FOLD, accentFold);
9193
}
92-
return builder.stem(newStemmer()).build();
94+
return builder.stem(stemmerFactory()).build();
9395
}
9496
}

opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TermAnalyzer.java

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
import java.util.Set;
2626

2727
import opennlp.tools.lemmatizer.Lemmatizer;
28-
import opennlp.tools.stemmer.SharingStemmer;
28+
import opennlp.tools.stemmer.CachingStemmer;
2929
import opennlp.tools.stemmer.Stemmer;
3030
import opennlp.tools.stemmer.StemmerFactory;
3131
import opennlp.tools.tokenize.uax29.WordTokenizer;
@@ -45,10 +45,10 @@
4545
* <p>An instance is immutable and is thread-safe when its configured transforms are. The built-in
4646
* character normalizers are stateless and the Snowball stemmers are thread-safe. When
4747
* {@link Dimension#STEM} is enabled through {@link Builder#stem(StemmerFactory)}, stemming is
48-
* routed through a {@link SharingStemmer} and the analyzer is safe to share across threads even
49-
* when the factory mints stateful stemmers. A raw {@link Stemmer} passed to
50-
* {@link Builder#stem(Stemmer)} is only safe when that stemmer is itself thread-safe or the
51-
* analyzer is confined to one thread.</p>
48+
* routed through a {@link CachingStemmer}: the analyzer is safe to share across threads even when
49+
* the factory mints stateful stemmers, and repeated words resolve from a bounded per-thread cache
50+
* instead of being re-stemmed. A raw {@link Stemmer} passed to {@link Builder#stem(Stemmer)} is
51+
* only safe when that stemmer is itself thread-safe or the analyzer is confined to one thread.</p>
5252
*/
5353
public final class TermAnalyzer {
5454

@@ -398,17 +398,31 @@ public Builder stem(Stemmer value) {
398398

399399
/**
400400
* Enables {@link Dimension#STEM} through a {@link StemmerFactory}. The analyzer receives a
401-
* {@link SharingStemmer} so it can be shared across threads.
401+
* {@link CachingStemmer} with the {@linkplain CachingStemmer#DEFAULT_CAPACITY default cache
402+
* capacity}: it can be shared across threads, and repeated words resolve from a bounded
403+
* per-thread cache instead of being re-stemmed.
402404
*
403405
* @param factory The stemmer factory. Must not be {@code null}.
404406
* @return this builder
405407
* @throws IllegalArgumentException if {@code factory} is {@code null}.
406408
*/
407409
public Builder stem(StemmerFactory factory) {
408-
if (factory == null) {
409-
throw new IllegalArgumentException("factory must not be null");
410-
}
411-
return stem(new SharingStemmer(factory));
410+
return stem(factory, CachingStemmer.DEFAULT_CAPACITY);
411+
}
412+
413+
/**
414+
* Enables {@link Dimension#STEM} through a {@link StemmerFactory} with an explicit stem cache
415+
* capacity, otherwise identical to {@link #stem(StemmerFactory)}.
416+
*
417+
* @param factory The stemmer factory. Must not be {@code null}.
418+
* @param cacheSize The maximum number of word-to-stem entries kept per thread; must be
419+
* positive.
420+
* @return this builder
421+
* @throws IllegalArgumentException if {@code factory} is {@code null} or {@code cacheSize} is
422+
* not positive.
423+
*/
424+
public Builder stem(StemmerFactory factory, int cacheSize) {
425+
return stem(new CachingStemmer(factory, cacheSize));
412426
}
413427

414428
/**

opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/TermAnalyzerTest.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
import opennlp.tools.stemmer.PorterStemmer;
3333
import opennlp.tools.stemmer.Stemmer;
3434
import opennlp.tools.stemmer.StemmerFactory;
35+
import opennlp.tools.stemmer.snowball.SnowballStemmer;
36+
import opennlp.tools.stemmer.snowball.SnowballStemmerFactory;
3537
import opennlp.tools.util.Span;
3638

3739
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -312,10 +314,29 @@ void testBuilderRejectsNullArguments() {
312314
() -> TermAnalyzer.builder().stem((Stemmer) null));
313315
assertThrows(IllegalArgumentException.class,
314316
() -> TermAnalyzer.builder().stem((StemmerFactory) null));
317+
assertThrows(IllegalArgumentException.class,
318+
() -> TermAnalyzer.builder().stem(new SnowballStemmerFactory(
319+
SnowballStemmer.ALGORITHM.ENGLISH), 0));
315320
assertThrows(IllegalArgumentException.class, () -> TermAnalyzer.builder().lemmatize(null));
316321
assertThrows(IllegalArgumentException.class, () -> TermAnalyzer.builder().tokenizer(null));
317322
}
318323

324+
@Test
325+
void testStemFactoryCachedResultsMatchDirectStemmer() {
326+
final TermAnalyzer cached = TermAnalyzer.builder().caseFold()
327+
.stem(new SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH)).build();
328+
final TermAnalyzer direct = TermAnalyzer.builder().caseFold()
329+
.stem(new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH)).build();
330+
331+
final String text = "Running runners kept running while the RUNNING continued";
332+
final List<Term> cachedTerms = cached.analyze(text);
333+
final List<Term> directTerms = direct.analyze(text);
334+
assertEquals(directTerms.size(), cachedTerms.size());
335+
for (int i = 0; i < directTerms.size(); i++) {
336+
assertEquals(directTerms.get(i).at(Dimension.STEM), cachedTerms.get(i).at(Dimension.STEM));
337+
}
338+
}
339+
319340
@Test
320341
void testMaxTokenLengthRejectsNonPositiveValues() {
321342
assertThrows(IllegalArgumentException.class, () -> TermAnalyzer.builder().maxTokenLength(0));

0 commit comments

Comments
 (0)