mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 18:09:05 -04:00
* feat: add Valkey Search vector store backend Add a new built-in Go gRPC store backend 'valkey-store' that implements the four Stores RPCs (Set/Get/Delete/Find) against the Valkey Search module (FT.*) using the pure-Go github.com/valkey-io/valkey-go client. It is selected via the existing per-request 'backend' field on /stores, so there is no proto or HTTP API change, and it mirrors the in-memory local-store while adding persistence across restarts and opt-in HNSW. Each vector is a Valkey HASH keyed by hex(little-endian float32); the index is created lazily on first Set (FLAT+COSINE by default), cosine similarity is derived as 1-distance, and namespaces get a collision-resistant token. Includes unit tests (valkey-go mock) and env-gated integration tests against valkey/valkey-bundle, plus build/matrix/gallery wiring and docs. Assisted-by: Kiro:claude-opus-4.8 golangci-lint Signed-off-by: Daria Korenieva <daric2612@gmail.com> * Address review feedback: recover persisted index dimension, harden Find - Load now recovers the persisted vector DIM from FT.INFO (not just index existence), so a post-restart Set/Find validates against the real DIM instead of silently re-learning a wrong one and dropping mismatched vectors from the index. This also restores Find's dimension check after a restart. - StoresFind treats a dropped/missing index as an empty store (empty result, no error) and clears the stale indexCreated flag, matching local-store's empty-store behaviour. - StoresSet reuses checkDims for its per-key length check so the four RPCs share one dimension-guard implementation. - Add unit tests for FT.INFO dimension recovery, loadIndexState, and the dropped-index Find path. Assisted-by: Kiro:claude-opus-4.8 Signed-off-by: Daria Korenieva <daric2612@gmail.com> * Address review feedback: TLS ServerName/CA, Find nil-check, config fail-fast Addresses external review comments on the valkey-store backend: - StoresFind now rejects a nil/empty query Key before dereferencing it, so a malformed gRPC request can no longer panic the backend. - TLS: derive ServerName (SNI) from the VALKEY_ADDR host so certificate verification works for IP-addressed endpoints, and add VALKEY_TLS_CA_CERT (custom CA bundle) and VALKEY_TLS_SKIP_VERIFY (testing-only) knobs. - Config integer parsing now fails fast on a malformed value (e.g. VALKEY_HNSW_M=1x6) instead of silently defaulting, matching the fail-fast behaviour of the index-algo/distance-metric validation. - Add VALKEY_DB (SELECT n) support for logical-DB isolation. - Cap the human-readable part of a namespace token at 64 chars so a very long model name cannot produce an unbounded key prefix / index name (the appended short hash keeps distinct namespaces collision-free). - Document the KNN-query injection-safety invariant (fields are constants) and why StoresGet uses a single aggregate DoMulti deadline for reads. - Unit tests for the Find nil/empty-key guard, fail-fast HNSW parsing, and VALKEY_DB parsing/validation; docs + .env updated for the new vars. Assisted-by: Kiro:claude-opus-4.8 golangci-lint Signed-off-by: Daria Korenieva <daric2612@gmail.com> * Address review feedback: configure valkey-store via model config richiejp asked that the valkey-store backend take its configuration from a model config rather than process-wide VALKEY_* environment variables, so multiple stores can each have their own Valkey config within one LocalAI process. This removes every env access from the backend and routes config through the model-config seam every other backend uses. - config.go: loadConfig(opts *pb.ModelOptions) now parses the model config `options:` list (key:value strings, split on the first ':') instead of os.Getenv. Option keys mirror the old VALKEY_* names without the prefix (addr, index_algo, distance_metric, ...). Defaults, fail-fast validation and the mandatory client name are unchanged. - store.go: Load threads opts into loadConfig; TLS comments/errors renamed off the VALKEY_* names. - core/backend/stores.go: StoreBackend and NewVectorStore take a *config.ModelConfigLoader, resolve the per-store ModelConfig by store name, and pass its Options (and Backend when unset) to the backend via WithLoadGRPCLoadModelOpts. No config -> default backend + built-in defaults, preserving the zero-config experience. - Endpoints/routes/application: thread the config loader to StoreBackend. - Unit + integration tests: configure via options; the integration test passes addr through the model-config path (VALKEY_ADDR is now only the test harness locating the server). - docs + .env: document the model-config options, drop the env var table. Assisted-by: Kiro:claude-opus-4.8 Signed-off-by: Daria Korenieva <daric2612@gmail.com> * Remove valkey-store informational comment from .env The backend is configured via model config, not env vars — the comment was unnecessary noise in .env. The configuration is already documented in docs/content/features/stores.md. Signed-off-by: Daria Korenieva <daric2612@gmail.com> * feat(valkey-store): gate Load on NamespacePrefix to refuse autoload probing Mirror local-store's pattern: reject model names without store.NamespacePrefix so the model loader's greedy autoload probe cannot bind an arbitrary model name to the vector store backend (the #9287 failure mode). Also adds unit tests for the gate covering: prefixed namespace, prefix alone, unprefixed model name, empty model, and nil opts. Signed-off-by: Daria Korenieva <daric2612@gmail.com> * feat(valkey-store): add username_env/password_env credential indirection Add support for resolving Valkey credentials from environment variables named in the model config, mirroring cloud-proxy's api_key_env pattern. This keeps secrets out of model YAML files and lets distinct store configs each reference their own credentials. Options: username_env / password_env name the env var holding the value. The direct username / password options still work and take precedence when both are set (backward compatible). Includes 5 unit tests and updated stores.md documentation. Signed-off-by: Daria Korenieva <daric2612@gmail.com> * fix: correct rebase artifacts in backend-matrix.yml and Makefile Fix two issues introduced by the conflict-resolution script during the rebase onto master: 1. .github/backend-matrix.yml: valkey-store entries were merged INTO the cloud-proxy entries (duplicate keys in same YAML map items) instead of being separate list items. This broke cloud-proxy Linux builds and the cloud-proxy darwin entry lost its build-type/lang. Fixed by making them standalone entries and restoring cloud-proxy exactly as on master. 2. Makefile: duplicated .NOTPARALLEL and docker-build-backends lines. Collapsed to single lines that are master's current content plus the valkey-store additions. Also adds the three optional pickups from #10801: - /valkey-store in .gitignore (the built binary) - valkey-store row in docs/content/reference/compatibility-table.md - valkey-store line in backend/README.md Signed-off-by: Daria Korenieva <daric2612@gmail.com> --------- Signed-off-by: Daria Korenieva <daric2612@gmail.com> Co-authored-by: Daria Korenieva <daric2612@gmail.com>
179 lines
6.3 KiB
Go
179 lines
6.3 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. Search returns the top-1 match (cosine similarity in [-1, 1])
|
|
// and the serialised payload, or ok=false on a clean miss.
|
|
type VectorStore interface {
|
|
Search(ctx context.Context, vec []float32) (similarity float64, payload []byte, ok bool, err error)
|
|
Insert(ctx context.Context, vec []float32, payload []byte) error
|
|
}
|
|
|
|
// 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, "")
|
|
}
|
|
|
|
func (s *localVectorStore) Search(ctx context.Context, vec []float32) (sim float64, payload []byte, ok bool, err error) {
|
|
start := time.Now()
|
|
outcome := "hit"
|
|
defer func() {
|
|
s.recordTrace(start, "search", len(vec), sim, outcome, err)
|
|
}()
|
|
be, berr := s.backend(ctx)
|
|
if berr != nil {
|
|
outcome = "backend_load_error"
|
|
return 0, nil, false, fmt.Errorf("vector store load: %w", berr)
|
|
}
|
|
_, values, similarities, ferr := store.Find(ctx, be, vec, 1)
|
|
if ferr != nil {
|
|
outcome = "find_error"
|
|
return 0, nil, false, fmt.Errorf("vector store find: %w", ferr)
|
|
}
|
|
if len(values) == 0 || len(similarities) == 0 {
|
|
outcome = "miss"
|
|
return 0, nil, false, nil
|
|
}
|
|
return float64(similarities[0]), values[0], true, nil
|
|
}
|
|
|
|
func (s *localVectorStore) Insert(ctx context.Context, vec []float32, payload []byte) (err error) {
|
|
start := time.Now()
|
|
outcome := "ok"
|
|
defer func() {
|
|
s.recordTrace(start, "insert", len(vec), 0, outcome, err)
|
|
}()
|
|
be, berr := s.backend(ctx)
|
|
if berr != nil {
|
|
outcome = "backend_load_error"
|
|
return fmt.Errorf("vector store load: %w", berr)
|
|
}
|
|
if serr := store.SetSingle(ctx, be, vec, payload); serr != nil {
|
|
outcome = "insert_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) recordTrace(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{
|
|
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...)
|
|
}
|