diff --git a/opennlp-api/src/main/java/opennlp/tools/termvector/TermVector.java b/opennlp-api/src/main/java/opennlp/tools/termvector/TermVector.java new file mode 100644 index 0000000000..42c654257d --- /dev/null +++ b/opennlp-api/src/main/java/opennlp/tools/termvector/TermVector.java @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.termvector; + +import java.util.List; + +import opennlp.tools.commons.ThreadSafe; +import opennlp.tools.util.Span; + +/** + * One entry of a term vector layer: a term, how often it occurs in the document, and + * where. + * + *

The {@link #term()} is the term's identity as the producing annotator determined + * it, typically a normalized form; the {@link #spans()} are the occurrence offsets and + * always point into the document's original text, never into a normalized form, + * so an index consumer can highlight every occurrence in what the caller supplied.

+ * + *

A term vector comes in one of two shapes, told apart by whether {@link #spans()} is + * empty. A full vector carries one span per occurrence, so + * {@code spans().size() == frequency()}. A scoring-only vector carries no spans + * at all, so consumers that only need term frequencies do not pay for offset storage. + * There is no third shape: a non-empty span list must match the frequency exactly.

+ * + *

Instances are immutable: the span list is copied on construction and the copy is + * unmodifiable.

+ * + * @param term The term string. Must not be {@code null}. + * @param frequency The number of occurrences in the document. Must be at least one. + * @param spans The occurrence spans in original text coordinates, one per occurrence, + * or an empty list for a scoring-only vector. Must not be {@code null} or + * contain {@code null} and, when non-empty, must hold exactly + * {@code frequency} spans. + * + * @since 3.0.0 + */ +@ThreadSafe +public record TermVector(String term, int frequency, List spans) { + + /** + * Validates the term vector and detaches the span list from the caller's input. + * + * @throws IllegalArgumentException Thrown if {@code term} is {@code null}, + * {@code frequency} is below one, {@code spans} is or contains {@code null}, + * or a non-empty {@code spans} list does not hold exactly {@code frequency} + * spans. + */ + public TermVector { + if (term == null) { + throw new IllegalArgumentException("term must not be null"); + } + if (frequency < 1) { + throw new IllegalArgumentException("frequency must be at least one: " + frequency); + } + if (spans == null) { + throw new IllegalArgumentException("spans must not be null"); + } + for (final Span span : spans) { + if (span == null) { + throw new IllegalArgumentException("spans must not contain null"); + } + } + if (!spans.isEmpty() && spans.size() != frequency) { + throw new IllegalArgumentException("a full term vector holds one span per " + + "occurrence: frequency is " + frequency + " but spans holds " + spans.size()); + } + spans = List.copyOf(spans); + } + + /** + * Creates a full {@link TermVector} whose frequency is derived from the occurrence + * spans. + * + * @param term The term string. Must not be {@code null}. + * @param spans The occurrence spans in original text coordinates. Must not be + * {@code null} or empty. + * @return A {@link TermVector} with {@code frequency() == spans.size()}. Never + * {@code null}. + * @throws IllegalArgumentException Thrown if {@code term} is {@code null} or + * {@code spans} is {@code null} or empty. + */ + public static TermVector withSpans(String term, List spans) { + if (spans == null || spans.isEmpty()) { + throw new IllegalArgumentException("spans must not be null or empty"); + } + return new TermVector(term, spans.size(), spans); + } + + /** + * Creates a scoring-only {@link TermVector} that carries the occurrence count without + * any offsets. + * + * @param term The term string. Must not be {@code null}. + * @param frequency The number of occurrences in the document. Must be at least one. + * @return A {@link TermVector} whose {@link #spans()} is empty. Never {@code null}. + * @throws IllegalArgumentException Thrown if {@code term} is {@code null} or + * {@code frequency} is below one. + */ + public static TermVector count(String term, int frequency) { + return new TermVector(term, frequency, List.of()); + } +} diff --git a/opennlp-api/src/test/java/opennlp/tools/termvector/TermVectorTest.java b/opennlp-api/src/test/java/opennlp/tools/termvector/TermVectorTest.java new file mode 100644 index 0000000000..8448f465d3 --- /dev/null +++ b/opennlp-api/src/test/java/opennlp/tools/termvector/TermVectorTest.java @@ -0,0 +1,105 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.termvector; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import opennlp.tools.util.Span; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the {@link TermVector} invariants: the two legal shapes (full with one span + * per occurrence, scoring-only with none), the validation of everything in between, and + * the immutability of the span list. + */ +public class TermVectorTest { + + @Test + void testWithSpansDerivesTheFrequency() { + final TermVector vector = + TermVector.withSpans("dog", List.of(new Span(4, 7), new Span(19, 22))); + assertEquals("dog", vector.term()); + assertEquals(2, vector.frequency()); + assertEquals(List.of(new Span(4, 7), new Span(19, 22)), vector.spans()); + } + + @Test + void testCountCarriesNoSpans() { + final TermVector vector = TermVector.count("dog", 3); + assertEquals("dog", vector.term()); + assertEquals(3, vector.frequency()); + assertTrue(vector.spans().isEmpty()); + } + + @Test + void testSpanListIsDetachedFromTheCallersInput() { + final List spans = new ArrayList<>(List.of(new Span(0, 3))); + final TermVector vector = TermVector.withSpans("the", spans); + spans.add(new Span(4, 7)); + assertEquals(1, vector.spans().size()); + assertThrows(UnsupportedOperationException.class, + () -> vector.spans().add(new Span(8, 11))); + } + + @Test + void testNullTermIsRejected() { + assertThrows(IllegalArgumentException.class, + () -> new TermVector(null, 1, List.of(new Span(0, 1)))); + } + + @Test + void testNullSpanListIsRejected() { + assertThrows(IllegalArgumentException.class, () -> new TermVector("dog", 1, null)); + } + + @Test + void testNullSpanElementIsRejected() { + final List spans = new ArrayList<>(); + spans.add(new Span(0, 3)); + spans.add(null); + assertThrows(IllegalArgumentException.class, () -> new TermVector("dog", 2, spans)); + assertThrows(IllegalArgumentException.class, () -> TermVector.withSpans("dog", spans)); + } + + @ParameterizedTest + @ValueSource(ints = {0, -1, Integer.MIN_VALUE}) + void testFrequencyBelowOneIsRejected(int frequency) { + assertThrows(IllegalArgumentException.class, () -> TermVector.count("dog", frequency)); + } + + @Test + void testEmptySpanListCannotDeriveAFrequency() { + assertThrows(IllegalArgumentException.class, + () -> TermVector.withSpans("dog", List.of())); + } + + @Test + void testPartialSpanListIsRejected() { + // Two occurrences but only one recorded span: neither full nor scoring-only. + assertThrows(IllegalArgumentException.class, + () -> new TermVector("dog", 2, List.of(new Span(0, 3)))); + } +} diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/termvector/TermVectorAnnotator.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/termvector/TermVectorAnnotator.java new file mode 100644 index 0000000000..4ed12d0d13 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/termvector/TermVectorAnnotator.java @@ -0,0 +1,318 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.termvector; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import opennlp.tools.document.Annotation; +import opennlp.tools.document.Document; +import opennlp.tools.document.DocumentAnnotator; +import opennlp.tools.document.LayerKey; +import opennlp.tools.document.Layers; +import opennlp.tools.util.Span; +import opennlp.tools.util.normalizer.AlignedText; +import opennlp.tools.util.normalizer.CharSequenceNormalizer; +import opennlp.tools.util.normalizer.OffsetAwareNormalizer; + +/** + * Rolls the token layer up into a term vector layer for index consumers: one + * {@link TermVector} per distinct term, carrying the term string, its occurrence count, + * and (in {@link Mode#FULL full mode}) the occurrence offsets. + * + *

Term identity comes from the annotator's inputs, not from logic of its own. Without + * a normalizer, the term is the token layer's value as-is, that is, the token's covered + * text in the original document. With a plain {@link CharSequenceNormalizer}, the general + * path, each token's covered text is normalized on its own to produce the term, so any + * normalizer works: case folding, NFC, accent folding, a stemmer-backed normalizer. With + * an {@link OffsetAwareNormalizer}, the whole document text is normalized once with its + * alignment recorded, each token span is mapped forward to the normalized form, and the + * covered normalized text is the term; this path can see across token boundaries but is + * limited to alignment-reporting normalizers. On every path, tokens that differ only by + * a normalization fold (case, an eszett expansion, collapsed whitespace) group together, + * and the occurrence spans emitted in {@link Mode#FULL full mode} are the token layer's + * own spans and therefore always point into the original text. A token whose normalized + * form is empty, for example one the normalizer deleted entirely, is omitted from the + * layer; an empty string is no term, and the token layer still accounts for the + * token.

+ * + *

The layer is {@link LayerKey.Scope#DOCUMENT document-scoped}: each {@link TermVector} + * is a whole-document statistic, so the annotations carry no span of their own and the + * occurrence offsets live inside the payload. The layer preserves first-occurrence + * order: the first token of a term fixes its position in the layer.

+ * + *

The annotator holds no per-call state; it is as thread-safe as the normalizer it + * was built with.

+ * + * @since 3.0.0 + */ +public final class TermVectorAnnotator implements DocumentAnnotator { + + /** + * The key of the term vector layer this annotator provides: a document-scoped layer + * of {@link TermVector} values, one per distinct term. + */ + public static final LayerKey TERM_VECTORS = + Layers.documentKey("term-vectors", TermVector.class); + + /** How much each {@link TermVector} records. */ + public enum Mode { + /** + * Counts occurrences and stores every occurrence span in original text coordinates. + */ + FULL, + /** + * Counts occurrences only; the emitted {@link TermVector term vectors} carry no + * spans, so scoring-only consumers do not pay for offset storage. + */ + SCORING_ONLY + } + + private final OffsetAwareNormalizer normalizer; + private final CharSequenceNormalizer tokenNormalizer; + private final Mode mode; + + /** + * Initializes a {@link Mode#FULL full mode} annotator that groups tokens by their + * covered text as-is. + */ + public TermVectorAnnotator() { + this(Mode.FULL); + } + + /** + * Initializes an annotator that groups tokens by their covered text as-is. + * + * @param mode How much each {@link TermVector} records. Must not be {@code null}. + * @throws IllegalArgumentException Thrown if {@code mode} is {@code null}. + */ + public TermVectorAnnotator(Mode mode) { + if (mode == null) { + throw new IllegalArgumentException("mode must not be null"); + } + this.normalizer = null; + this.tokenNormalizer = null; + this.mode = mode; + } + + /** + * Initializes a {@link Mode#FULL full mode} annotator that groups tokens by their + * per-token normalized form. This is the general path: the normalizer is applied to + * each token's covered text on its own, so any {@link CharSequenceNormalizer} works, + * including the folds that cannot report an alignment (case folding, NFC, accent + * folding, stemmer-backed normalizers). The occurrence spans stay the tokens' own + * spans in the original text. + * + * @param normalizer The normalizer that defines term identity, applied to each token's + * covered text. Must not be {@code null}. + * @throws IllegalArgumentException Thrown if {@code normalizer} is {@code null}. + */ + public TermVectorAnnotator(CharSequenceNormalizer normalizer) { + this(normalizer, Mode.FULL); + } + + /** + * Initializes an annotator that groups tokens by their per-token normalized form. This + * is the general path: the normalizer is applied to each token's covered text on its + * own, so any {@link CharSequenceNormalizer} works, including the folds that cannot + * report an alignment (case folding, NFC, accent folding, stemmer-backed normalizers). + * The occurrence spans stay the tokens' own spans in the original text. + * + * @param normalizer The normalizer that defines term identity, applied to each token's + * covered text. Must not be {@code null}. + * @param mode How much each {@link TermVector} records. Must not be {@code null}. + * @throws IllegalArgumentException Thrown if {@code normalizer} or {@code mode} is + * {@code null}. + */ + public TermVectorAnnotator(CharSequenceNormalizer normalizer, Mode mode) { + if (normalizer == null) { + throw new IllegalArgumentException("normalizer must not be null"); + } + if (mode == null) { + throw new IllegalArgumentException("mode must not be null"); + } + this.normalizer = null; + this.tokenNormalizer = normalizer; + this.mode = mode; + } + + /** + * Initializes a {@link Mode#FULL full mode} annotator that groups tokens by their + * normalized form through a whole-document alignment. Prefer the + * {@link #TermVectorAnnotator(CharSequenceNormalizer) plain-normalizer constructor} + * as the general path; this one only accepts alignment-reporting normalizers but can + * see across token boundaries, for example a whitespace collapse spanning two tokens. + * + * @param normalizer The normalizer that defines term identity, applied to the whole + * document text so token spans can be mapped into the normalized + * form through its alignment. Must not be {@code null}. + * @throws IllegalArgumentException Thrown if {@code normalizer} is {@code null}. + */ + public TermVectorAnnotator(OffsetAwareNormalizer normalizer) { + this(normalizer, Mode.FULL); + } + + /** + * Initializes an annotator that groups tokens by their normalized form through a + * whole-document alignment. Prefer the + * {@link #TermVectorAnnotator(CharSequenceNormalizer, Mode) plain-normalizer + * constructor} as the general path; this one only accepts alignment-reporting + * normalizers but can see across token boundaries, for example a whitespace collapse + * spanning two tokens. + * + * @param normalizer The normalizer that defines term identity, applied to the whole + * document text so token spans can be mapped into the normalized + * form through its alignment. Must not be {@code null}. + * @param mode How much each {@link TermVector} records. Must not be {@code null}. + * @throws IllegalArgumentException Thrown if {@code normalizer} or {@code mode} is + * {@code null}. + */ + public TermVectorAnnotator(OffsetAwareNormalizer normalizer, Mode mode) { + if (normalizer == null) { + throw new IllegalArgumentException("normalizer must not be null"); + } + if (mode == null) { + throw new IllegalArgumentException("mode must not be null"); + } + this.normalizer = normalizer; + this.tokenNormalizer = null; + this.mode = mode; + } + + /** + * Aggregates the token layer into the {@link #TERM_VECTORS} layer. A present-but-empty + * token layer yields a present-but-empty term vector layer. + * + * @param document The document to annotate. Must not be {@code null} and must contain + * the {@link Layers#TOKENS} layer. + * @return A new {@link Document} with the {@link #TERM_VECTORS} layer added. Never + * {@code null}. + * @throws IllegalArgumentException Thrown if {@code document} is {@code null}, lacks + * the {@link Layers#TOKENS} layer, or already carries the + * {@link #TERM_VECTORS} layer. + */ + @Override + public Document annotate(Document document) { + if (document == null) { + throw new IllegalArgumentException("document must not be null"); + } + if (!document.layers().contains(Layers.TOKENS)) { + throw new IllegalArgumentException("document lacks the required layer " + + Layers.TOKENS); + } + final List> tokens = document.get(Layers.TOKENS); + final AlignedText aligned = + normalizer != null ? normalizer.normalizeAligned(document.text()) : null; + final String normalized = aligned != null ? aligned.normalizedString() : null; + return document.with(TERM_VECTORS, mode == Mode.FULL + ? fullVectors(tokens, aligned, normalized) + : countVectors(tokens, aligned, normalized)); + } + + /** + * Aggregates the tokens into full term vectors, keeping every occurrence span. + * + * @param tokens The token layer. + * @param aligned The normalized document text with its alignment, or {@code null} when + * no normalizer is present. + * @param normalized The normalized document text, or {@code null} when no normalizer is + * present. + * @return One annotation per distinct term, in first-occurrence order. + */ + private List> fullVectors(List> tokens, + AlignedText aligned, String normalized) { + final Map> spansByTerm = new LinkedHashMap<>(); + for (final Annotation token : tokens) { + final String term = termOf(token, aligned, normalized); + if (!term.isEmpty()) { + spansByTerm.computeIfAbsent(term, key -> new ArrayList<>()).add(token.span()); + } + } + final List> vectors = new ArrayList<>(spansByTerm.size()); + for (final Map.Entry> entry : spansByTerm.entrySet()) { + vectors.add(Annotation.of(TermVector.withSpans(entry.getKey(), entry.getValue()))); + } + return vectors; + } + + /** + * Aggregates the tokens into scoring-only term vectors, so no offset storage is ever + * allocated. + * + * @param tokens The token layer. + * @param aligned The normalized document text with its alignment, or {@code null} when + * no normalizer is present. + * @param normalized The normalized document text, or {@code null} when no normalizer is + * present. + * @return One annotation per distinct term, in first-occurrence order. + */ + private List> countVectors(List> tokens, + AlignedText aligned, String normalized) { + final Map frequencies = new LinkedHashMap<>(); + for (final Annotation token : tokens) { + final String term = termOf(token, aligned, normalized); + if (!term.isEmpty()) { + frequencies.merge(term, 1, Integer::sum); + } + } + final List> vectors = new ArrayList<>(frequencies.size()); + for (final Map.Entry entry : frequencies.entrySet()) { + vectors.add(Annotation.of(TermVector.count(entry.getKey(), entry.getValue()))); + } + return vectors; + } + + /** {@inheritDoc} */ + @Override + public Set> requires() { + return Set.of(Layers.TOKENS); + } + + /** {@inheritDoc} */ + @Override + public Set> provides() { + return Set.of(TERM_VECTORS); + } + + /** + * Determines the term one token groups under: its covered text as-is, its covered text + * normalized on its own when a plain per-token normalizer is present, or the covered + * text of its span mapped into the normalized form when an offset-aware normalizer is + * present. + * + * @param token The token annotation. + * @param aligned The normalized document text with its alignment, or {@code null} + * when no offset-aware normalizer is present. + * @param normalized The normalized document text, or {@code null} when no offset-aware + * normalizer is present. + * @return The term string. Never {@code null}, possibly empty. + */ + private String termOf(Annotation token, AlignedText aligned, String normalized) { + if (tokenNormalizer != null) { + return tokenNormalizer.normalize(token.value()).toString(); + } + if (aligned == null) { + return token.value(); + } + final Span span = aligned.toNormalizedSpan(token.span().getStart(), token.span().getEnd()); + return normalized.substring(span.getStart(), span.getEnd()); + } +} diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/termvector/SingleSpaceTokens.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/termvector/SingleSpaceTokens.java new file mode 100644 index 0000000000..4f7beb4643 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/termvector/SingleSpaceTokens.java @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.termvector; + +import java.util.ArrayList; +import java.util.List; + +import opennlp.tools.document.Annotation; +import opennlp.tools.tokenize.Tokenizer; +import opennlp.tools.util.Span; + +/** + * Deterministic single-space tokenization shared by the term vector tests: splits on + * single space characters and keeps all other characters, including sentence-final + * periods, attached to their token. Runs of spaces yield no empty tokens, so every + * expected span follows directly from the input text. + */ +final class SingleSpaceTokens { + + /** + * A {@link Tokenizer} view of {@link #spans(String)}. Only the span-producing method + * is implemented because the annotator adapter calls no other method. + */ + static final Tokenizer TOKENIZER = new Tokenizer() { + + @Override + public String[] tokenize(String s) { + throw new UnsupportedOperationException("the adapter only calls tokenizePos"); + } + + @Override + public Span[] tokenizePos(String s) { + return spans(s).toArray(new Span[0]); + } + }; + + private SingleSpaceTokens() { + } + + /** + * Computes the token spans of a text split on single space characters. + * + * @param text The text to split. + * @return One span per token, in text order. + */ + static List spans(String text) { + final List spans = new ArrayList<>(); + int start = -1; + for (int i = 0; i <= text.length(); i++) { + final boolean boundary = i == text.length() || text.charAt(i) == ' '; + if (boundary && start >= 0) { + spans.add(new Span(start, i)); + start = -1; + } else if (!boundary && start < 0) { + start = i; + } + } + return spans; + } + + /** + * Builds a token layer from {@link #spans(String)}, each token valued with its covered + * text. + * + * @param text The text to split. + * @return One annotation per token, in text order. + */ + static List> tokens(String text) { + final List> tokens = new ArrayList<>(); + for (final Span span : spans(text)) { + tokens.add(new Annotation<>(span, text.substring(span.getStart(), span.getEnd()))); + } + return tokens; + } +} diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/termvector/TermVectorAnnotatorTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/termvector/TermVectorAnnotatorTest.java new file mode 100644 index 0000000000..33559371f9 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/termvector/TermVectorAnnotatorTest.java @@ -0,0 +1,424 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.termvector; + +import java.io.Serial; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import opennlp.tools.document.Annotation; +import opennlp.tools.document.Document; +import opennlp.tools.document.LayerKey; +import opennlp.tools.document.Layers; +import opennlp.tools.util.Span; +import opennlp.tools.util.normalizer.AlignedText; +import opennlp.tools.util.normalizer.Alignment; +import opennlp.tools.util.normalizer.CharSequenceNormalizer; +import opennlp.tools.util.normalizer.OffsetAwareNormalizer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the {@link TermVectorAnnotator} roll-up: term identity with and without a + * normalizer, both recording modes, and the graceful-degradation rules of the document + * pipeline. The token layer is built directly here; the wiring through a + * {@code DocumentAnalyzer} is covered by {@code TermVectorPipelineTest}. + */ +public class TermVectorAnnotatorTest { + + /** + * A deterministic {@link OffsetAwareNormalizer} that collapses every run of + * whitespace to one space and applies a full case fold, including the German eszett + * expansion {@code ß -> ss}. Every edit is recorded in an {@link Alignment}, so the + * expected spans below follow directly from the input text. + */ + private static final class WhitespaceCaseFoldNormalizer implements OffsetAwareNormalizer { + + @Serial + private static final long serialVersionUID = 1L; + + @Override + public CharSequence normalize(CharSequence text) { + return normalizeAligned(text).normalized(); + } + + @Override + public AlignedText normalizeAligned(CharSequence text) { + final String source = text.toString(); + final StringBuilder out = new StringBuilder(source.length()); + final Alignment.Builder alignment = new Alignment.Builder(source.length()); + int i = 0; + while (i < source.length()) { + final char c = source.charAt(i); + if (Character.isWhitespace(c)) { + int end = i; + while (end < source.length() && Character.isWhitespace(source.charAt(end))) { + end++; + } + out.append(' '); + alignment.replace(end - i, 1); + i = end; + } else if (c == 'ß') { + out.append("ss"); + alignment.replace(1, 2); + i++; + } else { + final String folded = String.valueOf(c).toLowerCase(Locale.ROOT); + out.append(folded); + if (folded.length() == 1 && folded.charAt(0) == c) { + alignment.equal(1); + } else { + alignment.replace(1, folded.length()); + } + i++; + } + } + return new AlignedText(source, out.toString(), alignment.build(source.length())); + } + } + + /** + * A deterministic {@link OffsetAwareNormalizer} that deletes every digit and copies + * every other character unchanged, so a token made up of digits alone normalizes to + * the empty string. + */ + private static final class DigitDeletingNormalizer implements OffsetAwareNormalizer { + + @Serial + private static final long serialVersionUID = 1L; + + @Override + public CharSequence normalize(CharSequence text) { + return normalizeAligned(text).normalized(); + } + + @Override + public AlignedText normalizeAligned(CharSequence text) { + final String source = text.toString(); + final StringBuilder out = new StringBuilder(source.length()); + final Alignment.Builder alignment = new Alignment.Builder(source.length()); + for (int i = 0; i < source.length(); i++) { + final char c = source.charAt(i); + if (Character.isDigit(c)) { + alignment.replace(1, 0); + } else { + out.append(c); + alignment.equal(1); + } + } + return new AlignedText(source, out.toString(), alignment.build(source.length())); + } + } + + private static final OffsetAwareNormalizer FOLD = new WhitespaceCaseFoldNormalizer(); + + private static final OffsetAwareNormalizer DROP_DIGITS = new DigitDeletingNormalizer(); + + /** + * A plain, alignment-free case folder for the per-token path: it stands in for the + * shipped normalizers (case fold, NFC, accent fold) that cannot report offsets and + * therefore cannot implement {@link OffsetAwareNormalizer}. + */ + private static final CharSequenceNormalizer PLAIN_LOWER = + text -> text.toString().toLowerCase(Locale.ROOT); + + /** + * A plain normalizer that deletes every digit, so a token made up of digits alone + * normalizes to the empty string on the per-token path. + */ + private static final CharSequenceNormalizer PLAIN_DROP_DIGITS = text -> { + final StringBuilder out = new StringBuilder(text.length()); + for (int i = 0; i < text.length(); i++) { + if (!Character.isDigit(text.charAt(i))) { + out.append(text.charAt(i)); + } + } + return out.toString(); + }; + + private static Document documentWithTokens(String text) { + return Document.of(text).with(Layers.TOKENS, SingleSpaceTokens.tokens(text)); + } + + @Test + void testFullModeGroupsByCoveredTextAsIs() { + final Document document = new TermVectorAnnotator() + .annotate(documentWithTokens("The dog barks. The dog naps.")); + + final List> vectors = + document.get(TermVectorAnnotator.TERM_VECTORS); + assertEquals(4, vectors.size()); + // The layer preserves first-occurrence order. + assertEquals(new TermVector("The", 2, List.of(new Span(0, 3), new Span(15, 18))), + vectors.get(0).value()); + assertEquals(new TermVector("dog", 2, List.of(new Span(4, 7), new Span(19, 22))), + vectors.get(1).value()); + assertEquals(new TermVector("barks.", 1, List.of(new Span(8, 14))), + vectors.get(2).value()); + assertEquals(new TermVector("naps.", 1, List.of(new Span(23, 28))), + vectors.get(3).value()); + } + + @Test + void testLayerIsDocumentScopedAndCarriesNoAnnotationSpans() { + final Document document = new TermVectorAnnotator() + .annotate(documentWithTokens("The dog barks.")); + assertEquals(LayerKey.Scope.DOCUMENT, TermVectorAnnotator.TERM_VECTORS.scope()); + for (final Annotation vector : document.get(TermVectorAnnotator.TERM_VECTORS)) { + assertNull(vector.span()); + } + } + + @Test + void testScoringOnlyModeOmitsSpans() { + final Document document = new TermVectorAnnotator(TermVectorAnnotator.Mode.SCORING_ONLY) + .annotate(documentWithTokens("The dog barks. The dog naps.")); + + final List> vectors = + document.get(TermVectorAnnotator.TERM_VECTORS); + assertEquals(4, vectors.size()); + assertEquals(TermVector.count("The", 2), vectors.get(0).value()); + assertEquals(TermVector.count("dog", 2), vectors.get(1).value()); + assertEquals(TermVector.count("barks.", 1), vectors.get(2).value()); + assertEquals(TermVector.count("naps.", 1), vectors.get(3).value()); + for (final Annotation vector : vectors) { + assertTrue(vector.value().spans().isEmpty()); + } + } + + /** + * The offset-fidelity case: whitespace collapse shifts offsets and the eszett case + * fold grows the text, yet every emitted occurrence span must land on the original + * text. {@code "Groß groß GROSS"} normalizes to {@code "gross gross gross"}, so all + * three tokens group under one term while their spans keep pointing at the original + * surface forms. + */ + @Test + void testNormalizationGroupsFoldedTokensWithOriginalOffsets() { + final String text = "Groß groß GROSS"; + final Document document = new TermVectorAnnotator(FOLD) + .annotate(documentWithTokens(text)); + + final List> vectors = + document.get(TermVectorAnnotator.TERM_VECTORS); + assertEquals(1, vectors.size()); + final TermVector vector = vectors.get(0).value(); + assertEquals("gross", vector.term()); + assertEquals(3, vector.frequency()); + assertEquals(List.of(new Span(0, 4), new Span(6, 10), new Span(12, 17)), vector.spans()); + + // Every occurrence span covers the original surface form, not the normalized one. + final List surfaceForms = + vector.spans().stream().map(s -> s.getCoveredText(text).toString()).toList(); + assertEquals(List.of("Groß", "groß", "GROSS"), surfaceForms); + + // The same spans round-trip through the alignment: a token's normalized span maps + // back to exactly its original span, through both edits. + final AlignedText aligned = FOLD.normalizeAligned(text); + assertEquals("gross gross gross", aligned.normalizedString()); + assertEquals(new Span(0, 4), aligned.toOriginalSpan(0, 5)); + assertEquals(new Span(6, 10), aligned.toOriginalSpan(6, 11)); + assertEquals(new Span(12, 17), aligned.toOriginalSpan(12, 17)); + } + + @Test + void testNormalizationKeepsDistinctTermsApart() { + final String text = "Das große Haus ist groß"; + final Document document = new TermVectorAnnotator(FOLD) + .annotate(documentWithTokens(text)); + + final List> vectors = + document.get(TermVectorAnnotator.TERM_VECTORS); + assertEquals(5, vectors.size()); + assertEquals(new TermVector("das", 1, List.of(new Span(0, 3))), vectors.get(0).value()); + assertEquals(new TermVector("grosse", 1, List.of(new Span(5, 10))), vectors.get(1).value()); + assertEquals(new TermVector("haus", 1, List.of(new Span(12, 16))), vectors.get(2).value()); + assertEquals(new TermVector("ist", 1, List.of(new Span(18, 21))), vectors.get(3).value()); + assertEquals(new TermVector("gross", 1, List.of(new Span(23, 27))), vectors.get(4).value()); + } + + @Test + void testScoringOnlyModeWithNormalizerCountsWithoutOffsets() { + final Document document = new TermVectorAnnotator(FOLD, + TermVectorAnnotator.Mode.SCORING_ONLY).annotate(documentWithTokens("Groß groß GROSS")); + + final List> vectors = + document.get(TermVectorAnnotator.TERM_VECTORS); + assertEquals(1, vectors.size()); + assertEquals(TermVector.count("gross", 3), vectors.get(0).value()); + } + + /** + * The general per-token path: a plain {@link CharSequenceNormalizer} defines term + * identity by folding each token's covered text, no alignment involved, while every + * occurrence span stays the token's own span in the original text. This admits the + * folds {@code buildAligned()} rejects (case fold, NFC, accent fold). + */ + @Test + void testPlainNormalizerGroupsFoldedTokensWithOriginalSpans() { + final String text = "Word word WORD"; + final Document document = new TermVectorAnnotator(PLAIN_LOWER) + .annotate(documentWithTokens(text)); + + final List> vectors = + document.get(TermVectorAnnotator.TERM_VECTORS); + assertEquals(1, vectors.size()); + assertEquals(new TermVector("word", 3, + List.of(new Span(0, 4), new Span(5, 9), new Span(10, 14))), vectors.get(0).value()); + + // Every occurrence span covers the original surface form, not the folded one. + final List surfaceForms = vectors.get(0).value().spans().stream() + .map(s -> s.getCoveredText(text).toString()).toList(); + assertEquals(List.of("Word", "word", "WORD"), surfaceForms); + } + + @Test + void testPlainNormalizerScoringOnlyModeCountsWithoutOffsets() { + final Document document = new TermVectorAnnotator(PLAIN_LOWER, + TermVectorAnnotator.Mode.SCORING_ONLY).annotate(documentWithTokens("Word word WORD")); + + final List> vectors = + document.get(TermVectorAnnotator.TERM_VECTORS); + assertEquals(1, vectors.size()); + assertEquals(TermVector.count("word", 3), vectors.get(0).value()); + } + + /** + * Empty-term omission behaves identically on the per-token path: tokens a plain + * normalizer folds to the empty string are left out of the layer, matching + * {@link #testTokensNormalizedAwayAreOmitted()}. + */ + @Test + void testPlainNormalizerOmitsDeletedTokens() { + final Document document = new TermVectorAnnotator(PLAIN_DROP_DIGITS) + .annotate(documentWithTokens("dog 42 dog 7")); + + final List> vectors = + document.get(TermVectorAnnotator.TERM_VECTORS); + assertEquals(1, vectors.size()); + assertEquals(new TermVector("dog", 2, List.of(new Span(0, 3), new Span(7, 10))), + vectors.get(0).value()); + } + + @Test + void testNullPlainNormalizerIsRejected() { + final CharSequenceNormalizer noNormalizer = null; + assertThrows(IllegalArgumentException.class, () -> new TermVectorAnnotator(noNormalizer)); + assertThrows(IllegalArgumentException.class, + () -> new TermVectorAnnotator(noNormalizer, TermVectorAnnotator.Mode.FULL)); + assertThrows(IllegalArgumentException.class, + () -> new TermVectorAnnotator(PLAIN_LOWER, null)); + } + + /** + * A token the normalizer deletes entirely is omitted from the layer: an empty string + * is no term, and the token layer still accounts for the token itself. + */ + @Test + void testTokensNormalizedAwayAreOmitted() { + final Document document = new TermVectorAnnotator(DROP_DIGITS) + .annotate(documentWithTokens("dog 42 dog 7")); + + final List> vectors = + document.get(TermVectorAnnotator.TERM_VECTORS); + assertEquals(1, vectors.size()); + assertEquals(new TermVector("dog", 2, List.of(new Span(0, 3), new Span(7, 10))), + vectors.get(0).value()); + } + + /** + * Spans are UTF-16 offsets, so a supplementary-plane character occupies two positions: + * {@code "𝕏 x 𝕏"} tokenizes to spans of width two around the surrogate pairs, and both + * occurrences group under one term whose spans still cover the original text exactly. + */ + @Test + void testSupplementaryPlaneTokensKeepUtf16Offsets() { + final String text = "𝕏 x 𝕏"; + final Document document = new TermVectorAnnotator() + .annotate(documentWithTokens(text)); + + final List> vectors = + document.get(TermVectorAnnotator.TERM_VECTORS); + assertEquals(2, vectors.size()); + assertEquals(new TermVector("𝕏", 2, List.of(new Span(0, 2), new Span(5, 7))), + vectors.get(0).value()); + assertEquals(new TermVector("x", 1, List.of(new Span(3, 4))), vectors.get(1).value()); + for (final Annotation vector : vectors) { + for (final Span span : vector.value().spans()) { + assertEquals(vector.value().term(), span.getCoveredText(text).toString()); + } + } + } + + /** + * A document whose every token normalizes to the empty string yields the layer + * present but empty, the same graceful degradation as an empty token layer. + */ + @Test + void testAllTokensNormalizedAwayYieldPresentButEmptyLayer() { + final Document document = new TermVectorAnnotator(DROP_DIGITS) + .annotate(documentWithTokens("42 7")); + assertTrue(document.layers().contains(TermVectorAnnotator.TERM_VECTORS)); + assertTrue(document.get(TermVectorAnnotator.TERM_VECTORS).isEmpty()); + } + + @Test + void testEmptyTokenLayerYieldsPresentButEmptyLayer() { + final Document document = new TermVectorAnnotator() + .annotate(Document.of("").with(Layers.TOKENS, List.of())); + assertTrue(document.layers().contains(TermVectorAnnotator.TERM_VECTORS)); + assertTrue(document.get(TermVectorAnnotator.TERM_VECTORS).isEmpty()); + } + + @Test + void testMissingTokenLayerIsRejected() { + final TermVectorAnnotator annotator = new TermVectorAnnotator(); + final Document bare = Document.of("The dog barks."); + assertThrows(IllegalArgumentException.class, () -> annotator.annotate(bare)); + } + + @Test + void testNullDocumentIsRejected() { + final TermVectorAnnotator annotator = new TermVectorAnnotator(); + assertThrows(IllegalArgumentException.class, () -> annotator.annotate(null)); + } + + @Test + void testNullConstructorArgumentsAreRejected() { + final TermVectorAnnotator.Mode noMode = null; + final OffsetAwareNormalizer noNormalizer = null; + assertThrows(IllegalArgumentException.class, () -> new TermVectorAnnotator(noMode)); + assertThrows(IllegalArgumentException.class, () -> new TermVectorAnnotator(noNormalizer)); + assertThrows(IllegalArgumentException.class, () -> new TermVectorAnnotator(FOLD, null)); + assertThrows(IllegalArgumentException.class, + () -> new TermVectorAnnotator(null, TermVectorAnnotator.Mode.FULL)); + } + + @Test + void testRequiresAndProvides() { + final TermVectorAnnotator annotator = new TermVectorAnnotator(); + assertEquals(Set.of(Layers.TOKENS), annotator.requires()); + assertEquals(Set.of(TermVectorAnnotator.TERM_VECTORS), annotator.provides()); + } +} diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/termvector/TermVectorNormalizedExampleTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/termvector/TermVectorNormalizedExampleTest.java new file mode 100644 index 0000000000..2868b021e7 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/termvector/TermVectorNormalizedExampleTest.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.termvector; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import opennlp.tools.document.Annotation; +import opennlp.tools.document.Document; +import opennlp.tools.document.DocumentAnalyzer; +import opennlp.tools.tokenize.TokenizerAnnotator; +import opennlp.tools.tokenize.WhitespaceTokenizer; +import opennlp.tools.util.Span; +import opennlp.tools.util.normalizer.CharSequenceNormalizer; +import opennlp.tools.util.normalizer.TextNormalizer; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Mirrors the normalized-term workflow shown in the term vector section of the manual: + * a whitespace tokenizer feeding a {@link TermVectorAnnotator} built with a shipped, + * plain {@link CharSequenceNormalizer} case folder. The folder defines term identity per + * token, so case variants group under one term, while every occurrence span stays the + * token's own span in the original text. + */ +public class TermVectorNormalizedExampleTest { + + /** + * The documented example: {@code "Word word WORD"} yields one term, {@code "word"}, + * with three occurrence spans, each the token's exact original span. + */ + @Test + void testCaseFoldedTermsKeepOriginalSpans() { + final CharSequenceNormalizer folder = TextNormalizer.builder().caseFold().build(); + final DocumentAnalyzer analyzer = DocumentAnalyzer.builder() + .add(new TokenizerAnnotator(WhitespaceTokenizer.INSTANCE)) + .add(new TermVectorAnnotator(folder)) + .build(); + + final Document document = analyzer.analyze("Word word WORD"); + + final List> vectors = + document.get(TermVectorAnnotator.TERM_VECTORS); + assertEquals(1, vectors.size()); + assertEquals(new TermVector("word", 3, + List.of(new Span(0, 4), new Span(5, 9), new Span(10, 14))), vectors.get(0).value()); + + // The spans point at the original surface forms, not the folded term. + final List surfaceForms = vectors.get(0).value().spans().stream() + .map(s -> s.getCoveredText(document.text()).toString()).toList(); + assertEquals(List.of("Word", "word", "WORD"), surfaceForms); + } +} diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/termvector/TermVectorPipelineTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/termvector/TermVectorPipelineTest.java new file mode 100644 index 0000000000..8510cfdac9 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/termvector/TermVectorPipelineTest.java @@ -0,0 +1,109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.termvector; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import opennlp.tools.document.Annotation; +import opennlp.tools.document.Document; +import opennlp.tools.document.DocumentAnalyzer; +import opennlp.tools.document.Layers; +import opennlp.tools.tokenize.TokenizerAnnotator; +import opennlp.tools.util.Span; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Wires the {@link TermVectorAnnotator} into a {@link DocumentAnalyzer} behind a + * {@link TokenizerAnnotator}: the token layer goes in, the term vector layer comes out, + * and nothing else about the document changes. The tokenizer is the shared deterministic + * single-space fixture, so every expected span follows directly from the input text. + */ +public class TermVectorPipelineTest { + + /** + * Runs tokenizer plus term vector roll-up over a text with repeated tokens and reads + * the aggregated layer back, span by span, in original text coordinates. + */ + @Test + void testTokenizerAndTermVectorPipeline() { + final DocumentAnalyzer analyzer = DocumentAnalyzer.builder() + .add(new TokenizerAnnotator(SingleSpaceTokens.TOKENIZER)) + .add(new TermVectorAnnotator()) + .build(); + + final Document document = analyzer.analyze("The dog barks. The dog naps."); + + // The document carries exactly the two layers the pipeline provides. + assertEquals(Set.of(Layers.TOKENS, TermVectorAnnotator.TERM_VECTORS), document.layers()); + + final List> vectors = + document.get(TermVectorAnnotator.TERM_VECTORS); + assertEquals(4, vectors.size()); + assertEquals(new TermVector("The", 2, List.of(new Span(0, 3), new Span(15, 18))), + vectors.get(0).value()); + assertEquals(new TermVector("dog", 2, List.of(new Span(4, 7), new Span(19, 22))), + vectors.get(1).value()); + assertEquals(new TermVector("barks.", 1, List.of(new Span(8, 14))), + vectors.get(2).value()); + assertEquals(new TermVector("naps.", 1, List.of(new Span(23, 28))), + vectors.get(3).value()); + + // Every occurrence span indexes into the original text and covers its own term. + for (final Annotation vector : vectors) { + for (final Span span : vector.value().spans()) { + assertEquals(vector.value().term(), span.getCoveredText(document.text()).toString()); + } + } + } + + /** + * The same pipeline in scoring-only mode: counts survive, offsets are never stored. + */ + @Test + void testScoringOnlyPipeline() { + final DocumentAnalyzer analyzer = DocumentAnalyzer.builder() + .add(new TokenizerAnnotator(SingleSpaceTokens.TOKENIZER)) + .add(new TermVectorAnnotator(TermVectorAnnotator.Mode.SCORING_ONLY)) + .build(); + + final Document document = analyzer.analyze("The dog barks. The dog naps."); + + final List> vectors = + document.get(TermVectorAnnotator.TERM_VECTORS); + assertEquals(4, vectors.size()); + assertEquals(TermVector.count("The", 2), vectors.get(0).value()); + assertEquals(TermVector.count("dog", 2), vectors.get(1).value()); + } + + /** + * The analyzer validates the pipeline at build time: the term vector annotator + * requires the token layer, so a pipeline without a tokenizer fails when it is + * assembled. + */ + @Test + void testPipelineWithoutTokenizerIsRejectedAtBuildTime() { + final DocumentAnalyzer.Builder builder = DocumentAnalyzer.builder() + .add(new TermVectorAnnotator()); + assertThrows(IllegalArgumentException.class, builder::build); + } +} diff --git a/opennlp-docs/src/docbkx/document.xml b/opennlp-docs/src/docbkx/document.xml index 7177fdf0bb..169a34c2fd 100644 --- a/opennlp-docs/src/docbkx/document.xml +++ b/opennlp-docs/src/docbkx/document.xml @@ -335,4 +335,60 @@ int firstTokenLength = lengths.get(0).value(); // 3, for "The" at [0..3)]]> adapters use. + +
+ Term vectors + + TermVectorAnnotator rolls the token layer up into a document-scoped + term vector layer for index consumers: one TermVector per distinct + term, carrying the term string, its occurrence count, and the occurrence spans in + original text coordinates. Term identity comes from the annotator's inputs: without + a normalizer the term is the token's covered text as-is; with a plain + CharSequenceNormalizer, the general path, each token's covered text is + normalized on its own to become the term; with an + OffsetAwareNormalizer the document text is normalized once and each + token's covered normalized text becomes the term. On every path tokens differing + only by a normalization fold group together while their spans keep pointing into + the original text. A token whose normalized form is empty is omitted from the + layer. The layer preserves first-occurrence order. + TermVectorPipelineTest#testTokenizerAndTermVectorPipeline asserts the + behavior shown here. + > vectors = document.get(TermVectorAnnotator.TERM_VECTORS); +// vectors.get(1).value() is ("dog", 2, [[4..7), [19..22)])]]> + + + + The normalized-term workflow passes any shipped + CharSequenceNormalizer to the plain-normalizer constructor: the + normalizer is applied to each token's covered text to produce the term, so the + case, NFC, and accent folds that buildAligned() rejects all work + here, and every occurrence span stays the token's own span in the original text. + TermVectorNormalizedExampleTest#testCaseFoldedTermsKeepOriginalSpans + asserts the behavior shown here. + > vectors = document.get(TermVectorAnnotator.TERM_VECTORS); +// vectors.get(0).value() is ("word", 3, [[0..4), [5..9), [10..14)])]]> + + + + Scoring-only consumers that never read offsets can skip storing them: + new TermVectorAnnotator(TermVectorAnnotator.Mode.SCORING_ONLY) emits + vectors that carry counts but no spans, and + TermVector.count("dog", 2) builds the matching expectation in tests. + +