Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
6a71703
OPENNLP-1885: Add opennlp-subword: pure-Java SentencePiece inference …
krickert Jul 10, 2026
fe4abdd
OPENNLP-1885: Speed up the encode path 2.3x, parity-checked at every …
krickert Jul 10, 2026
db1c371
OPENNLP-1885: Move the subword contract into opennlp-api
krickert Jul 11, 2026
f71db4d
OPENNLP-1885: Add WordpieceEncoder and fold the unreleased BertTokeni…
krickert Jul 12, 2026
abbf3e6
OPENNLP-1885: Document the hand-rolled protobuf reader rationale and …
krickert Jul 12, 2026
5c78b97
OPENNLP-1885: Trim commentary and tighten javadoc per review conventions
krickert Jul 12, 2026
dea3031
OPENNLP-1885: Tighten javadoc to contracts and document helpers and o…
krickert Jul 13, 2026
1f3883a
OPENNLP-1885: Declare serialVersionUID on SentencePieceTokenizer
krickert Jul 13, 2026
976326a
OPENNLP-1885: Trim residual commentary per review conventions
krickert Jul 13, 2026
bf3cacf
OPENNLP-1885: Document subword tokenization in the manual
krickert Jul 14, 2026
81b1581
OPENNLP-1885: Make the tokenizer graph serializable with computed UID…
krickert Jul 16, 2026
e430109
OPENNLP-1885: Guard tokenizer deserialization with an allow-listing O…
krickert Jul 17, 2026
b843cf7
OPENNLP-1885: Cite the SentencePiece usage example test in the manual
krickert Jul 20, 2026
f55fbec
OPENNLP-1885: Align null contracts and annotations with the review co…
krickert Jul 21, 2026
8ebca7e
OPENNLP-1885: Address review: checked InvalidFormatException for malf…
krickert Jul 21, 2026
80029ca
OPENNLP-1885: Address review: validation message style, shared test f…
krickert Jul 28, 2026
397c9ec
OPENNLP-1885: Deprecate BertTokenizer as a shim over WordpieceEncoder…
krickert Jul 29, 2026
632d996
OPENNLP-1885: Document fixture regeneration and Utf8Text span mapping
krickert Aug 6, 2026
9670c34
OPENNLP-1885: Attribute the double-array literature in DoubleArrayTrie
krickert Aug 6, 2026
d8b0449
OPENNLP-1885: Expand fixtures README into a validation tutorial and l…
krickert Aug 6, 2026
dcc33bd
OPENNLP-1885: Pin InvalidFormatException on a malformed precompiled c…
krickert Aug 8, 2026
f512cec
OPENNLP-1885: Fix loader exception type and wire opennlp-subword into…
krickert Aug 8, 2026
45db421
OPENNLP-1885: Reference the fixtures README by its in-tree path
krickert Aug 9, 2026
2d95887
Add opennlp-embeddings module with a safetensors reader
krickert Jul 7, 2026
dc3fa77
Add StaticEmbeddingModel: tokenize, gather, weight, mean-pool, normalize
krickert Jul 7, 2026
cffed1f
Add word similarity and analogy convenience API
krickert Jul 7, 2026
b13f849
Add a JMH benchmark for StaticEmbeddingModel, matching the perf-1850-…
krickert Jul 7, 2026
c9381b8
Review pass on opennlp-embeddings: analogy exclusion fixes, faster ne…
krickert Jul 7, 2026
a919ceb
Stream safetensors tensor data with positional reads
krickert Jul 9, 2026
795734b
Unit-test SafetensorsHeaderParser directly, reject trailing header ga…
krickert Jul 9, 2026
dcd15e9
Add StaticEmbeddingModel.load(modelDirectory) resolving switches from…
krickert Jul 9, 2026
e25ff87
Document opennlp-embeddings: Dev Manual chapter and module README
krickert Jul 9, 2026
c7610de
OPENNLP-1877: Address review: value-safe TensorInfo, checked IO, type…
krickert Jul 10, 2026
78414d9
OPENNLP-1877: Register opennlp-embeddings in the root dependencyManag…
krickert Jul 10, 2026
4cb03e8
OPENNLP-1877: Rename WordPieceVocabulary to WordpieceVocabulary
krickert Jul 11, 2026
e76555e
OPENNLP-1877: Add the TextEmbedder seam; both engines implement it
krickert Jul 11, 2026
d43f6e1
OPENNLP-1877: Give the embeddings module its own tokenization pipeline
krickert Jul 12, 2026
4aea42b
OPENNLP-1877: Load the tiny-vectors test model from the classpath stream
krickert Jul 12, 2026
b7ca1b6
OPENNLP-1877: Trim commentary and tighten javadoc per review conventions
krickert Jul 12, 2026
3f9bead
OPENNLP-1877: Rewrite the opennlp-embeddings README with diagrams and…
krickert Jul 12, 2026
99538d3
OPENNLP-1877: Document implementation-defined empty-input behavior on…
krickert Jul 12, 2026
110613c
OPENNLP-1877: Read F16 and BF16 safetensors tensors, not just F32
krickert Jul 12, 2026
78a3fd9
OPENNLP-1877: Support SentencePiece models through the SubwordTokeniz…
krickert Jul 13, 2026
d423eb2
OPENNLP-1877: Document helpers and validate record and parser boundaries
krickert Jul 13, 2026
9f7ea98
OPENNLP-1877: Let the embeddings JMH benchmark load real model direct…
krickert Jul 13, 2026
48e81e5
OPENNLP-1877: Add a distillation tutorial and real two-model benchmar…
krickert Jul 13, 2026
6c2a938
OPENNLP-1877: Load WordPiece tables that dropped the frame tokens
krickert Jul 13, 2026
2ae4912
OPENNLP-1877: Add the AssembleModel CLI to complete and verify distil…
krickert Jul 13, 2026
8b2c075
OPENNLP-1877: Ship the distillation script and the Python parity harness
krickert Jul 13, 2026
dfe5834
OPENNLP-1877: Trim residual commentary per review conventions
krickert Jul 13, 2026
0349b1f
OPENNLP-1877: Name format constants, document throws, and source or r…
krickert Jul 16, 2026
1fd2f68
OPENNLP-1877: Cite the static embedding usage example test in the manual
krickert Jul 20, 2026
9aa534c
OPENNLP-1877: Align dev helper placement and test literals with the r…
krickert Jul 21, 2026
d3cf3ff
OPENNLP-1877: Add a Java model distiller and address the review comments
krickert Jul 28, 2026
2501af1
OPENNLP-1877: Fix distiller correctness bugs and cover the untested s…
krickert Jul 28, 2026
1bc1928
OPENNLP-1877: Pin and verify teacher downloads
krickert Jul 28, 2026
7694191
OPENNLP-1877: Batch same-length inputs into one ONNX run in embedAll
krickert Aug 2, 2026
742148c
OPENNLP-1877: Mark public API experimental and reject bare minus headers
krickert Aug 6, 2026
595959d
OPENNLP-1877: Address review: complete experimental markers, hrefs, a…
krickert Aug 8, 2026
71b16b3
OPENNLP-1877: Throw InvalidFormatException for malformed model content
krickert Aug 9, 2026
7212e40
OPENNLP-1877: Demote test-only SafetensorsFile accessors to package-p…
krickert Aug 9, 2026
ddb1992
OPENNLP-1877: Document the DistillModel and AssembleModel tools in th…
krickert Aug 9, 2026
d78300e
OPENNLP-1877: Fail loud on a config.json declaring non-mean pooling
krickert Aug 10, 2026
b42043c
OPENNLP-1877: Clamp the neighbor scan's topK to the vocabulary size
krickert Aug 10, 2026
86963bc
OPENNLP-1877: Reject non-finite embedding matrix values at load time
krickert Aug 10, 2026
52ac814
OPENNLP-1877: Exercise Casing.CASED through the directory loader
krickert Aug 10, 2026
b0ae5e7
OPENNLP-1877: Mirror the manual's explicit-overload listing with a test
krickert Aug 10, 2026
851196f
OPENNLP-1877: Document semantic search in the manual with a mirrored …
krickert Aug 10, 2026
0e1b0fa
OPENNLP-1877: Parse teacher references and hex digests with cursor scans
krickert Aug 16, 2026
5cc79e3
OPENNLP-1877: Distill whole terms as extra rows matched before subwor…
krickert Aug 16, 2026
18d4d00
OPENNLP-1895: Quantized embedding matrix core: seeded rotation, Lloyd…
krickert Jul 24, 2026
b5813b2
OPENNLP-1895: Load quantized matrices in StaticEmbeddingModel and add…
krickert Jul 24, 2026
add1add
OPENNLP-1895: Reject ambiguous matrix source, widen the seed, documen…
krickert Jul 24, 2026
b6bee0a
OPENNLP-1895: Reject dimensions whose padded bit count overflows an int
krickert Jul 24, 2026
fe18a2d
OPENNLP-1895: Pin the checked loader contract for malformed quantized…
krickert Aug 9, 2026
21ce369
OPENNLP-1895: Fail malformed quantized matrices with InvalidFormatExc…
krickert Aug 9, 2026
c22616f
OPENNLP-1895: Follow the checked loader contract through the quantize…
krickert Aug 9, 2026
b8d1333
OPENNLP-1895: Pin InvalidFormatException for every malformed-content …
krickert Aug 10, 2026
c45b8df
OPENNLP-1895: Fail every malformed-content path with InvalidFormatExc…
krickert Aug 10, 2026
ded6805
OPENNLP-1895: Correct the quantized size numbers for power-of-two pad…
krickert Aug 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions dev/embeddings/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<!--
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.
-->

# Embeddings scripts

Developer scripts around the static embeddings module. None of them are part of the build; they
make the module's numbers and its worked example reproducible from a checkout.

## `distill_bge_m3.py`

The runnable form of the TRAINING.md worked example: distills the multilingual bge-m3 teacher
into a 256-dimension static table with Model2Vec. Needs a Python environment with
`model2vec[distill]` installed; the script's header shows the setup. After it finishes, copy the
teacher's `sentencepiece.bpe.model` next to the output and verify with the `AssembleModel`
command.

## `parity/`

The parity and single-thread speed comparison between this module and 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 throughputs measured with the same fixed-duration
methodology. `sh run.sh` after building the project; see the script header for the environment
overrides. A run passes only when the vectors agree within float tolerance, so the two speeds it
prints are for implementations producing the same answer.
52 changes: 52 additions & 0 deletions dev/embeddings/distill_bge_m3.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# 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.

"""Distills the multilingual bge-m3 teacher into a static embedding table.

This is the worked example from opennlp-extensions/opennlp-embeddings/TRAINING.md as a
runnable script. It needs a Python environment with model2vec's distill extra installed:

uv venv .venv-distill
uv pip install --python .venv-distill "model2vec[distill]"
.venv-distill/bin/python distill_bge_m3.py [output-dir]

After it finishes, copy the teacher's trained SentencePiece file
(sentencepiece.bpe.model on the model hub) into the output directory and run the
AssembleModel command to verify the directory loads:

opennlp-embeddings AssembleModel -modelDir <output-dir>

256 dimensions is the deliberate default: distilling the same teacher at 512 gives the
same cross-lingual similarity within noise while doubling the matrix and halving embed
throughput, because PCA to 256 already captures the useful variance.
"""

import os
import sys

from model2vec.distill import distill

out = sys.argv[1] if len(sys.argv) > 1 else "bge-m3-static"

static = distill("BAAI/bge-m3", pca_dims=256)
static.save_pretrained(out)
print("SAVED:", out, "dim:", static.dim)

print("=== output files ===")
for name in sorted(os.listdir(out)):
path = os.path.join(out, name)
print(f" {os.path.getsize(path):>12} {name}")
print("Now copy the teacher's sentencepiece.bpe.model into", out,
"and run: opennlp-embeddings AssembleModel -modelDir", out)
92 changes: 92 additions & 0 deletions dev/embeddings/parity/EmbedBenchM3.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* 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.
*/

import java.io.BufferedWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;

import opennlp.embeddings.StaticEmbeddingModel;

/**
* The JVM half of the parity and speed comparison (see run.sh). Loads the static table, writes
* one vector per input sentence for the parity check, then measures single-thread embed
* throughput with the same fixed-duration, warmup-discarded methodology the Python side uses.
*
* <p>Args: modelDir sentencesFile vectorsOut warmupSeconds measureSeconds</p>
*/
public final class EmbedBenchM3 {

/** Not instantiable. */
private EmbedBenchM3() {
}

/**
* Runs the parity dump and the single-thread throughput measurement.
*
* @param args modelDir, sentencesFile, vectorsOut, warmupSeconds, measureSeconds.
* @throws Exception Thrown if a file cannot be read or written.
*/
public static void main(String[] args) throws Exception {
final Path modelDir = Path.of(args[0]);
final List<String> sentences = Files.readAllLines(Path.of(args[1]), StandardCharsets.UTF_8)
.stream().map(String::strip).filter(s -> !s.isEmpty()).toList();
final Path vectorsOut = Path.of(args[2]);
final int warmupSeconds = Integer.parseInt(args[3]);
final int measureSeconds = Integer.parseInt(args[4]);

final long loadStart = System.nanoTime();
final StaticEmbeddingModel model = StaticEmbeddingModel.load(modelDir);
final double loadMs = (System.nanoTime() - loadStart) / 1e6;

// One vector per sentence, so the Python side can diff them for parity.
try (BufferedWriter writer = Files.newBufferedWriter(vectorsOut, StandardCharsets.UTF_8)) {
for (final String sentence : sentences) {
final float[] vector = model.embed(sentence);
final StringBuilder line = new StringBuilder();
for (int i = 0; i < vector.length; i++) {
if (i > 0) {
line.append(' ');
}
line.append(Float.toString(vector[i]));
}
writer.write(line.toString());
writer.newLine();
}
}

final long warmupEnd = System.nanoTime() + warmupSeconds * 1_000_000_000L;
int index = 0;
while (System.nanoTime() < warmupEnd) {
model.embed(sentences.get(index++ % sentences.size()));
}

long embedded = 0;
final long measureStart = System.nanoTime();
final long measureEnd = measureStart + measureSeconds * 1_000_000_000L;
index = 0;
while (System.nanoTime() < measureEnd) {
model.embed(sentences.get(index++ % sentences.size()));
embedded++;
}
final double seconds = (System.nanoTime() - measureStart) / 1e9;

System.out.printf("JVM load %.0f ms | %,.0f texts/s single-thread (%d embeds in %.1fs)%n",
loadMs, embedded / seconds, embedded, seconds);
}
}
93 changes: 93 additions & 0 deletions dev/embeddings/parity/parity_speed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# 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.

"""The Python half of the parity and speed comparison, plus the final parity check.

Loads the same static table with model2vec, writes one vector per sentence, measures
single-thread throughput with the same fixed-duration warmup-discarded loop the JVM side
uses, then loads the JVM's vectors (written first by run.sh) and reports the parity between
them.

The point is not to declare a winner; it is to show that both implementations produce the
same vectors, and to let anyone reproduce both numbers on their own hardware.

Usage: parity_speed.py <model-dir> [sentences-file] [jvm-vectors-file]
"""

import sys
import time

import numpy as np
from model2vec import StaticModel

WARMUP_SECONDS = 3
MEASURE_SECONDS = 5


def read_sentences(path):
with open(path, encoding="utf-8") as handle:
return [line.strip() for line in handle if line.strip()]


def main():
model_dir = sys.argv[1]
sentences_file = sys.argv[2] if len(sys.argv) > 2 else "sentences.txt"
jvm_vectors_file = sys.argv[3] if len(sys.argv) > 3 else "jvm_vectors.tsv"
sentences = read_sentences(sentences_file)

load_start = time.time()
model = StaticModel.from_pretrained(model_dir)
load_ms = (time.time() - load_start) * 1000.0

python_vectors = np.array([model.encode(s) for s in sentences], dtype=np.float32)

end = time.time() + WARMUP_SECONDS
i = 0
while time.time() < end:
model.encode(sentences[i % len(sentences)])
i += 1

embedded = 0
i = 0
start = time.time()
end = start + MEASURE_SECONDS
while time.time() < end:
model.encode(sentences[i % len(sentences)])
embedded += 1
i += 1
seconds = time.time() - start
print(f"Python load {load_ms:.0f} ms | {embedded / seconds:,.0f} texts/s single-thread "
f"({embedded} embeds in {seconds:.1f}s)")

jvm_vectors = np.loadtxt(jvm_vectors_file, dtype=np.float32)
if jvm_vectors.shape != python_vectors.shape:
print(f"PARITY FAIL: shape mismatch {jvm_vectors.shape} vs {python_vectors.shape}")
sys.exit(1)

max_abs_diff = float(np.abs(python_vectors - jvm_vectors).max())
cosines = [
float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
for a, b in zip(python_vectors, jvm_vectors)
]
print(f"Parity max abs diff {max_abs_diff:.2e} | min cosine {min(cosines):.6f} "
f"over {len(sentences)} sentences in {python_vectors.shape[1]} dims")
if min(cosines) < 0.9999:
print("PARITY FAIL: vectors diverge")
sys.exit(1)
print("Parity OK: the JVM and Python vectors are the same within float tolerance")


if __name__ == "__main__":
main()
47 changes: 47 additions & 0 deletions dev/embeddings/parity/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/bin/sh
# 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.

# Reproduces the parity and single-thread speed comparison between opennlp-embeddings and the
# model2vec Python reference: the same model and the same sentences on both sides, with the
# vector sets checked against each other. Run from this directory after building the project
# (mvn install, or at least mvn compile from the repository root).
#
# Environment overrides:
# MODEL_DIR the static model directory (default: bge-m3-static in this directory;
# see ../distill_bge_m3.py and opennlp-extensions/opennlp-embeddings/TRAINING.md
# to produce one)
# PYTHON a Python interpreter with model2vec installed (default: python3)
set -e

MODEL_DIR="${MODEL_DIR:-bge-m3-static}"
PYTHON="${PYTHON:-python3}"

# The repository root is three levels above this script.
ROOT=$(cd "$(dirname "$0")/../../.." && pwd)
CP="$ROOT/opennlp-api/target/classes:$ROOT/opennlp-core/opennlp-runtime/target/classes:$ROOT/opennlp-extensions/opennlp-subword/target/classes:$ROOT/opennlp-extensions/opennlp-embeddings/target/classes"

echo "Model: $MODEL_DIR"
echo "Sentences: $(grep -c . sentences.txt) lines, multilingual"
echo

# JVM side first: it writes jvm_vectors.tsv, which the Python side then diffs.
javac -cp "$CP" -d . EmbedBenchM3.java
java -cp "$CP:." EmbedBenchM3 "$MODEL_DIR" sentences.txt jvm_vectors.tsv 3 5

# Python side: prints its own rate, then reports parity against the JVM's vectors.
"$PYTHON" parity_speed.py "$MODEL_DIR"
20 changes: 20 additions & 0 deletions dev/embeddings/parity/sentences.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
The weather is beautiful today and the sky is clear.
Machine learning models turn text into vectors.
I would like a cup of coffee with milk please.
The quarterly financial results disappointed investors.
Das Wetter ist heute wunderschoen und der Himmel ist klar.
Maschinelles Lernen verwandelt Text in Vektoren.
Le temps est magnifique aujourd'hui et le ciel est degage.
Los modelos de aprendizaje automatico convierten texto en vectores.
今天天气很好,天空很晴朗。
机器学习模型把文本转换成向量。
今日はとても良い天気で空が澄んでいます。
機械学習モデルはテキストをベクトルに変換します。
Сегодня прекрасная погода и ясное небо.
Модели машинного обучения превращают текст в векторы.
La retrieval semantica trova documenti per significato non per parole.
Natural language processing is a field of artificial intelligence.
A quick brown fox jumps over the lazy dog near the river.
Embeddings place similar sentences close together in space.
Coffee, tea, and espresso are all popular hot drinks.
The library opens at nine in the morning on weekdays.
Loading
Loading