mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-15 12:18:58 -04:00
* fix(router): score classifier production-readiness Conversation trimming runs through the classifier model's chat template and trims by exact token count, sized to the model's n_batch which is now scaled to context so long probes can't crash the backend. Missing chat_message templates are a hard error at router build time. Router- facing factories (Embedder/Scorer/Reranker/TokenCounter) re-resolve ModelConfig per call so a model installed post-startup doesn't bind a stub Backend="" config and silently fall into the loader's auto- iterate path. New 'vector_store' backend trace recorded inside localVectorStore on every Search/Insert — including the backend-load-failure path that previously vanished into an xlog.Warn — with outcome tagging (hit/miss/empty_store/backend_load_error/find_error/insert_error/ok). Companion cleanup drops misleading similarity:0 and input_tokens_count:0 from non-hit and text-mode traces. Gallery local-store-development aliases to 'local-store' so the master image satisfies pkg/model.LocalStoreBackend lookups from the embedding cache. Misc: llama-cpp TokenizeString reads the correct 'prompt' JSON key (the original bug); ModelTokenize nil-guard; non-fatal mitm proxy startup; PII 'route_local' renamed to 'allow' with docs/UI in sync; model-editor footer no longer eats the edit area on small screens; several config-editor template/dropdown/section fixes. Tests: e2e router specs (casual/code-hint + long-conversation trim), vector_store trace specs, lazy-factory specs, gallery dev-alias resolution, Playwright trace badge + scroll regression. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(backend): auto-size batch to context for embedding and rerank models Embedding and rerank models pool over the whole input in a single physical batch (n_ubatch). With batch left at the 512 default, the backend rejects longer inputs with "input is too large to process", silently capping a large-context embedder (e.g. 8k/32k) at 512 tokens. Size n_batch to the context for these single-pass usecases, mirroring the existing FLAG_SCORE behaviour; an explicit batch: still wins. Extracts EffectiveContextSize/EffectiveBatchSize from grpcModelOpts so the effective decode window has one home for other callers to reuse. Adds an e2e-aio regression test that embeds a >512-token input. The AIO embedding model is switched to nomic-embed-text-v1.5 (2048 context) because the previous granite model was capped at 512 tokens and could not exercise the larger batch. Assisted-by: claude-code:claude-opus-4-8 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(gallery): raise arch-router scoring output cap via parallel:64 Scoring decodes the whole prompt+candidate in a single llama_decode and reads one logit row per candidate token. The vendored llama.cpp server caps causal output rows at n_parallel, so the default of 1 aborts with GGML_ASSERT(n_outputs_max <= cparams.n_outputs_max) on multi-token route labels. Set options: [parallel:64] on both arch-router quant entries to lift the cap; kv_unified (the grpc-server default) keeps the full context per sequence, so this does not split the KV cache. Assisted-by: claude-code:claude-opus-4-8 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> --------- Signed-off-by: Richard Palethorpe <io@richiejp.com>
120 lines
4.1 KiB
Go
120 lines
4.1 KiB
Go
package router
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/backend"
|
|
)
|
|
|
|
// RerankClassifier scores each policy description against the prompt
|
|
// via a reranker model and activates the labels whose relevance clears
|
|
// an absolute threshold. Robust when policy labels are abstract
|
|
// relative to user prompts — the description is the natural English
|
|
// the reranker was trained on.
|
|
type RerankClassifier struct {
|
|
reranker backend.Reranker
|
|
activationThreshold float64
|
|
// labels[i] is the policy label corresponding to documents[i] —
|
|
// both are scattered indices into the reranker's input order.
|
|
// Materialised once at construction so Classify never allocates
|
|
// them per call.
|
|
labels []string
|
|
documents []string
|
|
cache *labelSetCache
|
|
|
|
// budget trims the query to the reranker model's context minus the
|
|
// longest policy description (paired with the query per rerank call);
|
|
// nil reranks Probe.Prompt as built by the caller.
|
|
budget *lazyBudget
|
|
}
|
|
|
|
// defaultRerankActivationThreshold is the relevance floor a label
|
|
// must clear to be considered active. Reranker scores live in [0, 1]
|
|
// for cross-encoder / ColBERT heads; 0.5 picks "more positive than
|
|
// not on this label."
|
|
const defaultRerankActivationThreshold = 0.5
|
|
|
|
func NewRerankClassifier(policies []ScorePolicy, reranker backend.Reranker, cacheCap int, activationThreshold float64) *RerankClassifier {
|
|
if len(policies) == 0 {
|
|
panic("router/rerank: at least one policy is required")
|
|
}
|
|
if reranker == nil {
|
|
panic("router/rerank: reranker is required (configure router.classifier_model)")
|
|
}
|
|
for _, p := range policies {
|
|
if p.Label == "" {
|
|
panic("router/rerank: policy has empty label")
|
|
}
|
|
if p.Description == "" {
|
|
panic(fmt.Sprintf("router/rerank: policy %q has no description", p.Label))
|
|
}
|
|
}
|
|
if activationThreshold <= 0 {
|
|
activationThreshold = defaultRerankActivationThreshold
|
|
}
|
|
labels := make([]string, len(policies))
|
|
docs := make([]string, len(policies))
|
|
for i, p := range policies {
|
|
labels[i] = p.Label
|
|
docs[i] = p.Description
|
|
}
|
|
return &RerankClassifier{
|
|
reranker: reranker,
|
|
activationThreshold: activationThreshold,
|
|
labels: labels,
|
|
documents: docs,
|
|
cache: newLabelSetCache(cacheCap),
|
|
}
|
|
}
|
|
|
|
// WithTokenTrim wires the reranker model's own tokenizer and context so the
|
|
// query is trimmed to the most recent turns that fit alongside the longest
|
|
// policy description. nil tokenizer / non-positive context leaves trimming
|
|
// off. Returns the receiver for chaining at construction.
|
|
func (c *RerankClassifier) WithTokenTrim(tokenize func(string) (int, error), maxContextTokens int) *RerankClassifier {
|
|
c.budget = &lazyBudget{tokenize: tokenize, maxContext: maxContextTokens, extras: c.documents}
|
|
return c
|
|
}
|
|
|
|
func (c *RerankClassifier) Name() string { return ClassifierColbert }
|
|
|
|
func (c *RerankClassifier) Classify(ctx context.Context, p Probe) (Decision, error) {
|
|
start := time.Now()
|
|
query := trimmedProbeText(p, c.budget, identityRender)
|
|
key := cacheKey(query)
|
|
if hit, ok := c.cache.get(key); ok {
|
|
return Decision{Labels: hit, Score: 1.0, Latency: time.Since(start)}, nil
|
|
}
|
|
|
|
results, err := c.reranker.Rerank(ctx, query, c.documents)
|
|
if err != nil {
|
|
return errDecision(start, fmt.Errorf("rerank classify: %w", err))
|
|
}
|
|
|
|
// The reranker may return fewer-than-N entries (top_n filtering)
|
|
// or reorder them by score. Scatter back into input order so
|
|
// threshold + argmax don't depend on result ordering.
|
|
scores := make([]float64, len(c.labels))
|
|
for _, r := range results {
|
|
if r.Index < 0 || r.Index >= len(scores) {
|
|
continue
|
|
}
|
|
scores[r.Index] = float64(r.RelevanceScore)
|
|
}
|
|
|
|
active, bestIdx := selectActive(scores, c.labels, c.activationThreshold)
|
|
c.cache.put(key, active)
|
|
labelScores := NewLabelScores(c.labels, scores)
|
|
return Decision{
|
|
Labels: active,
|
|
Score: scores[bestIdx],
|
|
Latency: time.Since(start),
|
|
LabelScores: labelScores,
|
|
ActivationThreshold: c.activationThreshold,
|
|
}, nil
|
|
}
|
|
|
|
func (c *RerankClassifier) CacheLen() int { return c.cache.len() }
|