Local image embedding
Every multimodal embedding provider Lucenia offered was a cloud one — Vertex, Azure Vision, Bedrock.
That is fine until the imagery cannot leave, which across a good part of the geospatial world is the
ordinary case rather than the exception. The http provider refuses images outright, so there was no
local path at all.
The embedding-onnx module embeds imagery on the node, on ONNX Runtime. Nothing leaves the
cluster.
It is absent unless configured. There is no sensible default for which model, and a provider
that exists and fails on first use is worse than one never offered. Until you point it at model
files, a query naming it gets unknown provider, listing what is actually available.
Why it is a separate module
The runtime carries native libraries for five platforms and is measured in hundreds of megabytes. The shared embedding library is a dependency of both the memory and content-ingest modules, and neither does local inference — putting the runtime there would charge them for something they never call. The weight travels with the module that needs it.
Configuration
All settings are node-scoped and read at startup, so they go in opensearch.yml and take a node
restart. The two model paths are required; without them the provider is not registered.
| Setting | Default | Description |
|---|---|---|
embedding.onnx.image_model_path | (empty) | Path to the image tower's ONNX model file. Required. |
embedding.onnx.text_model_path | (empty) | Path to the text tower's ONNX model file. Required. |
embedding.onnx.dimension | 512 | The vector width both towers produce. Must match the model. |
embedding.onnx.image_size | 224 | The square edge, in pixels, images are resized to before inference. |
embedding.onnx.image_mean | 0.48145466, 0.4578275, 0.40821073 | Per-channel mean used to normalize pixels. |
embedding.onnx.image_std | 0.26862954, 0.26130258, 0.27577711 | Per-channel standard deviation used to normalize pixels. |
embedding.onnx.vocab_path | (empty) | Path to the tokenizer vocabulary, for embedding text. |
embedding.onnx.merges_path | (empty) | Path to the tokenizer merges file. |
embedding.onnx.context_length | 77 | The token budget for a text input. Longer input is truncated. |
embedding.onnx.provider_name | onnx | The name this provider registers under, and the name a query or pipeline uses. |
The defaults for image_size, image_mean, image_std, dimension and context_length are
CLIP's. A different model almost certainly wants different values, and getting them wrong produces
vectors that are quietly meaningless rather than an error.
embedding.onnx.image_model_path: /var/lib/lucenia/models/clip-image.onnx
embedding.onnx.text_model_path: /var/lib/lucenia/models/clip-text.onnx
embedding.onnx.vocab_path: /var/lib/lucenia/models/vocab.json
embedding.onnx.merges_path: /var/lib/lucenia/models/merges.txt
embedding.onnx.dimension: 512
Both towers embed into one space. An image embedded by the image tower and a text query embedded by the text tower are comparable only because they were trained together. Pointing the two settings at models from different checkpoints produces vectors that live in different spaces, and the search returns nearest neighbors that mean nothing. No error is raised.
Inference runs on its own thread pool
Running a model is CPU-bound and far longer than a search. On the search pool it would compete with
every other query on the node, so inference gets a fixed pool of its own, named local_inference,
with 2 threads and a bounded queue of 16.
Past the queue, work is rejected rather than queued indefinitely, naming the pool and the setting to raise. A request that fails quickly beats a node that quietly slows down for everything.
The pool is declared whether or not a model is configured, because a thread pool cannot be added to a running node and an operator may enable the model later.
Inference also registers as heavy work, so a node being drained waits for in-flight model calls to finish rather than dropping them — the same treatment raster work gets.
Using it
Once registered, onnx is a provider name like any other. Name it in the
embed processor to embed imagery at
ingest time, in the query_embedding
processor at search time, or on a semantic
query.
Contributing your own provider
The ONNX module is the first implementation of the EmbeddingProviderPlugin extension point, which
lets an installed plugin contribute embedding providers to the node instead of every consumer
carrying its own hardcoded list.
Before this, a provider could only be offered by a module compiled against it, and three consumers — memory, content ingest and k-NN — each kept their own switch over provider names. Those had already drifted apart in what they knew about.
package io.skylite.core.plugins;
public interface EmbeddingProviderPlugin {
/** Providers this plugin contributes, by the name a query or pipeline uses. */
default Map<String, EmbeddingProvider> getEmbeddingProviders(Settings settings, ThreadPool threadPool) {
return Collections.emptyMap();
}
}
The node collects these at startup. A contributed provider is added after the built-in hashing
provider, so a bare node still falls back to the zero-dependency default, and a plugin may
deliberately replace a default under its own name.
This is a different extension point from the ingest-content EmbeddingProvider. That one is
loaded through META-INF/services and serves the ingest enrich pipeline; see
Custom embedding provider.
EmbeddingProviderPlugin is node-level, contributed by an installed plugin, and is what reaches the
semantic query. Both exist in 0.13.0.
Related pages
- Embedding providers and credentials — the cloud providers and how they authenticate
- Extensibility — the other extension points
- Embed processor