Servers for embeddings, reranking and transcription
Status: Infinity and the TEI Docker image were run (2026-10-23 and 2026-10-29); the rest is written from the servers' own documentation. Read on 2026-10-23: Text Embeddings Inference (TEI) v1.9.4, Infinity 0.0.78, speaches v0.9.0-rc.3; their OpenAPI documents are kept in
tests/Fixtures/StandardServersand the clients are tested against them. What is still open (TEI under load, a successful TEI build from source, speaches, behaviour on a large file) is recorded in the Verification section ofdocs/superpowers/specs/2026-10-22-ai-standard-servers-design.mdin the application repository; this guide changes when it is done.
Laraplate does not run models inside PHP. The AI module calls standard HTTP servers on separate hosts (VM, LXC, bare metal), speaking OpenAI-style APIs. Any server that speaks the same API works; this guide documents the ones the module is tested against. It replaces the guides of the former sentence-transformers-api and whisper-api services, which are archived.
What talks to what
| Feature | API the module speaks | Documented server | Alternatives |
|---|---|---|---|
| Embeddings (search indexing, vector search, documentation RAG) | OpenAI POST /v1/embeddings |
TEI | Infinity (no Docker, own venv), Ollama, LocalAI, a cloud API |
Reranking (deep search) |
tei: POST /rerank {query, texts}; cohere: POST /rerank {model, query, documents} |
TEI with a reranker model | Infinity, vLLM (cohere) |
| Transcription (media analysis) | OpenAI POST /v1/audio/transcriptions |
speaches | LocalAI, any OpenAI-style server |
[Laravel + Horizon] --POST /v1/embeddings--> [TEI :8080, model e5] (AI_EMBEDDINGS_URL)
--POST /rerank---------> [TEI :8081, reranker] (AI_RERANK_URL)
--POST /v1/audio/transcriptions--> [speaches] (AI_TRANSCRIPTION_URL)
Restrict inbound access on every server host to trusted clients (VPN, private network, firewall) and set an API key on both sides: a server started without one is open.
Configuration
URLs, keys and timeouts are environment variables read by config/ai.php (ai.servers); the settings in Filament only choose among the servers the installer declared. Each URL is the root of the server, with no trailing path.
| Variable | Default | Purpose |
|---|---|---|
AI_EMBEDDINGS_URL |
unset | Default embeddings server. Unset: no profile that uses it is offered |
AI_EMBEDDINGS_API_KEY |
unset | Bearer key of that server |
AI_EMBEDDINGS_TIMEOUT |
30 |
Seconds an embeddings request may take; a CPU-bound server is slow |
AI_EMBEDDINGS_BATCH_SIZE |
32 |
Documents per request; the client halves it when a request fails or is slow. TEI refuses more than its --max-client-batch-size (32 by default) |
AI_RERANK_URL, AI_RERANK_API_KEY |
unset | Reranker. Unset: no reranking |
AI_RERANK_DIALECT |
tei |
tei or cohere |
AI_RERANK_MODEL |
unset | Model to rank with; required for cohere |
AI_TRANSCRIPTION_URL, AI_TRANSCRIPTION_API_KEY |
unset | Transcription server. Unset: media index without a transcript |
AI_TRANSCRIPTION_TIMEOUT |
120 |
Seconds a transcription request may take |
The module ships one embedding profile, openai_compatible:intfloat/multilingual-e5-small, served by the default server. A profile of ai.features.embeddings.models may name another server of ai.servers ('server' => 'embeddings_other', an entry with its own url and api_key); a named server without a URL falls back to the default one. TEI serves one model per process, so a deployment that adds a second embedding model runs a second TEI process and declares its server and profile in configuration; Infinity, Ollama and LocalAI serve many models from one process and need only AI_EMBEDDINGS_URL. The profile (openai_compatible:<model>) names the model the server must run; the module sends it in every request and refuses an answer from another model.
The transcription models the dropdown offers are the list ai.features.media_analysis.transcription.models (default Systran/faster-whisper-base); add the ones your server serves, as it names them.
Text Embeddings Inference (embeddings and reranking)
TEI has no pip package and no binary on its releases. Use the Docker image: it carries Intel MKL and needs no build. One container per model, with a memory cap so that it cannot starve the other services of the host:
docker run -d --name tei-e5 --restart unless-stopped --memory 1500m --memory-swap 1500m \
-p 8080:80 -v /opt/tei-data:/data \
ghcr.io/huggingface/text-embeddings-inference:cpu-1.9.4 \
--model-id intfloat/multilingual-e5-small --api-key "$KEY" --max-client-batch-size 32
cpu-1.9.4 is the CPU image of the documented release. The container listens on port 80, the first start downloads the model (about 500 MB), and the e5 container used 858 MiB after its first request on a 4 GB host.
Without Docker TEI is built once from source (libssl-dev and gcc needed), and the build needs a recent Intel oneMKL: -F mkl links libmkl_*, and the package of Ubuntu 22.04 (intel-mkl 2020.4) is too old, the link fails with undefined symbol: hgemm_. Install a newer oneMKL and point MKLROOT at it before building (the build script of intel-mkl-src reads that variable); the minimum version was not measured, and a source build with the distribution's MKL was tried and does not link.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
git clone --branch v1.9.4 https://github.com/huggingface/text-embeddings-inference
cd text-embeddings-inference && cargo install --path router -F mkl
One process per model. With a binary built from source, for example under systemd (ExecStart=); the Docker image takes the same arguments after its name:
text-embeddings-router --model-id intfloat/multilingual-e5-small --port 8080 --api-key "$KEY" --max-client-batch-size 32
text-embeddings-router --model-id cross-encoder/mmarco-mMiniLMv2-L12-H384-v1 --port 8081 --api-key "$KEY" --max-client-batch-size 64
The reranker is a second container with the same memory cap, another port and a cross-encoder model (the embeddings container cannot rerank: TEI loads one model per process). cross-encoder/mmarco-mMiniLMv2-L12-H384-v1 loads and scores on TEI v1.9.4 (2026-10-29), and the container used about 970 MiB at rest, so on a 4 GB host shared with other services the reranker costs about 1 GB more; it is optional and off by default.
docker run -d --name tei-rerank --restart unless-stopped --memory 1500m --memory-swap 1500m \
-p 8081:80 -v /opt/tei-data:/data \
ghcr.io/huggingface/text-embeddings-inference:cpu-1.9.4 \
--model-id cross-encoder/mmarco-mMiniLMv2-L12-H384-v1 --api-key "$KEY" --max-client-batch-size 64
To expose a single port, put nginx in front and give each server a path prefix. The module appends /v1/embeddings, /info and /rerank to the URL, so the trailing slash of proxy_pass must strip the prefix and the URLs carry no slash at the end (AI_EMBEDDINGS_URL=http://host/embed, AI_RERANK_URL=http://host/rank). Publish the containers on 127.0.0.1 (-p 127.0.0.1:8080:80) once nginx works, so only nginx is reachable:
server {
listen 80;
server_name _;
location /embed/ { proxy_pass http://127.0.0.1:8080/; proxy_read_timeout 120s; }
location /rank/ { proxy_pass http://127.0.0.1:8081/; proxy_read_timeout 120s; }
}
- Do not pass
--default-prompt. Laraplate adds thequery:andpassage:prefixes itself (PrefixingEmbeddingsProvider); a server that adds them too embedsquery: query: .... The probe cannot detect it. --max-client-batch-sizemust be at least the embeddings batch size (32) and, for the reranker, 64, the most pairs one rerank call carries;ai:servers:checkreads it fromGET /infoand says when it is lower.GET /healthand/metricsstay public; every other route needs the key. The model must be one TEI supports:intfloat/multilingual-e5-smallloads and passesai:servers:check(Docker image, 2026-10-29); the reranker modelcross-encoder/mmarco-mMiniLMv2-L12-H384-v1loads and scores too (2026-10-29).- Rerank scores come back in [0, 1] (TEI applies a sigmoid unless
raw_scores); the module never asks for raw scores.
Infinity (no Docker, alternative)
Infinity v1 needs its own virtual environment (Python below 3.14, numpy<2, sentence-transformers<4, transformers<=5.0): it cannot share /opt/ai-env with newer pins. Version 0.0.78 is a maintenance release of v1; Infinity V3 has a source-available, restricted licence.
python3.13 -m venv /opt/infinity-venv
/opt/infinity-venv/bin/pip install 'infinity_emb[all]==0.0.78'
/opt/infinity-venv/bin/infinity_emb v2 --model-id intfloat/multilingual-e5-small --model-id cross-encoder/mmarco-mMiniLMv2-L12-H384-v1 \
--engine torch --device cpu --port 7997 --api-key "$KEY" --url-prefix /v1
- Infinity publishes
/embeddingsand/rerankunder its--url-prefix(confirm the flag withinfinity_emb v2 --help). Start it with/v1, which is where the embeddings client sends its requests, and point the reranker at the same prefix:AI_RERANK_URL=http://host:7997/v1,AI_RERANK_DIALECT=cohere,AI_RERANK_MODEL=cross-encoder/mmarco-mMiniLMv2-L12-H384-v1. - One process serves several models; the profile's model is sent in each request.
GET /healthof Infinity is a liveness answer only.
Transcription (speaches or any OpenAI-style server)
The module posts the file and the model of the transcription choice (features.media_analysis.transcription.model, for example openai_compatible:Systran/faster-whisper-base) to POST /v1/audio/transcriptions with response_format=json and reads only text. Install and start speaches as its own documentation says (it is uv-based, Python 3.12, and faster-whisper runs on CPU), set its API key if it has one, and:
AI_TRANSCRIPTION_URL=http://speaches-host:8000
AI_TRANSCRIPTION_API_KEY=...
The module does not limit the size or the duration of an upload and no limit was found in the settings of speaches: put a body-size limit on the reverse proxy in front of it, and size the timeout (AI_TRANSCRIPTION_TIMEOUT) for the longest file you accept. A transcription that fails (a 413 from the proxy, a busy server, a timeout) leaves the media indexed without a transcript and logs one warning with the request id.
Checking a deployment
php artisan ai:servers:check # probes every configured server; exit code 1 when one is misconfigured
php artisan ai:embeddings:probe openai_compatible:intfloat/multilingual-e5-small
ai:servers:check sends each embedding profile a probe through the server it names and checks the model, the vector length, the norm (a profile that declares normalize needs vectors of norm 1) and the batch limit; it asks the reranker to score two fixed pairs; transcription is listed but not probed, because it would need audio.
A failed call logs Self-hosted AI service call failed with service (embeddings, rerank, transcription), endpoint (no credentials), status and a request_id, which is also sent as the X-Request-Id header: where the server or its reverse proxy logs request headers, the same id leads to the line of that call.
Switching the embedding model
ai:embeddings:switch <profile> checks that the server of the target profile answers, runs the profile's model, returns vectors of its dimensions and norm, then re-embeds and reindexes in the background. With TEI the target model needs its own process first: start it, set the URL of the profile's server, run php artisan ai:servers:check, then switch. The same model served by another server gives the same vectors (up to numeric noise), so moving from the former service to TEI with the same model does not need re-embedding; the profile key changed from sentence_transformers: to openai_compatible:, so a development database is reseeded or switched with ai:embeddings:switch.
From the former services
| Before | Now |
|---|---|
SENTENCE_TRANSFORMERS_URL, _API_KEY, _TIMEOUT, _BATCH_SIZE |
AI_EMBEDDINGS_URL, _API_KEY, _TIMEOUT, _BATCH_SIZE |
CROSS_ENCODER_URL, CROSS_ENCODER_API_KEY |
AI_RERANK_URL, AI_RERANK_API_KEY, plus AI_RERANK_DIALECT and, for cohere, AI_RERANK_MODEL |
WHISPER_URL, WHISPER_API_KEY, WHISPER_TIMEOUT |
AI_TRANSCRIPTION_URL, AI_TRANSCRIPTION_API_KEY, AI_TRANSCRIPTION_TIMEOUT |
profile sentence_transformers:<model> |
openai_compatible:<model> |
transcription choice whisper |
openai_compatible:<model> |
GET /health with api_version, /models, /ready |
none: ai:servers:check probes each server |