OPENNLP-1911: Add reproducible evaluation for bounded in-memory embedding search - #1215
Draft
krickert wants to merge 244 commits into
Draft
OPENNLP-1911: Add reproducible evaluation for bounded in-memory embedding search#1215krickert wants to merge 244 commits into
krickert wants to merge 244 commits into
Conversation
…with exact original-text spans New opennlp-extensions module implementing SentencePiece model inference without native code: the ModelProto reader, the model-embedded normalizer (precompiled character map over a Darts-clone double-array trie, whitespace collapsing and escaping, the dummy word-boundary marker), unigram best-path segmentation, BPE agenda merging, byte fallback, and user-defined symbol handling. The public contract is SubwordTokenizer/SubwordPiece; every piece reports the exact UTF-16 span of the caller's original text it came from, and the model normalizer is also exposed as an OffsetAwareNormalizer producing AlignedText. Parity with the reference implementation is asserted, not assumed: five tiny bundled models (unigram, unigram with byte fallback, BPE, identity normalization, whitespace-as-suffix) carry fixtures generated by the sentencepiece Python package over 40 inputs each, checked piece for piece, id for id, span for span, plus each model's embedded self-test samples. An opt-in test (-Dopennlp.subword.eval.dir) runs the same assertions against real downloaded models; T5-small and ALBERT-base-v2 pass exactly, including mixed scripts, emoji ZWJ sequences, BOM, and CRLF inputs.
…step The vocabulary trie dispatches wide nodes (the root and first level of a real vocabulary) through a 256-entry direct table, one load per byte, and scans narrow nodes' short label slices linearly instead of binary searching; a randomized differential test holds both layouts against a map-backed reference, and moving the duplicate-piece detection into the counting pass fixes the index error it previously produced. Non-unknown segments reuse the vocabulary's piece string instead of decoding their bytes, since the trie match means the bytes are identical. The normalizer precomputes, per possible first byte, whether any character-map rule or user-defined symbol starts with it; a clear bit proves the prefix machinery would pass the byte through raw, so plain ASCII text skips it entirely. The per-chunk record became a per-call scratch, the input view keeps its oversized buffers with an explicit length instead of trimming (pure-ASCII text gets an identity offset map and no map array at all), the Viterbi scratch is one interleaved array with scores as raw float bits, and the character-map trie walk relies on the JVM's own bounds checks with the fail-loud translation on the cold path. All 37 bundled parity tests and the T5-small and ALBERT real-model fixtures pass byte-identically. Single-thread throughput on the T5-small vocabulary goes from 2.83M to 6.47M pieces per second, from 0.62x to 1.42x of the reference implementation measured through its Python binding.
SubwordTokenizer and SubwordPiece move to opennlp.tools.tokenize, next to Tokenizer and WordpieceTokenizer, matching where every other seam of this round lives. The opennlp-subword module keeps only the SentencePiece implementation.
…zer into it WordpieceEncoder in opennlp-api runs the full BERT tokenization pipeline as a SubwordTokenizer: every piece carries its vocabulary id and the span of the original text, surviving the normalization steps that change, insert, and remove characters. Content is computed with the same library calls the previous pipeline made; offsets come from a per-code-point rerun, with contextual case mappings (Greek final sigma) falling back to word-wide spans that widen but never misplace. List and map constructors cover line-number and explicit-id vocabularies. BertTokenizer, unreleased and superseded, is removed. The dl tokenizer creation builds on the encoder behind the existing Tokenizer plumbing via a package-private adapter with unchanged special-token selection, and a vocabulary missing its special tokens now fails at construction instead of at the first id mapping, pinned by a test. Parity is enforced twice: a differential suite against the reference pipeline (kept test-only as ReferenceBertPipeline) over a curated corpus plus 800 randomized inputs, and the removed class's reference token sequences ported case for case. WordpieceTokenizer is untouched.
…verrides Applies the review conventions from the OPENNLP-1869 review: class javadoc states the contracts instead of design narrative, every override carries inheritDoc with its null contract, and the private helpers are documented.
The tokenizer is Serializable through the OffsetAwareNormalizer contract but declared no serialVersionUID, which the compiler warns about. Added the serialver-computed value so it matches the convention used across the normalizer classes.
Adds a Subword Tokenization section to the Tokenizer chapter: the SubwordTokenizer contract and its original-text span guarantee, loading and using a SentencePiece model including the OffsetAwareNormalizer face, and the WordpieceEncoder pipeline with its vocab.txt construction and special-token framing.
…s, name the format constants, document every helper
…bjectInputFilter SentencePieceTokenizer gains serialize(OutputStream) and deserialize(InputStream) methods. Reads are filtered through an ObjectInputFilter that allow-lists only the classes reachable from a legitimate tokenizer graph and bounds graph depth, references, and array length; foreign payloads are rejected with InvalidClassException before being materialised. Limits are adjustable through a DeserializationLimits record for unusually large vocabularies; the allow-list is not configurable. The serialVersionUID is recomputed for the new public methods.
Add SentencePieceUsageExampleTest asserting the load-and-encode workflow and point the tokenizer manual section at it.
…ormed models, tag helpers, javadoc throws
…ixtures, thread safety wording - Normalize the argument validation messages to the project style, naming the offending parameter and dropping the leading article and the trailing period, in WordpieceEncoder, SentencePieceTokenizer, ModelProtoReader, BpeEncoder, UnigramEncoder and the SubwordPiece compact constructor. - Stop promising thread safety in the SubwordTokenizer contract and state that it is implementation specific instead; the manual now records that both shipped implementations are immutable and therefore safe for concurrent use. - Drop the @throws IllegalArgumentException tags that only repeated the inherited contract on the normalize and normalizeAligned overrides, leaving a plain {@inheritdoc} as the rest of the class does. - Remove commentary about release history rather than about the code: the pointer to the BertTokenizer class of the 3.0.0 milestone builds in WordpieceTokenizer, and the "frozen" qualifier on the ReferenceBertPipeline baseline. - Move the bundled model loading and the fixture file reading out of SentencePieceParityTest into SentencePieceFixtures, so the alignment, validation and serialization tests no longer reach into another test class for a tokenizer. - Extract MODEL_SUFFIX and FIXTURES_SUFFIX constants on SentencePieceFixtures and use them in SentencePieceRealModelEvalTest when deriving a fixture path from a model path, instead of repeating the two literals. - Fold the five duplicated @valuesource model lists into a single SentencePieceFixtures#models @MethodSource, so adding a bundled model stays a one line change. - Correct the parity test javadoc, which credited a nonexistent gen_fixtures.tsv sibling script instead of the gen_fixtures.py script in the test resources. - Pin accessors that had no coverage: every score is finite and out of range ids are rejected, byte pieces occur only in byte fallback models and always render in the <0x..> form, and isByte rejects negative ids. - Assert SubwordPiece.span() next to start and end in WordpieceEncoderTest so the derived span stays covered by the piece assertions. - Document the IOException of the serialized helper in the serialization test and fully qualify the OutputStream javadoc link now that the import is gone.
… per review Applies the review: the old entry point stays through one stable release instead of being removed, and the DL extension point keeps its descriptor. - Recreate BertTokenizer in opennlp-api as a thin shim, deprecated since 3.0.0 forRemoval, with the original three Set based constructors and the original tokenizePos message. tokenize delegates to encodeToPieces; ids are synthesized from the set order because the tokenize path never reads them. Null contract follows this branch's reviewed convention, IllegalArgumentException, documented in the throws clauses. - Delete the package-private EncoderTokenizer; the adapter now lives in opennlp-api where downstream code can reach it. AbstractDL's protected createTokenizer returns BertTokenizer again, restoring the override descriptor so an already compiled subclass keeps overriding at runtime, and createPipelineTokenizer hands back the shim. - Delete ReferenceBertPipeline and point the curated and randomized differential tests in WordpieceEncoderTest at the shim, pinning shim and encoder to one sequence. Add BertTokenizerTest covering each constructor's argument validation, the default special token chain, and the exact tokenizePos message. Independent expected sequences continue to live in WordpieceEncoderReferenceSequencesTest. - Fix a real divergence the compatibility check surfaced: the encoder kept U+2028 and U+2029 inside words while the old pipeline split on them, so a word carrying a line or paragraph separator became the unknown piece. cleanAndIsolateCjk now maps Zl and Zp to a space, with a span asserting regression test. - Manual: the WordPiece section describes the deprecation and the migration, including the Set to List vocabulary change.
Add a README for regenerating the bundled SentencePiece parity fixtures and clarify that Utf8Text is the encode-path span bridge behind SubwordPiece offsets.
State that the reader is an independent re-implementation of the serialized format, cite Aoe (1989), Yata et al. (2007), and Kanda et al. (2023) in the class javadoc, and decode the bit-9 offset extension with a plain conditional instead of the branchless form.
…ink it from the manual State that the reference implementation produces the expected fixture outputs, add the end-to-end validation steps for the bundled and real models, and point the manual's SentencePiece section at the README.
The absolute GitHub URL 404s until merge and pins the branch layout.
New extension module, targeting a modern (2025) static-embedding distillation format as OpenNLP's word2vec/GloVe successor: same flat per-token vector table artifact shape, pure JVM lookup at inference time, no PyTorch/ONNX runtime dependency. SafetensorsFile/SafetensorsHeaderParser read the safetensors format (8-byte little-endian header length, JSON header describing each tensor's dtype/shape/byte range, then raw tensor bytes). Hand-rolled cursor parser scoped to the header's actual shape, no third-party JSON dependency, matching the project's existing data-file reader discipline. safetensors carries no executable content (unlike PyTorch's pickle-based checkpoints), so no XXE-style hardening is needed, only ordinary malformed-input handling. singleMatrixTensorName() deliberately does not guess a tensor key name convention: distillation tools do not agree on one, so it auto-detects the lone 2-D F32 tensor and fails loud listing every candidate when that is ambiguous, rather than risk silently loading the wrong tensor. Next: tokenizer wiring and the mean-pool/normalize lookup path, targeting minishlab/potion-base-8M as the v1 reference model.
WordPieceVocabulary reads a BERT-style vocab.txt (line number is the token's row id, the format minishlab/potion-base-8M and the wider BGE/BERT family ship). StaticEmbeddingModel wires it to the existing BertTokenizer/WordpieceTokenizer (reused as-is, no new tokenizer code) and the safetensors reader from the previous commit, and implements the pooling formula. The formula is verified against MinishLab's Rust reference implementation (model2vec-rs), not assumed: [CLS]/[SEP] are stripped before pooling since this is table lookup, not transformer input (the tokenizer always adds them, so this class trims the first/last token rather than needing a second tokenizer mode); unknown tokens are dropped from both the sum and the denominator; each pooled token's vector is multiplied by an optional per-token weight from a second "weights" tensor when the safetensors file has one; the sum is divided by the plain pooled-token count, not the sum of weights, which is the exact detail source-verification caught (the two give different results whenever a weight isn't 1.0, and guessing wrong would have silently produced vectors that don't match the reference Python/Rust output). Normalization uses an epsilon floor so a token-less input yields a zero vector instead of a division by zero. Tests hand-compute the expected pooled vectors for a small synthetic vocabulary and safetensors fixture, including a dedicated test that distinguishes the weighted-sum/token-count-denominator behavior from the (wrong) weighted-sum/sum-of-weights alternative.
similarity(text1, text2): cosine similarity between two pooled embeddings. mostSimilar(text, topK): nearest vocabulary tokens to a pooled query vector, brute-force over the vocabulary (fine at the tens-of- thousands-of-rows scale this module targets; an ANN index is a documented, deferred follow-up, not v1 scope). Excludes the special tokens only; a single-word query's own vocabulary row is, correctly, its own top match, unlike gensim's convention of excluding the query word, which does not generalize to multi-word text queries anyway. analogy(a, b, c, topK): the classic word2vec vector arithmetic (embed(b) - embed(a) + embed(c)), additionally excluding a, b, and c themselves from the results, which is load-bearing here (not just convention) since all three are trivially close to the constructed target vector. Tests use a small fixture with genuinely non-collinear vectors (the pooling-math fixture in StaticEmbeddingModelTest is deliberately collinear, which is ideal for hand-computing weighted averages but would make every pairwise similarity a trivial 1.0), built so the analogy has an exact answer: king - man + woman == queen.
…followup jmh profile Same opt-in jmh Maven profile pattern already used by opennlp-runtime (build-helper adds src/jmh/java as a test source root, jmh-core plus the annotation processor, activated only via -Pjmh; the default mvn verify is unaffected). Fixture is synthesized at the real minishlab/potion-base-8M scale (29,528 rows, 256 dimensions, both verified against the live model repo earlier) rather than downloaded, so the benchmark has no network dependency, but seeded with real English words so the benchmark sentences hit actual vocabulary entries instead of degenerating into all-[UNK] lookups. Forked run (2 forks x 10 iterations, the annotated configuration, not the quick-iteration main() override): embed (5 short sentences/op): 999,222.698 +/- 29,452.980 ops/s mostSimilarTop10 (full ~29.5k-row scan): 3,292.110 +/- 203.639 ops/s This is the JVM-only raw-throughput baseline the design doc calls for before any "faster than Python" claim; the concurrent-load comparison against a Python baseline is a separate, later benchmark.
…ighbor scan, thread-safety hardening
Two analogy() bugs fixed. Passing equal terms crashed with
IllegalArgumentException("duplicate element") from Set.of; and the
exclusion compared raw input strings against vocabulary tokens, so on
an uncased model analogy("Man", "King", "Woman", k) handed "king"
straight back as a result. Exclusion now folds the terms through the
model's own tokenizer and excludes the resulting vocabulary rows,
which makes it case- and accent-consistent with embed() and tolerant
of equal or multiword terms. Both are pinned by new tests.
Nearest-neighbor scan reworked around three observations: per-row L2
norms are constants of the model, so they are precomputed at load
instead of recomputed (with a sqrt) for every row on every query; the
top-K selection now uses a bounded min-heap over primitive parallel
arrays instead of materializing and fully sorting one record per
vocabulary row per query; and the special-token check is a
precomputed boolean mask instead of per-row string hashing. The dot
loop uses four accumulators because the JIT must not reorder
floating-point additions and so cannot unroll the reduction itself.
The zero-norm-row NaN guard is preserved and now has its own test.
embed() drops an OptionalInt allocation per token (primitive -1
sentinel) and hoists the weight branch out of the accumulation loop.
Forked JMH, same configuration and fixture as the recorded baseline:
embed: 999,222 -> 1,041,654 ops/s (+4.2%)
mostSimilarTop10: 3,292 -> 9,173 ops/s (2.79x)
Thread safety reviewed and hardened: @threadsafe on
StaticEmbeddingModel, SafetensorsFile, and WordPieceVocabulary; the
class javadoc now documents why the one piece of global mutable state
in the tokenizer chain (WhitespaceTokenizer.INSTANCE's keepNewLines
flag) cannot affect results, since BERT basic tokenization replaces
all whitespace with plain spaces before that split runs; and a new
concurrency test runs 8 threads against one shared instance comparing
every result to the single-threaded reference.
Replaces the whole-file byte[] (capped at 2 GB by Java's int-indexed arrays, and failing as an opaque OutOfMemoryError beyond it) with positional FileChannel reads: the header is read eagerly, tensor data streams straight into the caller's float[] through a reused 1 MB chunk. File size is now unlimited; the remaining ceiling is per decoded tensor (a float[] holds at most ~2.1 billion elements) and is checked with a clear message. Peak load memory drops since file bytes and the decoded array no longer coexist. A file truncated between read() and readFloat32() fails loud instead of returning partial data.
…rbage Writing the direct tests surfaced one gap: parseTop stopped at the closing brace and silently ignored anything after it. Trailing whitespace stays legal (writers space-pad the header to align the data section), any other trailing content now fails loud.
A relative path with a '..' segment is ambiguous as a teacher reference: Windows collapses '..' lexically without checking that the segment before it exists, so a misspelled hub id such as BAAI/.. silently names the working directory there while POSIX reports it as nonexistent. This is the windows-latest CI failure in HuggingFaceModelCacheTest testMalformedTeacherReferenceIsRejectedBeforeAnyRequest[14]. Red evidence on Linux: the new test fails against the current code with 'Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown', because resolve(src/..) returns the module directory.
Windows collapses '..' lexically without checking that the segment before it exists, so resolve(BAAI/..) treated the working directory as the teacher directory there while POSIX rejects the reference; this was the windows-latest CI failure in HuggingFaceModelCacheTest testMalformedTeacherReferenceIsRejectedBeforeAnyRequest[14]. The local directory shortcut now refuses relative paths containing a '..' segment, so the rejection is identical on every platform. Absolute paths and clean relative paths are unaffected, and a local directory still wins over the hub. Also rework the teacher-name escaping test, which created a directory named teacher"quoted: the quote is illegal in Windows file names, so the test could not even start there. The name-to-config plumbing is now covered with a plain directory name, and the JSON escaper behind tokenizer_name is exercised directly with quotes, backslashes, and control characters.
…vector-search-evaluation
…-1885-sentencepiece
Three regression tests failed before enforcing the vector length across batches. The encoder now rejects inconsistent output before distillation writes model files. Add an original ONNX lookup fixture, executable distillation examples and matching manual guidance. Clean embeddings, dependency and manual verification passed; all 496 embeddings tests passed.
Six regression cases failed when float midpoint rounding selected a non-nearest level. Preserve double precision for encoding thresholds and test exact midpoint selection. Add independent ONQ2 fixtures for 2-, 3-, and 4-bit matrices, including saved bytes, decoded coordinates, scoring, pooling, truncation, and trailing data checks.
10 file-size tests failed with the 4-byte metadata estimate; 13 truncated-header tests failed with EOFException. Count the 8-byte scale and norm, preserve the read failure cause, and test saved-index queries. Update the manual and persistence example. Index file content and scoring are unchanged.
Report files must be separate from each other and from passage and dictionary inputs, including symbolic and hard links. Check paths before evaluation and before writing. 20 input-overwrite tests and 3 controlled-clock tests failed before correction. Additional cases cover report aliases, truncated path names, late link changes, cleanup failures, and directory-link resolution. Measure index construction, insertion, and freeze; document the timing scope in Markdown and TSV. Share internal checks with the test-only HNSW command.
Correct embedding batch accounting and incremental JMH annotation processing. Add loading and allocation benchmarks, fixture tests and usage instructions.
…ector-search-evaluation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Depends on #1214, which depends on #1213 and #1152. Please review this after its parents.
This adds reproducible evaluation tooling for OpenNLP's bounded in-memory vector indexes:
Lucene 10.4.0 is test scope only. The HNSW adapter and baseline are test sources, and neither Lucene classes nor the adapter are present in the shipped opennlp-embeddings JAR. The baseline is intended to make exact scan, quantized scan, and graph-index tradeoffs visible, not to add a production Lucene provider in this ticket.
The retrieval tasks are reproducible diagnostics without manually labeled judgments. Their results are not presented as claims of general production search relevance.
Verification
The unrestricted upstream reactor test command additionally encountered unrelated model-download failures in inherited runtime and formats tests. Those failures could not obtain public tokenizer and sentence models; 1,682 runtime unit tests passed before the download-dependent failures.
JIRA
https://issues.apache.org/jira/browse/OPENNLP-1911