Skip to content

OPENNLP-1833: gRPC document analysis service with embeddings and chunking#493

Draft
krickert wants to merge 44 commits into
mainfrom
OPENNLP-1833-grpc-expansion
Draft

OPENNLP-1833: gRPC document analysis service with embeddings and chunking#493
krickert wants to merge 44 commits into
mainfrom
OPENNLP-1833-grpc-expansion

Conversation

@krickert

Copy link
Copy Markdown

See https://issues.apache.org/jira/browse/OPENNLP-1833

Draft for early feedback on the gRPC expansion. This branch restructures the
opennlp-grpc sandbox module around a v1 document-centric API and builds it out
into a working analysis server.

API / contract

  • AnalyzeDocument RPC over a v1 proto contract (opennlp_document, opennlp_pipeline, opennlp_service)
  • Explicit offset encoding selection (UTF-8 byte / UTF-16 / code point) with server-side mapping
  • AnalysisProfile / AnalysisOptions with strict validation: unsupported steps, backends and
    models are rejected with meaningful gRPC status codes instead of being silently ignored
  • Model bundle catalog (ListModelBundles) reflecting actually loaded components

Embeddings

  • EmbeddingProvider abstraction with ONNX Runtime CPU and CUDA implementations behind a
    strict factory (model.embedder.backend=onnx|cuda)
  • Models declared per id in server config, loaded eagerly at startup, dimension read from
    ONNX session metadata
  • Standard single-segment BERT encoding (mask=1, types=0), OOV-to-UNK mapping, truncation
    at 512 wordpieces, deterministic native resource management
  • GPU build via -Dgpu swaps onnxruntime for onnxruntime_gpu so the CPU and CUDA runtimes
    never coexist on the classpath

Chunking (RAG-style segmentation)

  • sentence, token-window and semantic algorithms via chunk_embed_configs and PIPELINE_STEP_CHUNK
  • Per-chunk embeddings with multiple models per group, plus group statistics
  • Semantic chunking on consecutive-sentence cosine similarity with percentile or fixed
    thresholds and min/max chunk size constraints

Testing

  • 41 unit tests covering providers, factory, chunkers, offset mapping and analyzer policies,
    green on both CPU and GPU build flavors
  • End-to-end verified against the public all-MiniLM-L6-v2 ONNX export: server embeddings are
    bit-identical to the corrected SentenceVectorsDL output (see OPENNLP-1836)

Known follow-ups

  • Classpath model discovery (opennlp-models-*) breaks inside the shaded jar because each
    model jar ships a root-level model.properties; the shaded server currently needs explicit
    model.sentence_detector.path / model.tokenizer.path config
  • OpenVINO and remote/composite providers are future work behind the same provider interface

krickert added 30 commits June 6, 2026 17:13
- Move to org.apache.opennlp.grpc.* package layout
- Document-centric OpenNlpAnalysisService (AnalyzeDocument, GetServiceInfo,
  ListModelBundles) replacing the per-tool POC services
- Single-bundle ModelBundleCache with shared thread-safe *ME singletons;
  fail-fast UNIMPLEMENTED for steps/configs not yet supported
- Tidy PipelineStepPolicy, dependency layering (Netty/reflection in service
  module), server version constant and exit code
- Reconcile design RFC with the on-disk v1 protos
…he gRPC server

Adds an EmbeddingProvider abstraction with ONNX Runtime CPU and CUDA
implementations behind a strict factory (model.embedder.backend=onnx|cuda).
Embedding models are declared per id in the server config, loaded eagerly,
and the dimension is read from the ONNX session metadata. The embedder uses
the standard single-segment BERT encoding, maps OOV tokens to the unknown id,
truncates at 512 wordpieces, only sends inputs the model declares, and closes
all native resources deterministically.

Chunking adds sentence, token-window and semantic algorithms, wired through
chunk_embed_configs and PIPELINE_STEP_CHUNK with per-chunk embeddings and
group statistics. Semantic chunking places boundaries on consecutive-sentence
cosine similarity with percentile or fixed thresholds and min/max size
constraints.

The GPU build (-Dgpu) swaps onnxruntime for onnxruntime_gpu so the CPU and
CUDA runtimes never coexist on the classpath. Covered by unit tests for the
providers, factory, chunkers and analyzer paths; README updated.
OpenNlpGrpcServerIT relied on DefaultClassPathModelProvider classpath
scanning, which is unreliable on macOS and Windows in the failsafe JVM.
Unpack English sentence and tokenizer models to target/test-classes/models
and pass explicit model paths in a generated integration-test config.
Replaces the hardcoded backend switch with an EmbeddingBackendFactory
service provider interface. The onnx and cuda backends register through
META-INF/services and additional backends (e.g. OpenVINO) can be added
by placing a jar on the classpath, selectable via model.embedder.backend
without server changes. Unknown backends are rejected with the list of
registered ids. Includes an SPI extension test via a test-only backend.
The implicit outer classname derived from opennlp_document.proto
(OpennlpDocument) collides with the OpenNlpDocument message class on
case-insensitive filesystems, breaking the build on macOS and Windows.
The config was written as a hand-formatted string but read back with
Properties.load, which treats backslashes as escape characters and
corrupts Windows model paths. Properties.store escapes them correctly.
Treat all C* categories (private use, unassigned, surrogates) as control
characters, matching the reference BERT implementation.
The model id is backend-agnostic; the server resolves it against whatever
embedding provider is configured. Removes the last backend-specific name
from the request contract while it is still in draft. Field number is
unchanged, so the binary wire format is unaffected.
Replace the InferenceBackend enum in the wire contract with a server-side
backend SPI: providers now report a backend_id string published per model in
the catalog, and the EmbeddingProvider interface is batch-first.

New modules:
- opennlp-grpc-backend-tei: delegates embedding inference to HuggingFace Text
  Embeddings Inference over its native gRPC API, with eager endpoint
  validation, dimension discovery and concurrent batch fan-out.
- opennlp-grpc-backend-openvino: delegates to OpenVINO Model Server or any
  KServe v2 compatible server. Served models accept raw text (BYTES) and
  return FP32 embeddings; a helper script exports HuggingFace models as a
  single fused OpenVINO graph (tokenizer, encoder, mean pooling, L2 norm).
- opennlp-grpc-integration-tests: black-box tests that spawn the shaded
  server jar and exercise it over the network against a stub TEI backend on
  every build, plus opt-in suites against real TEI and OVMS containers
  started by the bundled scripts (CPU and GPU).

Verified live with all-MiniLM-L6-v2 (CPU and CUDA), Qwen3-Embedding-0.6B
(CUDA) via TEI, and all-MiniLM-L6-v2 via OVMS on CPU, asserting unit-norm
vectors and topical similarity ordering end to end.
…er into focused helpers

New pipeline steps backed by the bundled 1.3.0 models: PIPELINE_STEP_POS_TAG,
PIPELINE_STEP_LEMMATIZE and PIPELINE_STEP_LANGUAGE_DETECT. The language detector
loads through model.properties scanning since the model is language-independent.

BasicDocumentAnalyzer had grown to five responsibilities; it is now an
orchestrator in processor.basic delegating to AnalysisRequestValidator,
ClassicStepRunner, EmbedChunkStepRunner, DocumentOffsetEncoder and
StepDiagnostics. The in-process embedding backends moved to embedding.onnx
and embedding.cuda, mirroring the embedding.tei and embedding.openvino
backend modules; only the SPI remains in the embedding package.
Add named entity recognition via classic OpenNLP NameFinderME models,
configured per entity type with model.name_finder.<type>.path, exposed
through the en-ner profile/bundle, and harden the implementation:

- Match entity types case-insensitively on both sides: configuration
  keys and ner_entity_types request filters are normalized identically,
  so PERSON and person are equivalent.
- Stop claiming a language for operator-supplied name finder models in
  the catalog: their language is unknown to the server, so the NER
  ModelDescriptors no longer set locale/languages (the bundle still
  reports en for its English sentence/tokenizer backbone).
- Advertise the en-ner profile only when name finder models are loaded,
  matching the model catalog (which already hides the en-ner bundle in
  that case) so GetServiceInfo never promises an unusable profile.
- Remove dead NameFinderRegistry API (entityTypeSet, modelPath and the
  backing modelPaths map).

Replace the network-dependent NER tests, which downloaded a legacy
SourceForge model and silently skipped when offline, with a hermetic
fixture that trains a tiny person model in-memory (MAXENT). Add
OpenNlpGrpcServerNerLiveIT exercising NER end-to-end through the spawned
server process with real assertions: catalog language claim, entity
detection (asserted on UTF-16 offsets), case-insensitive filter,
profile-by-id resolution, and unknown-type rejection.
…lp-dl)

Bump opennlp.version to 3.0.0-SNAPSHOT to consume the thread-safe
opennlp-dl components. OnnxSentenceEmbedder extends AbstractDL, whose
env/session/tokenizer/vocab fields became final and are now initialized
through a protected constructor; delegate to super(...) instead of
assigning the inherited fields directly. The shared ONNX session is
consequently leak-protected by AbstractDL on failed construction.
…nt NER thread-safety

Address review feedback on the NER work:

- Loading a configured model path that does not exist is an operator
  error, not a server fault. NameFinderRegistry.loadNameFinder and
  ModelBundleCache.loadModel/loadLanguageDetectorModel now catch
  FileNotFoundException and throw AnalysisException.notFound with the
  offending path, instead of mapping it to INTERNAL. Other IOExceptions
  (unexpected deserialization failures) remain INTERNAL.
- Document on NameFinderRegistry that the shared NameFinderME singletons
  rely on OpenNLP 3.0 NameFinderME being thread-safe, and why
  clearAdaptiveData() is still invoked per document (stateless RPC
  semantics, per-thread reset).

Add a test asserting a configured-but-missing name finder path is
rejected with NOT_FOUND.
…K, multi-emoji)

Extend OffsetMapperTest beyond the single surrogate-pair case to cover
the offset conversions Copilot flagged: a combining mark (U+0301, 2 UTF-8
bytes), a 3-byte CJK character, and back-to-back surrogate pairs, across
UTF-8 byte and code-point encodings.
…ntity type)

Refactor the classic NER path behind a NerModel abstraction, with no
behavior change, to prepare for ONNX/transformer NER models that emit
several entity types from one model.

- NerModel: a recognizer keyed by model id that declares the entity
  types it emits and returns NamedEntity records with document-relative
  spans (each backend owns its coordinate mapping).
- ClassicNerModel: wraps one NameFinderME (one entity type), moving the
  token-index -> document-offset mapping and entity-type resolution out
  of ClassicStepRunner.
- NameFinderRegistry now stores models keyed by id with an entity-type
  index, and exposes modelsForTypes(...) to select the distinct models to
  run for a request. Removed get(); the orchestrator no longer reaches
  for a raw NameFinderME.
- ClassicStepRunner.findNamedEntities runs each selected model once,
  filters emitted entities to the requested types and deduplicates
  identical spans (a no-op for single-type classic models, the seam for
  multi-type models).

Existing NER unit tests and the live NER IT pass unchanged.
Add a second NER backend that serves ONNX transformer models through the
thread-safe opennlp-dl NameFinderDL, alongside the classic NameFinderME
models, behind the NerModel abstraction.

- DlNerModel wraps NameFinderDL. NameFinderDL returns character offsets
  over the space-joined token text; DlNerModel.documentSpan maps those
  back to document offsets by overlapping tokens (unit-tested directly
  with synthetic spans, since the offset mapping is the real risk).
- NameFinderRegistry loads model.name_finder_dl.<id>.{path,vocab,labels,
  entity_type,backend,gpu_device_id} entries alongside the classic
  namespace (no key collision), validating attributes and mapping missing
  files to NOT_FOUND. ONNX models need a sentence detector, threaded in
  from ModelBundleCache; create(config) keeps the classic-only behaviour.
- The model catalog reports each name finder's real backend_id
  (opennlp-me / onnx / cuda).

The current opennlp-dl NameFinderDL recognizes a single entity type, so
each ONNX entry emits one configured type; the NerModel seam already
supports multi-type models. Existing NER tests and the live NER IT pass
unchanged; new tests cover the offset mapping and DL config validation.
…el download

Prove the ONNX name finder backend end-to-end against a real model without
redistributing it. The dl-ner build profile downloads the ONNX export of
dslim/bert-base-NER (MIT) from HuggingFace into target/ at build time, and
BasicDocumentAnalyzerDlNerTest configures the DL name finder, runs NER over a
sentence and asserts a person entity with correct document offsets. The label
set is written by the test (public model metadata, not weights).

The model is fetched at build time only; it is never bundled into a built
artifact and is not redistributed. Without the profile the test skips, so the
default build performs no download.
Now that NameFinderDL recognizes every BIO type and labels its spans, make the
DlNerModel backend multi-type:

- DlNerModel reports each entity under the model's own label (span.getType(),
  normalized) instead of a single configured type, and declares all the types
  it can emit.
- NameFinderRegistry derives an ONNX model's entity types from its BIO labels
  (B-/I- prefixes stripped) and drops the now-redundant entity_type config key.

A single ONNX model (e.g. dslim/bert-base-NER) therefore serves PER, ORG, LOC,
MISC, ... and the orchestrator filters by the requested ner_entity_types. The
end-to-end test asserts both a person and a location entity from one model.
Introduce a NerBackendFactory service provider interface, mirroring the
embedding backend SPI, so name finders are contributed by discoverable
backends instead of being hard-wired into the registry.

- NerBackendFactory: factoryId() + create(config, context) -> List<NerModel>
- NerBackendContext: shares the sentence detector ONNX finders require
- ClassicNerBackendFactory ("classic"): model.name_finder.<type>.path
- OnnxNerBackendFactory ("onnx"): model.name_finder_dl.<id>.*, entity types
  derived from BIO labels (multi-type per model)
- NameFinderRegistry discovers factories via ServiceLoader and aggregates
  their models (dedup by factoryId); parsing/loading moved into the factories
- Register built-in backends in META-INF/services
- Third-party backends register their own factory the same way - no server
  change required

Tests: StubNerBackendFactory proves external discovery; full module suite
green.
Add document categorization to the gRPC pipeline, mirroring the NER
backend SPI. A DocCategorizerModel interface is discovered via
ServiceLoader through DocCategorizerBackendFactory, with built-in
classic (DocumentCategorizerME) and ONNX (DocumentCategorizerDL)
backends plus a "roll your own" extension point.

- DocCategorizerModel / OpenNlpDocCategorizerModel wrapper with a
  TOKENS|RAW_TEXT input mode so maxent models score tokens and
  transformer models score whole text; AutoCloseable for ONNX sessions.
- DocCategorizerRegistry aggregates factories, resolves the default
  model id (model.doccat.default_id; sole model is implicit default),
  and surfaces ambiguity as INVALID_ARGUMENT.
- PipelineStepPolicy, ProfileRegistry (en-doccat profile/bundle),
  ModelBundleCache (catalog + close), ClassicStepRunner, and
  AnalysisRequestValidator wire the DOC_CATEGORIZE step end-to-end.
- Tests: in-memory weather/finance topic fixture, registry unit tests,
  analyzer integration tests, and a ServiceLoader stub backend proving
  third-party extension. README documents classic, ONNX, and SPI config.
…an-SNAPSHOT

The gRPC DL backends depend on opennlp-dl bug fixes that are not yet in
the published 3.0.0-SNAPSHOT: OPENNLP-1844 (thread-safety), OPENNLP-1845
(doccat softmax), and OPENNLP-1846 (multi-type NER). A distinctly named,
locally-built version pins those patches so a plain 3.0.0-SNAPSHOT
refresh cannot clobber them.

Scoped to opennlp-grpc so opennlp.models.version still tracks the
apache-published models. Revert this override to the inherited
3.0.0-SNAPSHOT once the three PRs merge to apache/opennlp main.
…he doccat SPI

Add sentence-level sentiment, populating AnnotatedSentence.sentiment_label
and sentiment_confidence when a request runs PIPELINE_STEP_SENTIMENT.
Sentiment is document categorization applied per sentence, so it reuses
the DocCategorizerModel abstraction and DocCategorizerBackendFactory SPI
rather than duplicating them.

- DocCategorizerRegistry is now namespace-aware: createForNamespace
  canonicalizes model.<ns>.* / model.<ns>_dl.* keys onto the built-in
  model.doccat* keys the factories read and drops other namespaces, so the
  same classic/ONNX/third-party backends serve every namespace while the
  catalogs stay isolated. create() is the "doccat" namespace.
- SentimentRegistry is a thin wrapper over the model.sentiment.* namespace
  with its own default_id selection and messages. A roll-your-own backend
  is written once and serves both capabilities.
- PipelineStepPolicy, ProfileRegistry (en-sentiment profile/bundle),
  ModelBundleCache (registry + catalog + close), ClassicStepRunner
  (classify each sentence, write label + winning-category confidence),
  BasicDocumentAnalyzer, and AnalysisRequestValidator wire the step.

Also make the token prerequisite conditional on the model's input mode via
DocCategorizerModel.requiresTokens(): classic (token) categorizers still
require TOKENIZE, but raw-text models (ONNX DocumentCategorizerDL) run
under a DOC_CATEGORIZE-only profile, and raw-text sentiment models run
with SENTENCE_DETECT but no TOKENIZE. This fixes a prerequisite that
previously blocked the documented raw-text ONNX path.

Tests: in-memory positive/negative sentiment fixture, registry unit tests
(incl. both directions of namespace isolation and SPI reuse), per-sentence
analyzer integration tests, and raw-text no-tokenize paths for both doccat
and sentiment via a raw-text stub backend. README documents sentiment
configuration (classic, ONNX, SPI).
…a format selector

Add constituency parsing to the gRPC pipeline. PIPELINE_STEP_PARSE builds a
phrase-structure parse per sentence and writes it to AnnotatedSentence.parse_tree.

Proto (gRPC-native, not a 1:1 of OpenNLP's object graph):
- ParseTree carries two independent views of one parse: a structured ParseNode
  tree (root) and the standard Penn-Treebank bracketed string (penn_treebank).
- ParseNode gains an explicit kind (NONTERMINAL/TERMINAL) and token_index so
  terminals link back to the sentence's token list instead of repeating text;
  it keeps label, span, children, probability.
- AnalysisOptions.parse_formats (repeated ParseFormat) lets a client select which
  view(s) to populate; empty defaults to STRUCTURED + BRACKETED.

Backend + pipeline:
- ModelBundleCache loads an optional shared parser from model.parser.path (the
  model is large and operator-supplied, not bundled); isParserAvailable/getParser
  and an en-parse catalog bundle.
- PipelineStepPolicy, ProfileRegistry (en-parse profile/bundle), ClassicStepRunner
  (Parse.createFromTokens -> parser.parse -> ParseTreeConverter), BasicDocumentAnalyzer
  dispatch, and AnalysisRequestValidator (NOT_FOUND when no parser; parse-format
  resolution). DocumentOffsetEncoder now recurses parse-node spans.
- ParseTreeConverter turns an OpenNLP Parse into the ParseTree views: preterminals
  become TERMINAL nodes with the POS label + token_index, nonterminal spans cover
  their descendants, and the bracketed view comes from Parse.show().

Tests: hermetic ParseTreeConverterTest (built from Parse.parseParse, no model) for
typing, token linkage, span coverage and format selection; BasicDocumentAnalyzerParseTest
for the NOT_FOUND path plus an opt-in real-model end-to-end gated on -Dparser.model.path.
PARSE was the last unimplemented step, so the obsolete "unimplemented step" policy test
becomes implementsEveryDefinedPipelineStep, asserting the full PipelineStep surface is
implemented. README documents parser config, the two representations, and the selector.
Fixes, by severity, with regression tests for each:

- High: NER named-entity spans were never offset-encoded, so they stayed in
  UTF-16 char indices while every other span was converted to the requested
  encoding. DocumentOffsetEncoder now remaps entity spans too.
- High: OpenNLP's parser is not thread-safe (its beam search mutates
  per-instance state), but a single shared Parser was used across concurrent
  requests. Each request thread now gets its own Parser from the shared,
  immutable ParserModel via a ThreadLocal. (The classic POS/tokenizer/
  sentence/lemmatizer ME components are thread-safe in 3.0 and unchanged.)
- Service boundary: analyzeDocument only caught AnalysisException; any other
  RuntimeException escaped as an opaque gRPC UNKNOWN. Unexpected exceptions
  now map to INTERNAL, logged server-side, without leaking internals.
- NER DL ONNX sessions leaked at shutdown: NameFinderRegistry/DlNerModel are
  now AutoCloseable and ModelBundleCache.close() releases them; the cache
  constructor also releases already-built models if a later load fails.
- Parse node probability was OpenNLP's raw log-prob written into the [0,1]
  field, and was emitted regardless of include_probabilities. It is now
  exp()'d and clamped to [0,1], and gated by include_probabilities.
- pos_tag_format was silently ignored; a non-UNSPECIFIED value now fails fast
  with UNIMPLEMENTED instead of returning the model's native tagset.
- ONNX doc categorizer: a blank line in the categories file produced a sparse
  index map (NPE at load) and a blank model id was accepted; both now fail
  with a clear INVALID_ARGUMENT. Registry ids are normalized at registration
  so a backend returning a mixed-case id is still found.
- Remote (TEI) backend failures collapsed to INTERNAL; a new UNAVAILABLE
  failure type maps them to gRPC UNAVAILABLE (retryable).
- ChunkGroupStats.total_tokens double-counted tokens shared by overlapping
  token-window chunks; it now counts each token once.

Adds DocumentOffsetEncoderTest and OpenNlpAnalysisServiceImplTest, plus a
GrpcStatusMapper exhaustiveness test, an opt-in parser concurrency stress
test, and targeted tests across the registries and chunking.
Apache-grade documentation pass on the gRPC modules.

Javadoc:
- Override the sandbox parent's doclint=none with doclint=all + failOnWarnings
  for the gRPC modules (generated protobuf excluded via sourcepath /
  excludePackageNames), so missing or malformed Javadoc now fails the build.
- Fill every flagged public-API Javadoc gap across the service module
  (DocumentAnalyzer, AnalysisException, GrpcStatusMapper, ModelBundleCache,
  ProfileRegistry, the registries, OpenNlpGrpcServer, ...) and the two backend
  factory classes, with accurate @param/@return/@throws and null/normalization
  semantics.
- Add Apache license headers to the two main SPI META-INF/services files
  (surfaced by apache-rat, which runs at package/install).

Proto:
- Comprehensively comment the v1 .proto API (service, RPCs, messages, fields,
  enums) grounded in the server implementation: gRPC error model, step
  preconditions, chunking units/constraints, offset-encoding semantics. Notes
  where the contract is not yet populated (DocumentAnalytics) or fields are
  currently no-ops (clean_text/preserve_urls).

Ignore local-only buf proto tooling (buf.yaml/buf.gen.yaml/API.md) via a new
opennlp-grpc/.gitignore so it is never committed.

Comment/build-config changes only; no behavior changes. Full gRPC tree builds
green (doclint + rat) and all tests pass.
Add classic shallow (syntactic) chunking, the last classic OpenNLP component
with an unfilled proto field. PIPELINE_STEP_SYNTACTIC_CHUNK groups each
sentence's tokens into base phrases (NP, VP, ...) and fills
AnnotatedSentence.syntactic_chunks. Distinct from CHUNK, which is segmentation
chunking for embedding.

- New PIPELINE_STEP_SYNTACTIC_CHUNK enum value (dedicated step, clean
  separation from CHUNK) and the en-chunk profile/bundle.
- ModelBundleCache loads an optional shared ChunkerME from model.chunker.path
  (operator-supplied, not bundled). ChunkerME is @threadsafe, so one instance
  is shared (unlike the parser).
- ClassicStepRunner.chunkSyntactic runs chunkAsSpans over each sentence's
  tokens + POS tags; toChunkResult maps the token-index spans to document
  spans with the chunker's phrase tag.
- AnalysisRequestValidator requires a configured chunker (NOT_FOUND) and
  POS_TAG in the profile (FAILED_PRECONDITION); BasicDocumentAnalyzer dispatch;
  DocumentOffsetEncoder now remaps syntactic-chunk spans.

Tests: hermetic toChunkResult conversion (token-index -> document span), the
NOT_FOUND path, the offset round-trip for chunk spans, and an opt-in
real-model end-to-end gated on -Dchunker.model.path. README documents it.

Every PipelineStep is now implemented. Comment/behavior changes build green
(doclint + rat) and all tests pass.
…gistry

Introduce the multi-backend pattern: one logical model id may be served by
several engines at once, resolved by priority with fallback, and pinnable to
a specific engine by a strongly-typed argument (never a parsed id string).

- RankedBackends<T>: generic registry — logical id -> priority-sorted engine
  registrations; resolve(id) (ranked) / resolve(id, engine) (pinned);
  invoke(id, op) with fallback / invoke(id, engine, op) pinned; builder dedups
  (id, engine). This is the reusable core the other capabilities will adopt.
- CompositeEmbeddingProvider: first consumer. EmbeddingProviderFactory now
  loads EVERY configured backend (ServiceLoader) and aggregates them, instead
  of selecting one via model.embedder.backend (removed). A model id served by
  multiple engines uses model.embedder.<id>.<engine>.priority; engines serving
  one logical id must report the same dimension (fallback safety, enforced at
  startup); model.embedder.default_id picks the default model.
- ONNX vs CUDA now use per-engine path keys (.onnx.path / .cuda.path) so each
  engine claims only its own models and a model definition (vocab/pooling) is
  shared across engines; CUDA stays inert without a GPU when no .cuda.path
  models are configured. EmbeddingProvider gains backendId(modelId) so the
  catalog reports each model's resolved engine.

Tests: RankedBackends (priority/fallback/typed pinning/dedup) and the composite
(routing, fallback, typed engine pin, dimension-mismatch, default) with stub
engines; factory + backend ServiceLoader tests updated for aggregation. Whole
gRPC tree builds green (doclint + rat) and tests pass.

Follow-ups (Layer 1): TEI/OpenVINO return-empty-when-no-models, ONNX
padded-tensor batching + TEI single-batch request.
krickert added 11 commits June 14, 2026 11:07
…ence

Complete embedding Layer 1 for multi-engine deploys.

- TEI and OpenVINO providers now construct inert (isAvailable()==false, no
  models) when no .tei.target / .openvino.target is configured, instead of
  throwing. The composite aggregates every discovered backend, so a deploy
  that only configures ONNX must not be aborted by an unconfigured remote
  engine. Genuinely misconfigured endpoints (unreachable, missing model_name,
  non-embedding model) still fail fast at startup.
- OnnxSentenceEmbedder gains a true padded-tensor batch path: all sequences
  are right-padded to the batch max and run as one [batch, maxLength]
  session.run with attention_mask=0 on the pads; each row is pooled over its
  real tokens only, so masked-out padding cannot affect a vector. The ONNX
  provider overrides embedBatch to use it, replacing per-text dispatch.

TEI batching is intentionally left as concurrent-unary fan-out: TEI's gRPC
Embed RPC takes a single string (no batch RPC exists in the gRPC surface), and
TEI does its own server-side dynamic batching across in-flight requests.

Tests: OnnxEmbeddingBatchParityTest (opt-in, -Ddl.embedding.model.dir) asserts
the batched vectors equal the per-text vectors within 1e-4 across mixed-length
inputs — verified here against all-MiniLM-L6-v2. TEI/OpenVINO inert-when-empty
tests replace the old reject-on-empty tests. Whole gRPC tree builds and tests
green.
Make the server's concurrency model virtual-thread based and switch TEI
batching to streaming, so blocking remote calls scale without tying up
platform threads.

- OpenNlpGrpcServer dispatches every RPC handler on a per-task virtual-thread
  executor (ServerBuilder.executor). Netty's event loops stay platform
  threads; only the application-callback executor is virtual, so a handler
  that blocks on a remote backend, a streaming latch, or native inference
  unmounts its carrier instead of pinning it. The executor is shut down with
  the server.
- TEI embedBatch now sends the whole batch over the bidirectional EmbedStream
  RPC and collects the responses (which TEI returns in request order while
  batching server-side), replacing the concurrent-unary fan-out. This is one
  call instead of N and the streaming path a fully streaming v2 will build on.
- TEI and OpenVINO channels get a provider-owned virtual-thread executor for
  their callbacks, lifecycle-managed across the active, inert, and
  failed-construction paths.

No synchronized blocks exist on our request path, so there is no carrier
pinning to worry about; remote calls are the I/O-bound work virtual threads
are for.

Tests: TEI/integration fake servers implement the bidi EmbedStream (echoing
one response per request, in order); the existing order-preserving and live
TEI batch tests now exercise the streaming path. Whole gRPC tree builds and
tests green, doclint clean.
Generalize the multi-backend pattern to NER as its own concept (it produces
spans over the whole text, not interchangeable vectors), so the same recognizer
can be served by several engines and a request can combine them.

Resolution is count-driven by the request's NerEnginePolicy.engines:
- none  -> each recognizer's highest-priority engine, with fallback on failure;
- one   -> pin that engine (no fallback); recognizers it doesn't serve are skipped;
- many  -> run all listed engines and UNION their entities.
Engines are a strongly-typed list (never a parsed id@engine string); an unknown
engine => NOT_FOUND, a blank one => INVALID_ARGUMENT.

Output is made understandable for the union case:
- NamedEntity now carries `text` (the exact matched surface text, always set) and
  `sources` (every recognizer/engine that produced it, each with its probability
  and — when a provider tokenized to different offsets — its own span).
- MERGE_STRATEGY: CONSENSUS (default) collapses same-type overlapping hits into
  one entity whose canonical span comes from the highest-priority producer and
  whose `sources` list all contributors; RAW keeps each producer's hit separate.

Internals:
- NameFinderRegistry now groups NerModels into a RankedBackends<NerModel> keyed by
  recognizer id; registering the same id under different backends is the
  multi-engine case (same id + same backend is still an error). A type->recognizer
  ids index drives type selection.
- NerModel gains priority(); the classic (model.name_finder.<id>.priority) and ONNX
  (model.name_finder_dl.<id>.priority) backends read their own priority key.
- NerEntityResolver applies the policy + merge per sentence and attaches provenance.
- DocumentOffsetEncoder also remaps per-source spans into the response encoding.

Tests: NerEntityResolver (default/pin/union, RAW/CONSENSUS, divergent-offset
provenance, priority fallback, all-fail rethrow, type filter); registry updated to
the id-based API; end-to-end NER asserts text + sources and engine pin/unknown-engine.
Whole gRPC tree builds and tests green; doclint clean.
Extend the multi-provider span pattern (built for NER) to syntactic chunking,
and generalize the request-side policy so capabilities share one type.

- EnginePolicy replaces the NER-specific NerEnginePolicy (same shape: engines +
  MergeStrategy). AnalysisProfile gains chunk_engine_policy and parse_engine_policy
  alongside ner_engine_policy; NER migrated to the shared type.
- Chunker is now a provider/registry like NER: ChunkerBackendFactory SPI +
  ClassicChunkerBackendFactory (reads model.chunker.<id>.path + .priority, was the
  single model.chunker.path) + ChunkerRegistry grouping chunkers by id into a
  RankedBackends<ChunkerModel>. ModelBundleCache holds the registry; the validator
  takes it (validates chunk_engine_policy engines: unknown => NOT_FOUND).
- ChunkResolver applies the count-driven policy per sentence (none = top priority
  with fallback; one = pinned; many = union) and attaches provenance + text:
  ChunkSpan gains `text` and `sources` (ChunkSource: chunker_id, engine, and its
  own span when it diverges). MERGE_STRATEGY CONSENSUS (default) collapses same-tag
  overlapping chunks; RAW keeps each separate. DocumentOffsetEncoder remaps
  per-source chunk spans too.

Tests: ChunkResolver (default/pin/union, RAW/CONSENSUS, divergent-offset provenance,
priority fallback, all-fail rethrow); the token->document span mapping moved to a
hermetic ClassicChunkerModelTest; end-to-end chunk test asserts text + sources.
Whole gRPC tree builds and tests green; doclint clean.
Extend the multi-provider pattern to constituency parsing. Unlike the span
producers (NER, chunking), a parse is a tree, so a multi-engine union returns
one tree per engine rather than a merged tree; MergeStrategy does not apply.

- Parser is now a provider/registry like the others: ParserBackendFactory SPI +
  ClassicParserBackendFactory (reads model.parser.<id>.path + .priority, was the
  single model.parser.path) + ParserRegistry grouping parsers by id into a
  RankedBackends<ParserModel>. The SPI returns the engine-agnostic gRPC ParseTree
  (so a non-OpenNLP engine can implement it); ClassicParserModel keeps OpenNLP's
  not-thread-safe parser per-thread. ModelBundleCache holds the registry; the
  validator takes it (validates parse_engine_policy engines: unknown => NOT_FOUND).
- ParseResolver applies the count-driven policy per sentence (none = top priority
  with fallback; one = pinned; many = one tree per engine). Each tree is stamped
  with its provenance: ParseTree gains parser_id and engine. AnnotatedSentence
  gains parse_trees: the primary tree stays on parse_tree (back-compatible), and
  parse_trees carries the full list only when a union produced more than one.
  DocumentOffsetEncoder remaps every union tree's spans too.
- ParseTreeConverter moved from processor.basic to the model package so the SPI
  model can produce ParseTree; its test moved with it.

Tests: ParseResolver (default/pin/union tree-per-engine, priority fallback,
all-fail rethrow); ParseTreeConverter test relocated; end-to-end parse asserts
parser_id/engine provenance and empty parse_trees for the single-engine case.
Whole gRPC tree builds and tests green; doclint clean.
Add representative "centroid" vectors over existing embeddings — the cheap,
CPU-only piece of the semantic-graph pattern (mean of member vectors, no extra
inference).

- ChunkEmbeddingGroup gains `centroids`: one EmbeddingResult per embedding model,
  the element-wise mean of that group's chunk vectors, spanning all its chunks
  (granularity EMBEDDING_GRANULARITY_GROUP_CENTROID, new). Computed in
  ChunkEmbedProcessor from the per-model vectors it already has.
- OpenNlpDocument gains `document_centroids`: one per model, the mean of the
  document's sentence-level embeddings (granularity DOCUMENT), computed in the
  EMBED step. Set only when EMBED produced sentence embeddings.
- Centroids helper does the element-wise average; DocumentOffsetEncoder remaps the
  new centroid source spans into the response encoding.

These are hierarchical-retrieval primitives (group/document representative
vectors) and apply to every grouping the server already emits.

Tests: Centroids unit (element-wise mean, single-vector, empty=>null); end-to-end
assertions that the group centroid equals the mean of its chunk vectors per model
and the document centroid equals the mean of the sentence vectors. Whole gRPC tree
builds and tests green; doclint clean.
Add a request-driven category-chunking output: group the document's sentences
by their per-sentence category (the SENTIMENT label), concatenate each category's
sentences into one chunk, embed it, and attach a centroid — the "categorize then
cluster + embed + centroid" idea, emitted as ordinary ChunkEmbeddingGroups.

- New repeated CategoryChunkConfigEntry on AnalyzeDocumentRequest (config_id,
  optional `categories` allowlist, embedding_model_ids, result_set_name). Mirrors
  chunk_embed_configs and reuses ChunkEmbeddingGroup as the output: one chunk per
  category (chunk_tag = the label, text_content = the concatenated sentences,
  contained_sentence_indices populated), plus the per-model group centroid.
- ChunkEmbedProcessor.buildCategoryGroup groups by normalized sentiment label,
  preserving first-appearance order; an allowlist restricts/orders the categories
  (case-insensitive). Sentences without a label are ignored.
- Runs when category_chunk_configs is present (after the strategy chunking).
  Validated up front: requires SENTIMENT in the profile (else FAILED_PRECONDITION),
  a non-blank config_id, and supported embedding models.

Today the grouping key is the sentiment label (the only per-sentence category the
server produces); the surface generalizes to other per-sentence categories later.

Tests: buildCategoryGroup grouping/concat/centroid, allowlist restrict+order
(case-insensitive), unlabelled sentences ignored; end-to-end rejection when
SENTIMENT is absent. Whole gRPC tree builds and tests green; doclint clean.
@mawiesne mawiesne changed the title OPENNLP-1833 - gRPC document analysis service with embeddings and chunking OPENNLP-1833: gRPC document analysis service with embeddings and chunking Jun 19, 2026
krickert added 3 commits July 2, 2026 22:32
…ize, uax29, term layers)

Built against 3.0.0-kristian-SNAPSHOT of the full OPENNLP-1850 stack
plus the 1868 full-case-fold and 1869 emoji/emoticon rungs.

- PIPELINE_STEP_NORMALIZE: offset-aware text normalization with the
  full rung set (invisible/whitespace/quotes/dashes/digits/ellipsis/
  bullets/full case fold/emoji folds and the offset-opaque NFC/NFKC/
  case/accent/confusable rungs). The response carries normalized_text
  plus AlignmentRun edit runs reconstructed from the library Alignment
  (AlignmentRuns), rescaled by the offset-encoding pass so run units
  always match AnnotationSpan units on both texts. require_alignment
  (default true) rejects offset-opaque rungs with INVALID_ARGUMENT;
  opting out yields text-only plus a diagnostic.
- tokenizer_engine "uax29": rule-based Unicode UAX #29 word tokenizer
  as a model-free TOKENIZE engine, populating the new Token.word_type.
- AnalysisProfile.term_dimensions: per-token character-level Term
  layers (Token.term_layers) computed with the library's cumulative
  ladder semantics; STEM/LEMMA and unknown names rejected.
- DlNerModel maps ONNX spans through findInOriginal
  (OffsetMappingNameFinder), so entity offsets stay document-exact
  when the finder normalizes internally.
- NamedEntity reserves field numbers 10-19 for entity resolution
  (geocoding) payloads.
- Validator guards for the new surface; NORMALIZE registered in
  PipelineStepPolicy; ParityStepsTest covers typed uax29 tokens in
  document coordinates, aligned and opaque normalize paths, alignment
  run reconstruction and full-coverage invariants, term layers, and
  the UTF-8 byte rescale. Full service suite: 219 tests green.
- AnalysisProfile.term_profile selects the library's per-language
  NormalizationProfiles registry (e.g. "en", "de") and computes
  Token.term_layers with the profile's matching analyzer, including
  its language-appropriate Snowball stem layer. The analyzer is built
  per request because Snowball stemmers are stateful and must not be
  shared across threads.
- Mutually exclusive with term_dimensions; requires TOKENIZE; a
  language with no registered profile => NOT_FOUND. Validator guards
  included.
- Tests: profile ladder on English (STEM layer, run from Running),
  unknown-language fail-loud, and a rung-mapping invariant test that
  every declared NormalizationRung maps into the builder and that the
  offset-aware claims hold against the library's buildAligned gate.
  Full service suite: 222 tests green.
parity for the merged Unicode stack

Wire parity with the library: the OPENNLP-205 sentence span mapping is
pinned through AnalyzeDocument, every parity-batch request validator
guard is covered by tests, and the README documents the model-free
Unicode analysis surfaces.

opennlp-grpc-backend-static serves static embedding tables through the
opennlp-embeddings module: pure JVM, no native runtime, one immutable
model instance shared by every request thread. Models are declared as
a published model directory or as explicit safetensors and vocabulary
paths; misconfiguration fails at startup. A default_id naming another
engine's model no longer crashes the onnx and tei providers in
mixed-engine configurations; the composite still validates the id
against the union of engines.

EmbedText is a new bidirectional streaming RPC for pre-segmented
texts: the client streams texts, the server streams one vector per
text back in request order, one model per stream. Response writes are
gated on transport readiness with a bounded elastic window, and a
client that stops reading gets RESOURCE_EXHAUSTED instead of growing
the server heap.

ModelBundleCache now hands each server thread its own ME decoder over
the shared immutable models; the decoders keep per-call state and were
shared across request threads before.

benchmarks/embedding-throughput carries three methodology-matched
harnesses (in-process, gRPC unary and streaming, Python baseline) and
measured numbers for the static, onnx, and tei backends through the
same wire.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant