This is an automated email from the ASF dual-hosted git repository.

krickert pushed a commit to branch feat/GH-1392-embed-padding
in repository https://gitbox.apache.org/repos/asf/opennlp.git

commit 8c1bc0759f714ada6be8d342105a52306f5997be
Author: Kristian Rickert <[email protected]>
AuthorDate: Sun Oct 11 01:56:51 2026 -0400

    GH-1392: Add padding strategies for embedAll
    
    embedAll grouped texts by exact tokenized length, so on natural text most
    inferences took one or two texts. With PaddingStrategy.LONGEST, texts are
    ordered by length and each batch is padded to its longest member.
    MAX_LENGTH pads all inputs to the maximum length, for models that need a
    fixed input size. EXACT_LENGTH remains the default and behaves as before.
    All strategies stay within 16384 token positions per inference. The onnx
    provider gets a padding option.
    
    The padding id comes from the vocabulary ([PAD], or <pad> for RoBERTa).
    Padded positions are 0 in the attention mask and mean pooling skips them,
    so vectors match up to floating-point rounding when the model honors the
    mask. batches is an instance method now, since it depends on the
    strategy.
    
    Without the batching change, the padding tests fail:
    
      testPaddingBatches:
        expected: <[[1, 2, 4, 0, 3]]> but was: <[[0, 3], [1, 2, 4]]>
      testPaddedBatchesRespectTheCap:
        expected: <[4096, 905]> but was: <[4096, 904, 1]>
      testPaddingIdIsReadFromTheVocabulary:
        array contents differ at index [0], expected: <10.0> but was: <7.0>
    
    tiny-masked.onnx sums the ids times the attention mask, so a test can
    tell a padded position with mask 0 from one with mask 1. It is generated
    by gen_tiny_masked_model.py next to it.
    
    Signed-off-by: Kristian Rickert <[email protected]>
---
 .../src/main/java/opennlp/dl/AbstractDL.java       |  31 +-
 .../dl/vectors/OnnxTextEmbedderProvider.java       |  48 ++-
 .../java/opennlp/dl/vectors/PaddingStrategy.java   |  57 ++++
 .../java/opennlp/dl/vectors/SentenceVectorsDL.java | 179 +++++++++--
 .../dl/vectors/OnnxTextEmbedderProviderTest.java   |   3 +-
 .../vectors/SentenceVectorsDLBatchLimitTest.java   |  34 ++-
 .../dl/vectors/SentenceVectorsDLEmbedderTest.java  | 157 +++++++++-
 .../vectors/SentenceVectorsDLPaddingEdgesTest.java | 337 +++++++++++++++++++++
 .../java/opennlp/dl/vectors/TinyVectorModels.java  |  54 +++-
 .../opennlp/dl/vectors/gen_tiny_masked_model.py    | 102 +++++++
 .../resources/opennlp/dl/vectors/tiny-masked.onnx  | Bin 0 -> 603 bytes
 opennlp-docs/src/docbkx/machine-learning.xml       |  15 +-
 rat-excludes                                       |   1 +
 13 files changed, 960 insertions(+), 58 deletions(-)

diff --git 
a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/AbstractDL.java 
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/AbstractDL.java
index bb16ce410..63586d683 100644
--- 
a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/AbstractDL.java
+++ 
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/AbstractDL.java
@@ -74,6 +74,10 @@ public abstract class AbstractDL implements AutoCloseable {
   private static final String WORDPIECE_SUBWORD_PREFIX = "##";
   /** The first non-whitespace character of a vocabulary file that is read as 
JSON. */
   private static final String JSON_OBJECT_START = "{";
+  /** The padding token of a BERT vocabulary. */
+  private static final String BERT_PAD_TOKEN = "[PAD]";
+  /** The padding token of a RoBERTa vocabulary. */
+  private static final String ROBERTA_PAD_TOKEN = "<pad>";
 
   protected final OrtEnvironment env;
   protected final OrtSession session;
@@ -408,13 +412,32 @@ public abstract class AbstractDL implements AutoCloseable 
{
   }
 
   /**
-   * The classification, separator and unknown tokens a tokenizer adds or 
emits.
+   * {@return the vocabulary id of the padding token} The padding token is 
{@code <pad>} for a
+   * RoBERTa vocabulary and {@code [PAD]} otherwise.
+   *
+   * @param vocab The vocabulary map.
+   * @throws IllegalArgumentException Thrown if the vocabulary has no padding 
token, or if it has
+   *     the RoBERTa classification and separator tokens but no unknown token.
+   */
+  protected static long padTokenId(final Map<String, Integer> vocab) {
+    final String padToken = specialTokens(vocab).pad();
+    final Integer id = vocab.get(padToken);
+    if (id == null) {
+      throw new IllegalArgumentException("Padding needs a " + padToken
+          + " token, but the vocabulary has none.");
+    }
+    return id;
+  }
+
+  /**
+   * The classification, separator, unknown and padding tokens of a vocabulary.
    *
    * @param cls The classification token.
    * @param sep The separator token.
    * @param unk The unknown token.
+   * @param pad The padding token.
    */
-  private record SpecialTokens(String cls, String sep, String unk) {
+  private record SpecialTokens(String cls, String sep, String unk, String pad) 
{
   }
 
   /**
@@ -430,10 +453,10 @@ public abstract class AbstractDL implements AutoCloseable 
{
     if (vocab.containsKey(WordpieceTokenizer.ROBERTA_CLS_TOKEN)
         && vocab.containsKey(WordpieceTokenizer.ROBERTA_SEP_TOKEN)) {
       return new SpecialTokens(WordpieceTokenizer.ROBERTA_CLS_TOKEN,
-          WordpieceTokenizer.ROBERTA_SEP_TOKEN, resolveUnknownToken(vocab));
+          WordpieceTokenizer.ROBERTA_SEP_TOKEN, resolveUnknownToken(vocab), 
ROBERTA_PAD_TOKEN);
     }
     return new SpecialTokens(WordpieceTokenizer.BERT_CLS_TOKEN,
-        WordpieceTokenizer.BERT_SEP_TOKEN, WordpieceTokenizer.BERT_UNK_TOKEN);
+        WordpieceTokenizer.BERT_SEP_TOKEN, WordpieceTokenizer.BERT_UNK_TOKEN, 
BERT_PAD_TOKEN);
   }
 
   /**
diff --git 
a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/OnnxTextEmbedderProvider.java
 
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/OnnxTextEmbedderProvider.java
index a07da704e..1fbceeb3d 100644
--- 
a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/OnnxTextEmbedderProvider.java
+++ 
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/OnnxTextEmbedderProvider.java
@@ -19,22 +19,25 @@ package opennlp.dl.vectors;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.util.Locale;
+import java.util.ArrayList;
+import java.util.List;
 
 import ai.onnxruntime.OrtEnvironment;
 import ai.onnxruntime.OrtException;
 
+import opennlp.dl.InferenceOptions;
 import opennlp.tools.embeddings.TextEmbedder;
 import opennlp.tools.embeddings.TextEmbedderProvider;
 import opennlp.tools.util.ParamChecks;
+import opennlp.tools.util.StringUtil;
 import opennlp.tools.util.ext.ProviderSpec;
 
 /**
  * Provides a {@link SentenceVectorsDL} under the name {@value #NAME}. It 
supports a spec whose
  * location is a local file named {@code *.onnx}, in any letter case, and 
whose only options are
  * {@value #VOCABULARY_OPTION}, {@value #LOWER_CASE_OPTION}, {@value 
#POOLING_OPTION},
- * {@value #NORMALIZE_OPTION} and {@value #MAX_LENGTH_OPTION}. The option 
values are checked by
- * {@link #create(ProviderSpec)}, which also initializes the ONNX Runtime.
+ * {@value #NORMALIZE_OPTION}, {@value #MAX_LENGTH_OPTION} and {@value 
#PADDING_OPTION}. The option
+ * values are checked by {@link #create(ProviderSpec)}, which also initializes 
the ONNX Runtime.
  *
  * @since 3.0.0
  */
@@ -64,11 +67,17 @@ public final class OnnxTextEmbedderProvider implements 
TextEmbedderProvider {
    */
   public static final String MAX_LENGTH_OPTION = "maxLength";
 
+  /**
+   * The option that selects the {@link PaddingStrategy}: {@code exact_length} 
by default,
+   * {@code longest} or {@code max_length}.
+   */
+  public static final String PADDING_OPTION = "padding";
+
   private static final String MODEL_SUFFIX = ".onnx";
   private static final String TRUE = "true";
   private static final String FALSE = "false";
-  private static final String MEAN = 
Pooling.MEAN.name().toLowerCase(Locale.ROOT);
-  private static final String CLS = 
Pooling.CLS.name().toLowerCase(Locale.ROOT);
+  private static final String MEAN = 
StringUtil.toLowerCase(Pooling.MEAN.name());
+  private static final String CLS = StringUtil.toLowerCase(Pooling.CLS.name());
   private static final int MIN_MAX_LENGTH = 2;
 
   @Override
@@ -94,7 +103,7 @@ public final class OnnxTextEmbedderProvider implements 
TextEmbedderProvider {
     ParamChecks.requireNonNullArg(spec, "spec");
     return spec.path().isPresent() && spec.locationEndsWith(MODEL_SUFFIX)
         && spec.hasOnlyOptions(VOCABULARY_OPTION, LOWER_CASE_OPTION, 
POOLING_OPTION,
-            NORMALIZE_OPTION, MAX_LENGTH_OPTION);
+            NORMALIZE_OPTION, MAX_LENGTH_OPTION, PADDING_OPTION);
   }
 
   @Override
@@ -112,10 +121,11 @@ public final class OnnxTextEmbedderProvider implements 
TextEmbedderProvider {
     final boolean normalize = booleanOption(spec, NORMALIZE_OPTION);
     final Pooling pooling = poolingOption(spec);
     final int maxLength = maxLengthOption(spec);
+    final PaddingStrategy padding = paddingOption(spec);
     final Path vocabularyPath = 
model.toAbsolutePath().getParent().resolve(vocabulary);
     try {
       return new SentenceVectorsDL(model.toFile(), vocabularyPath.toFile(), 
lowerCase, pooling,
-          normalize, maxLength);
+          normalize, maxLength, padding, new InferenceOptions());
     } catch (final OrtException e) {
       throw new IOException("Cannot load the ONNX model " + model, e);
     } catch (final LinkageError e) {
@@ -159,6 +169,30 @@ public final class OnnxTextEmbedderProvider implements 
TextEmbedderProvider {
     throw new IllegalArgumentException(POOLING_OPTION + " must be " + MEAN + " 
or " + CLS);
   }
 
+  /**
+   * Reads the {@value #PADDING_OPTION} option.
+   *
+   * @param spec The spec to read.
+   * @return The padding strategy, {@link PaddingStrategy#EXACT_LENGTH} if the 
option is not set.
+   * @throws IllegalArgumentException Thrown if the value is not the lower 
case name of a
+   *     {@link PaddingStrategy}.
+   */
+  private PaddingStrategy paddingOption(final ProviderSpec spec) {
+    final String value = spec.option(PADDING_OPTION, null);
+    if (value == null) {
+      return SentenceVectorsDL.DEFAULT_PADDING;
+    }
+    final List<String> names = new ArrayList<>();
+    for (final PaddingStrategy padding : PaddingStrategy.values()) {
+      final String name = StringUtil.toLowerCase(padding.name());
+      if (name.equals(value)) {
+        return padding;
+      }
+      names.add(name);
+    }
+    throw new IllegalArgumentException(PADDING_OPTION + " must be one of " + 
names);
+  }
+
   /**
    * Reads the {@value #MAX_LENGTH_OPTION} option.
    *
diff --git 
a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/PaddingStrategy.java
 
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/PaddingStrategy.java
new file mode 100644
index 000000000..fba022ec5
--- /dev/null
+++ 
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/PaddingStrategy.java
@@ -0,0 +1,57 @@
+/*
+ * 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.dl.vectors;
+
+import java.util.List;
+
+/**
+ * How {@link SentenceVectorsDL#embedAll(List)} shapes the tensors of one 
inference when the texts
+ * of a call differ in tokenized length.
+ *
+ * <p>A padded position is {@code 0} in the attention mask and {@link Pooling} 
reads only the
+ * positions a row's own mask covers, so the vectors of all three strategies 
match up to
+ * floating-point rounding, provided the model honors the attention mask. A
+ * {@code sentence_embedding} output that ignores the mask changes with 
padding. The strategies
+ * differ in tensor shape and in how many times the session runs.</p>
+ *
+ * @since 3.0.0
+ */
+public enum PaddingStrategy {
+
+  /**
+   * No padding. Texts are grouped by their exact tokenized length and each 
group runs on its own.
+   * This is the default. On natural text most groups hold one or two texts.
+   */
+  EXACT_LENGTH,
+
+  /**
+   * Every row of a batch is padded to the longest row of that batch. Texts 
are ordered by length
+   * before batching, so one long text does not pad out a batch of short ones. 
Requires a padding
+   * token in the vocabulary.
+   */
+  LONGEST,
+
+  /**
+   * Every row is padded to the maximum length, so every inference has the 
same shape. Single
+   * calls such as {@link SentenceVectorsDL#embed(CharSequence)} and
+   * {@link SentenceVectorsDL#getVectors(String)} pad to the maximum length as 
well. Useful for a
+   * model or accelerator that needs a fixed sequence length. Requires a 
padding token in the
+   * vocabulary.
+   */
+  MAX_LENGTH
+}
diff --git 
a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/SentenceVectorsDL.java
 
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/SentenceVectorsDL.java
index 290f1a74c..031ef2b51 100644
--- 
a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/SentenceVectorsDL.java
+++ 
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/SentenceVectorsDL.java
@@ -22,6 +22,8 @@ import java.io.IOException;
 import java.io.UncheckedIOException;
 import java.nio.LongBuffer;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -74,9 +76,10 @@ import opennlp.tools.util.ParamChecks;
  * This thread-safety guarantee applies until {@link #close()} is called; 
callers must not race
  * {@code close()} with inference methods.</p>
  *
- * <p>{@link #embedAll(List)} runs one batched session per distinct tokenized 
length, so a batch
- * of same-length inputs costs one inference instead of one per input. A group 
larger than
- * {@value #MAX_BATCH_TOKEN_POSITIONS} token positions is split into several 
inferences.</p>
+ * <p>{@link #embedAll(List)} batches its texts according to the configured
+ * {@link PaddingStrategy}. By default it does not pad and runs one inference 
per distinct
+ * tokenized length; {@link PaddingStrategy#LONGEST} runs texts of mixed 
lengths together. No
+ * inference exceeds {@value #MAX_BATCH_TOKEN_POSITIONS} token positions.</p>
  */
 @ThreadSafe
 public class SentenceVectorsDL extends AbstractDL implements TextEmbedder {
@@ -92,6 +95,9 @@ public class SentenceVectorsDL extends AbstractDL implements 
TextEmbedder {
    */
   static final int MAX_BATCH_TOKEN_POSITIONS = 16384;
 
+  /** The {@link PaddingStrategy} used if none is given. */
+  public static final PaddingStrategy DEFAULT_PADDING = 
PaddingStrategy.EXACT_LENGTH;
+
   private static final String SENTENCE_EMBEDDING = "sentence_embedding";
   private static final int POOLED_RANK = 2;
   private static final int TOKEN_RANK = 3;
@@ -103,6 +109,8 @@ public class SentenceVectorsDL extends AbstractDL 
implements TextEmbedder {
   private final String outputName;
   private final boolean pooledOutput;
   private final int dimension;
+  private final PaddingStrategy padding;
+  private final long padTokenId;
 
   /**
    * Instantiates a {@link SentenceVectorsDL sentence vector generator} for an
@@ -195,7 +203,8 @@ public class SentenceVectorsDL extends AbstractDL 
implements TextEmbedder {
    * @param maxLength The maximum number of tokens per input, {@code [CLS]} 
and {@code [SEP]}
    *     included; longer input is truncated. Must be at least {@code 2}.
    * @param inferenceOptions The {@link InferenceOptions}, of which the GPU 
settings and the lower
-   *     case setting are used. Must not be {@code null}.
+   *     case setting are used. Must not be {@code null}. {@link 
#embedAll(List)} batches by
+   *     {@link #DEFAULT_PADDING}.
    *
    * @throws IllegalArgumentException Thrown if {@code pooling} or {@code 
inferenceOptions} is
    *     {@code null}, if {@code inferenceOptions} holds invalid split 
options, if
@@ -215,16 +224,61 @@ public class SentenceVectorsDL extends AbstractDL 
implements TextEmbedder {
       final InferenceOptions inferenceOptions)
       throws OrtException, IOException {
 
+    this(model, vocabulary, lowerCase, pooling, normalize, maxLength, 
DEFAULT_PADDING,
+        inferenceOptions);
+
+  }
+
+  /**
+   * Instantiates a {@link SentenceVectorsDL sentence vector generator} using 
ONNX models, with
+   * the {@link PaddingStrategy} that {@link #embedAll(List)} batches by and 
the
+   * {@link InferenceOptions} the session is built from.
+   *
+   * @param model The file name of a sentence vectors ONNX model.
+   * @param vocabulary The file name of the vocabulary file for the model.
+   * @param lowerCase {@code true} for uncased models (lower casing and accent
+   *     stripping during tokenization), {@code false} for cased models. A 
lower case setting in
+   *     {@code inferenceOptions} takes precedence.
+   * @param pooling How token vectors are pooled. Not used for a model with a
+   *     {@code sentence_embedding} output. Must not be {@code null}.
+   * @param normalize {@code true} to scale every vector to unit length.
+   * @param maxLength The maximum number of tokens per input, {@code [CLS]} 
and {@code [SEP]}
+   *     included; longer input is truncated. Must be at least {@code 2}.
+   * @param padding How {@link #embedAll(List)} batches texts of different 
lengths. Must not be
+   *     {@code null}. Every strategy but {@link PaddingStrategy#EXACT_LENGTH} 
needs a
+   *     {@code [PAD]} token in the vocabulary, or {@code <pad>} for a RoBERTa 
vocabulary.
+   * @param inferenceOptions The {@link InferenceOptions}, of which the GPU 
settings and the lower
+   *     case setting are used. Must not be {@code null}.
+   *
+   * @throws IllegalArgumentException Thrown if {@code pooling}, {@code 
padding} or
+   *     {@code inferenceOptions} is {@code null}, if {@code inferenceOptions} 
contains invalid
+   *     split options, if {@code maxLength} is less than {@code 2}, if the 
model has no output of
+   *     shape {@code [batch, hidden]} or {@code [batch, tokens, hidden]}, or 
if {@code padding}
+   *     pads and the vocabulary has no padding token.
+   * @throws OrtException Thrown if the {@code model} cannot be loaded, or if 
a GPU was requested
+   *     and ONNX Runtime cannot provide it. ONNX Runtime does not fall back 
to the CPU.
+   * @throws IOException Thrown if errors occurred loading the {@code model} 
or {@code vocabulary}.
+   *
+   * @since 3.0.0
+   */
+  public SentenceVectorsDL(final File model, final File vocabulary, final 
boolean lowerCase,
+      final Pooling pooling, final boolean normalize, final int maxLength,
+      final PaddingStrategy padding, final InferenceOptions inferenceOptions)
+      throws OrtException, IOException {
+
     super(model, vocabulary, sessionOptions(inferenceOptions),
         resolveLowerCase(inferenceOptions, lowerCase));
     try {
       ParamChecks.requireNonNullArg(pooling, "pooling");
+      ParamChecks.requireNonNullArg(padding, "padding");
       if (maxLength < MIN_LENGTH) {
         throw new IllegalArgumentException("maxLength must be at least " + 
MIN_LENGTH);
       }
       this.pooling = pooling;
       this.normalize = normalize;
       this.maxLength = maxLength;
+      this.padding = padding;
+      this.padTokenId = padding == PaddingStrategy.EXACT_LENGTH ? 0 : 
padTokenId(vocab);
       final Map<String, NodeInfo> outputs = session.getOutputInfo();
       this.outputName = selectOutput(outputs);
       final long[] shape = ((TensorInfo) 
outputs.get(outputName).getInfo()).getShape();
@@ -278,8 +332,12 @@ public class SentenceVectorsDL extends AbstractDL 
implements TextEmbedder {
   /**
    * {@inheritDoc}
    *
-   * <p>The inputs are grouped by tokenized length, never padded, and each 
group runs in
-   * inferences of at most {@value #MAX_BATCH_TOKEN_POSITIONS} token 
positions.</p>
+   * <p>The inputs are tokenized up front and batched according to the 
configured
+   * {@link PaddingStrategy}, so that no inference exceeds {@value 
#MAX_BATCH_TOKEN_POSITIONS}
+   * token positions. A padded position is {@code 0} in the attention mask, so 
vector {@code i}
+   * matches what {@link #embed(CharSequence)} returns for input {@code i} up 
to floating-point
+   * rounding, provided the model honors the attention mask. A {@code 
sentence_embedding} output
+   * that ignores the mask changes with padding.</p>
    */
   @Override
   public float[][] embedAll(final List<? extends CharSequence> texts) {
@@ -314,29 +372,69 @@ public class SentenceVectorsDL extends AbstractDL 
implements TextEmbedder {
   }
 
   /**
-   * Splits the indices of a call into the batches that run as single 
inferences. A batch contains
-   * indices of one tokenized length, in call order, and no more rows than fit 
in
-   * {@value #MAX_BATCH_TOKEN_POSITIONS} token positions, but always at least 
one.
+   * Splits the indices of a call into the batches that run as single 
inferences. No batch
+   * exceeds {@value #MAX_BATCH_TOKEN_POSITIONS} token positions, but each has 
at least one row.
+   *
+   * <p>Under {@link PaddingStrategy#EXACT_LENGTH} a batch contains indices of 
one tokenized length,
+   * in call order. Under {@link PaddingStrategy#MAX_LENGTH} every row is the 
maximum length wide,
+   * so batches are consecutive indices in call order. Under {@link 
PaddingStrategy#LONGEST} the
+   * indices are ordered by tokenized length and cut where the next row would 
exceed the cap.</p>
    *
    * @param encoded The encodings of the call, in call order.
    * @return The batches, each a list of indices into {@code encoded}.
    */
-  static List<List<Integer>> batches(final Tokens[] encoded) {
-    final Map<Integer, List<Integer>> byLength = new LinkedHashMap<>();
+  List<List<Integer>> batches(final Tokens[] encoded) {
+    final List<List<Integer>> batches = new ArrayList<>();
+    if (padding == PaddingStrategy.EXACT_LENGTH) {
+      final Map<Integer, List<Integer>> byLength = new LinkedHashMap<>();
+      for (int i = 0; i < encoded.length; i++) {
+        byLength.computeIfAbsent(encoded[i].ids().length, length -> new 
ArrayList<>()).add(i);
+      }
+      for (final Map.Entry<Integer, List<Integer>> group : 
byLength.entrySet()) {
+        addCapped(batches, group.getValue(), group.getKey());
+      }
+      return batches;
+    }
+    final List<Integer> order = new ArrayList<>(encoded.length);
     for (int i = 0; i < encoded.length; i++) {
-      byLength.computeIfAbsent(encoded[i].ids().length, length -> new 
ArrayList<>()).add(i);
+      order.add(i);
     }
-    final List<List<Integer>> batches = new ArrayList<>();
-    for (final Map.Entry<Integer, List<Integer>> group : byLength.entrySet()) {
-      final int rows = Math.max(1, MAX_BATCH_TOKEN_POSITIONS / group.getKey());
-      final List<Integer> indices = group.getValue();
-      for (int from = 0; from < indices.size(); from += rows) {
-        batches.add(indices.subList(from, Math.min(from + rows, 
indices.size())));
+    if (padding == PaddingStrategy.MAX_LENGTH) {
+      addCapped(batches, order, maxLength);
+      return batches;
+    }
+    order.sort(Comparator.comparingInt(i -> encoded[i].ids().length));
+    int from = 0;
+    for (int i = 0; i < order.size(); i++) {
+      // Sorted ascending, so the row being added is the longest of its batch.
+      final long width = encoded[order.get(i)].ids().length;
+      if (i > from && (i - from + 1) * width > MAX_BATCH_TOKEN_POSITIONS) {
+        batches.add(order.subList(from, i));
+        from = i;
       }
     }
+    if (from < order.size()) {
+      batches.add(order.subList(from, order.size()));
+    }
     return batches;
   }
 
+  /**
+   * Adds indices that all run at one row width as consecutive batches of at 
most
+   * {@value #MAX_BATCH_TOKEN_POSITIONS} token positions, and at least one row.
+   *
+   * @param batches The batches to add to.
+   * @param indices The indices to split, in the order they run.
+   * @param width The row width they run at.
+   */
+  private static void addCapped(final List<List<Integer>> batches, final 
List<Integer> indices,
+      final int width) {
+    final int rows = Math.max(1, MAX_BATCH_TOKEN_POSITIONS / width);
+    for (int from = 0; from < indices.size(); from += rows) {
+      batches.add(indices.subList(from, Math.min(from + rows, 
indices.size())));
+    }
+  }
+
   /**
    * {@inheritDoc}
    *
@@ -387,23 +485,27 @@ public class SentenceVectorsDL extends AbstractDL 
implements TextEmbedder {
   }
 
   /**
-   * Runs one inference over encodings of the same length and returns one 
sentence vector per
-   * encoding, in order.
+   * Runs one inference over a batch of encodings and returns one sentence 
vector per encoding, in
+   * order. Rows shorter than the batch width are padded: {@code input_ids} 
with the padding
+   * token id, {@code attention_mask} and {@code token_type_ids} with {@code 
0}.
    *
-   * @param batch The encodings, all of the same length.
+   * @param batch The encodings, of any lengths.
    * @return The sentence vectors.
    * @throws OrtException Thrown if an error occurs during inference.
    */
   private float[][] run(final Tokens[] batch) throws OrtException {
 
-    final int length = batch[0].ids().length;
+    final int length = width(batch);
     final long[] ids = new long[batch.length * length];
     final long[] mask = new long[batch.length * length];
     final long[] types = new long[batch.length * length];
     for (int b = 0; b < batch.length; b++) {
-      System.arraycopy(batch[b].ids(), 0, ids, b * length, length);
-      System.arraycopy(batch[b].mask(), 0, mask, b * length, length);
-      System.arraycopy(batch[b].types(), 0, types, b * length, length);
+      final int rowLength = batch[b].ids().length;
+      System.arraycopy(batch[b].ids(), 0, ids, b * length, rowLength);
+      System.arraycopy(batch[b].mask(), 0, mask, b * length, rowLength);
+      System.arraycopy(batch[b].types(), 0, types, b * length, rowLength);
+      // mask and types stay 0 past the row; only the ids need the padding 
token id.
+      Arrays.fill(ids, b * length + rowLength, (b + 1) * length, padTokenId);
     }
 
     final Map<String, OnnxTensor> inputs = new HashMap<>();
@@ -438,10 +540,29 @@ public class SentenceVectorsDL extends AbstractDL 
implements TextEmbedder {
   }
 
   /**
-   * Pools the token vectors of one input into its sentence vector.
+   * {@return the row width of one inference} It is the maximum length under
+   * {@link PaddingStrategy#MAX_LENGTH}, otherwise the longest row of the 
batch.
+   *
+   * @param batch The encodings of one inference, at least one.
+   */
+  private int width(final Tokens[] batch) {
+    if (padding == PaddingStrategy.MAX_LENGTH) {
+      return maxLength;
+    }
+    int longest = 0;
+    for (final Tokens row : batch) {
+      longest = Math.max(longest, row.ids().length);
+    }
+    return longest;
+  }
+
+  /**
+   * Pools the token vectors of one input into its sentence vector. Only the 
first
+   * {@code mask.length} positions are read, so padding after the row is 
ignored.
    *
-   * @param tokenVectors The vectors of the input's tokens.
-   * @param mask The attention mask of the input.
+   * @param tokenVectors The vectors of the input's tokens, at least as many 
as {@code mask} is
+   *     long.
+   * @param mask The attention mask of the input, without padding.
    * @return A new array holding the sentence vector.
    */
   private float[] pool(final float[][] tokenVectors, final long[] mask) {
@@ -450,7 +571,7 @@ public class SentenceVectorsDL extends AbstractDL 
implements TextEmbedder {
     }
     final float[] sum = new float[tokenVectors[0].length];
     int count = 0;
-    for (int t = 0; t < tokenVectors.length; t++) {
+    for (int t = 0; t < mask.length; t++) {
       if (mask[t] != 0) {
         for (int d = 0; d < sum.length; d++) {
           sum[d] += tokenVectors[t][d];
diff --git 
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/OnnxTextEmbedderProviderTest.java
 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/OnnxTextEmbedderProviderTest.java
index 45625292e..e57bb8632 100644
--- 
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/OnnxTextEmbedderProviderTest.java
+++ 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/OnnxTextEmbedderProviderTest.java
@@ -32,6 +32,7 @@ import static 
opennlp.dl.vectors.OnnxTextEmbedderProvider.LOWER_CASE_OPTION;
 import static opennlp.dl.vectors.OnnxTextEmbedderProvider.MAX_LENGTH_OPTION;
 import static opennlp.dl.vectors.OnnxTextEmbedderProvider.NAME;
 import static opennlp.dl.vectors.OnnxTextEmbedderProvider.NORMALIZE_OPTION;
+import static opennlp.dl.vectors.OnnxTextEmbedderProvider.PADDING_OPTION;
 import static opennlp.dl.vectors.OnnxTextEmbedderProvider.POOLING_OPTION;
 import static opennlp.dl.vectors.OnnxTextEmbedderProvider.VOCABULARY_OPTION;
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -63,7 +64,7 @@ class OnnxTextEmbedderProviderTest {
         Map.of(VOCABULARY_OPTION, "vocab.txt", LOWER_CASE_OPTION, "false"))));
     assertTrue(provider.supports(ProviderSpec.of(Path.of(model),
         Map.of(VOCABULARY_OPTION, "vocab.txt", POOLING_OPTION, "cls", 
NORMALIZE_OPTION, "false",
-            MAX_LENGTH_OPTION, "256"))));
+            MAX_LENGTH_OPTION, "256", PADDING_OPTION, "longest"))));
   }
 
   @ParameterizedTest
diff --git 
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLBatchLimitTest.java
 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLBatchLimitTest.java
index 01c5b03c2..5b27037a3 100644
--- 
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLBatchLimitTest.java
+++ 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLBatchLimitTest.java
@@ -24,6 +24,8 @@ import java.util.Collections;
 import java.util.List;
 import java.util.stream.Stream;
 
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 import org.junit.jupiter.params.ParameterizedTest;
@@ -38,6 +40,7 @@ import static 
opennlp.dl.vectors.TinyVectorModels.TINY_VECTORS;
 import static opennlp.dl.vectors.TinyVectorModels.gpuOrAbort;
 import static opennlp.dl.vectors.TinyVectorModels.model;
 import static opennlp.dl.vectors.TinyVectorModels.scale;
+import static opennlp.dl.vectors.TinyVectorModels.tokens;
 import static opennlp.dl.vectors.TinyVectorModels.vocab;
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -76,13 +79,28 @@ class SentenceVectorsDLBatchLimitTest {
         maxLength);
   }
 
+  /** The directory of the shared embedder. */
+  @TempDir
+  static Path shared;
+
+  /** An embedder that does not pad, whose batches the tests below check. */
+  private static SentenceVectorsDL exact;
+
   /**
-   * {@return an encoding of {@code length} tokens; only its length matters 
for batching}
+   * Opens the shared embedder.
    *
-   * @param length The number of tokens.
+   * @throws Exception Thrown if the model cannot be loaded.
    */
-  private static Tokens tokens(final int length) {
-    return new Tokens(new String[length], new long[length], new long[length], 
new long[length]);
+  @BeforeAll
+  static void openExact() throws Exception {
+    exact = vectors(shared, TINY_VECTORS, Pooling.MEAN, false,
+        SentenceVectorsDL.DEFAULT_MAX_LENGTH);
+  }
+
+  /** Closes the shared embedder. */
+  @AfterAll
+  static void closeExact() {
+    exact.close();
   }
 
   /**
@@ -94,7 +112,7 @@ class SentenceVectorsDLBatchLimitTest {
   private static List<Integer> sizes(final int length, final int count) {
     final Tokens[] encoded = Collections.nCopies(count, 
tokens(length)).toArray(new Tokens[0]);
     final List<Integer> sizes = new ArrayList<>();
-    for (final List<Integer> batch : SentenceVectorsDL.batches(encoded)) {
+    for (final List<Integer> batch : exact.batches(encoded)) {
       sizes.add(batch.size());
     }
     return sizes;
@@ -152,13 +170,13 @@ class SentenceVectorsDLBatchLimitTest {
   /** An empty call has no batches. */
   @Test
   void testEmptyCallHasNoBatches() {
-    assertTrue(SentenceVectorsDL.batches(new Tokens[0]).isEmpty());
+    assertTrue(exact.batches(new Tokens[0]).isEmpty());
   }
 
   /** Inputs of different lengths run one batch per length, in the order each 
length appears. */
   @Test
   void testGroupsByLengthInCallOrder() {
-    assertEquals(List.of(List.of(0, 3), List.of(1, 2, 4)), 
SentenceVectorsDL.batches(
+    assertEquals(List.of(List.of(0, 3), List.of(1, 2, 4)), exact.batches(
         new Tokens[] {tokens(4), tokens(3), tokens(3), tokens(4), tokens(3)}));
   }
 
@@ -172,7 +190,7 @@ class SentenceVectorsDLBatchLimitTest {
     Arrays.fill(encoded, tokens(4));
     encoded[1] = tokens(3);
     encoded[2] = tokens(3);
-    final List<List<Integer>> batches = SentenceVectorsDL.batches(encoded);
+    final List<List<Integer>> batches = exact.batches(encoded);
     assertEquals(3, batches.size());
     assertEquals(LIMIT / 4, batches.get(0).size());
     assertEquals(List.of(0, 3), batches.get(0).subList(0, 2));
diff --git 
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLEmbedderTest.java
 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLEmbedderTest.java
index d12d97878..61198e668 100644
--- 
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLEmbedderTest.java
+++ 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLEmbedderTest.java
@@ -21,13 +21,19 @@ import java.io.File;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
 
+import opennlp.dl.InferenceOptions;
+import opennlp.dl.Tokens;
 import opennlp.tools.embeddings.TextEmbedder;
 import opennlp.tools.embeddings.TextEmbedderProvider;
 import opennlp.tools.util.InvalidFormatException;
@@ -37,13 +43,16 @@ import opennlp.tools.util.ext.Providers;
 import static opennlp.dl.vectors.OnnxTextEmbedderProvider.LOWER_CASE_OPTION;
 import static opennlp.dl.vectors.OnnxTextEmbedderProvider.MAX_LENGTH_OPTION;
 import static opennlp.dl.vectors.OnnxTextEmbedderProvider.NORMALIZE_OPTION;
+import static opennlp.dl.vectors.OnnxTextEmbedderProvider.PADDING_OPTION;
 import static opennlp.dl.vectors.OnnxTextEmbedderProvider.POOLING_OPTION;
 import static opennlp.dl.vectors.OnnxTextEmbedderProvider.VOCABULARY_OPTION;
 import static opennlp.dl.vectors.TinyVectorModels.TINY_POOLED;
 import static opennlp.dl.vectors.TinyVectorModels.TINY_VECTORS;
 import static opennlp.dl.vectors.TinyVectorModels.model;
 import static opennlp.dl.vectors.TinyVectorModels.scale;
+import static opennlp.dl.vectors.TinyVectorModels.tokens;
 import static opennlp.dl.vectors.TinyVectorModels.vocab;
+import static opennlp.dl.vectors.TinyVectorModels.vocabPad6;
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotSame;
@@ -88,7 +97,14 @@ class SentenceVectorsDLEmbedderTest {
 
   private static SentenceVectorsDL meanVectors(final Path dir, final int 
maxLength)
       throws Exception {
-    return new SentenceVectorsDL(model(dir, TINY_VECTORS), vocab(dir), true, 
Pooling.MEAN, false, maxLength);
+    return new SentenceVectorsDL(model(dir, TINY_VECTORS), vocab(dir), true, 
Pooling.MEAN, false,
+        maxLength);
+  }
+
+  private static SentenceVectorsDL meanVectors(final Path dir, final int 
maxLength,
+      final PaddingStrategy padding) throws Exception {
+    return new SentenceVectorsDL(model(dir, TINY_VECTORS), vocab(dir), true, 
Pooling.MEAN, false,
+        maxLength, padding, new InferenceOptions());
   }
 
   @Test
@@ -111,6 +127,13 @@ class SentenceVectorsDLEmbedderTest {
     try (TextEmbedder cls = provider.create(clsSpec)) {
       assertArrayEquals(CLS_VECTOR, cls.embed("hello world"), DELTA);
     }
+    for (final String padding : List.of("exact_length", "longest", 
"max_length")) {
+      try (TextEmbedder padded = provider.create(ProviderSpec.of(graph,
+          Map.of(VOCABULARY_OPTION, "vocab.txt", PADDING_OPTION, padding)))) {
+        assertArrayEquals(UNIT_VECTOR, padded.embedAll(List.of("hello world", 
"hello"))[1],
+            DELTA, padding);
+      }
+    }
     assertThrows(IllegalArgumentException.class, () -> provider.create(null));
     assertThrows(IllegalArgumentException.class,
         () -> provider.create(ProviderSpec.of(dir, options)), "a directory");
@@ -122,7 +145,8 @@ class SentenceVectorsDLEmbedderTest {
     for (final Map.Entry<String, String> invalid : 
List.of(Map.entry(LOWER_CASE_OPTION, "invalid"),
         Map.entry(NORMALIZE_OPTION, "yes"), Map.entry(POOLING_OPTION, "max"),
         Map.entry(POOLING_OPTION, "MEAN"), Map.entry(MAX_LENGTH_OPTION, "1"),
-        Map.entry(MAX_LENGTH_OPTION, "many"), Map.entry("typo", "true"))) {
+        Map.entry(MAX_LENGTH_OPTION, "many"), Map.entry(PADDING_OPTION, 
"none"),
+        Map.entry(PADDING_OPTION, "LONGEST"), Map.entry("typo", "true"))) {
       assertThrows(IllegalArgumentException.class, () -> 
provider.create(ProviderSpec.of(graph,
           Map.of(VOCABULARY_OPTION, "vocab.txt", invalid.getKey(), 
invalid.getValue()))),
           invalid.toString());
@@ -248,6 +272,135 @@ class SentenceVectorsDLEmbedderTest {
     }
   }
 
+  /**
+   * {@return the number of rows of each batch}
+   *
+   * @param batches The batches.
+   */
+  private static List<Integer> sizes(final List<List<Integer>> batches) {
+    return batches.stream().map(List::size).toList();
+  }
+
+  /**
+   * Asserts that every padding strategy returns, for each input of a 
mixed-length call, the
+   * vector that input gets on its own: padded positions are masked out of the 
pooling.
+   */
+  @ParameterizedTest
+  @EnumSource(PaddingStrategy.class)
+  void testPaddingKeepsSingleInputVectors(final PaddingStrategy padding, 
@TempDir final Path dir)
+      throws Exception {
+    try (SentenceVectorsDL vectors = meanVectors(dir, 6, padding)) {
+      final List<String> texts = List.of("hello world", "hello", "world", 
"hello world",
+          "hello", "");
+      final float[][] batch = vectors.embedAll(texts);
+      for (int i = 0; i < texts.size(); i++) {
+        assertArrayEquals(vectors.embed(texts.get(i)), batch[i], DELTA, 
texts.get(i));
+      }
+      assertArrayEquals(HELLO_WORLD_MEAN, batch[0], DELTA);
+      assertArrayEquals(HELLO_MEAN, batch[1], DELTA);
+      // "world" = [CLS]=7 world=5 [SEP]=3, mean 5.
+      assertArrayEquals(scale(5), batch[2], DELTA);
+      assertArrayEquals(HELLO_WORLD_MEAN, batch[3], DELTA);
+      assertArrayEquals(HELLO_MEAN, batch[4], DELTA);
+      // "" = [CLS]=7 [SEP]=3, mean 5.
+      assertArrayEquals(scale(5), batch[5], DELTA);
+    }
+  }
+
+  /**
+   * Asserts the batches of each strategy for one mixed-length call: one per 
length without
+   * padding, otherwise one holding every row, ordered by length under LONGEST.
+   */
+  @Test
+  void testPaddingBatches(@TempDir final Path dir) throws Exception {
+    final Tokens[] rows = {tokens(4), tokens(3), tokens(3), tokens(4), 
tokens(3)};
+    try (SentenceVectorsDL exact = meanVectors(dir, 6, 
PaddingStrategy.EXACT_LENGTH);
+         SentenceVectorsDL longest = meanVectors(dir, 6, 
PaddingStrategy.LONGEST);
+         SentenceVectorsDL fixed = meanVectors(dir, 6, 
PaddingStrategy.MAX_LENGTH)) {
+      assertEquals(List.of(List.of(0, 3), List.of(1, 2, 4)), 
exact.batches(rows));
+      assertEquals(List.of(List.of(1, 2, 4, 0, 3)), longest.batches(rows));
+      assertEquals(List.of(List.of(0, 1, 2, 3, 4)), fixed.batches(rows));
+    }
+  }
+
+  /**
+   * Asserts that padded batches also respect the token position cap. LONGEST 
orders the inputs
+   * by length, so the one short input shares the first batch with the long 
ones.
+   */
+  @Test
+  void testPaddedBatchesRespectTheCap(@TempDir final Path dir) throws 
Exception {
+    final List<String> texts = new ArrayList<>(Collections.nCopies(5000,
+        "hello world"));
+    texts.add("hello");
+    final List<Tokens> encoded = new ArrayList<>(Collections.nCopies(5000, 
tokens(4)));
+    encoded.add(tokens(3));
+    final Tokens[] rows = encoded.toArray(new Tokens[0]);
+    try (SentenceVectorsDL longest = meanVectors(dir, 8, 
PaddingStrategy.LONGEST);
+         SentenceVectorsDL fixed = meanVectors(dir, 8, 
PaddingStrategy.MAX_LENGTH)) {
+      final List<List<Integer>> longestBatches = longest.batches(rows);
+      assertEquals(List.of(4096, 905), sizes(longestBatches));
+      assertEquals(5000, longestBatches.get(0).get(0));
+      assertEquals(List.of(2048, 2048, 905), sizes(fixed.batches(rows)));
+      final float[][] batch = longest.embedAll(texts);
+      assertArrayEquals(HELLO_WORLD_MEAN, batch[4999], DELTA);
+      assertArrayEquals(HELLO_MEAN, batch[5000], DELTA);
+    }
+  }
+
+  /**
+   * Asserts that the padding id comes from the vocabulary. {@code 
tiny-pooled.onnx} sums the ids
+   * of every position without reading the mask, so a padded row shows the id 
that was placed.
+   */
+  @Test
+  void testPaddingIdIsReadFromTheVocabulary(@TempDir final Path dir) throws 
Exception {
+    try (SentenceVectorsDL vectors = new SentenceVectorsDL(model(dir, 
"tiny-pooled.onnx"),
+        vocabPad6(dir), true, Pooling.MEAN, false, 8, PaddingStrategy.LONGEST,
+        new InferenceOptions())) {
+      final float[][] batch = vectors.embedAll(List.of("hello world", 
"hello"));
+      assertArrayEquals(HELLO_WORLD_SUM, batch[0], DELTA);
+      assertArrayEquals(scale(14 + 6), batch[1], DELTA);
+    }
+  }
+
+  /**
+   * Asserts that padded positions are {@code 0} in the attention mask. {@code 
tiny-masked.onnx}
+   * sums the ids times the mask, so a padded row gives the sum of its own ids 
only; a mask of
+   * {@code 1} at the padded position would add the padding id {@code 6}.
+   */
+  @ParameterizedTest
+  @EnumSource(value = PaddingStrategy.class, names = {"LONGEST", "MAX_LENGTH"})
+  void testPaddedPositionsAreMaskedOut(final PaddingStrategy padding, @TempDir 
final Path dir)
+      throws Exception {
+    try (SentenceVectorsDL vectors = new SentenceVectorsDL(model(dir, 
"tiny-masked.onnx"),
+        vocabPad6(dir), true, Pooling.MEAN, false, 8, padding, new 
InferenceOptions())) {
+      final float[][] batch = vectors.embedAll(List.of("hello world", 
"hello"));
+      assertArrayEquals(HELLO_WORLD_SUM, batch[0], DELTA);
+      assertArrayEquals(scale(14), batch[1], DELTA);
+    }
+  }
+
+  /**
+   * Asserts that a padding strategy needs a padding token, and that no 
padding does not.
+   */
+  @Test
+  void testPaddingRequiresAPaddingToken(@TempDir final Path dir) throws 
Exception {
+    final Path vocabulary = dir.resolve("vocab-nopad.txt");
+    Files.write(vocabulary, List.of("unused0", "unused1", "[UNK]", "[SEP]", 
"hello", "world",
+        "unused2", "[CLS]"));
+    for (final PaddingStrategy padding : List.of(PaddingStrategy.LONGEST,
+        PaddingStrategy.MAX_LENGTH)) {
+      assertThrows(IllegalArgumentException.class, () -> new 
SentenceVectorsDL(model(dir, TINY_VECTORS),
+          vocabulary.toFile(), true, Pooling.MEAN, false, 8, padding, new 
InferenceOptions()),
+          padding.name());
+    }
+    try (SentenceVectorsDL exact = new SentenceVectorsDL(model(dir, 
TINY_VECTORS), vocabulary.toFile(), true,
+        Pooling.MEAN, false, 8, PaddingStrategy.EXACT_LENGTH, new 
InferenceOptions())) {
+      assertArrayEquals(HELLO_MEAN, exact.embed("hello"), DELTA);
+    }
+    assertEquals("padding must not be null", 
assertThrows(IllegalArgumentException.class,
+        () -> meanVectors(dir, 8, null)).getMessage());
+  }
+
   /**
    * Asserts the batch contract edges: an empty input yields an empty batch, 
and a
    * {@code null} element is rejected rather than failing later inside the 
session.
diff --git 
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLPaddingEdgesTest.java
 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLPaddingEdgesTest.java
new file mode 100644
index 000000000..e54068e0f
--- /dev/null
+++ 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLPaddingEdgesTest.java
@@ -0,0 +1,337 @@
+/*
+ * 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.dl.vectors;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import opennlp.dl.InferenceOptions;
+import opennlp.dl.Tokens;
+
+import static opennlp.dl.vectors.TinyVectorModels.TINY_MASKED;
+import static opennlp.dl.vectors.TinyVectorModels.TINY_POOLED;
+import static opennlp.dl.vectors.TinyVectorModels.TINY_VECTORS;
+import static opennlp.dl.vectors.TinyVectorModels.gpuOrAbort;
+import static opennlp.dl.vectors.TinyVectorModels.model;
+import static opennlp.dl.vectors.TinyVectorModels.scale;
+import static opennlp.dl.vectors.TinyVectorModels.vocab;
+import static opennlp.dl.vectors.TinyVectorModels.vocabPad6;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Edge cases of the padding strategies of {@link 
SentenceVectorsDL#embedAll(List)}: the limit of
+ * token positions per inference under padding, other vocabularies, pooling 
and normalization of
+ * padded rows, a model that ignores the mask, and a GPU.
+ *
+ * <p>With the test vocabulary "hello" encodes to 3 tokens and "hello world" 
to 4.</p>
+ */
+class SentenceVectorsDLPaddingEdgesTest {
+
+  private static final int LIMIT = SentenceVectorsDL.MAX_BATCH_TOKEN_POSITIONS;
+
+  private static final float DELTA = 1e-5f;
+
+  private static final float[] UNIT = scale(1 / (float) Math.sqrt(5.25));
+
+  private static File write(final Path dir, final String name, final 
List<String> tokens)
+      throws IOException {
+    final Path file = dir.resolve(name);
+    Files.write(file, tokens);
+    return file.toFile();
+  }
+
+  private static SentenceVectorsDL embedder(final File model, final File 
vocabulary,
+      final Pooling pooling, final boolean normalize, final int maxLength,
+      final PaddingStrategy padding) throws Exception {
+    return new SentenceVectorsDL(model, vocabulary, true, pooling, normalize, 
maxLength, padding,
+        new InferenceOptions());
+  }
+
+  private static Tokens[] tokens(final int length, final int count) {
+    return Collections.nCopies(count, 
TinyVectorModels.tokens(length)).toArray(new Tokens[0]);
+  }
+
+  private static List<Integer> sizes(final List<List<Integer>> batches) {
+    return batches.stream().map(List::size).toList();
+  }
+
+  // The plan tests do not run a text, so one embedder per strategy is enough.
+  private static SentenceVectorsDL planner(final Path dir, final 
PaddingStrategy padding,
+      final int maxLength) throws Exception {
+    return embedder(model(dir, TINY_VECTORS), vocab(dir), Pooling.MEAN, false, 
maxLength,
+        padding);
+  }
+
+  /**
+   * LONGEST fills an inference to exactly the limit, splits at one row more, 
and runs a row wider
+   * than the limit on its own.
+   */
+  @Test
+  void testLongestAtTheLimitAndOverIt(@TempDir final Path dir) throws 
Exception {
+    try (SentenceVectorsDL longest = planner(dir, PaddingStrategy.LONGEST, 8)) 
{
+      assertEquals(List.of(LIMIT / 4), sizes(longest.batches(tokens(4, LIMIT / 
4))));
+      assertEquals(List.of(LIMIT / 4, 1), sizes(longest.batches(tokens(4, 
LIMIT / 4 + 1))));
+      assertEquals(List.of(LIMIT / 2, 1), sizes(longest.batches(tokens(2, 
LIMIT / 2 + 1))));
+      assertEquals(List.of(List.of(0)), longest.batches(tokens(LIMIT + 1, 1)));
+      final Tokens[] mixed = {tokens(LIMIT + 1, 1)[0], tokens(3, 1)[0], 
tokens(LIMIT + 1, 1)[0]};
+      assertEquals(List.of(List.of(1), List.of(0), List.of(2)), 
longest.batches(mixed));
+    }
+  }
+
+  /** MAX_LENGTH with a length above the limit runs one row per inference. */
+  @Test
+  void testMaxLengthAboveTheLimitRunsOneRowEach(@TempDir final Path dir) 
throws Exception {
+    try (SentenceVectorsDL fixed = planner(dir, PaddingStrategy.MAX_LENGTH, 
LIMIT + 5)) {
+      assertEquals(List.of(1, 1, 1), sizes(fixed.batches(tokens(3, 3))));
+    }
+  }
+
+  /** MAX_LENGTH with lengths that divide the limit evenly, or equal it. */
+  @ParameterizedTest
+  @MethodSource("maxLengthPlans")
+  void testMaxLengthThatDividesTheLimit(final int maxLength, final int count,
+      final List<Integer> expected, @TempDir final Path dir) throws Exception {
+    try (SentenceVectorsDL fixed = planner(dir, PaddingStrategy.MAX_LENGTH, 
maxLength)) {
+      assertEquals(expected, sizes(fixed.batches(tokens(3, count))));
+    }
+  }
+
+  static Stream<Arguments> maxLengthPlans() {
+    return Stream.of(
+        Arguments.of(8, 2048, List.of(2048)),
+        Arguments.of(8, 2049, List.of(2048, 1)),
+        Arguments.of(8, 4097, List.of(2048, 2048, 1)),
+        Arguments.of(LIMIT, 2, List.of(1, 1)),
+        Arguments.of(LIMIT / 2, 3, List.of(2, 1)));
+  }
+
+  /**
+   * An input as long as a MAX_LENGTH wider than the limit still embeds, in 
call order, next to
+   * short inputs.
+   */
+  @Test
+  void testMaxLengthAboveTheLimitEmbeds(@TempDir final Path dir) throws 
Exception {
+    try (SentenceVectorsDL fixed = embedder(model(dir, TINY_MASKED), 
vocabPad6(dir),
+        Pooling.MEAN, false, LIMIT + 5, PaddingStrategy.MAX_LENGTH)) {
+      final float[][] batch = fixed.embedAll(List.of("hello world", "hello", 
"world"));
+      assertArrayEquals(scale(19), batch[0], DELTA);
+      assertArrayEquals(scale(14), batch[1], DELTA);
+      assertArrayEquals(scale(15), batch[2], DELTA);
+    }
+  }
+
+  /** Alternating lengths with duplicates come back in input order after 
LONGEST sorts them. */
+  @Test
+  void testInterleavedLengthsKeepInputOrder(@TempDir final Path dir) throws 
Exception {
+    try (SentenceVectorsDL longest = embedder(model(dir, TINY_MASKED), 
vocabPad6(dir),
+        Pooling.MEAN, false, 8, PaddingStrategy.LONGEST)) {
+      final List<String> texts = new ArrayList<>();
+      for (int i = 0; i < LIMIT / 4 + 1; i++) {
+        texts.add("hello world");
+        texts.add("hello");
+        texts.add("hello world");
+        texts.add("world");
+      }
+      final float[][] batch = longest.embedAll(texts);
+      assertEquals(texts.size(), batch.length);
+      for (int i = 0; i < batch.length; i++) {
+        final String text = texts.get(i);
+        final float sum = "hello world".equals(text) ? 19 : 
"hello".equals(text) ? 14 : 15;
+        assertArrayEquals(scale(sum), batch[i], DELTA, "row " + i);
+      }
+    }
+  }
+
+  /**
+   * A RoBERTa vocabulary with {@code <pad>} pads with that token: {@code 
tiny-masked.onnx} sums
+   * the ids times the mask, so padded rows give the sum of their own ids.
+   */
+  @ParameterizedTest
+  @EnumSource(value = PaddingStrategy.class, names = {"LONGEST", "MAX_LENGTH"})
+  void testRobertaVocabularyPads(final PaddingStrategy padding, @TempDir final 
Path dir)
+      throws Exception {
+    // <s>=0 <pad>=1 </s>=2 <unk>=3 hello=4 world=5
+    final File roberta = write(dir, "roberta.txt",
+        List.of("<s>", "<pad>", "</s>", "<unk>", "hello", "world"));
+    try (SentenceVectorsDL vectors = embedder(model(dir, TINY_MASKED), roberta,
+        Pooling.MEAN, false, 8, padding)) {
+      final float[][] batch = vectors.embedAll(List.of("hello world", 
"hello"));
+      assertArrayEquals(scale(11), batch[0], DELTA);
+      assertArrayEquals(scale(6), batch[1], DELTA);
+    }
+  }
+
+  /** A padding id of 0, the usual case, gives the same masked sums as the id 
6 fixture. */
+  @ParameterizedTest
+  @EnumSource(value = PaddingStrategy.class, names = {"LONGEST", "MAX_LENGTH"})
+  void testPaddingIdZero(final PaddingStrategy padding, @TempDir final Path 
dir)
+      throws Exception {
+    try (SentenceVectorsDL vectors = embedder(model(dir, TINY_MASKED), 
vocab(dir),
+        Pooling.MEAN, false, 8, padding)) {
+      final float[][] batch = vectors.embedAll(List.of("hello world", "hello", 
"world", ""));
+      assertArrayEquals(scale(19), batch[0], DELTA);
+      assertArrayEquals(scale(14), batch[1], DELTA);
+      assertArrayEquals(scale(15), batch[2], DELTA);
+      assertArrayEquals(scale(10), batch[3], DELTA);
+    }
+  }
+
+  static Stream<Arguments> poolingCases() {
+    final List<Arguments> cases = new ArrayList<>();
+    for (final PaddingStrategy padding : List.of(PaddingStrategy.LONGEST,
+        PaddingStrategy.MAX_LENGTH)) {
+      cases.add(Arguments.of(padding, Pooling.CLS, false, scale(7), scale(7)));
+      cases.add(Arguments.of(padding, Pooling.CLS, true, UNIT, UNIT));
+      cases.add(Arguments.of(padding, Pooling.MEAN, true, UNIT, UNIT));
+    }
+    return cases.stream();
+  }
+
+  /**
+   * CLS pooling reads the first position, which padding never moves, and unit 
length scaling
+   * applies per row, for padded rows too.
+   */
+  @ParameterizedTest
+  @MethodSource("poolingCases")
+  void testClsAndNormalizeUnderPadding(final PaddingStrategy padding, final 
Pooling pooling,
+      final boolean normalize, final float[] helloWorld, final float[] hello,
+      @TempDir final Path dir) throws Exception {
+    try (SentenceVectorsDL vectors = embedder(model(dir, TINY_VECTORS), 
vocabPad6(dir),
+        pooling, normalize, 8, padding)) {
+      final float[][] batch = vectors.embedAll(List.of("hello world", "hello", 
"hello world"));
+      assertArrayEquals(helloWorld, batch[0], DELTA);
+      assertArrayEquals(hello, batch[1], DELTA);
+      assertArrayEquals(helloWorld, batch[2], DELTA);
+    }
+  }
+
+  /**
+   * The pooled-output model ignores the mask: a padded row also sums the 
padding id, so a batch
+   * differs from the single-input result under LONGEST, and every call pads 
to the maximum length
+   * under MAX_LENGTH, single calls included. This pins the documented caveat.
+   */
+  @Test
+  void testPooledOutputModelSumsThePadding(@TempDir final Path dir) throws 
Exception {
+    final File pooled = model(dir, TINY_POOLED);
+    final File vocabulary = vocabPad6(dir);
+    try (SentenceVectorsDL longest = embedder(pooled, vocabulary, 
Pooling.MEAN, false, 8,
+        PaddingStrategy.LONGEST);
+         SentenceVectorsDL fixed = embedder(pooled, vocabulary, Pooling.MEAN, 
false, 8,
+             PaddingStrategy.MAX_LENGTH)) {
+      final float[][] batch = longest.embedAll(List.of("hello world", 
"hello"));
+      assertArrayEquals(scale(19), batch[0], DELTA);
+      assertArrayEquals(scale(14 + 6), batch[1], DELTA);
+      assertFalse(java.util.Arrays.equals(longest.embed("hello"), batch[1]));
+      assertArrayEquals(scale(14), longest.embed("hello"), DELTA);
+      assertArrayEquals(scale(14 + 5 * 6), fixed.embed("hello"), DELTA);
+      assertArrayEquals(scale(19 + 4 * 6), fixed.embed("hello world"), DELTA);
+    }
+  }
+
+  /**
+   * A single {@code embed} under MAX_LENGTH pads to the maximum length: a 
model that ignores the
+   * mask shows the padding ids, a model that reads it gives the unpadded sum.
+   */
+  @Test
+  void testSingleEmbedUnderMaxLengthIsPadded(@TempDir final Path dir) throws 
Exception {
+    try (SentenceVectorsDL ignoring = embedder(model(dir, TINY_POOLED), 
vocabPad6(dir),
+        Pooling.MEAN, false, 6, PaddingStrategy.MAX_LENGTH);
+         SentenceVectorsDL masked = embedder(model(dir, TINY_MASKED), 
vocabPad6(dir),
+             Pooling.MEAN, false, 6, PaddingStrategy.MAX_LENGTH)) {
+      assertArrayEquals(scale(14 + 3 * 6), ignoring.embed("hello"), DELTA);
+      assertArrayEquals(scale(14), masked.embed("hello"), DELTA);
+      assertEquals(3, masked.dimension());
+    }
+  }
+
+  static Stream<Arguments> boundaryPlans() {
+    final List<Arguments> cases = new ArrayList<>();
+    for (final PaddingStrategy padding : PaddingStrategy.values()) {
+      cases.add(Arguments.of(padding, 4, LIMIT / 4, List.of(LIMIT / 4)));
+      cases.add(Arguments.of(padding, 4, LIMIT / 4 + 1, List.of(LIMIT / 4, 
1)));
+      cases.add(Arguments.of(padding, 2, LIMIT / 2, List.of(LIMIT / 2)));
+      cases.add(Arguments.of(padding, 2, 0, List.of()));
+    }
+    return cases.stream();
+  }
+
+  /**
+   * The row count boundaries of GH-1391 hold under every strategy, with the 
maximum length set
+   * to the width of the rows so that MAX_LENGTH plans the same inferences.
+   */
+  @ParameterizedTest
+  @MethodSource("boundaryPlans")
+  void testRowCountBoundariesUnderEveryStrategy(final PaddingStrategy padding, 
final int width,
+      final int count, final List<Integer> expected, @TempDir final Path dir) 
throws Exception {
+    try (SentenceVectorsDL vectors = planner(dir, padding, width)) {
+      assertEquals(expected, sizes(vectors.batches(tokens(width, count))));
+    }
+  }
+
+  /** A row as wide as the limit runs alone under every strategy, as does a 
wider one. */
+  @ParameterizedTest
+  @EnumSource(PaddingStrategy.class)
+  void testRowAtTheLimitRunsAlone(final PaddingStrategy padding, @TempDir 
final Path dir)
+      throws Exception {
+    try (SentenceVectorsDL vectors = planner(dir, padding, LIMIT)) {
+      assertEquals(List.of(1, 1, 1), sizes(vectors.batches(tokens(LIMIT, 3))));
+      assertEquals(List.of(1, 1), sizes(vectors.batches(tokens(LIMIT + 1, 
2))));
+    }
+  }
+
+  /**
+   * LONGEST and MAX_LENGTH give the vectors of the CPU session on a CUDA 
device 0 for a call that
+   * splits; skipped where there is no CUDA device.
+   */
+  @ParameterizedTest
+  @EnumSource(value = PaddingStrategy.class, names = {"LONGEST", "MAX_LENGTH"})
+  void testGpuMatchesTheCpuUnderPadding(final PaddingStrategy padding, 
@TempDir final Path dir)
+      throws Exception {
+    final File model = model(dir, TINY_MASKED);
+    final File vocabulary = vocabPad6(dir);
+    final List<String> texts = new ArrayList<>(Collections.nCopies(LIMIT / 4 + 
1, "hello world"));
+    texts.add("hello");
+    texts.add("world");
+    try (SentenceVectorsDL gpu = gpuOrAbort(model, vocabulary, Pooling.MEAN, 
false, 8, padding);
+         SentenceVectorsDL cpu = embedder(model, vocabulary, Pooling.MEAN, 
false, 8, padding)) {
+      final float[][] expected = cpu.embedAll(texts);
+      final float[][] actual = gpu.embedAll(texts);
+      assertEquals(expected.length, actual.length);
+      for (int i = 0; i < expected.length; i++) {
+        assertArrayEquals(expected[i], actual[i], DELTA, "row " + i);
+      }
+      assertTrue(expected.length > LIMIT / 4);
+    }
+  }
+}
diff --git 
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/TinyVectorModels.java
 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/TinyVectorModels.java
index 9cb6bcc28..8be2defb4 100644
--- 
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/TinyVectorModels.java
+++ 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/TinyVectorModels.java
@@ -30,6 +30,7 @@ import ai.onnxruntime.OrtException;
 import ai.onnxruntime.OrtProvider;
 
 import opennlp.dl.InferenceOptions;
+import opennlp.dl.Tokens;
 
 import static org.junit.jupiter.api.Assumptions.abort;
 import static org.junit.jupiter.api.Assumptions.assumeTrue;
@@ -48,6 +49,9 @@ public final class TinyVectorModels {
   /** The model with an additional {@code sentence_embedding} output. */
   public static final String TINY_POOLED = "tiny-pooled.onnx";
 
+  /** The model whose output depends on the attention mask, see {@code 
gen_tiny_masked_model.py}. */
+  public static final String TINY_MASKED = "tiny-masked.onnx";
+
   private static final String VECTORS_RESOURCES = "/opennlp/dl/vectors/";
 
   private TinyVectorModels() {
@@ -101,6 +105,30 @@ public final class TinyVectorModels {
     return file.toFile();
   }
 
+  /**
+   * Writes the vocabulary of {@link #vocab(Path)} with {@code [PAD]} at id 6 
instead of 0, so a
+   * padded position is visible as that id.
+   *
+   * @param dir The directory to write into.
+   * @return The vocabulary file.
+   * @throws IOException Thrown if the vocabulary cannot be written.
+   */
+  public static File vocabPad6(final Path dir) throws IOException {
+    final Path file = dir.resolve("vocab-pad6.txt");
+    Files.write(file, List.of("unused0", "unused1", "[UNK]", "[SEP]", "hello", 
"world",
+        "[PAD]", "[CLS]"));
+    return file.toFile();
+  }
+
+  /**
+   * {@return an encoding of {@code length} tokens; only its length matters 
for batching}
+   *
+   * @param length The number of tokens.
+   */
+  public static Tokens tokens(final int length) {
+    return new Tokens(new String[length], new long[length], new long[length], 
new long[length]);
+  }
+
   /**
    * {@return {@code W} times {@code factor}, the expected vector for a mean 
or summed id}
    *
@@ -122,6 +150,23 @@ public final class TinyVectorModels {
     return options;
   }
 
+  /**
+   * Opens a session on CUDA device {@code 0} with the default maximum length 
and padding, or
+   * aborts the calling test where there is no usable CUDA device.
+   *
+   * @param model The model file.
+   * @param vocab The vocabulary file.
+   * @param pooling The pooling.
+   * @param normalize {@code true} to scale every vector to unit length.
+   * @return The session on the GPU.
+   * @throws IOException Thrown if the model or vocabulary cannot be read.
+   */
+  public static SentenceVectorsDL gpuOrAbort(final File model, final File 
vocab,
+      final Pooling pooling, final boolean normalize) throws IOException {
+    return gpuOrAbort(model, vocab, pooling, normalize, 
SentenceVectorsDL.DEFAULT_MAX_LENGTH,
+        SentenceVectorsDL.DEFAULT_PADDING);
+  }
+
   /**
    * Opens a session on CUDA device {@code 0}, or aborts the calling test 
where the ONNX Runtime
    * on the classpath has no CUDA execution provider or the device is not 
usable.
@@ -130,16 +175,19 @@ public final class TinyVectorModels {
    * @param vocab The vocabulary file.
    * @param pooling The pooling.
    * @param normalize {@code true} to scale every vector to unit length.
+   * @param maxLength The maximum number of tokens per input.
+   * @param padding The padding strategy.
    * @return The session on the GPU.
    * @throws IOException Thrown if the model or vocabulary cannot be read.
    */
   public static SentenceVectorsDL gpuOrAbort(final File model, final File 
vocab,
-      final Pooling pooling, final boolean normalize) throws IOException {
+      final Pooling pooling, final boolean normalize, final int maxLength,
+      final PaddingStrategy padding) throws IOException {
     
assumeTrue(OrtEnvironment.getAvailableProviders().contains(OrtProvider.CUDA),
         "the ONNX Runtime on the classpath has no CUDA execution provider");
     try {
-      return new SentenceVectorsDL(model, vocab, true, pooling, normalize,
-          SentenceVectorsDL.DEFAULT_MAX_LENGTH, gpu(0));
+      return new SentenceVectorsDL(model, vocab, true, pooling, normalize, 
maxLength, padding,
+          gpu(0));
     } catch (OrtException e) {
       return abort("no usable CUDA device 0: " + e.getMessage());
     }
diff --git 
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/resources/opennlp/dl/vectors/gen_tiny_masked_model.py
 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/resources/opennlp/dl/vectors/gen_tiny_masked_model.py
new file mode 100644
index 000000000..9f0e726f9
--- /dev/null
+++ 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/resources/opennlp/dl/vectors/gen_tiny_masked_model.py
@@ -0,0 +1,102 @@
+# 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.
+
+# Generates tiny-masked.onnx, the model behind the SentenceVectorsDL padding 
tests that
+# depend on attention_mask.
+#
+# The graph declares the three BERT-style inputs and two outputs, in this 
order:
+#   token_embeddings[b][t]   = float(input_ids[b][t] * attention_mask[b][t]) * 
W
+#                                                                    shape 
[batch, tokens, 3]
+#   sentence_embedding[b]    = sum over t of token_embeddings[b][t] shape 
[batch, 3]
+# with W = [[0.5, -1.0, 2.0]], so the pooled output of an input is the sum of 
the vocabulary ids
+# at positions the mask covers, times W. A padded position with a mask of 0 
adds 0; one
+# with a mask of 1 adds the padding id, which is how the tests distinguish 
them.
+#
+# Uses only the Python standard library. Regenerate with: python3 
gen_tiny_masked_model.py
+
+import array
+import struct
+
+
+def varint(n):
+    out = bytearray()
+    while True:
+        b = n & 0x7F
+        n >>= 7
+        if n:
+            out.append(b | 0x80)
+        else:
+            out.append(b)
+            return bytes(out)
+
+
+def key(field, wire):
+    return varint((field << 3) | wire)
+
+
+def num(field, n):
+    return key(field, 0) + varint(n)
+
+
+def raw(field, b):
+    if isinstance(b, str):
+        b = b.encode()
+    return key(field, 2) + varint(len(b)) + b
+
+
+def tensor(name, dims, dtype, data):
+    return b"".join(num(1, d) for d in dims) + num(2, dtype) + raw(8, name) + 
raw(9, data)
+
+
+def node(op, inputs, outputs, attrs=()):
+    return (b"".join(raw(1, i) for i in inputs) + b"".join(raw(2, o) for o in 
outputs)
+            + raw(4, op) + b"".join(raw(5, a) for a in attrs))
+
+
+def attr_int(name, value):
+    return raw(1, name) + num(3, value) + num(20, 2)
+
+
+def value_info(name, elem, dims):
+    shape = b"".join(raw(1, num(1, d) if isinstance(d, int) else raw(2, d)) 
for d in dims)
+    return raw(1, name) + raw(2, raw(1, num(1, elem) + raw(2, shape)))
+
+
+FLOAT, INT64 = 1, 7
+nodes = [
+    node("Cast", ["input_ids"], ["ids_float"], [attr_int("to", FLOAT)]),
+    node("Cast", ["attention_mask"], ["mask_float"], [attr_int("to", FLOAT)]),
+    node("Mul", ["ids_float", "mask_float"], ["masked_ids"]),
+    node("Unsqueeze", ["masked_ids", "axes2"], ["ids_3d"]),
+    node("MatMul", ["ids_3d", "w"], ["token_embeddings"]),
+    node("ReduceSum", ["token_embeddings", "axes1"], ["sentence_embedding"],
+         [attr_int("keepdims", 0)]),
+]
+initializers = [
+    tensor("axes2", [1], INT64, array.array("q", [2]).tobytes()),
+    tensor("axes1", [1], INT64, array.array("q", [1]).tobytes()),
+    tensor("w", [1, 3], FLOAT, struct.pack("<3f", 0.5, -1.0, 2.0)),
+]
+inputs = [value_info(n, INT64, ["batch", "tokens"])
+          for n in ("input_ids", "attention_mask", "token_type_ids")]
+outputs = [value_info("token_embeddings", FLOAT, ["batch", "tokens", 3]),
+           value_info("sentence_embedding", FLOAT, ["batch", 3])]
+graph = (b"".join(raw(1, n) for n in nodes) + raw(2, "tiny-masked")
+         + b"".join(raw(5, t) for t in initializers)
+         + b"".join(raw(11, i) for i in inputs) + b"".join(raw(12, o) for o in 
outputs))
+model = num(1, 8) + raw(8, raw(1, "") + num(2, 13)) + raw(7, graph)
+with open("tiny-masked.onnx", "wb") as f:
+    f.write(model)
+print("wrote tiny-masked.onnx,", len(model), "bytes")
diff --git 
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/resources/opennlp/dl/vectors/tiny-masked.onnx
 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/resources/opennlp/dl/vectors/tiny-masked.onnx
new file mode 100644
index 000000000..3f17cf264
Binary files /dev/null and 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/resources/opennlp/dl/vectors/tiny-masked.onnx
 differ
diff --git a/opennlp-docs/src/docbkx/machine-learning.xml 
b/opennlp-docs/src/docbkx/machine-learning.xml
index c1810d0ce..00831b676 100644
--- a/opennlp-docs/src/docbkx/machine-learning.xml
+++ b/opennlp-docs/src/docbkx/machine-learning.xml
@@ -173,10 +173,17 @@ try (TextEmbedder embedder = 
providers.select(spec).create(spec)) {
                        <code>lowerCase=false</code>, <code>pooling=mean</code> 
(default) or
                        <code>pooling=cls</code>, <code>normalize=true</code> 
(default) or
                        <code>normalize=false</code>, and 
<code>maxLength</code>, the maximum number of tokens
-                       per input (512 by default); longer input is truncated. 
The defaults match
-                       sentence-transformers models such as all-MiniLM-L6-v2. 
A model with a
-                       <code>sentence_embedding</code> output is used as it 
is, without pooling. A spec with
-                       another option is not supported. The key
+                       per input (512 by default); longer input is truncated. 
The option <code>padding</code>
+                       sets how <code>embedAll</code> batches texts of 
different lengths:
+                       <code>exact_length</code> (default) does not pad and 
runs one inference per tokenized
+                       length, <code>longest</code> pads each batch to its 
longest text, and
+                       <code>max_length</code> pads every text to 
<code>maxLength</code>. The vectors match up
+                       to floating-point rounding either way, provided the 
model honors the attention mask; a
+                       <code>sentence_embedding</code> output that ignores the 
mask changes with padding.
+                       Padding needs a <code>[PAD]</code> token in the 
vocabulary, or <code>&lt;pad&gt;</code>
+                       for a RoBERTa vocabulary. The defaults match 
sentence-transformers models such as
+                       all-MiniLM-L6-v2. A model with a 
<code>sentence_embedding</code> output is used as it
+                       is, without pooling. A spec with another option is not 
supported. The key
                        
<code>opennlp.provider.opennlp.tools.embeddings.TextEmbedderProvider</code> 
selects a
                        provider by name. Excluding <code>opennlp-dl</code> 
leaves the API without a provider
                        until another one is installed. Existing 
<code>SentenceVectorsDL</code> constructors remain
diff --git a/rat-excludes b/rat-excludes
index 09d8e7f5d..0493dfdcc 100644
--- a/rat-excludes
+++ b/rat-excludes
@@ -71,6 +71,7 @@ 
src/test/resources/opennlp/tools/tokenize/uax29/WordBreakTest.txt
 # opennlp-dl tiny ONNX test models (project-generated binaries, see the 
gen_tiny_*_model.py scripts next to them)
 src/test/resources/opennlp/dl/vectors/tiny-vectors.onnx
 src/test/resources/opennlp/dl/vectors/tiny-pooled.onnx
+src/test/resources/opennlp/dl/vectors/tiny-masked.onnx
 
 # test fixtures and expected outputs (data files, no Apache header possible)
 src/test/resources/opennlp/tools/util/wordvector/glove-example-*.txt

Reply via email to