ADR-055: Embeddings join the configuration path; dimensions metadata 

Status

Accepted

Date

2026-07-13

Authors

Netresearch DTT GmbH

Context 

The three-tier model (Provider → Model → Configuration, ADR-001) reaches every chat-shaped capability: completeWithConfiguration(), chatWithConfiguration(), streamChatWithConfiguration() and chatWithToolsForConfiguration() all resolve the adapter from a DB-backed LlmConfiguration (vault key + model + pricing) and run through the middleware pipeline, so budgets are enforced and cost is attributed per configuration.

Embeddings did not. LlmServiceManager::embed() only accepted EmbeddingOptions with raw provider/model strings, resolved against ExtensionConfiguration and a model-less transient configuration. An embedding consumer that persists vectors (a search index, semantic auto-linking — see the scope boundary in ADR-050) therefore had to duplicate provider, model and dimensionality into its own extension configuration, bypassing per-configuration budgets and cost attribution entirely.

The dimensionality gap made this worse: no record anywhere stated how many dimensions a model's vectors have. A consumer validating a persisted vector index against the configured model had to run a live "calibration probe" — embed a throwaway string and count the floats — which costs a provider call and fails when the provider is unreachable.

Decision 

Embeddings join the configuration path. LlmServiceManager::embedForConfiguration() mirrors chatWithToolsForConfiguration(): it resolves the adapter via getAdapterFromConfiguration(), runs through the middleware pipeline with ProviderOperation::Embedding and the budget metadata from the options, and guards the embeddings feature the same way embed() does (UnsupportedFeatureException when the provider lacks it). Per-call EmbeddingOptions take precedence over the configuration's stored defaults — an options model overrides the configuration's model id. Caching mirrors embed(): a positive cache_ttl places a cache key on the call context (keyed on the configuration identifier plus the effective model), so two configurations pointing at different models never share cache entries.

The high-level feature service follows: EmbeddingService::embedForConfiguration() and embedBatchForConfiguration() delegate to the manager and populate beUserUid via the shared auto-populate wiring, exactly like the existing embed()/embedBatch() paths.

Model records carry dimensions metadata. tx_nrllm_model gains a dimensions column (integer, 0 = unknown, declared like context_length), surfaced in the TCA next to the other model limits and on the Model entity as getDimensions()/setDimensions(). It is descriptive metadata: nothing in nr_llm enforces it at call time.

Consequences 

  • Embedding consumers select a backend-managed configuration instead of duplicating provider + model + dimensions into their own extension configuration; per-configuration budgets and cost attribution apply to embeddings like to every chat-shaped capability.
  • A consumer can validate a persisted vector index against the configured model by comparing its stored dimensionality with getLlmModel()->getDimensions() — no live calibration probe, no provider round-trip. A value of 0 means "unknown"; consumers fall back to their previous behaviour then.
  • LlmServiceManagerInterface and EmbeddingServiceInterface gained methods — implementers outside this repo must add them.
  • nr_llm's embedding capability remains stateless (ADR-050): the configuration path changes how the call is resolved and accounted, not what is persisted. Vector stores stay out of scope.