Skip to main content
Version: Next

Writing a Custom Embedding Provider

The bundle ships one provider, selected with provider: default, which speaks the OpenAI-compatible embeddings shape. Write your own when you need something it cannot express:

  • an endpoint with a different request or response body,
  • dynamic authentication (request signing, mTLS, a token that must be refreshed) — a static API-key header needs no custom provider, see custom request headers,
  • your own batching, or per-request parameters the endpoint requires,
  • preprocessing on the way out — including normalizing vectors yourself if the service does not return unit-length vectors (see serving invariants).

Two steps: implement the interface, then select it in the model configuration.

1. Implement the provider

Extend AbstractHttpEmbeddingProvider rather than implementing the bare interface — it already gives you endpoint scheme validation, an unencrypted-transport warning, bearer-auth and custom options.headers handling, retries with backoff on transient failures (network, 429, 5xx), and per-call timeout/attempt resolution.

<?php
declare(strict_types=1);

namespace App\Embedding;

use Pimcore\Bundle\BackendPowerToolsBundle\Provider\Embedding\AbstractHttpEmbeddingProvider;
use Pimcore\Bundle\BackendPowerToolsBundle\Utils\ValueObjects\Embedding\EmbeddingModelConfig;

final class MyEmbeddingProvider extends AbstractHttpEmbeddingProvider
{
private const int BATCH = 32;

public function getType(): string
{
return 'my-provider';
}

public function embed(array $inputs, EmbeddingModelConfig $config, string $inputKind, array $options = []): array
{
$this->assertAllowedScheme($config->getEndpoint());

$vectors = [];
foreach (array_chunk($inputs, self::BATCH, true) as $batch) {
$keys = array_keys($batch); // original indices, to restore global order

$response = $this->requestJson(
$config->getEndpoint(),
[
'timeout' => $this->timeoutFor($options, $config),
'headers' => $this->jsonHeaders($config),
'json' => ['model' => $config->getModel(), 'texts' => array_values($batch)],
],
$this->maxAttemptsFor($options),
);

foreach ($response['results'] ?? [] as $position => $result) {
if (!isset($keys[$position], $result['vector'])) {
continue;
}
$vectors[$keys[$position]] = array_map(static fn ($v): float => (float) $v, $result['vector']);
}
}

ksort($vectors);

return $vectors;
}
}

No service tagging is needed: EmbeddingProviderInterface carries #[AutoconfigureTag('pimcore.bpt.embedding_provider')], so any service implementing it is registered automatically as long as your bundle or app has autoconfiguration enabled (the Symfony default).

2. Select it for a model

provider matches the string your getType() returns:

pimcore_backend_power_tools:
embeddings:
enabled: true
models:
my-text-model:
provider: 'my-provider'
modality: 'text'
endpoint: 'https://inference.example.com/embed'
model: 'my-org/my-embedding-model'
dimension: 768
options:
# free-form, provider-specific: read with $config->getOption('...')
pooling: 'mean'

Two model ids may use different providers; each is resolved independently.

The contract in detail

public function getType(): string;

/**
* @param list<string> $inputs prepared inputs (text, or base64-encoded image)
* @param array{timeout?:int,maxAttempts?:int} $options per-call overrides
*
* @return array<int, list<float>> vectors keyed by input index
*
* @throws EmbeddingConfigException if the configured endpoint is invalid
*/
public function embed(array $inputs, EmbeddingModelConfig $config, string $inputKind, array $options = []): array;

Return value — the part that is easy to get wrong. The result is keyed by the input index, and a gap must stay a gap. If the service could not embed input 3, omit key 3 rather than re-packing the array: the caller then degrades only that one element and marks it for backfill. Re-indexing the array misaligns every following vector onto the wrong element, which no error will reveal.

$inputKind is 'text' or 'image' and is independent of the model's storage modality — a cross-modal image model receives 'text' when embedding a query. Base64-encoded thumbnails arrive as 'image'; call assertTextOnly() if your provider only supports text.

$options carries per-call overrides that your provider should honour:

KeyMeaning
timeoutReplaces the model's configured timeout, in seconds.
maxAttemptsCaps total request attempts; 1 means no retry.

The interactive query path sets both deliberately (a short timeout and no retries), because a user waiting on a search cannot absorb the document-side retry budget. timeoutFor() and maxAttemptsFor() resolve them for you.

Failure handling. Let transient failures retry (requestJson() does this) and let permanent ones throw — the caller degrades the affected elements, marks them for backfill, and repairs them on the next run. Never return a partially filled vector or a zero vector as a substitute for a failure: it would be indexed as if it were real, and the identity key would mark the element as done.

Dimensions are validated centrally against the model's dimension, so a wrong-sized vector is rejected rather than indexed. Return what the service gave you.

caution

Embeddings are an experimental feature. EmbeddingProviderInterface, AbstractHttpEmbeddingProvider and EmbeddingModelConfig are public API for this extension point, but may still change in breaking ways between releases.

Verifying your provider

Configure one model against your provider, then run a reconciled pass — it reports whether vectors actually landed:

bin/console bpt:embeddings:reindex --reconcile

Then check quality with the Evaluation Tool. If results come back ranked almost randomly with near-identical scores, your service is returning unnormalized vectors — normalize them in the provider or fix the service.