mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-13 06:45:26 -04:00
* 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>
297 lines
10 KiB
Go
297 lines
10 KiB
Go
package backend
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/trace"
|
|
|
|
"github.com/mudler/LocalAI/pkg/grpc"
|
|
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
|
"github.com/mudler/LocalAI/pkg/model"
|
|
"github.com/mudler/LocalAI/pkg/store"
|
|
)
|
|
|
|
// VectorStore is the narrowed KNN store used by the router's embedding
|
|
// cache and the KNN classifier. Search returns the top-1 match (cosine
|
|
// similarity in [-1, 1]) and the serialised payload, or ok=false on a
|
|
// clean miss. SearchK returns up to k nearest neighbours ordered by
|
|
// descending similarity; an empty slice is a clean miss.
|
|
type VectorStore interface {
|
|
Search(ctx context.Context, vec []float32) (similarity float64, payload []byte, ok bool, err error)
|
|
SearchK(ctx context.Context, vec []float32, k int) ([]Neighbor, error)
|
|
Insert(ctx context.Context, vec []float32, payload []byte) error
|
|
}
|
|
|
|
// Neighbor is one SearchK result — the stored payload and its cosine
|
|
// similarity to the query vector.
|
|
type Neighbor struct {
|
|
Similarity float64
|
|
Payload []byte
|
|
}
|
|
|
|
// NewVectorStore returns a VectorStore backed by the local-store
|
|
// gRPC backend, namespaced by storeName so two routers don't collide.
|
|
// cl resolves the per-store model config (backend + options); it may be nil,
|
|
// in which case the store falls back to the default backend and its built-in
|
|
// defaults.
|
|
func NewVectorStore(loader *model.ModelLoader, appConfig *config.ApplicationConfig, cl *config.ModelConfigLoader, storeName string) VectorStore {
|
|
if storeName == "" {
|
|
return nil
|
|
}
|
|
return &localVectorStore{loader: loader, appConfig: appConfig, cl: cl, storeName: storeName}
|
|
}
|
|
|
|
type localVectorStore struct {
|
|
loader *model.ModelLoader
|
|
appConfig *config.ApplicationConfig
|
|
cl *config.ModelConfigLoader
|
|
storeName string
|
|
}
|
|
|
|
func (s *localVectorStore) backend(_ context.Context) (grpc.Backend, error) {
|
|
return StoreBackend(s.loader, s.appConfig, s.cl, s.storeName, "")
|
|
}
|
|
|
|
// Search is the top-1 special case of SearchK; delegating keeps the
|
|
// backend-load/Find/trace plumbing in one place (SearchK records the
|
|
// identically-shaped trace, so /api/backend-traces sees no difference).
|
|
func (s *localVectorStore) Search(ctx context.Context, vec []float32) (float64, []byte, bool, error) {
|
|
neighbors, err := s.SearchK(ctx, vec, 1)
|
|
if err != nil || len(neighbors) == 0 {
|
|
return 0, nil, false, err
|
|
}
|
|
return neighbors[0].Similarity, neighbors[0].Payload, true, nil
|
|
}
|
|
|
|
func (s *localVectorStore) SearchK(ctx context.Context, vec []float32, k int) (neighbors []Neighbor, err error) {
|
|
outcome := "hit"
|
|
sim := 0.0
|
|
be, berr := s.backend(ctx)
|
|
if berr != nil {
|
|
outcome = "backend_load_error"
|
|
err = fmt.Errorf("vector store load: %w", berr)
|
|
s.recordTrace("", time.Now(), "search", len(vec), 0, outcome, err)
|
|
return nil, err
|
|
}
|
|
release, err := AcquireGlobalBackendSlot()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer release()
|
|
start := time.Now()
|
|
traceID := s.beginTrace(start, "search")
|
|
defer func() {
|
|
s.recordTrace(traceID, start, "search", len(vec), sim, outcome, err)
|
|
}()
|
|
_, values, similarities, ferr := store.Find(ctx, be, vec, k)
|
|
if ferr != nil {
|
|
outcome = "find_error"
|
|
return nil, fmt.Errorf("vector store find: %w", ferr)
|
|
}
|
|
if len(values) == 0 {
|
|
outcome = "miss"
|
|
return nil, nil
|
|
}
|
|
neighbors = make([]Neighbor, 0, len(values))
|
|
for i, v := range values {
|
|
neighbors = append(neighbors, Neighbor{Similarity: float64(similarities[i]), Payload: v})
|
|
}
|
|
sim = neighbors[0].Similarity
|
|
return neighbors, nil
|
|
}
|
|
|
|
func (s *localVectorStore) Insert(ctx context.Context, vec []float32, payload []byte) (err error) {
|
|
outcome := "ok"
|
|
be, berr := s.backend(ctx)
|
|
if berr != nil {
|
|
outcome = "backend_load_error"
|
|
err = fmt.Errorf("vector store load: %w", berr)
|
|
s.recordTrace("", time.Now(), "insert", len(vec), 0, outcome, err)
|
|
return err
|
|
}
|
|
release, err := AcquireGlobalBackendSlot()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer release()
|
|
start := time.Now()
|
|
traceID := s.beginTrace(start, "insert")
|
|
defer func() {
|
|
s.recordTrace(traceID, start, "insert", len(vec), 0, outcome, err)
|
|
}()
|
|
if serr := store.SetSingle(ctx, be, vec, payload); serr != nil {
|
|
outcome = "insert_error"
|
|
return serr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// InsertBatch upserts many vectors in one gRPC round-trip. Not part of
|
|
// the VectorStore interface — the corpus manager type-asserts for it
|
|
// and falls back to per-entry Insert on stores that lack it.
|
|
func (s *localVectorStore) InsertBatch(ctx context.Context, vecs [][]float32, payloads [][]byte) (err error) {
|
|
outcome := "ok"
|
|
dim := 0
|
|
if len(vecs) > 0 {
|
|
dim = len(vecs[0])
|
|
}
|
|
be, berr := s.backend(ctx)
|
|
if berr != nil {
|
|
outcome = "backend_load_error"
|
|
err = fmt.Errorf("vector store load: %w", berr)
|
|
s.recordTrace("", time.Now(), "insert_batch", dim, 0, outcome, err)
|
|
return err
|
|
}
|
|
release, err := AcquireGlobalBackendSlot()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer release()
|
|
start := time.Now()
|
|
traceID := s.beginTrace(start, "insert_batch")
|
|
defer func() {
|
|
s.recordTrace(traceID, start, "insert_batch", dim, 0, outcome, err)
|
|
}()
|
|
if serr := store.SetCols(ctx, be, vecs, payloads); serr != nil {
|
|
outcome = "insert_error"
|
|
return serr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Delete removes vectors by key. Optional capability like InsertBatch;
|
|
// used by the corpus manager's Clear so a wiped corpus also leaves the
|
|
// live index.
|
|
func (s *localVectorStore) Delete(ctx context.Context, vecs [][]float32) (err error) {
|
|
outcome := "ok"
|
|
dim := 0
|
|
if len(vecs) > 0 {
|
|
dim = len(vecs[0])
|
|
}
|
|
be, berr := s.backend(ctx)
|
|
if berr != nil {
|
|
outcome = "backend_load_error"
|
|
err = fmt.Errorf("vector store load: %w", berr)
|
|
s.recordTrace("", time.Now(), "delete", dim, 0, outcome, err)
|
|
return err
|
|
}
|
|
release, err := AcquireGlobalBackendSlot()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer release()
|
|
start := time.Now()
|
|
traceID := s.beginTrace(start, "delete")
|
|
defer func() {
|
|
s.recordTrace(traceID, start, "delete", dim, 0, outcome, err)
|
|
}()
|
|
if serr := store.DeleteCols(ctx, be, vecs); serr != nil {
|
|
outcome = "delete_error"
|
|
return serr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// recordTrace surfaces vector-store calls in /api/backend-traces, including
|
|
// the backend-load-failure path that otherwise vanishes into an xlog.Warn.
|
|
// modelName uses the store namespace (e.g. "router-cache-smart-router") so
|
|
// admins can tell which router's cache misbehaved; the backend is always
|
|
// "local-store" and can't disambiguate.
|
|
func (s *localVectorStore) beginTrace(start time.Time, op string) string {
|
|
if s.appConfig == nil || !s.appConfig.EnableTracing {
|
|
return ""
|
|
}
|
|
trace.InitBackendTracingIfEnabled(s.appConfig.TracingMaxItems, s.appConfig.TracingMaxBodyBytes)
|
|
return trace.BeginBackendTrace(trace.BackendTrace{Timestamp: start, Type: trace.BackendTraceVectorStore, ModelName: s.storeName, Backend: model.LocalStoreBackend, Summary: op})
|
|
}
|
|
|
|
func (s *localVectorStore) recordTrace(traceID string, start time.Time, op string, vecDim int, sim float64, outcome string, err error) {
|
|
if s.appConfig == nil || !s.appConfig.EnableTracing {
|
|
return
|
|
}
|
|
trace.InitBackendTracingIfEnabled(s.appConfig.TracingMaxItems, s.appConfig.TracingMaxBodyBytes)
|
|
errStr := ""
|
|
if err != nil {
|
|
errStr = err.Error()
|
|
}
|
|
summary := op + " " + outcome
|
|
if op == "search" && outcome == "hit" {
|
|
summary = fmt.Sprintf("search hit (sim=%.3f)", sim)
|
|
}
|
|
data := map[string]any{
|
|
"op": op,
|
|
"outcome": outcome,
|
|
"vector_dim": vecDim,
|
|
}
|
|
// Only include similarity for a real neighbor — miss/empty_store would
|
|
// otherwise render "similarity: 0" and read as a measured value.
|
|
if op == "search" && outcome == "hit" {
|
|
data["similarity"] = sim
|
|
}
|
|
trace.RecordBackendTrace(trace.BackendTrace{
|
|
ID: traceID,
|
|
Timestamp: start,
|
|
Duration: time.Since(start),
|
|
Type: trace.BackendTraceVectorStore,
|
|
ModelName: s.storeName,
|
|
Backend: model.LocalStoreBackend,
|
|
Summary: summary,
|
|
Error: errStr,
|
|
Data: data,
|
|
})
|
|
}
|
|
|
|
func StoreBackend(sl *model.ModelLoader, appConfig *config.ApplicationConfig, cl *config.ModelConfigLoader, storeName string, backend string) (grpc.Backend, error) {
|
|
// Resolve the per-store model config (keyed by the store namespace, which
|
|
// is the model ID for a store). This is the LocalAI-native config surface:
|
|
// a store's backend selection and its backend-specific settings live in a
|
|
// model YAML's `backend:` and `options:` fields, so different stores can
|
|
// point at different servers/indexes. When no config exists for the store,
|
|
// we fall back to the default backend and let the backend apply its own
|
|
// built-in defaults — preserving the zero-config experience.
|
|
var loadOpts []string
|
|
if cl != nil {
|
|
if cfg, ok := cl.GetModelConfig(storeName); ok {
|
|
if backend == "" {
|
|
backend = cfg.Backend
|
|
}
|
|
loadOpts = cfg.Options
|
|
}
|
|
}
|
|
|
|
if backend == "" {
|
|
backend = model.LocalStoreBackend
|
|
}
|
|
// ModelLoader caches backend processes by `modelID`, not by the `model`
|
|
// passed via WithModel. Without a distinct modelID, every StoreBackend
|
|
// call collapses to the same `modelID=""` cache slot — face (512-D) and
|
|
// voice (192-D) biometrics would then share the same local-store process
|
|
// and the second enrollment would fail with
|
|
// Try to add key with length N when existing length is M
|
|
// Use the store namespace as modelID so each namespace gets its own
|
|
// process instance and its own in-memory Store{}.
|
|
//
|
|
// The model name sent over gRPC carries store.NamespacePrefix so the
|
|
// backend can tell a genuine store load from the greedy autoload
|
|
// probing it with LLM model names; local-store refuses names without
|
|
// the prefix (core and backend ship from the same release, so the
|
|
// convention upgrades in lockstep).
|
|
sc := []model.Option{
|
|
model.WithBackendString(backend),
|
|
model.WithModelID(storeName),
|
|
model.WithModel(store.NamespacePrefix + storeName),
|
|
}
|
|
|
|
// Thread the store's configured options through to the backend's LoadModel
|
|
// via ModelOptions.Options (field 62). The loader clones these opts and
|
|
// overrides only Model/ModelFile, so the namespace set above is preserved.
|
|
if len(loadOpts) > 0 {
|
|
sc = append(sc, model.WithLoadGRPCLoadModelOpts(&pb.ModelOptions{Options: loadOpts}))
|
|
}
|
|
|
|
return sl.Load(sc...)
|
|
}
|