Files
LocalAI/core/services/routing/router/rerank.go
Richard Palethorpe 085fc53bbc fix(router): production-ready request router + auto-size batch for embedding/rerank (#10104)
* 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>
2026-06-12 16:21:15 +02:00

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() }