Skip to content

OPENNLP-1877: Static embedding engine for modern distilled embedding tables (opennlp-embeddings) - #1152

Draft
krickert wants to merge 47 commits into
apache:OPENNLP-1885-sentencepiecefrom
ai-pipestream:static-embeddings
Draft

OPENNLP-1877: Static embedding engine for modern distilled embedding tables (opennlp-embeddings)#1152
krickert wants to merge 47 commits into
apache:OPENNLP-1885-sentencepiecefrom
ai-pipestream:static-embeddings

Conversation

@krickert

@krickert krickert commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Stacked on #1165 (sentencepiece). This PR is based on the sentencepiece branch so the SentencePiece models load; merge #1165 first, then retarget this PR to main.

Adds a new opennlp-extensions/opennlp-embeddings module: a pure-JVM engine for modern static embedding tables (Model2Vec-family distillations, the 2024/2025 successors to word2vec/GloVe: same flat per-token table shape, sentence-transformer semantics, inference is pure lookup).

Positioning: this is the speed tier of text embedding, not a replacement for anything. The ONNX SentenceVectorsDL in opennlp-dl runs the actual transformer and produces contextual vectors, so this isn't made to be a replacement as that's the OOTB accuracy tier.

This module trades that context for a static table: no native runtime, no GPU, and throughput in the hundreds of thousands of texts per second per core. Different pipeline stages want different points on that curve. Matches the philosophy of this style of embedding.

What's in it:

  • TextEmbedder (new interface in opennlp-api, package opennlp.tools.embeddings): the text-level embedding contract. embed(CharSequence), embedAll(List) (default implementation loops; runtimes that batch efficiently should override), and dimension(). It is the text-level counterpart of the existing word-level WordVectorTable, and the javadoc states that layer difference explicitly. StaticEmbeddingModel is the first implementation. SentenceVectorsDL is the natural second one: adopting the interface is purely additive (its existing constructors and getVectors are untouched), and its ONNX runtime is exactly what the overridable embedAll batch method exists for. That adoption is a separate discussion, not part of this PR.
  • SafetensorsFile: reads the safetensors format with a purpose-built cursor parser for the JSON header (no third-party JSON dependency; decodes F32, F16, and BF16, widening the 16-bit types to float). safetensors carries no executable content, unlike pickle-based checkpoints, so loading is safe by construction. The embedding matrix is auto-detected as the single 2-D float tensor, failing loud and listing candidates on ambiguity rather than guessing a key-name convention.
  • WordpieceVocabulary: BERT-style vocab.txt, line number = embedding row id. (Named to match the existing WordpieceTokenizer casing.)
  • StaticEmbeddingModel: embeds through the existing BertTokenizer/WordpieceTokenizer, reused unchanged. The pooling formula is verified against the Moust reference implementations:[CLS]/[SEP] never pooled, unknown tokens dropped from sum and denominator, optional per-row weights tensor, token-count denominator, epsilon-floored normalization.
  • Convenience surface: similarity, mostSimilar (bounded top-K over precomputed row norms), analogy (input
    terms excluded by folding through the mode

Posture: code only, bring your own tab fetched at build or run time, no new dependencies.

Thread safety: immutable, @ThreadSafe, with an 8-thread concurrency test comparing every result against the
single-threaded reference.

Measured (JMH, opt-in jmh profile matching opennlp-runtime's pattern; fixture at real published-table scale, 29,528 x 256): embed ~766k short sentences/s on one core (1.04M ops/s of 5 sentences at 32 threads); full-vocabulary top-10 scan 649/s per core, ~9.2k/s at 32 threads.

Verification: opennlp-embeddings 43/0 plus the new interface contract test, mvn verify green including checkstyle and forbiddenapis.

Follow-ups (deliberately out of this PR): TextEmbedder with a real batchedembedAll (pending discussion with its author), the gRPC backend in opennlp-sandbox, a concurrent-load comparison against a Python baseline, an ANN index for mostSimilar, and bundled-default-model license diligence.

https://issues.apache.org/jira/browse/OPEN-1877

@mawiesne
mawiesne marked this pull request as draft July 8, 2026 04:32
@krickert
krickert force-pushed the static-embeddings branch 2 times, most recently from 2085efd to 7f6f67f Compare July 8, 2026 14:44
@krickert
krickert marked this pull request as ready for review July 8, 2026 15:00
@krickert
krickert force-pushed the static-embeddings branch 2 times, most recently from e60bdba to 7f6f67f Compare July 8, 2026 19:05
@jzonthemtn

Copy link
Copy Markdown
Contributor

This probably requires an update to the docs? Probably doesn't need to be extensive - just a description of when to use it and how.

@krickert

krickert commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

This probably requires an update to the docs? Probably doesn't need to be extensive - just a description of when to use it and how.

I'll do that now

@krickert
krickert force-pushed the static-embeddings branch from 7f6f67f to e60bdba Compare July 9, 2026 20:02
@krickert

krickert commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Done in 76d58c5: a Static Embeddings chapter in the Dev Manual (when to use a static table over a contextual model, both load calls, thread safety, the no-bundled-model license note) plus a module README with the same ground covered for people landing in the source tree.

@rzo1

rzo1 commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Hi @krickert - as mentioned on Slack I currently dont have the time for a manual review but I just let Fable do a comprehensive review on this PR. Here is the result:

Blocking / should be addressed before merge:

  • opennlp-embeddings/src/main/java/opennlp/embeddings/TensorInfo.java: The public record exposes its mutable int[] shape component directly, so a caller doing tensorInfo(name).shape()[0] = 0 corrupts the metadata that readFloat32() and StaticEmbeddingModel.load() later validate against. The record-generated equals/hashCode also compare the array by identity, which is surprising for exported API. shape() should return a defensive copy (and the constructor copy its argument), or the component should be an immutable representation, with equals/hashCode overridden accordingly.
  • opennlp-embeddings/src/main/java/opennlp/embeddings/SafetensorsFile.java: The new public API (StaticEmbeddingModel, SafetensorsFile, TensorInfo, Neighbor) lacks the @Experimental marker the codebase applies to the analogous WordVectorTable API. Once released in 3.0.0 the safetensors parsing surface becomes a compatibility commitment even though the PR lists follow-ups (ANN index, gRPC provider) likely to reshape it. Either annotate the new public classes @Experimental or narrow SafetensorsFile/TensorInfo to package-private until the surface settles.
  • opennlp-extensions/pom.xml / opennlp-distr: The new module is not added as a dependency in opennlp-distr/pom.xml nor given an apidocs fileSet in opennlp-distr/src/main/assembly/bin.xml, unlike morfologik, uima and spellcheck (see Include opennlp-spellcheck extension in opennlp-distr aggregator #1115 and Include APIdocs in assembly of binary distr #1116). The binary distribution will ship neither the jar nor the javadoc despite the new manual chapter documenting the module.
  • opennlp-embeddings/src/main/java/opennlp/embeddings/WordPieceVocabulary.java: No dedicated test class, and the fromLines helper's stated purpose (letting tests build a vocabulary from in-memory lines) is unused by any test. The documented duplicate-token rejection, the -1 sentinel of id(), and token(int) bounds are all untested. Please add a WordPieceVocabularyTest (which would also justify fromLines) or drop fromLines and its stale comment.

Minor:

  • opennlp-embeddings/src/main/java/opennlp/embeddings/JsonCursor.java: parseUnicodeEscape accepts signed hex because Integer.parseInt(hex, 16) tolerates a leading +/-, so \u-0FF is silently decoded to a wrong character instead of being rejected. Validate the four characters are hex digits before parsing.
  • opennlp-embeddings/src/main/java/opennlp/embeddings/JsonCursor.java: skipValue's number branch accepts malformed tokens (a lone -, or junk like 1e++--..5) as valid numbers, contradicting the documented fail-loud contract for malformed input.
  • opennlp-embeddings/src/main/java/opennlp/embeddings/TensorInfo.java: elementCount() can overflow long for tensors with three or more large dimensions, and a wrapped small positive value can slip past readFloat32's validation for crafted headers. Use Math.multiplyExact-style checking.
  • opennlp-embeddings/src/main/java/opennlp/embeddings/StaticEmbeddingModel.java: The load methods throw UncheckedIOException/IllegalArgumentException instead of the checked IOException that every other OpenNLP model/resource loader declares (e.g. Glove, BaseModel-style constructors). This is a lasting API-shape decision that should be made deliberately before release.
  • opennlp-embeddings/src/main/java/opennlp/embeddings/StaticEmbeddingModel.java: load(Path, Path, boolean lowerCase, boolean normalize) takes two adjacent positional boolean flags; swapping them produces silently wrong embeddings. Consider an options/builder parameter or two-value enums before the API ships.
  • opennlp-embeddings/src/main/java/opennlp/embeddings/StaticEmbeddingModel.java: embed() pays a redundant second full isolatePunctuation pass per call because BertTokenizer.normalize already isolates punctuation and WordpieceTokenizer.tokenize re-runs it. Results are correct (the pass is idempotent), and the code lives in the reused opennlp-api tokenizers, but it is an optimization opportunity on the module's hot path.
  • opennlp-embeddings/src/test/java/opennlp/embeddings/StaticEmbeddingModelConcurrencyTest.java: The ~30-line safetensors fixture writer is copy-pasted five times across the tests and the JMH benchmark. A small package-private test utility (e.g. SafetensorsTestFiles.write(dir, name, rows...)) would remove the duplication.
  • opennlp-embeddings/src/test/java/opennlp/embeddings/StaticEmbeddingModelConcurrencyTest.java: The class javadoc claims the test "Mirrors the LexiconConcurrencyTest pattern from the opennlp-wordnet module", but neither that test nor an opennlp-wordnet module exists in this repository. Correct or remove the reference.
  • opennlp-embeddings/src/main/java/opennlp/embeddings/SafetensorsFile.java: The readFloat32 branch rejecting an element-count/byte-range mismatch has no test, unlike every neighboring error branch. One small negative test (e.g. shape [2] with data_offsets [0,4]) would pin it.
  • opennlp-embeddings/src/main/java/opennlp/embeddings/FlatJsonFields.java (and the rest of the module): else and catch are placed on their own line after the closing brace, diverging from the } else { / } catch (...) { style used throughout the existing codebase. Checkstyle does not enforce this, but please align with the project style.
  • opennlp-embeddings/src/main/java/opennlp/embeddings/StaticEmbeddingModel.java: The pooling logic is documented as "verified against" MinishLab's MIT-licensed model2vec-rs. Please confirm no code was translated or ported from those sources; a derived port would require an MIT attribution entry in the distribution LICENSE file.

Human review will follow.

@krickert

Copy link
Copy Markdown
Contributor Author

Thanks. Addressed in 9dd6ff7, one pushback on the marker question:

Blocking:

  • TensorInfo: the shape is now copied on construction and on access, equals/hashCode compare by value, and elementCount() rejects overflow from crafted headers via exact multiplication. Tests pin the defensive copies, the value semantics, and the overflow.
  • Experimental marker: not applied, deliberately. The public surface is five types with a small, verified contract: the pooling formula is pinned against the reference implementations, the safetensors reader implements a frozen file format, and the listed follow-ups are additive consumers (an ANN index slots behind mostSimilar, the gRPC provider is a caller), not surface reshapes. WordVectorTable's marker reflected an unfinished feature; this one is finished. If the release manager prefers the annotation anyway it is a two-line change.
  • Distribution wiring: opennlp-embeddings added to the distr dependencies and its apidocs fileSet to the assembly, matching the other extension modules.
  • WordPieceVocabulary: dedicated test class added covering the line-number ids, duplicate rejection (naming both lines), the -1 sentinel, and token(int) bounds, which now fail loud with IllegalArgumentException instead of leaking an IndexOutOfBoundsException.

Minor:

  • Unicode escapes now require four hex digits; signed input fails loud. Tested.
  • skipValue holds numbers in skipped fields to the JSON grammar; malformed tokens fail loud, well-formed scientific notation still passes. Tested both ways.
  • elementCount overflow: fixed with the TensorInfo change above.
  • Checked IOException: agreed, the load and read entry points now declare IOException like the rest of the project's loaders; UncheckedIOException is gone from the module.
  • Boolean flags: replaced with Casing (UNCASED/CASED) and Normalization (L2/NONE) enums, so the switches cannot be swapped silently. Manual, README, tests, and benchmark updated.
  • The redundant punctuation pass lives in the reused opennlp-api tokenizers, so the fix belongs there; noted as a follow-up rather than worked around here.
  • Fixture writer: extracted to a shared test utility used by the tests; the JMH benchmark keeps its own copy because it compiles in a separate source set.
  • Stale javadoc reference removed.
  • The element-count versus byte-range guard has its negative test.
  • else/catch placement aligned with project style across the module.
  • model2vec-rs: no code was translated or ported. The implementation was written against the safetensors format specification and the documented Model2Vec pooling semantics; the Rust and Python implementations were used as behavioral oracles (outputs compared for numerical parity), not as source. No attribution entry is required.

@krickert krickert self-assigned this Jul 10, 2026
@krickert
krickert marked this pull request as draft July 11, 2026 13:22
@krickert

Copy link
Copy Markdown
Contributor Author

Moved this to draft - interfacing to match Onnx impl. Onnx's impl is better for accuracy, this one is for speed. Update incoming.

@krickert

Copy link
Copy Markdown
Contributor Author

Before this leaves draft: re-verify the two embed() throughput rows in the README performance table against the original JMH runs. The surrounding prose was reworked, but the numbers were deliberately left untouched pending that check.

@krickert
krickert deleted the branch apache:OPENNLP-1885-sentencepiece July 16, 2026 11:01
@krickert krickert closed this Jul 16, 2026
@krickert krickert reopened this Jul 16, 2026
krickert added 15 commits August 9, 2026 08:44
…led directories

A Model2Vec distillation writes model.safetensors, tokenizer.json, and config.json, but not the vocab.txt and tokenizer_config.json a WordPiece model needs, nor the trained SentencePiece .model file. AssembleModel completes a WordPiece directory by deriving the two missing files from tokenizer.json (the vocabulary in id order, the casing from the normalizer's lowercase flag) and reports the SentencePiece .model file it cannot fabricate, then loads the result to verify it. Wired as its own opennlp-embeddings command with a launcher script and distribution entry, mirroring the spellcheck module.
scripts/distill_bge_m3.py is the runnable form of the TRAINING.md worked example. scripts/parity holds the reproducible comparison against the model2vec Python reference: the same model and the same multilingual sentences on both sides, the two vector sets checked against each other, and both single-thread throughputs measured with the same fixed-duration methodology. A run passes only when the vectors agree within float tolerance, so the two speeds it prints are for implementations producing the same answer.
Add StaticEmbeddingUsageExampleTest asserting the load-and-query workflow and
point the embeddings manual section at it.
Distillation, so producing a table no longer needs a Python environment:

- Add ModelDistiller, reproducing Model2Vec in Java: clean the teacher's vocabulary,
  run every surviving token through the teacher's ONNX graph as [bos, token, eos] and
  mean-pool the last hidden states, project onto the top principal components, then
  scale each row by its Zipf weight sif / (sif + p).
- Add OnnxTeacherEncoder for that forward pass, feeding the graph exactly the inputs it
  declares (input_ids, attention_mask, plus a zero token_type_ids for the BERT-family
  graphs that ask for one), and declare the onnxruntime dependency in the module at the
  root-managed version, the same engine opennlp-dl already runs.
- Add TeacherTokenizer, which decides which vocabulary rows survive into the table and
  rewrites tokenizer.json to describe it, copying every field it does not change byte
  for byte so the result stays a faithful fast-tokenizer description.
- Add RandomizedPca (Halko, Martinsson, Tropp), because a dense SVD of a 250k-row
  multilingual matrix is not practical in pure Java; component signs are fixed the way
  scikit-learn's svd_flip fixes them, so two runs are comparable rather than mirrored.
- Add SafetensorsWriter, the write side of the format SafetensorsFile reads, streaming
  the matrix in chunks so its overhead beyond the caller's array is constant.
- Add HuggingFaceModelCache so -teacher accepts a hub id and its files download once
  into a local cache; an optional file the repository does not have (a WordPiece
  teacher has no SentencePiece model) is reported absent, not an error.
- Register the DistillModel tool in the module CLI. It ends by assembling and verifying
  its own output directory, so a run that prints a summary is a directory that loads.

Documentation:

- Rewrite TRAINING.md around the DistillModel command instead of the Python uv and
  model2vec setup, keep the Python flow only as the parity reference, and record that
  two tables distilled independently from one teacher agree on their pairwise geometry
  but not axis by axis.
- Point the README "Getting a model" section at DistillModel as an alternative to
  downloading an already released table.

Folded duplication:

- Move firstRegularFile into ModelFileNames, next to the name lists it scans;
  StaticEmbeddingModel and ModelAssembler each carried a copy.
- Fold the boolean and string readers of FlatJsonFields onto one top-level walker with
  a ValueReader seam, so the object grammar, the duplicate-field check, and the
  trailing-content check exist once.
- Collapse the two identical file checks in EmbeddingVocabulary into requireRegularFile.
- Reduce SentenceVectorsDL.declaredOutputDimension to the single first-output read the
  loop actually performed, since every path returned on the first output anyway.

Javadoc and commentary:

- Document JsonCursor.consumeLiteral, requireEnd, and the new position(), and the two
  writer overloads in SafetensorsTestFiles.
- Fix TensorInfo's stale link to SafetensorsFile.readFloat32, which is readFloats now,
  and move its element-count description into the {@return} form.
- Describe Neighbor.token as one subword piece of the model's tokenizer rather than a
  WordPiece, now that SentencePiece tables load too.
- State on TextEmbedder that thread safety is implementation specific, which is what
  the two implementations actually promise, and mark up null in its tags.
- Drop the commentary that narrates history rather than behavior: the benchmark's
  design-doc justification, the "before the fix" and "used to crash" notes in the
  similarity tests, and the "untouched by the interface adoption" note in the DL test.

Tests:

- Add EmbeddingTestFixtures holding the king/queen/man/woman analogy table and the JSON
  string quoter, and point the similarity, concurrency, SentencePiece, usage-example,
  and assembler tests at it instead of their own copies of each.
- Build the size-mismatch and zero-vector fixtures with SafetensorsTestFiles rather
  than hand-rolling the header and the little-endian payload in the test.
- Pin FlatJsonFields.topLevelString: escapes, absent versus explicit null, nested names
  not matching, non-string values, duplicates, and null arguments.
- Add ModelDistillerTest, RandomizedPcaTest, and TeacherTokenizerTest over the
  distiller's pure pieces, and drop a duplicate IOException import in
  FlatJsonFieldsTest.

Build:

- Sort opennlp-spellcheck before opennlp-subword in the extensions module list.
…urface

A review of the model distiller, which had not been reviewed before, found
four defects that produce a wrong result rather than an error, and a set of
classes with no tests at all.

Correctness:

- RandomizedPca floored the CholeskyQR jitter at an absolute value, so the
  decomposition was not scale invariant. Scaling the input down by 1e-6 turned
  an exact rank-6 recovery into an explained variance of 0.0017 with pairwise
  dot products wrong by three orders of magnitude, while still returning a
  plausible table. The jitter is now relative to the Gram trace.
- RandomizedPca returned NaN instead of failing when the centered matrix was
  exactly zero, and a single non-finite input value poisoned its column mean
  and turned the whole result NaN. Both are now rejected.
- The distiller guard named nanToZero tested only Float.isNaN, so an infinity
  from the teacher passed through into the PCA. Renamed to nonFiniteToZero and
  switched to Float.isFinite, with the divergence from numpy nan_to_num noted.
- The PCA-skip branch kept the un-reduced matrix but left the requested
  dimension as the row stride, so the Zipf loop scaled the wrong cells and the
  writer rejected the array. Reachable with any vocabulary smaller than
  pcaDims, for example 100 tokens at the default 256.

Tests, all previously absent:

- SafetensorsWriter had no test file and its one case wrote six floats, so the
  1 MiB streaming loop never crossed a chunk boundary. Added a 400x1024 round
  trip along with the header layout, alignment and reject paths.
- ModelDistillerTest never called distill, leaving every argument check
  unverified. It now covers all five reject paths.
- Added first tests for ModelFileNames, HuggingFaceModelCache,
  OnnxTeacherEncoder and the module CLI.
- RandomizedPcaTest pins scale invariance, the non-finite rejection and
  determinism across pool sizes.

Also corrected the TRAINING.md WordPiece section, which told the reader to run
AssembleModel on output the distiller has already assembled.

Module tests go from 156 to 273, none skipped.
The distiller fetched teacher weights from a moving ref and executed the ONNX
graph without checking anything. DownloadUtil has always refused a model whose
sha512 sidecar it cannot read, so this brings the hub path to the same posture.

- Resolve the ref to a commit once, then request every file at that sha, so a
  force-push midway cannot mix two revisions into one cache directory. A
  teacher may now name a revision as org/model@revision.
- Verify every file against the digest the hub publishes in x-linked-etag,
  choosing the algorithm by hex length: 40 is the git blob SHA-1 over
  "blob <len>\0" and the bytes, 64 is the SHA-256 of the content. The digest is
  computed over what was written to disk. A mismatch deletes the partial file
  and throws, in DownloadUtil's wording.
- Refuse a file whose digest the hub does not publish, rather than accept
  something unverifiable. This is the point of the change.
- Record the resolved commit in .opennlp-revision, which also marks the
  directory as a complete snapshot, and carry it into the distilled model's
  config.json as teacher_revision so a table can name the teacher it came from.
- Make the cache directory name injective. It replaced '/', '.' and '@' without
  distinguishing them, so acme/model@v1, acme/model.v1 and acme/model_v1 shared
  one directory, and the cached path answers without contacting the hub, which
  would have served one teacher another's files.

Headers are read by walking HttpResponse.previousResponse(): with Redirect.NORMAL
the final CDN response carries neither header, while the redirecting hub response
carries both. A test fails if that walk is removed.

Tests need no network. A loopback HttpServer serves canned replies and records
the requests made, covering both digest forms, a corrupted body, a missing and
six malformed etags, optional and required 404s, the redirect path, the zero
request cached path, revision pinning and the directory collision.

Module tests go from 273 to 310, none skipped.
The default TextEmbedder.embedAll embeds one text at a time, so a document
pipeline paid one session run per sentence or token span. SentenceVectorsDL
now tokenizes the batch up front, groups inputs by tokenized length, and
runs each group through the session once with shape [group size, length].

Grouping by length means a batch never pads: every row is computed from
exactly the tensors its single-input call would have used, so results match
the per-text calls bit for bit, which the new test pins with exact float
equality over mixed-length inputs.
Annotate the public embeddings types with @experimental, note that in
the manual, and fail loud on a lone "-" in a skipped safetensors field.
…nd edge tests

Annotate ModelAssembler, ModelDistiller, and TextEmbedder with
@experimental like the other public embeddings types and extend the
manual's note. Link Model2Vec and the randomized SVD paper at first
mention. Make the usage example test mirror the manual's directory-load
listing. Cover whitespace-only and supplementary-plane input in the
embed path. Document the Checksum constructor.
Malformed model content (malformed safetensors headers and config files,
dimension and row-count disagreements, ambiguous matrix sources, bare
minus headers) now throws the checked opennlp.tools.util
.InvalidFormatException across StaticEmbeddingModel.load and every
loader it calls, matching SentencePieceTokenizer.load.
IllegalArgumentException stays for caller argument errors only. The CLI
tools report the new type with the same exit code as before.
…rivate

readFloat32(String) and metadata() have no main-source callers; only the
module's own tests use them. Shrink the experimental public surface.
…e manual

Add a Command Line Tools section to the embeddings chapter covering the
bin/embeddings launcher and both tools: purpose, invocation shape, and
the load-based verification each run ends with, in the style of the
manual's other tool sections.
The distiller writes the pooling field but the loader never read it, so a
third-party model declaring another pooling silently mean-pooled. Only mean
pooling is implemented, so the loader now rejects any other declared value
with an InvalidFormatException naming it; a declared mean still loads.
mostSimilar and analogy sized the candidate arrays by the raw topK before
any clamping, so mostSimilar(word, Integer.MAX_VALUE) failed with an
OutOfMemoryError. The scan can never yield more than one neighbor per row,
so the capacity is now the smaller of topK and the row count.
The distiller path zeroes non-finite teacher values, but the loader accepted
any bytes, and a single NaN row defeats the zero-norm guard and the TopK
comparison, silently corrupting similarity rankings. Loading a matrix that
holds a NaN or infinity now throws InvalidFormatException naming the row.
do_lower_case=false was never tested; the new case asserts that a cased
model matches lower-case vocabulary entries as-is and folds upper-case
text to the skipped unknown token instead of lower-casing it first.
The chapter's second programlisting (the explicit load and
loadSentencePiece overloads) had no test pinning it. testExplicitOverloads
loads both fixture layouts exactly as the listing shows, backed by a new
minimal SentencePiece directory fixture, and the chapter now cites the
test the way it already cites the directory-load listing.
…test

The headline use case, ranking documents against a query by cosine
similarity, existed only in the module README. The chapter gains a
Semantic Search section whose listing scores each document with the
public similarity method and sorts by descending score, and the new
StaticEmbeddingSearchExampleTest asserts the exact ranking on the
analogy fixture's known geometry.
krickert added a commit that referenced this pull request Aug 15, 2026
The hub cache compiled two Patterns for the org/model@revision reference and
the hex shape of commit shas and digests. Hand scans over the same ASCII
grammars replace them; the module's parsing is now regex-free throughout.
…d pieces

A distillation can now take a term list, a learned corpus vocabulary of whole
words and multi-word phrases. Each term is segmented by the teacher's own
tokenizer, encoded through the teacher as one sequence, and appended to the
table after the subword rows, recorded as terms.txt in the model directory.
The same PCA and Zipf pipeline spans all rows, and a term equal to a
surviving vocabulary token is dropped as a duplicate row.

At embed time the model matches text against its terms greedily
longest-first over case-folded word runs (StringUtil.toLowerCase, one code
point to one code point) and pools a matched term's single row in place of
its words' subword pieces; text between matches tokenizes as before, and a
model without a terms file embeds exactly as it did. Terms are neighbor
candidates in mostSimilar like any token. The DistillModel tool gains
-terms, documented in the manual.

TeacherTokenizer's two Patterns (the unused-token filter and the template
splitter) are replaced with cursor scans along the way.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants