Skip to content

OPENNLP-1937: Expose text embeddings as a generic SPI interface - #1290

Open
krickert wants to merge 5 commits into
apache:mainfrom
ai-pipestream:OPENNLP-1937-text-embedder-api
Open

krickert wants to merge 5 commits into
apache:mainfrom
ai-pipestream:OPENNLP-1937-text-embedder-api

Conversation

@krickert

@krickert krickert commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Based on main. Adds opennlp.tools.embeddings.TextEmbedder and TextEmbedderProvider to opennlp-api, makes SentenceVectorsDL implement the embedder, and registers it as the onnx provider in opennlp-dl through META-INF/services.

Core had no contract for "text in, vector out". WordVectorTable looks up a stored vector for one word, and SentenceVectorsDL exposes vectors only through the concrete class, so no API could accept an arbitrary embedder, and an add-on can only implement a contract present in a released core version.

The interface

TextEmbedder: embed(CharSequence), a defaulted embedAll(List) for implementations that batch, and dimension(). In SentenceVectorsDL, embed adapts getVectors (which now rejects null input), embedAll buckets inputs by token count and runs one session per bucket so a batch has no padding and each result matches the single-input path, and dimension() reads the model output metadata with a cached measurement as fallback.

Discovery and selection

TextEmbedderProvider has name(), priority() (default 0), isAvailable() (default true), supports(model, options) and load(model, options). Providers register in META-INF/services/opennlp.tools.embeddings.TextEmbedderProvider; construction must not load models or initialize a native runtime.

TextEmbedderProviders in opennlp-api names no backend:

  • select(model, options) considers the installed providers that are available and support the request, and returns the one with the highest priority. A tie is an IllegalStateException naming the classes and the pin property. No candidate is an IllegalArgumentException naming the installed providers.
  • The system property opennlp.embedder.provider pins a provider by name; get(name) does the same in code. Several providers of one name are ranked by priority, so a module such as a future opennlp-dl-gpu provider can replace the ONNX one under the same name with a larger priority, without an API change.
  • installed() lists providers through ServiceLoader.stream() and skips a registration that cannot be loaded or instantiated, with a warning, so one broken jar does not hide the others.

OnnxTextEmbedderProvider supports a model file named *.onnx with its own options (vocabulary, required, resolved against the model's directory when relative; lowerCase, default true) and is available when the ONNX Runtime classes load. ONNX is the default only as a packaging fact: it is the one provider in the default distribution.

Tests

  • TextEmbedderProvidersTest, 15 tests over providers registered in a temporary class loader: name lookup and independent model ownership, priority replacement under one name, same-priority ambiguity, unavailable and throwing availability checks, capability routing, the pin property including a missing pinned name, skipped broken registrations (wrong type, no public constructor, missing class), a blank name, and no provider at all. None of them names a backend.
  • OnnxTextEmbedderProviderTest: the capability rule for file names and options, availability, and the registration in opennlp-dl.
  • SentenceVectorsDLEmbedderTest runs a real ONNX session. The bundled graph is 373 bytes and computes output[b][t] = float(input_ids[b][t]) * [0.5, -1, 2], so expected vectors follow from the ids; the generator script is committed next to it. rat-excludes and .gitignore gain entries for the binary, since *.onnx is ignored repo-wide.

opennlp-api and opennlp-dl with checkstyle and forbiddenapis, -Dopennlp.forkCount=1.

Manual

machine-learning.xml, section "Text embedding providers": the contract, how selection ranks providers, the pin property, the ONNX options, and that discovery uses the class path because OpenNLP ships no module descriptors.

Component providers, the contract-neutral form

The last two commits add the same registration model for any contract, in opennlp.tools.util.ext, so an add-on can ship an annotator, a gazetteer, a stemmer or an embedder and register it the same way:

  • ComponentProvider<T>: type() (the contract, for example DocumentAnnotator.class), name(), priority(), isAvailable(), supports(ComponentSpec), create(ComponentSpec).
  • ComponentSpec: the request, an immutable location (file or directory, or none) plus ordered string options, with hasOnlyOptions, locationEndsWith and option for capability checks. This is the spec type from the review comment.
  • ComponentProviders: installed(type), get(type, name) ranked by priority under one name, supporting(type, spec) as the ranked list for callers that want every matching component, select(type, spec) as the single best with a tie reported as ambiguous, and one pin property per contract, opennlp.provider.<Contract>. One META-INF/services/opennlp.tools.util.ext.ComponentProvider file per jar; a broken registration is skipped with a warning. No reflection and no regular expressions in the lookup path.
  • First registration: StemmerAnnotatorProvider in opennlp-runtime, name stemmer, contract DocumentAnnotator, option algorithm (porter or a Snowball name).

TextEmbedderProvider keeps its Path plus Map signature in this PR so the embedder review stays readable; converging it on ComponentProvider<TextEmbedder> is a mechanical follow-up.

Tests: ComponentProvidersTest (13), ComponentSpecTest (14), StemmerAnnotatorProviderTest (10). The introduction chapter adds the section "Component providers".

Open points

  • JPMS: these are the first META-INF/services files in the repository and there is no module-info.java. The manual states the class path is the supported configuration; a provides clause is a decision for when module descriptors are introduced.
  • Whether TextEmbedderProvider should extend ComponentProvider<TextEmbedder> in this PR or in the follow-up.

OPENNLP-1937

@krickert
krickert marked this pull request as ready for review September 8, 2026 12:51
@rzo1

rzo1 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

This needs to wait after M6.

@rzo1
rzo1 marked this pull request as draft September 8, 2026 12:59
krickert added a commit to ai-pipestream/opennlp that referenced this pull request Sep 9, 2026
OPENNLP-1937 (apache#1290) proposes this interface for opennlp-api without the
experimental marker. This branch had an earlier copy that still used it, so the
integration build hit an add/add conflict on the file and could resolve it
either way.

Take the upstream file as is. No behaviour changes: an annotation, an import,
and a javadoc paragraph.
@krickert
krickert marked this pull request as ready for review September 12, 2026 02:52
@krickert
krickert marked this pull request as draft September 14, 2026 02:23
@krickert

Copy link
Copy Markdown
Contributor Author

Putting this in draft. We should separate ONNX and CLI from the embedding library. ONNX can remain the default through an SPI, while downstream projects can exclude it and supply another implementation. Any native integration currently pulls in both unnecessarily. This would fix that concern.

I’ll include the split in this ticket. It should be a focused packaging change that preserves the default runtime behavior and makes add-ons easier to use. It's not a lot of work to do.

@krickert krickert changed the title OPENNLP-1937: Expose text embeddings as a generic interface OPENNLP-1937: Expose text embeddings as a generic SPI interface Sep 14, 2026
Select model factories by provider ID, retain ONNX as the default, and
allow independently owned models through the closeable TextEmbedder API.
Keep model initialization out of provider discovery and preserve the
existing SentenceVectorsDL inference path.

Tests cover multiple providers, missing and duplicate IDs, independent
model lifetimes, and ONNX output parity. The root model-path regression
first failed with NullPointerException; it now rejects the invalid path.
The SPI tests initially failed to compile before the API was introduced.

Validation: targeted Maven verify with dependencies, apache-rat:check,
and final path-validation regression tests passed.
Comment thread opennlp-api/src/main/java/opennlp/tools/embeddings/TextEmbedderProviders.java Outdated
Comment thread opennlp-api/src/main/java/opennlp/tools/embeddings/TextEmbedderProviders.java Outdated
@mawiesne
mawiesne marked this pull request as ready for review September 16, 2026 06:30
@mawiesne mawiesne added java Pull requests that update Java code tests Pull requests that add or update test code labels Sep 16, 2026
@rzo1

rzo1 commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

The discovery half of this is fine — ServiceLoader + META-INF/services is the right mechanism. My concern is the selection half: ONNX leaks into opennlp-api in a way that undercuts what the PR is trying to achieve.

Where the binding sits

Three places, all in opennlp-api:

  • TextEmbedderProviders.DEFAULT_PROVIDER = "onnx" — the API module ships a string constant naming a backend that lives in opennlp-core/opennlp-ml/opennlp-dl.
  • getDefault(), whose Javadoc reads "Selects the default ONNX provider". Exclude opennlp-dl and the API's own default entry point throws IllegalArgumentException.
  • TextEmbedderProvidersTest.worksWithoutAnyBackend asserts get("onnx", …) throws — the API's test suite encodes a downstream module's provider id.

The docs say it out loud: "The API has no ONNX or CLI dependency""the default remains onnx." Both can't be true.

There is a second-order problem with keying on name() under strict uniqueness. opennlp-dl-gpu exists today and depends on opennlp-dl (it only swaps the onnxruntime artifact, so there is no duplicate yet). The moment it wants its own provider, the natural id is "onnx" — same capability, better implementation — and get() answers with IllegalStateException: Duplicate text embedder provider. "There can be only one name" has no way to express "prefer this one."

Also, the loop propagates ServiceConfigurationError out of the ServiceLoader iterator, so one broken provider jar breaks lookup of every provider, including unrelated ones.

Proposal: CDI's resolution model, in plain Java SE

CDI discovers portable extensions through ServiceLoader as well, so registration is already right here. What is missing is that CDI resolves by type + qualifiers, ranks with @Priority, and lets a bean veto itself — the container never hard-codes an implementation name.

CDI equivalent here
META-INF/services/…spi.Extension META-INF/services/…TextEmbedderProvider — already in this PR
bean type + qualifiers supports(model, options) capability match instead of a magic id
@Default / @Alternative + @Priority int priority() — highest available wins
@Vetoed, unsatisfied-vs-ambiguous resolution boolean isAvailable() — a provider whose native runtime is absent removes itself
beans.xml <alternatives> system property opennlp.embedder.provider=<name> to pin explicitly

In opennlp-api, with the word "onnx" appearing nowhere:

public interface TextEmbedderProvider {
  String name();                                    // identity: pinning + diagnostics, not the routing key
  default int priority() { return 0; }              // @Priority analogue, larger wins
  default boolean isAvailable() { return true; }    // native runtime actually loadable
  boolean supports(Path model, Map<String, String> options);
  TextEmbedder load(Path model, Map<String, String> options) throws IOException;
}
public final class TextEmbedders {
  public static final String PROVIDER_PROPERTY = "opennlp.embedder.provider";
  // no DEFAULT_PROVIDER constant

  public static TextEmbedderProvider select(Path model, Map<String, String> options,
                                            ClassLoader loader) {
    String pinned = System.getProperty(PROVIDER_PROPERTY);
    if (pinned != null && !pinned.isBlank()) {
      return byName(pinned, loader);
    }
    List<TextEmbedderProvider> candidates = installed(loader).stream()
        .filter(TextEmbedderProvider::isAvailable)
        .filter(p -> p.supports(model, options))
        .toList();
    // highest priority wins; only a genuine tie at the top is ambiguous, and that
    // message tells the caller to set PROVIDER_PROPERTY
    ...
  }
}

installed() should use ServiceLoader.stream() and skip an individual provider whose Provider::get throws ServiceConfigurationError, so one bad jar degrades instead of taking the whole lookup down — which is exactly the case this PR is meant to serve.

OnnxTextEmbedderProvider in opennlp-dl then adds:

@Override
public boolean supports(Path model, Map<String, String> options) {
  return model != null && model.getFileName().toString().endsWith(".onnx");
}

ONNX stays the de facto default because it is the only provider in the default distribution — a packaging fact, not an API constant. A future opennlp-dl-gpu provider declares priority() == 100 and wins automatically: no API change, no duplicate-id explosion.

Alternatives considered

  • Reuse the existing opennlp.tools.util.ext.ExtensionLoader. Keeps one extension story instead of two, and it already has the allowed-package guard. But it is @Internal, class-name-driven, and has no auto-discovery, so "drop the jar in and it works" does not happen. Better as a complement: ServiceLoader for discovery, ExtensionLoader for an explicit class-name override from a model descriptor.
  • A StreamFactoryRegistry-style static registry. Already in the codebase, but core has to import every implementation — that is the coupling being complained about, just moved.
  • Real CDI or OSGi services. A jakarta.* dependency in OpenNLP core is a non-starter.

Two more things worth deciding on this PR

  1. This would be the first META-INF/services file in the repo, and there is no module-info.java anywhere. Fine on the classpath; on the module path a JPMS consumer sees nothing without a provides clause. Worth a conscious decision now rather than discovering it later.
  2. load(Path, Map<String, String>) binds every provider to a filesystem path and stringly-typed options, while opennlp-core/opennlp-model-resolver already resolves models from the classpath. I would treat a small EmbedderSpec value type (location + options) as a follow-up rather than a blocker — but it changes the supports() signature above, so it is cheaper to decide before this lands.

The minimal version is small: drop DEFAULT_PROVIDER and getDefault()'s ONNX knowledge, add priority() / isAvailable() / supports(), rank instead of failing on duplicates, and fix the one API test.

…nstead of a fixed name

The API no longer names a backend. TextEmbedderProviders drops the
DEFAULT_PROVIDER constant and getDefault(); select(model, options)
picks among the installed providers that are available and support the
request, and the highest priority wins. Two supporting providers with
the same priority are reported as ambiguous with their class names and
the system property opennlp.embedder.provider, which pins a provider by
name. get(name) ranks providers of one name by priority as well, so a
module can replace another module's provider under the same name.
installed() lists providers through ServiceLoader.stream() and skips a
registration that cannot be loaded or instantiated, with a warning, so
one broken jar does not hide the other providers.

TextEmbedderProvider adds priority(), isAvailable() and
supports(model, options). OnnxTextEmbedderProvider supports a model
file named *.onnx with its own options and is available when the ONNX
Runtime classes load; the checks do not initialize the runtime.

Tests: TextEmbedderProvidersTest covers name lookup, priority
replacement, same-priority ambiguity, unavailable and failing
availability checks, capability routing, the pin property, skipped
broken registrations and a blank name, 15 tests, none naming a
backend. OnnxTextEmbedderProviderTest covers the capability rule and
the registration in opennlp-dl. SentenceVectorsDLEmbedderTest selects
the provider for the model instead of asking for a default.

The manual section describes selection, the pin property, and that
discovery uses the class path, since OpenNLP ships no module
descriptors.
@krickert
krickert force-pushed the OPENNLP-1937-text-embedder-api branch from bc1ef85 to 1378f64 Compare September 16, 2026 06:54
@krickert

Copy link
Copy Markdown
Contributor Author

Thanks, both points taken. Pushed in 1378f64 on top of the rebase onto main:

  • DEFAULT_PROVIDER and getDefault() are removed from the API. TextEmbedderProviders now has select(model, options) (available, supporting, highest priority wins; a tie names the classes and the pin property), get(name) (same-name providers ranked by priority, so a gpu provider can replace the ONNX one under the same name), installed() (ServiceLoader.stream, a broken registration is skipped with a warning), and the property opennlp.embedder.provider.
  • TextEmbedderProvider has priority(), isAvailable() and supports(model, options). The ONNX provider supports *.onnx with its own options and is available when the runtime classes load, without initializing the runtime.
  • The API test no longer names a backend; 15 tests cover ranking, ties, availability, the pin, and skipped registrations. OnnxTextEmbedderProviderTest covers the capability rule and the registration.
  • The manual section describes selection and states that discovery uses the class path, since there is no module descriptor yet.

On the two decisions: I would keep Path plus options for now and add a spec type together with the model resolver integration, since that is where class path models come from. If you would rather have the spec before this merges, say so and I will add it here. The JPMS provides clause I would leave until module descriptors are introduced; the manual states the class path is the supported setup.

@mawiesne
mawiesne requested review from jzonthemtn and rzo1 September 16, 2026 07:16
…ce-loaded components

A contract-neutral registration and selection model for optional
implementations, the shape the text embedder review chose: a
provider names the contract it creates, its name, a priority, whether
it is available in this process, and whether it supports a request. A
request is a ComponentSpec, a model location plus options, so a provider
can claim a request without opening anything. Providers register once
per jar under META-INF/services/opennlp.tools.util.ext.ComponentProvider
and are looked up by contract: installed(), get(name) ranked by
priority under one name, supporting(spec) as the ranked list for
callers that want every matching component, select(spec) as the single
best one with a tie reported as ambiguous, and a per-contract pin
property opennlp.provider.<Contract>. A registration that cannot be
loaded is skipped with a warning. No reflection and no regular
expressions in the lookup path.

Tests: ComponentProvidersTest covers per-contract listing, independent
creation, name lookup, priority replacement, ties, unavailable and
throwing availability checks, capability routing and the ranked list,
the pin property, skipped registrations, blank names and a missing
contract, 13 tests. ComponentSpecTest covers copying, ordering, null
rejection, option checks, case-insensitive location suffixes, withOption
and equality, 14 tests. The introduction chapter adds a section on
component providers.

(cherry picked from commit 005541f)
StemmerAnnotatorProvider in opennlp-runtime is the first ComponentProvider
registration: contract DocumentAnnotator, name stemmer, supporting a
request without a location with algorithm as the single option, absent or
porter for the Porter stemmer and any Snowball algorithm name otherwise,
compared through StringUtil case mapping. The provider is listed in the
module's META-INF/services file. StemmerAnnotatorProviderTest covers the
identity, the supported and rejected requests, the annotators it creates
for porter and english over a tokenized document, and the registration
through ComponentProviders, 10 tests. The introduction section names it
as the example.

(cherry picked from commit d60943ec05c915e613972d11cb896c4f987d7aa1)
krickert added a commit to ai-pipestream/opennlp that referenced this pull request Sep 16, 2026
@krickert

Copy link
Copy Markdown
Contributor Author

Pushed two more commits, 1bf8cbb and ca15862, with the contract-neutral form of the same model: ComponentProvider<T>, ComponentSpec and ComponentProviders in opennlp.tools.util.ext, plus the stemmer annotator registered as the first DocumentAnnotator component. ComponentSpec is the spec type from the earlier comment: a location plus options, so a provider can claim a request without opening anything. TextEmbedderProvider is unchanged here; making it a ComponentProvider<TextEmbedder> is a mechanical follow-up once the shape is agreed. The PR body has the details.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

java Pull requests that update Java code tests Pull requests that add or update test code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants