Files
LocalAI/backend/go/valkey-store/encoding.go
localai-org-maint-bot 9bfd71387b feat(stores): add Valkey Search vector store backend (#11196)
* 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>
2026-07-29 20:12:29 +02:00

81 lines
3.1 KiB
Go

package main
// Vector⇄key encoding: the "vector IS the key" resolution.
//
// local-store keys entries *by* the vector itself (a []float32). Valkey hashes
// are keyed by strings, so we synthesise a deterministic, lossless key:
//
// key = prefix + hex(little-endian float32 bytes of the vector)
//
// The same vector always produces the same bytes, so HSET is an upsert and
// HGET/DEL are exact matches — and the encoding is reversible, so we can hand
// the original []float32 back on Get/Find.
//
// Divergence from local-store (documented and tested): local-store compares
// keys with slices.Compare, which treats -0.0 == +0.0 and orders NaN, so those
// collapse to the same logical key. Byte-encoding makes -0.0 and +0.0 (and any
// distinct NaN bit-pattern) *distinct* keys. We accept this on purpose: a
// lossless, deterministic, exact round-trip is more valuable for a persistent
// store than reproducing local-store's float-equality quirk, and callers never
// rely on -0.0/+0.0 aliasing.
import (
"encoding/binary"
"encoding/hex"
"fmt"
"math"
"strings"
)
// _float32Bytes is the wire width of a single FLOAT32 component.
const _float32Bytes = 4
// vecToBytes encodes a vector as little-endian float32 bytes. This is byte-for
// -byte identical to valkey.VectorString32, so the value we store in the hash
// `vec` field and the bytes we hash into the key share one encoding.
func vecToBytes(v []float32) []byte {
b := make([]byte, len(v)*_float32Bytes)
for i, e := range v {
off := i * _float32Bytes
binary.LittleEndian.PutUint32(b[off:off+_float32Bytes], math.Float32bits(e))
}
return b
}
// bytesToVec reverses vecToBytes. It rejects a payload whose length is not a
// multiple of the float32 width, which would indicate a corrupted/foreign value.
func bytesToVec(b []byte) ([]float32, error) {
if len(b)%_float32Bytes != 0 {
return nil, fmt.Errorf("valkey-store: vector byte length %d is not a multiple of %d", len(b), _float32Bytes)
}
v := make([]float32, len(b)/_float32Bytes)
for i := range v {
off := i * _float32Bytes
v[i] = math.Float32frombits(binary.LittleEndian.Uint32(b[off : off+_float32Bytes]))
}
return v, nil
}
// encodeKey builds the Valkey hash key for a vector: prefix + hex(bytes).
// Hex keeps the key printable (so it is safe in FT.CREATE PREFIX and in logs)
// while staying lossless.
func encodeKey(prefix string, v []float32) string {
return prefix + hex.EncodeToString(vecToBytes(v))
}
// decodeKey reverses encodeKey. It is intentionally retained as the tested,
// symmetric inverse of encodeKey — it is NOT on the hot Find path (StoresFind
// decodes the returned `vec` bytes via bytesToVec directly), but keeping the
// key↔vector mapping provably invertible guards the encoding contract and is
// exercised by the round-trip unit tests.
func decodeKey(prefix, key string) ([]float32, error) {
if !strings.HasPrefix(key, prefix) {
return nil, fmt.Errorf("valkey-store: key %q does not have expected prefix %q", key, prefix)
}
b, err := hex.DecodeString(strings.TrimPrefix(key, prefix))
if err != nil {
return nil, fmt.Errorf("valkey-store: decode key hex: %w", err)
}
return bytesToVec(b)
}