* feat(router): make KNN a first-class classifier with a persisted, curated corpus
Add `classifier: knn` — similarity-weighted voting over labelled
example prompts. Unlike score/colbert it needs no classifier model:
label knowledge lives in a corpus seeded and curated through the
admin API, so routing decisions are deterministic, auditable, and
grounded in graded experience rather than a model's opinion.
Epistemic gate: corpus entries below knn.similarity_threshold cannot
vote; when none clears it the classifier activates no labels and the
router uses the fallback — a prompt unlike all labelled experience is
treated as undecidable, not guessed. Decisions record
nearest_similarity (also on fallback rows) so admins can see how far
the nearest labelled experience was; the Routing tab explains
out-of-corpus fallbacks and shows per-label corpus counts.
Persistence: one JSONL file per router under
<data path>/router-corpus (text, labels, vector, embedder
fingerprint). The file is the source of truth; the local-store index
is rebuilt from it at classifier build time and stays a pure
in-memory index. Entries recorded under a different embedding model
re-embed on load. Also corrects the docs' false claim that
local-store collections persist — the embedding cache never survived
restarts (and still doesn't); the corpus does.
Corpus input is API-only by design (entries may contain example user
content): POST /api/router/{name}/corpus seeds (labels validated
against declared policies, embedded server-side, indexed
immediately), GET .../corpus/stats inspects — label counts only,
entry texts are never returned by any surface — DELETE .../corpus
wipes. Admin-gated like the sibling router endpoints, and exposed as
MCP tools (seed_router_corpus / get_router_corpus_stats /
clear_router_corpus) in both the httpapi and inproc clients with
coverage-test route mappings.
Plumbing: VectorStore gains SearchK (top-K was hardcoded to 1);
local-store gets InsertBatch/Delete as optional fast paths;
RouterConfig gains a knn block (embedding_model, k,
similarity_threshold, vote_threshold, store_name) with meta-registry
fields; the classifier dropdown now offers knn and the
previously-missing colbert; embedding_cache is ignored (with a
warning) for knn — it IS an embedding-KNN lookup; the stale
/api/instructions intelligent-routing entry is rewritten (it
described a classifier that no longer exists); swagger regenerated.
Tests: KNN vote/gate specs with hand-computed vote shares, corpus
manager suite (restart reload without re-embedding, fingerprint
re-embed, dedupe, hostile store names), middleware specs (corpus
routing, gate fallback, config validation, cache-wrap refusal),
corpus endpoint specs pinning the texts-never-returned contract, MCP
catalog + route-mapping gates, and a Playwright spec for corpus
stats and the out-of-corpus decision detail.
Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>
* feat(router): name consulted corpus neighbours in knn decisions
Every knn decision (decision log rows and the /api/router/decide
response) now carries neighbors: the K retrieved corpus entries by
descending similarity - including ones below the epistemic gate, which
is what makes fallback decisions diagnosable - each as {id, similarity,
labels}. The id is the entry's content hash (first 8 bytes of the
SHA-256 of its text, hex): stable across reseeds and re-embeds, and
text-free, so an external platform that seeded the corpus can recompute
text->id on its own copy and bucket decisions by corpus region (per-
region reliability accounting) without corpus text ever leaving the
server. A corrupt index payload surfaces as an id-less neighbour at a
real similarity instead of disappearing.
Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>
* refactor(router): deduplicate knn plumbing and cut corpus hot-path waste
Post-review cleanup of the knn-first-class-router branch; no behaviour
changes on the API surface.
Reuse/altitude:
- RouterKNNConfig.ResolvedStoreName is now the single source of the
router-corpus-<name> default (was hand-derived in four files).
- corpus.ResolveKNNRouter + corpus.Seed carry the shared model
resolution and seed validation; the REST endpoints and the assistant
MCP client are thin transport adapters over them, with sentinel
errors mapped to HTTP statuses at the echo boundary.
- middleware.NewClassifierDeps assembles the classifier dependency set
once for all five entry points (OpenAI, Anthropic, realtime, decide,
corpus) instead of five hand-copied literals.
- router.AllClassifiers feeds both the status endpoint and the
unknown-classifier error, ending the classifier-list drift.
- Per-classifier requirements moved out of validateRouterPolicies into
their buildClassifier arms; the knn arm owns its embedding_cache
opt-out instead of a name-check in the shared wrap tail.
- adminOnly replaces four inline copies of the admin gate in the
middleware routes.
- localVectorStore.Search delegates to SearchK (identical traces).
Efficiency:
- Manager.Add embeds outside the manager mutex and appends to the
JSONL file (O(new) instead of O(corpus) rewrite); a torn tail from a
crash mid-append is tolerated on read and repaired on next write.
- Stats memoises per store keyed on the file's stat fingerprint and no
longer takes the manager mutex, so the 5s status poll stops parsing
vector-laden JSONL and stops blocking behind seeds.
- KNN Classify decodes each neighbour payload once (was twice) and
builds refs and votes in a single pass with one fallback return.
- Corpus file writes fsync before rename/close.
- The corpus manager is built eagerly in newApplication (sync.Once
dropped); test helper dead branch removed.
Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>
* feat(router): bind knn corpus vectors to an embedder fingerprint and fail closed on mismatch
Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>
* chore(mcp): align corpus tool prompts and the mutating-tool safety list
Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>
* feat(proto,backend): report embedding shape from the llama-cpp backend
Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>
* feat(embeddings): Go-side pooling — mean/last/decayed_mean with half-life
Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>
* feat(embeddings): accept chat messages[] and per-request pooling on /v1/embeddings
Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>
* chore(middleware): name the failing fields when post-merge validation 400s
An intermittent post-merge validation failure surfaced as an opaque 400
during integration (pooling scheme mismatch that no client had sent).
Log the model, the request's pooling override, and the merged config's
pooling fields at the failure point so the next occurrence identifies
whether the request or the stored config carried the bad value.
Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>
* fix(embeddings): scheme override must not inherit the config's half-life
A model config defaulting to decayed_mean pooling carries
pooling_half_life_tokens; a request overriding the scheme to mean/last
without its own half-life inherited that value, and post-merge
validation rejected the pair the server itself had assembled. Zero the
inherited half-life when the overridden scheme is not decayed_mean; a
request that explicitly pairs a half-life with a non-decayed scheme
still 400s.
Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>
* fix embedding pooling validation and router bounds
Declare backend embedding layouts and reject incompatible pooling modes. Reset local-store dimensions after a full clear, validate KNN thresholds, and add real backend and store integration coverage.
Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>
* ci: run local-store integration tests
Build and install the local-store backend in the Linux test job, then run the existing store integration suite so new specs are discovered automatically.
Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>
---------
Signed-off-by: Richard Palethorpe <io@richiejp.com>
9.0 KiB
+++ disableToc = false title = "Embeddings" weight = 60 url = "/features/embeddings/" +++
LocalAI supports generating embeddings for text or list of tokens.
For face embeddings specifically, see the Face Recognition feature - it produces 512-d L2-normalized vectors tuned for face similarity.
For the API documentation you can refer to the OpenAI docs: https://platform.openai.com/docs/api-reference/embeddings
Model compatibility
The embedding endpoint is compatible with llama.cpp models, bert.cpp models and sentence-transformers models available in huggingface.
Using Gallery Models
LocalAI provides a model gallery with pre-configured embedding models. To use a gallery model:
- Ensure the model is available in the gallery (check [Model Gallery]({{%relref "features/model-gallery" %}}))
- Use the model name directly in your API calls
Example gallery models:
qwen3-embedding-4b- Qwen3 Embedding 4B modelqwen3-embedding-8b- Qwen3 Embedding 8B modelqwen3-embedding-0.6b- Qwen3 Embedding 0.6B model
Example: Using Qwen3-Embedding-4B from Gallery
curl http://localhost:8080/embeddings -X POST -H "Content-Type: application/json" -d '{
"input": "My text to embed",
"model": "qwen3-embedding-4b",
"dimensions": 2560
}'
Manual Setup
Create a YAML config file in the models directory. Specify the backend and the model file.
name: text-embedding-ada-002 # The model name used in the API
parameters:
model: <model_file>
backend: "<backend>"
embeddings: true
Huggingface embeddings
To use sentence-transformers and models in huggingface you can use the sentencetransformers embedding backend.
name: text-embedding-ada-002
backend: sentencetransformers
embeddings: true
parameters:
model: all-MiniLM-L6-v2
The sentencetransformers backend uses Python sentence-transformers. For a list of all pre-trained models available see here: https://github.com/UKPLab/sentence-transformers#pre-trained-models
{{% notice note %}}
- The
sentencetransformersbackend is an optional backend of LocalAI and uses Python. If you are runningLocalAIfrom the containers you are good to go and should be already configured for use. - For local execution, you also have to specify the extra backend in the
EXTERNAL_GRPC_BACKENDSenvironment variable.- Example:
EXTERNAL_GRPC_BACKENDS="sentencetransformers:/path/to/LocalAI/backend/python/sentencetransformers/sentencetransformers.py"
- Example:
- The
sentencetransformersbackend does support only embeddings of text, and not of tokens. If you need to embed tokens you can use thebertbackend orllama.cpp. - No models are required to be downloaded before using the
sentencetransformersbackend. The models will be downloaded automatically the first time the API is used.
{{% /notice %}}
Llama.cpp embeddings
Embeddings with llama.cpp are supported with the llama-cpp backend, it needs to be enabled with embeddings set to true.
name: my-awesome-model
backend: llama-cpp
embeddings: true
parameters:
model: ggml-file.bin
Then you can use the API to generate embeddings:
curl http://localhost:8080/embeddings -X POST -H "Content-Type: application/json" -d '{
"input": "My text",
"model": "my-awesome-model"
}' | jq "."
Embedding chat conversations and Go-side pooling
/v1/embeddings also accepts a chat conversation via messages (a LocalAI
extension), plus a per-request pooling scheme that LocalAI applies itself to
the backend's raw per-token vectors:
curl http://localhost:8080/v1/embeddings -X POST -H "Content-Type: application/json" -d '{
"model": "my-awesome-model",
"messages": [
{"role": "system", "content": "You are a support agent."},
{"role": "user", "content": "My invoice is wrong."}
],
"pooling": "decayed_mean",
"pooling_half_life_tokens": 256
}'
- One conversation per request; the response is the standard OpenAI embeddings
shape with a single
data[0].embeddingitem. inputandmessagesare mutually exclusive (400 otherwise); an unknownpoolingvalue is also a 400.- If the model config carries both
template.chatandtemplate.chat_message, the conversation renders exactly like a chat prompt, so the embedding matches what a chat model would actually see. Otherwise a frozen role-prefixed fallback is used (<role>: <content>lines joined by newlines, empty-content messages skipped). Non-text content parts (images, audio, video) are ignored.
pooling selects how the per-token vectors are reduced to one embedding:
| Value | Meaning |
|---|---|
(empty) / backend |
The backend pools by itself — the default, today's exact behavior. |
mean |
Average of all token vectors. |
last |
The last token's vector. |
decayed_mean |
Recency-weighted mean: token i of T weighs 2^(-(T-1-i)/H) with half-life H = pooling_half_life_tokens (default 256) — recent turns dominate without erasing earlier context. |
Go-side schemes need raw per-token vectors from the backend. Each backend
declares whether an embedding result is final or per-token; LocalAI rejects a
Go-side scheme for a final vector and rejects backend pass-through for a
per-token matrix instead of guessing from its shape. Older backends that do
not declare a layout remain compatible with backend pooling only.
llama.cpp chooses this layout when the model is loaded. LocalAI automatically
adds the pooling:none backend option when a llama.cpp model sets a Go-side
parameters.pooling scheme. That raw-loaded instance can switch between
mean, last, and decayed_mean per request, but it cannot switch back to
backend pooling without reloading. Conversely, a backend-pooled llama.cpp
instance rejects per-request Go pooling. Other backends may support Go-side
pooling when they explicitly return per-token vectors.
After Go-side pooling, the vector is normalized with llama.cpp's
embd_normalize rule (default L2; configurable through
options: ["embd_normalize:<n>"]).
Model-level defaults live under parameters::
name: conversation-embedder
backend: llama-cpp
embeddings: true
parameters:
model: ggml-file.bin
pooling: decayed_mean
pooling_half_life_tokens: 256
Go-side pooling requires an up-to-date backend that reports its embedding layout. A legacy backend fails closed for Go-side schemes with an error asking you to rebuild or update it.
💡 Examples
- Example that uses LLamaIndex and LocalAI as embedding: here.
⚠️ Common Issues and Troubleshooting
Issue: Embedding model not returning correct results
Symptoms:
- Model returns empty or incorrect embeddings
- API returns errors when calling embedding endpoint
Common Causes:
-
Incorrect model filename: Ensure you're using the correct filename from the gallery or your model file location.
- Gallery models use specific filenames (e.g.,
Qwen3-Embedding-4B-Q4_K_M.gguf) - Check the [Model Gallery]({{%relref "features/model-gallery" %}}) for correct filenames
- Gallery models use specific filenames (e.g.,
-
Context size mismatch: Ensure your
context_sizesetting doesn't exceed the model's maximum context length.- Qwen3-Embedding-4B: max 32k (32768) context
- Qwen3-Embedding-8B: max 32k (32768) context
- Qwen3-Embedding-0.6B: max 32k (32768) context
-
Missing
embeddings: trueflag: The model configuration must haveembeddings: trueset.
Correct Configuration Example:
name: qwen3-embedding-4b
backend: llama-cpp
embeddings: true
context_size: 32768
parameters:
model: Qwen3-Embedding-4B-Q4_K_M.gguf
Issue: Dimension mismatch
Symptoms:
- Returned embedding dimensions don't match expected dimensions
Solution:
- Use the
dimensionsparameter in your API request to specify the output dimension - Qwen3-Embedding models support dimensions from 32 to 2560 (4B) or 4096 (8B)
curl http://localhost:8080/embeddings -X POST -H "Content-Type: application/json" -d '{
"input": "My text",
"model": "qwen3-embedding-4b",
"dimensions": 1024
}'
Issue: Model not found
Symptoms:
- API returns 404 or "model not found" error
Solution:
- Ensure the model is properly configured in the models directory
- Check that the model name in your API request matches the
namefield in the configuration - For gallery models, ensure the gallery is properly loaded
Qwen3 Embedding Models Specifics
The Qwen3 Embedding series models have these characteristics:
| Model | Parameters | Max Context | Max Dimensions | Supported Languages |
|---|---|---|---|---|
| qwen3-embedding-0.6b | 0.6B | 32k | 1024 | 100+ |
| qwen3-embedding-4b | 4B | 32k | 2560 | 100+ |
| qwen3-embedding-8b | 8B | 32k | 4096 | 100+ |
All models support:
- User-defined output dimensions (32 to max dimensions)
- Multilingual text embedding (100+ languages)
- Instruction-tuned embedding with custom instructions