feat(stores): Adds Qdrant vector store backend

Signed-off-by: Anush008 <mail@anush.sh>

(cherry picked from commit 2f556b67f5)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Anush008 authored and Ettore Di Giacinto committed 2026-10-13 08:13:55 +00:00
1 parent dee4d8a399
commit cb97cb1ed9
23 files changed
+1025 -32

No files matched your search

+1 -1
View File
@@ -176,7 +176,7 @@ Deliberately **not** applied to:
| Workflow | Why it must keep seeing `backend/**` |
|---|---|
| `test.yml` | `TEST_PATHS` explicitly includes `./backend/go/cloud-proxy/...`, `./backend/go/local-store/...` and `./backend/go/valkey-store/...` |
| `test.yml` | `TEST_PATHS` explicitly includes `./backend/go/cloud-proxy/...`, `./backend/go/local-store/...`, `./backend/go/valkey-store/...` and `./backend/go/qdrant-store/...` |
| `lint.yml` | `.golangci.yml` carries `backend/`-scoped rules, so golangci-lint covers that tree |
| `tests-e2e.yml` | The e2e suite drives real backends over gRPC |
| `backend_pr.yml` | This is the workflow whose entire job is to rebuild the changed backend |
+33
View File
@@ -6006,6 +6006,35 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
# qdrant-store
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-cpu-qdrant-store'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "qdrant-store"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/arm64'
platform-tag: 'arm64'
tag-latest: 'auto'
tag-suffix: '-cpu-qdrant-store'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "qdrant-store"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
# rfdetr
- build-type: ''
cuda-major-version: ""
@@ -6790,6 +6819,10 @@ includeDarwin:
tag-suffix: "-metal-darwin-arm64-valkey-store"
build-type: "metal"
lang: "go"
- backend: "qdrant-store"
tag-suffix: "-metal-darwin-arm64-qdrant-store"
build-type: "metal"
lang: "go"
- backend: "llama-cpp-quantization"
tag-suffix: "-metal-darwin-arm64-llama-cpp-quantization"
build-type: "mps"
+1
View File
@@ -32,6 +32,7 @@ LocalAI
/localai-proxy
/local-store
/valkey-store
/qdrant-store
# prevent above rules from omitting the helm chart
!charts/*
# prevent above rules from omitting the api/localai folder
+12 -3
View File
@@ -1,5 +1,5 @@
# Disable parallel execution for backend builds
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/turboquant backends/bonsai backends/outetts backends/piper backends/stablediffusion-ggml backends/trellis2cpp backends/trellis2cpp-darwin backends/whisper backends/crispasr backends/parakeet-cpp backends/moss-transcribe-cpp backends/nemo-speech-cpp backends/faster-whisper backends/silero-vad backends/local-store backends/valkey-store backends/cloud-proxy backends/localai-proxy backends/huggingface backends/rfdetr backends/rfdetr-cpp backends/insightface backends/speaker-recognition backends/kitten-tts backends/kokoro backends/chatterbox backends/llama-cpp-darwin backends/neutts build-darwin-python-backend build-darwin-go-backend backends/mlx backends/mlx-video backends/diffuser-darwin backends/mlx-vlm backends/mlx-audio backends/mlx-distributed backends/stablediffusion-ggml-darwin backends/vllm backends/vllm-omni backends/longcat-video backends/sglang backends/moonshine backends/pocket-tts backends/qwen-tts backends/faster-qwen3-tts backends/qwen-asr backends/nemo backends/voxcpm backends/whisperx backends/ace-step backends/acestep-cpp backends/fish-speech backends/voxtral backends/opus backends/trl backends/llama-cpp-quantization backends/kokoros backends/sam3-cpp backends/qwen3-tts-cpp backends/moss-tts-cpp backends/magpie-tts-cpp backends/vllm-cpp backends/omnivoice-cpp backends/vibevoice-cpp backends/localvqe backends/tinygrad backends/sherpa-onnx backends/ds4 backends/ds4-darwin backends/liquid-audio backends/supertonic backends/depth-anything-cpp backends/privacy-filter backends/privacy-filter-darwin backends/audio-cpp backends/audio-cpp-darwin
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/turboquant backends/bonsai backends/outetts backends/piper backends/stablediffusion-ggml backends/trellis2cpp backends/trellis2cpp-darwin backends/whisper backends/crispasr backends/parakeet-cpp backends/moss-transcribe-cpp backends/nemo-speech-cpp backends/faster-whisper backends/silero-vad backends/local-store backends/valkey-store backends/qdrant-store backends/cloud-proxy backends/localai-proxy backends/huggingface backends/rfdetr backends/rfdetr-cpp backends/insightface backends/speaker-recognition backends/kitten-tts backends/kokoro backends/chatterbox backends/llama-cpp-darwin backends/neutts build-darwin-python-backend build-darwin-go-backend backends/mlx backends/mlx-video backends/diffuser-darwin backends/mlx-vlm backends/mlx-audio backends/mlx-distributed backends/stablediffusion-ggml-darwin backends/vllm backends/vllm-omni backends/longcat-video backends/sglang backends/moonshine backends/pocket-tts backends/qwen-tts backends/faster-qwen3-tts backends/qwen-asr backends/nemo backends/voxcpm backends/whisperx backends/ace-step backends/acestep-cpp backends/fish-speech backends/voxtral backends/opus backends/trl backends/llama-cpp-quantization backends/kokoros backends/sam3-cpp backends/qwen3-tts-cpp backends/moss-tts-cpp backends/magpie-tts-cpp backends/vllm-cpp backends/omnivoice-cpp backends/vibevoice-cpp backends/localvqe backends/tinygrad backends/sherpa-onnx backends/ds4 backends/ds4-darwin backends/liquid-audio backends/supertonic backends/depth-anything-cpp backends/privacy-filter backends/privacy-filter-darwin backends/audio-cpp backends/audio-cpp-darwin
.NOTPARALLEL: backends/whisper-medusa
.NOTPARALLEL: backends/funasr
@@ -76,7 +76,7 @@ else
GORELEASER=$(shell which goreleaser)
endif
TEST_PATHS?=./api/... ./pkg/... ./core/... ./backend/go/cloud-proxy/... ./backend/go/localai-proxy/... ./backend/go/local-store/... ./backend/go/valkey-store/...
TEST_PATHS?=./api/... ./pkg/... ./core/... ./backend/go/cloud-proxy/... ./backend/go/localai-proxy/... ./backend/go/local-store/... ./backend/go/valkey-store/... ./backend/go/qdrant-store/...
## Coverage output and the committed baseline that CI compares against.
## The gate is strict: total coverage must never decrease (no tolerance).
@@ -428,6 +428,11 @@ test-valkey-store: backends/valkey-store
BACKENDS_PATH=$(abspath ./)/backends \
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --flake-attempts $(TEST_FLAKES) --label-filter='valkey' -v -r tests/integration
## Needs a Qdrant gRPC endpoint in $$QDRANT_ADDR, e.g. localhost:6334.
test-qdrant-store: backends/qdrant-store
BACKENDS_PATH=$(abspath ./)/backends \
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --flake-attempts $(TEST_FLAKES) --label-filter='qdrant' -v -r tests/integration
test-opus:
@echo 'Running opus backend tests'
$(MAKE) -C backend/go/opus libopusshim.so
@@ -642,6 +647,7 @@ prepare-test-extra: protogen-python
$(MAKE) -C backend/go/trellis2cpp
$(MAKE) -C backend/go/kimodocpp
$(MAKE) -C backend/go/valkey-store
$(MAKE) -C backend/go/qdrant-store
test-extra: prepare-test-extra
$(MAKE) -C backend/python/transformers test
@@ -681,6 +687,7 @@ test-extra: prepare-test-extra
$(MAKE) -C backend/go/trellis2cpp test
$(MAKE) -C backend/go/kimodocpp test
$(MAKE) -C backend/go/valkey-store test
$(MAKE) -C backend/go/qdrant-store test
##
## End-to-end gRPC tests that exercise a built backend container image.
@@ -1330,6 +1337,7 @@ BACKEND_AUDIO_CPP = audio-cpp|audio-cpp|.|false|false
BACKEND_PIPER = piper|golang|.|false|true
BACKEND_LOCAL_STORE = local-store|golang|.|false|true
BACKEND_VALKEY_STORE = valkey-store|golang|.|false|true
BACKEND_QDRANT_STORE = qdrant-store|golang|.|false|true
BACKEND_CLOUD_PROXY = cloud-proxy|golang|.|false|true
BACKEND_LOCALAI_PROXY = localai-proxy|golang|.|false|true
BACKEND_HUGGINGFACE = huggingface|golang|.|false|true
@@ -1437,6 +1445,7 @@ $(eval $(call generate-docker-build-target,$(BACKEND_AUDIO_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_PIPER)))
$(eval $(call generate-docker-build-target,$(BACKEND_LOCAL_STORE)))
$(eval $(call generate-docker-build-target,$(BACKEND_VALKEY_STORE)))
$(eval $(call generate-docker-build-target,$(BACKEND_QDRANT_STORE)))
$(eval $(call generate-docker-build-target,$(BACKEND_CLOUD_PROXY)))
$(eval $(call generate-docker-build-target,$(BACKEND_LOCALAI_PROXY)))
$(eval $(call generate-docker-build-target,$(BACKEND_HUGGINGFACE)))
@@ -1514,7 +1523,7 @@ $(eval $(call generate-docker-build-target,$(BACKEND_SUPERTONIC)))
docker-save-%: backend-images
docker save local-ai-backend:$* -o backend-images/$*.tar
docker-build-backends: docker-build-llama-cpp docker-build-ik-llama-cpp docker-build-turboquant docker-build-bonsai docker-build-ds4 docker-build-rerankers docker-build-vllm docker-build-vllm-omni docker-build-longcat-video docker-build-sglang docker-build-transformers docker-build-outetts docker-build-diffusers docker-build-kokoro docker-build-faster-whisper docker-build-crispasr docker-build-coqui docker-build-chatterbox docker-build-vibevoice docker-build-liquid-audio docker-build-moonshine docker-build-pocket-tts docker-build-qwen-tts docker-build-fish-speech docker-build-faster-qwen3-tts docker-build-qwen-asr docker-build-nemo docker-build-voxcpm docker-build-whisperx docker-build-ace-step docker-build-acestep-cpp docker-build-voxtral docker-build-mlx-distributed docker-build-trl docker-build-llama-cpp-quantization docker-build-tinygrad docker-build-kokoros docker-build-sam3-cpp docker-build-rfdetr-cpp docker-build-qwen3-tts-cpp docker-build-moss-tts-cpp docker-build-magpie-tts-cpp docker-build-vllm-cpp docker-build-omnivoice-cpp docker-build-vibevoice-cpp docker-build-localvqe docker-build-insightface docker-build-speaker-recognition docker-build-sherpa-onnx docker-build-cloud-proxy docker-build-localai-proxy docker-build-supertonic docker-build-depth-anything-cpp docker-build-moss-transcribe-cpp docker-build-nemo-speech-cpp docker-build-privacy-filter docker-build-trellis2cpp docker-build-valkey-store docker-build-audio-cpp
docker-build-backends: docker-build-llama-cpp docker-build-ik-llama-cpp docker-build-turboquant docker-build-bonsai docker-build-ds4 docker-build-rerankers docker-build-vllm docker-build-vllm-omni docker-build-longcat-video docker-build-sglang docker-build-transformers docker-build-outetts docker-build-diffusers docker-build-kokoro docker-build-faster-whisper docker-build-crispasr docker-build-coqui docker-build-chatterbox docker-build-vibevoice docker-build-liquid-audio docker-build-moonshine docker-build-pocket-tts docker-build-qwen-tts docker-build-fish-speech docker-build-faster-qwen3-tts docker-build-qwen-asr docker-build-nemo docker-build-voxcpm docker-build-whisperx docker-build-ace-step docker-build-acestep-cpp docker-build-voxtral docker-build-mlx-distributed docker-build-trl docker-build-llama-cpp-quantization docker-build-tinygrad docker-build-kokoros docker-build-sam3-cpp docker-build-rfdetr-cpp docker-build-qwen3-tts-cpp docker-build-moss-tts-cpp docker-build-magpie-tts-cpp docker-build-vllm-cpp docker-build-omnivoice-cpp docker-build-vibevoice-cpp docker-build-localvqe docker-build-insightface docker-build-speaker-recognition docker-build-sherpa-onnx docker-build-cloud-proxy docker-build-localai-proxy docker-build-supertonic docker-build-depth-anything-cpp docker-build-moss-transcribe-cpp docker-build-nemo-speech-cpp docker-build-privacy-filter docker-build-trellis2cpp docker-build-valkey-store docker-build-qdrant-store docker-build-audio-cpp
docker-build-backends: docker-build-whisper-medusa
docker-build-backends: docker-build-funasr
+1
View File
@@ -57,6 +57,7 @@ The backend system provides language-specific Dockerfiles that handle the build
- **piper**: Text-to-speech synthesis Golang with C bindings using rhaspy/piper
- **local-store**: Vector storage backend
- **valkey-store**: Durable vector storage backend backed by Valkey Search (FT.*)
- **qdrant-store**: Durable vector storage backend backed by Qdrant (self-hosted or Qdrant Cloud)
#### C++ Backends (`cpp/`)
- **llama-cpp**: Llama.cpp integration
+16
View File
@@ -0,0 +1,16 @@
GOCMD=go
TEST_FLAKES?=5
qdrant-store:
CGO_ENABLED=0 $(GOCMD) build -ldflags "$(LD_FLAGS)" -tags "$(GO_TAGS)" -o qdrant-store ./
package:
bash package.sh
build: qdrant-store package
test:
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --flake-attempts $(TEST_FLAKES) -v -r ./
clean:
rm -f qdrant-store
+105
View File
@@ -0,0 +1,105 @@
package main
import (
"fmt"
"net"
"os"
"strconv"
"strings"
"time"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/qdrant/go-client/qdrant"
)
type Config struct {
Host string
Port int
APIKey string
UseTLS bool
TLSSkipVerify bool
TLSCACert string
Collection string
Distance qdrant.Distance
RequestTimeout time.Duration
}
func (c Config) Addr() string {
return net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
}
func loadConfig(opts *pb.ModelOptions) (Config, error) {
cfg := Config{
Host: "localhost",
Port: 6334,
Distance: qdrant.Distance_Cosine,
RequestTimeout: 5 * time.Second,
}
var apiKeyEnv string
for _, o := range opts.GetOptions() {
key, value, _ := strings.Cut(o, ":")
key, value = strings.ToLower(strings.TrimSpace(key)), strings.TrimSpace(value)
var err error
switch key {
case "addr":
cfg.Host, cfg.Port, err = parseAddr(value)
case "api_key":
cfg.APIKey = value
case "api_key_env":
apiKeyEnv = value
case "tls":
cfg.UseTLS, err = strconv.ParseBool(value)
case "tls_skip_verify":
cfg.TLSSkipVerify, err = strconv.ParseBool(value)
case "tls_ca_cert":
cfg.TLSCACert = value
case "collection":
cfg.Collection = value
case "distance_metric":
cfg.Distance, err = parseDistance(value)
case "request_timeout_ms":
var ms uint64
ms, err = strconv.ParseUint(value, 10, 31)
if ms > 0 {
cfg.RequestTimeout = time.Duration(ms) * time.Millisecond
}
}
if err != nil {
return Config{}, fmt.Errorf("qdrant-store: invalid option %q: %w", o, err)
}
}
if cfg.TLSCACert != "" || cfg.TLSSkipVerify {
cfg.UseTLS = true
}
if cfg.APIKey == "" && apiKeyEnv != "" {
cfg.APIKey = os.Getenv(apiKeyEnv)
}
return cfg, nil
}
func parseAddr(addr string) (string, int, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
if !strings.Contains(err.Error(), "missing port") {
return "", 0, err
}
return strings.Trim(addr, "[]"), 6334, nil
}
p, err := strconv.Atoi(port)
if err != nil || p < 1 || p > 65535 {
return "", 0, fmt.Errorf("bad port %q", port)
}
return host, p, nil
}
func parseDistance(name string) (qdrant.Distance, error) {
for n, v := range qdrant.Distance_value {
if v != int32(qdrant.Distance_UnknownDistance) && strings.EqualFold(n, name) {
return qdrant.Distance(v), nil
}
}
return 0, fmt.Errorf("want COSINE, EUCLID, DOT or MANHATTAN")
}
+23
View File
@@ -0,0 +1,23 @@
package main
import (
"flag"
"os"
grpc "github.com/mudler/LocalAI/pkg/grpc"
"github.com/mudler/xlog"
)
var (
addr = flag.String("addr", "localhost:50051", "the address to connect to")
)
func main() {
xlog.SetLogger(xlog.NewLogger(xlog.LogLevel(os.Getenv("LOCALAI_LOG_LEVEL")), os.Getenv("LOCALAI_LOG_FORMAT")))
flag.Parse()
if err := grpc.StartServer(*addr, &QdrantStore{}); err != nil {
panic(err)
}
}
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
set -e
CURDIR=$(dirname "$(realpath "$0")")
mkdir -p "$CURDIR/package"
cp -avf "$CURDIR/qdrant-store" "$CURDIR/package/"
cp -rfv "$CURDIR/run.sh" "$CURDIR/package/"
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
set -ex
CURDIR=$(dirname "$(realpath "$0")")
exec "$CURDIR"/qdrant-store "$@"
+347
View File
@@ -0,0 +1,347 @@
package main
import (
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"fmt"
"math"
"os"
"strings"
"github.com/google/uuid"
"github.com/mudler/LocalAI/pkg/grpc/base"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/LocalAI/pkg/store"
"github.com/qdrant/go-client/qdrant"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const (
payloadKey = "key"
payloadValue = "value"
// Results carry up to 50 MiB of base64-encoded values.
maxRecvMsgBytes = 128 << 20
)
var pointNamespace = uuid.NewSHA1(uuid.NameSpaceURL, []byte("https://localai.io/qdrant-store"))
func newClient(cfg Config) (*qdrant.Client, error) {
qcfg := &qdrant.Config{
Host: cfg.Host,
Port: cfg.Port,
APIKey: cfg.APIKey,
UseTLS: cfg.UseTLS,
SkipCompatibilityCheck: true,
GrpcOptions: []grpc.DialOption{grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxRecvMsgBytes))},
}
if cfg.UseTLS {
qcfg.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS13, InsecureSkipVerify: cfg.TLSSkipVerify}
if cfg.TLSCACert != "" {
pem, err := os.ReadFile(cfg.TLSCACert)
if err != nil {
return nil, fmt.Errorf("read tls_ca_cert: %w", err)
}
qcfg.TLSConfig.RootCAs = x509.NewCertPool()
if !qcfg.TLSConfig.RootCAs.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("tls_ca_cert %q has no valid certificate", cfg.TLSCACert)
}
}
}
return qdrant.NewClient(qcfg)
}
type QdrantStore struct {
base.SingleThread
client *qdrant.Client
cfg Config
collection string
checked bool
}
func (s *QdrantStore) Load(opts *pb.ModelOptions) error {
// The loader's greedy autoload probes every backend with real model names.
if !strings.HasPrefix(opts.GetModel(), store.NamespacePrefix) {
return fmt.Errorf("qdrant-store: refusing to load %q: not a store namespace", opts.GetModel())
}
cfg, err := loadConfig(opts)
if err != nil {
return err
}
client, err := newClient(cfg)
if err != nil {
return fmt.Errorf("qdrant-store: connect to %s: %w", cfg.Addr(), err)
}
s.client, s.cfg, s.collection = client, cfg, collectionName(cfg, opts.GetModel())
ctx, cancel := s.ctx()
defer cancel()
if err := s.checkCollection(ctx); err != nil && !isNotFound(err) {
_ = client.Close()
return fmt.Errorf("qdrant-store: %s: %w", cfg.Addr(), err)
}
return nil
}
func (s *QdrantStore) checkCollection(ctx context.Context) error {
info, err := s.client.GetCollectionInfo(ctx, s.collection)
if err != nil {
return err
}
params := info.GetConfig().GetParams().GetVectorsConfig().GetParams()
if params == nil {
return fmt.Errorf("collection %s does not use a single unnamed vector", s.collection)
}
if params.GetDistance() != s.cfg.Distance {
return fmt.Errorf("collection %s uses distance %s but distance_metric is %s", s.collection, params.GetDistance(), s.cfg.Distance)
}
s.checked = true
return nil
}
func (s *QdrantStore) Free() error {
if s.client == nil {
return nil
}
return s.client.Close()
}
func (s *QdrantStore) StoresSet(opts *pb.StoresSetOptions) error {
if len(opts.Keys) == 0 || len(opts.Keys) != len(opts.Values) {
return fmt.Errorf("qdrant-store: Set: need matching keys and values, got %d and %d", len(opts.Keys), len(opts.Values))
}
points := make([]*qdrant.PointStruct, len(opts.Keys))
for i, k := range opts.Keys {
if err := checkFinite(k.Floats); err != nil {
return fmt.Errorf("qdrant-store: Set: key %d: %w", i, err)
}
points[i] = &qdrant.PointStruct{
Id: pointID(k.Floats),
Vectors: qdrant.NewVectorsDense(k.Floats),
// Qdrant normalises COSINE vectors, so the exact key goes in the payload.
Payload: qdrant.NewValueMap(map[string]any{
payloadKey: base64.StdEncoding.EncodeToString(vecToBytes(k.Floats)),
payloadValue: base64.StdEncoding.EncodeToString(opts.Values[i].Bytes),
}),
}
}
err := s.upsert(points)
if isNotFound(err) {
if err := s.createCollection(len(opts.Keys[0].Floats)); err != nil {
return fmt.Errorf("qdrant-store: Set: %w", err)
}
err = s.upsert(points)
}
if err != nil {
return fmt.Errorf("qdrant-store: Set: %w", err)
}
return nil
}
func (s *QdrantStore) upsert(points []*qdrant.PointStruct) error {
ctx, cancel := s.ctx()
defer cancel()
_, err := s.client.Upsert(ctx, &qdrant.UpsertPoints{
CollectionName: s.collection,
Wait: qdrant.PtrOf(true),
Points: points,
})
return err
}
func (s *QdrantStore) createCollection(dim int) error {
ctx, cancel := s.ctx()
defer cancel()
err := s.client.CreateCollection(ctx, &qdrant.CreateCollection{
CollectionName: s.collection,
VectorsConfig: qdrant.NewVectorsConfig(&qdrant.VectorParams{Size: uint64(dim), Distance: s.cfg.Distance}),
})
if err != nil && status.Code(err) != codes.AlreadyExists {
return fmt.Errorf("create collection %s: %w", s.collection, err)
}
return nil
}
func (s *QdrantStore) StoresGet(opts *pb.StoresGetOptions) (pb.StoresGetResult, error) {
if len(opts.Keys) == 0 {
return pb.StoresGetResult{}, nil
}
ctx, cancel := s.ctx()
defer cancel()
points, err := s.client.Get(ctx, &qdrant.GetPoints{
CollectionName: s.collection,
Ids: pointIDs(opts.Keys),
WithPayload: qdrant.NewWithPayloadInclude(payloadKey, payloadValue),
})
if isNotFound(err) {
return pb.StoresGetResult{}, nil
}
if err != nil {
return pb.StoresGetResult{}, fmt.Errorf("qdrant-store: Get: %w", err)
}
var keys [][]float32
var values [][]byte
for _, p := range points {
key, value, err := decodePayload(p.GetPayload())
if err != nil {
return pb.StoresGetResult{}, fmt.Errorf("qdrant-store: Get: %w", err)
}
keys = append(keys, key)
values = append(values, value)
}
return pb.StoresGetResult{Keys: store.WrapKeys(keys), Values: store.WrapValues(values)}, nil
}
func (s *QdrantStore) StoresDelete(opts *pb.StoresDeleteOptions) error {
if len(opts.Keys) == 0 {
return fmt.Errorf("qdrant-store: Delete: no keys to delete")
}
ctx, cancel := s.ctx()
defer cancel()
_, err := s.client.Delete(ctx, &qdrant.DeletePoints{
CollectionName: s.collection,
Wait: qdrant.PtrOf(true),
Points: qdrant.NewPointsSelector(pointIDs(opts.Keys)...),
})
if err != nil && !isNotFound(err) {
return fmt.Errorf("qdrant-store: Delete: %w", err)
}
return nil
}
func (s *QdrantStore) StoresFind(opts *pb.StoresFindOptions) (pb.StoresFindResult, error) {
query := opts.GetKey().GetFloats()
if opts.TopK < 1 {
return pb.StoresFindResult{}, fmt.Errorf("qdrant-store: Find: topK = %d, must be >= 1", opts.TopK)
}
if err := checkFinite(query); err != nil {
return pb.StoresFindResult{}, fmt.Errorf("qdrant-store: Find: %w", err)
}
ctx, cancel := s.ctx()
defer cancel()
if !s.checked {
err := s.checkCollection(ctx)
if isNotFound(err) {
return pb.StoresFindResult{}, nil
}
if err != nil {
return pb.StoresFindResult{}, fmt.Errorf("qdrant-store: Find: %w", err)
}
}
points, err := s.client.Query(ctx, &qdrant.QueryPoints{
CollectionName: s.collection,
Query: qdrant.NewQueryDense(query),
Limit: qdrant.PtrOf(uint64(opts.TopK)),
WithPayload: qdrant.NewWithPayloadInclude(payloadKey, payloadValue),
})
if isNotFound(err) {
return pb.StoresFindResult{}, nil
}
if err != nil {
return pb.StoresFindResult{}, fmt.Errorf("qdrant-store: Find: %w", err)
}
var keys [][]float32
var values [][]byte
var sims []float32
for _, p := range points {
key, value, err := decodePayload(p.GetPayload())
if err != nil {
return pb.StoresFindResult{}, fmt.Errorf("qdrant-store: Find: %w", err)
}
keys = append(keys, key)
values = append(values, value)
sims = append(sims, p.GetScore())
}
return pb.StoresFindResult{Keys: store.WrapKeys(keys), Values: store.WrapValues(values), Similarities: sims}, nil
}
func (s *QdrantStore) ctx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), s.cfg.RequestTimeout)
}
func pointID(vec []float32) *qdrant.PointId {
return qdrant.NewID(uuid.NewSHA1(pointNamespace, vecToBytes(vec)).String())
}
func pointIDs(keys []*pb.StoresKey) []*qdrant.PointId {
ids := make([]*qdrant.PointId, len(keys))
for i, k := range keys {
ids[i] = pointID(k.Floats)
}
return ids
}
func vecToBytes(vec []float32) []byte {
b := make([]byte, 4*len(vec))
for i, x := range vec {
binary.LittleEndian.PutUint32(b[4*i:], math.Float32bits(x))
}
return b
}
func decodePayload(payload map[string]*qdrant.Value) ([]float32, []byte, error) {
k, okKey := payload[payloadKey]
v, okValue := payload[payloadValue]
if !okKey || !okValue {
return nil, nil, fmt.Errorf("point has no %q/%q payload; not written by qdrant-store", payloadKey, payloadValue)
}
keyBytes, err := base64.StdEncoding.DecodeString(k.GetStringValue())
if err != nil {
return nil, nil, fmt.Errorf("decode key payload: %w", err)
}
if len(keyBytes)%4 != 0 {
return nil, nil, fmt.Errorf("key payload of %d bytes is not a float32 vector", len(keyBytes))
}
value, err := base64.StdEncoding.DecodeString(v.GetStringValue())
if err != nil {
return nil, nil, fmt.Errorf("decode value payload: %w", err)
}
key := make([]float32, len(keyBytes)/4)
for i := range key {
key[i] = math.Float32frombits(binary.LittleEndian.Uint32(keyBytes[4*i:]))
}
return key, value, nil
}
func collectionName(cfg Config, model string) string {
if cfg.Collection != "" {
return cfg.Collection
}
ns := strings.TrimPrefix(model, store.NamespacePrefix)
readable := strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-' || r == '.' {
return r
}
return '_'
}, ns)
if len(readable) > 64 {
readable = readable[:64]
}
sum := sha256.Sum256([]byte(ns))
return "localai_" + readable + "-" + hex.EncodeToString(sum[:4])
}
func checkFinite(vec []float32) error {
for i, x := range vec {
if math.IsNaN(float64(x)) || math.IsInf(float64(x), 0) {
return fmt.Errorf("component %d is %v; vectors must be finite", i, x)
}
}
return nil
}
func isNotFound(err error) bool {
return status.Code(err) == codes.NotFound
}
@@ -0,0 +1,13 @@
package main
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestQdrantStore(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "qdrant-store test suite")
}
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"math"
"time"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/LocalAI/pkg/store"
"github.com/qdrant/go-client/qdrant"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("loadConfig", func() {
It("applies defaults and parses every option", func() {
cfg, err := loadConfig(&pb.ModelOptions{})
Expect(err).NotTo(HaveOccurred())
Expect(cfg).To(Equal(Config{Host: "localhost", Port: 6334, Distance: qdrant.Distance_Cosine, RequestTimeout: 5 * time.Second}))
GinkgoT().Setenv("QDRANT_STORE_TEST_KEY", "from-env")
cfg, err = loadConfig(&pb.ModelOptions{Options: []string{"addr:[::1]", "api_key_env:QDRANT_STORE_TEST_KEY", "tls:true",
"tls_skip_verify:true", "tls_ca_cert:/ca.pem", "collection:c", "distance_metric:dot", "request_timeout_ms:250"}})
Expect(err).NotTo(HaveOccurred())
Expect(cfg).To(Equal(Config{Host: "::1", Port: 6334, APIKey: "from-env", UseTLS: true, TLSSkipVerify: true,
TLSCACert: "/ca.pem", Collection: "c", Distance: qdrant.Distance_Dot, RequestTimeout: 250 * time.Millisecond}))
})
It("turns TLS on for tls_ca_cert and tls_skip_verify", func() {
for _, o := range []string{"tls_ca_cert:/ca.pem", "tls_skip_verify:true"} {
cfg, err := loadConfig(&pb.ModelOptions{Options: []string{o}})
Expect(err).NotTo(HaveOccurred())
Expect(cfg.UseTLS).To(BeTrue(), o)
}
})
It("rejects malformed values", func() {
for _, o := range []string{"addr:host:0", "addr:https://x:6333", "tls:maybe", "distance_metric:L2", "request_timeout_ms:soon"} {
_, err := loadConfig(&pb.ModelOptions{Options: []string{o}})
Expect(err).To(HaveOccurred(), o)
}
})
})
var _ = Describe("QdrantStore", func() {
It("derives deterministic point IDs and distinct collection names", func() {
Expect(pointID([]float32{1, 2})).To(Equal(pointID([]float32{1, 2})))
Expect(pointID([]float32{1, 2})).NotTo(Equal(pointID([]float32{2, 1})))
Expect(collectionName(Config{}, "store://faces")).To(HavePrefix("localai_faces-"))
Expect(collectionName(Config{}, "store://a b")).NotTo(Equal(collectionName(Config{}, "store://a/b")))
Expect(collectionName(Config{Collection: "mine"}, "store://faces")).To(Equal("mine"))
})
It("fails to load a non-store name, a bad tls_ca_cert or an unreachable server", func() {
load := func(model string, opts ...string) error {
return (&QdrantStore{}).Load(&pb.ModelOptions{Model: model, Options: opts})
}
Expect(load("llama-3.gguf")).To(MatchError(ContainSubstring("not a store namespace")))
Expect(load("store://x", "tls_ca_cert:/nonexistent.pem")).To(MatchError(ContainSubstring("read tls_ca_cert")))
Expect(load("store://x", "addr:127.0.0.1:1")).To(MatchError(ContainSubstring("127.0.0.1:1")))
})
It("rejects invalid input before calling Qdrant", func() {
s := &QdrantStore{}
set := func(keys [][]float32, values ...[]byte) error {
return s.StoresSet(&pb.StoresSetOptions{Keys: store.WrapKeys(keys), Values: store.WrapValues(values)})
}
Expect(set(nil)).To(HaveOccurred())
Expect(set([][]float32{{1, 2}})).To(HaveOccurred())
Expect(set([][]float32{{float32(math.NaN()), 1}}, []byte("a"))).To(MatchError(ContainSubstring("must be finite")))
Expect(s.StoresDelete(&pb.StoresDeleteOptions{})).To(HaveOccurred())
_, err := s.StoresFind(&pb.StoresFindOptions{Key: &pb.StoresKey{Floats: []float32{1}}, TopK: 0})
Expect(err).To(MatchError(ContainSubstring("topK")))
})
})
+48
View File
@@ -2070,6 +2070,25 @@
capabilities:
default: "cpu-valkey-store"
metal: "metal-valkey-store"
- &qdrant-store
name: "qdrant-store"
urls:
- https://github.com/mudler/LocalAI
- https://github.com/qdrant/qdrant
description: |
Qdrant Store is a Qdrant-backed vector store for LocalAI. It persists
vectors across restarts in HNSW-indexed collections and works with
self-hosted Qdrant (single node or cluster) and Qdrant Cloud. Requires a
reachable Qdrant server (gRPC port, 6334 by default).
tags:
- vector-database
- qdrant
- open-source
- CPU
license: MIT
capabilities:
default: "cpu-qdrant-store"
metal: "metal-qdrant-store"
- &kitten-tts
name: "kitten-tts"
urls:
@@ -2692,6 +2711,35 @@
uri: "quay.io/go-skynet/local-ai-backends:master-metal-darwin-arm64-valkey-store"
mirrors:
- localai/localai-backends:master-metal-darwin-arm64-valkey-store
- !!merge <<: *qdrant-store
name: "cpu-qdrant-store"
alias: "qdrant-store"
uri: "quay.io/go-skynet/local-ai-backends:latest-cpu-qdrant-store"
mirrors:
- localai/localai-backends:latest-cpu-qdrant-store
- !!merge <<: *qdrant-store
name: "cpu-qdrant-store-development"
alias: "qdrant-store"
uri: "quay.io/go-skynet/local-ai-backends:master-cpu-qdrant-store"
mirrors:
- localai/localai-backends:master-cpu-qdrant-store
- !!merge <<: *qdrant-store
name: "qdrant-store-development"
alias: "qdrant-store"
capabilities:
default: "cpu-qdrant-store-development"
metal: "metal-qdrant-store-development"
- !!merge <<: *qdrant-store
name: "metal-qdrant-store"
uri: "quay.io/go-skynet/local-ai-backends:latest-metal-darwin-arm64-qdrant-store"
mirrors:
- localai/localai-backends:latest-metal-darwin-arm64-qdrant-store
- !!merge <<: *qdrant-store
name: "metal-qdrant-store-development"
alias: "qdrant-store"
uri: "quay.io/go-skynet/local-ai-backends:master-metal-darwin-arm64-qdrant-store"
mirrors:
- localai/localai-backends:master-metal-darwin-arm64-qdrant-store
- !!merge <<: *opus
name: "cpu-opus"
uri: "quay.io/go-skynet/local-ai-backends:latest-cpu-opus"
+32 -24
View File
@@ -118,16 +118,6 @@ var _ = Describe("localVectorStore tracing", func() {
})
var _ = Describe("resolveStoreBackend", func() {
valkeyCfg := func(opts ...string) *config.ModelConfigLoader {
cl := config.NewModelConfigLoader(GinkgoT().TempDir())
cl.ReplaceModelConfigs([]config.ModelConfig{{
Name: "faces",
Backend: model.ValkeyStoreBackend,
Options: opts,
}})
return cl
}
It("defaults to local-store with no config and no explicit backend", func() {
name, opts, id := resolveStoreBackend(nil, "some-store", "")
Expect(name).To(Equal(model.LocalStoreBackend))
@@ -136,24 +126,42 @@ var _ = Describe("resolveStoreBackend", func() {
})
It("uses the store's model config backend and options", func() {
name, opts, id := resolveStoreBackend(valkeyCfg("addr:valkey:6379"), "faces", "")
Expect(name).To(Equal(model.ValkeyStoreBackend))
Expect(opts).To(Equal([]string{"addr:valkey:6379"}))
cl := config.NewModelConfigLoader(GinkgoT().TempDir())
cl.ReplaceModelConfigs([]config.ModelConfig{{
Name: "faces",
Backend: model.QdrantStoreBackend,
Options: []string{"addr:qdrant:6334"},
}})
name, opts, id := resolveStoreBackend(cl, "faces", "")
Expect(name).To(Equal(model.QdrantStoreBackend))
Expect(opts).To(Equal([]string{"addr:qdrant:6334"}))
Expect(id).To(Equal("faces"))
})
It("keeps the config's options and slot when the explicit backend is the configured one (aliases included)", func() {
name, opts, id := resolveStoreBackend(valkeyCfg("addr:valkey:6379"), "faces", "valkey")
Expect(name).To(Equal("valkey"))
Expect(opts).To(Equal([]string{"addr:valkey:6379"}))
cl := config.NewModelConfigLoader(GinkgoT().TempDir())
cl.ReplaceModelConfigs([]config.ModelConfig{{
Name: "faces",
Backend: model.QdrantStoreBackend,
Options: []string{"addr:qdrant:6334"},
}})
name, opts, id := resolveStoreBackend(cl, "faces", "qdrant")
Expect(name).To(Equal("qdrant"))
Expect(opts).To(Equal([]string{"addr:qdrant:6334"}))
Expect(id).To(Equal("faces"))
})
It("gives an explicit override of another backend its own slot and not the config's options", func() {
name, opts, id := resolveStoreBackend(valkeyCfg("addr:valkey:6379"), "faces", model.LocalStoreBackend)
Expect(name).To(Equal(model.LocalStoreBackend))
cl := config.NewModelConfigLoader(GinkgoT().TempDir())
cl.ReplaceModelConfigs([]config.ModelConfig{{
Name: "faces",
Backend: model.ValkeyStoreBackend,
Options: []string{"addr:valkey:6379"},
}})
name, opts, id := resolveStoreBackend(cl, "faces", model.QdrantStoreBackend)
Expect(name).To(Equal(model.QdrantStoreBackend))
Expect(opts).To(BeEmpty())
Expect(id).To(Equal("faces@local-store"))
Expect(id).To(Equal("faces@qdrant-store"))
})
It("passes the options of a config without backend: to an explicit backend", func() {
@@ -169,17 +177,17 @@ var _ = Describe("resolveStoreBackend", func() {
})
It("separates an override from the default local-store slot when there is no config", func() {
_, _, id := resolveStoreBackend(nil, "mix", "valkey")
Expect(id).To(Equal("mix@valkey-store"))
_, _, id := resolveStoreBackend(nil, "mix", "qdrant")
Expect(id).To(Equal("mix@qdrant-store"))
_, _, id = resolveStoreBackend(nil, "mix", "embedded-store")
Expect(id).To(Equal("mix"))
})
It("compares backends by canonical name (aliases, case)", func() {
Expect(model.CanonicalBackend("valkey")).To(Equal(model.ValkeyStoreBackend))
Expect(model.CanonicalBackend("qdrant")).To(Equal(model.QdrantStoreBackend))
Expect(model.CanonicalBackend("Valkey")).To(Equal(model.ValkeyStoreBackend))
Expect(model.CanonicalBackend("embedded-store")).To(Equal(model.LocalStoreBackend))
Expect(model.CanonicalBackend(model.ValkeyStoreBackend)).To(Equal(model.ValkeyStoreBackend))
Expect(model.CanonicalBackend("Valkey-Store")).To(Equal(model.ValkeyStoreBackend))
Expect(model.CanonicalBackend(model.QdrantStoreBackend)).To(Equal(model.QdrantStoreBackend))
Expect(model.CanonicalBackend("Qdrant-Store")).To(Equal(model.QdrantStoreBackend))
})
})
+1 -1
View File
@@ -314,7 +314,7 @@ LocalAI supports various types of backends:
- **Vision & Detection Backends**: For object detection, segmentation, depth, and face/voice recognition (e.g., rf-detr.cpp, locate-anything.cpp, sam3.cpp, insightface)
- **Audio Processing Backends**: For voice activity detection and audio enhancement (e.g., Silero VAD, LocalVQE, [audio.cpp]({{%relref "features/audio-cpp" %}}))
- **Source Separation & Voice Conversion Backends**: For splitting a mix into named stems (vocals, drums, bass) and for converting speech or singing to a target voice (e.g., [audio.cpp]({{%relref "features/audio-cpp" %}}))
- **Utility Backends**: For reranking, PII/NER token classification, fine-tuning, quantization, and vector storage (e.g., rerankers, privacy-filter.cpp, TRL, local-store, valkey-store)
- **Utility Backends**: For reranking, PII/NER token classification, fine-tuning, quantization, and vector storage (e.g., rerankers, privacy-filter.cpp, TRL, local-store, valkey-store, qdrant-store)
See the [Backend & Model Compatibility Table]({{%relref "reference/compatibility-table" %}}) for the full catalog.
+94 -1
View File
@@ -51,12 +51,28 @@ but you can select a different store backend per request (see [Backends](#backen
## Backends
Each `/stores/*` request accepts an optional `backend` field selecting the store implementation.
Two backends ship with LocalAI:
Three backends ship with LocalAI:
| Backend | `backend` value | Persistence | Notes |
|---------|-----------------|-------------|-------|
| Local (default) | `local-store` (alias `embedded-store`) | In-memory, lost on restart | Exact cosine similarity, zero configuration. |
| Valkey Search | `valkey-store` (alias `valkey`) | Durable (Valkey RDB/AOF) | Backed by a Valkey Search (`FT.*`) server; survives restarts and supports opt-in HNSW. |
| Qdrant | `qdrant-store` (alias `qdrant`) | Durable (Qdrant storage) | Backed by a Qdrant server or cluster, or Qdrant Cloud; HNSW-indexed; on a single node a `find` right after a `set` sees the new vectors. |
Internal features that use stores (face and voice recognition, the router's KNN classifier and
embedding cache) don't send a `backend` field. To move the router's stores (`router-cache-<router>`
and `router-corpus-<router>`) to a durable backend, create a model config named after the store with
the `backend:` and `options:` shown below. Keep the default cosine `distance_metric` for them: they
compare similarities against cosine thresholds. Keep face and voice recognition on `local-store`
for now: their registries remember enrolled IDs only in memory, so after a restart entries in a
durable store could no longer be forgotten. With tracing enabled, the router's vector-store calls
appear in `/api/backend-traces` under the backend each store resolved to.
A `backend` field that names a different backend than the store's own (its model config's
`backend:`, or `local-store` when there is none) is served by a separate backend instance, so it
never lands on the store's usual backend. It gets the model config's `options:` only if the config
has no `backend:`; otherwise those options belong to the configured backend, and it gets its own
defaults.
### Valkey store backend
@@ -146,6 +162,83 @@ bundle; `tls_skip_verify:true` disables verification entirely and should only be
local testing.
{{% /notice %}}
### Qdrant store backend
The `qdrant-store` backend keeps vectors in a [Qdrant](https://qdrant.tech/) collection, so the
data survives a LocalAI restart. It works with a single Qdrant node, a Qdrant cluster and Qdrant
Cloud. It talks to Qdrant's **gRPC** port (`6334` by default), not the `6333` REST port.
Select it by passing `"backend": "qdrant-store"` (or the `"qdrant"` alias) on any `/stores/*` request:
```
curl -X POST http://localhost:8080/stores/set \
-H "Content-Type: application/json" \
-d '{"backend": "qdrant-store", "store": "my-vectors", "keys": [[0.1, 0.2], [0.3, 0.4]], "values": ["foo", "bar"]}'
```
As with Valkey, the connection and collection are configured through a **model config** named
after the store, with the settings in `options:` as `key:value` strings:
```yaml
name: my-vectors
backend: qdrant-store
options:
- addr:xyz-example.eu-central.aws.cloud.qdrant.io:6334
- tls:true
- api_key_env:QDRANT_API_KEY
- distance_metric:COSINE
```
When no config exists for a store, the backend connects to `localhost:6334` with the defaults
below.
Each store gets its own collection, named `localai_<store>-<hash>` (for example
`localai_my-vectors-1a2b3c4d`) unless you set `collection`. It is created on the first `set` with
the dimension of the first key, and Qdrant rejects keys and queries of any other dimension, even
after every key is deleted. To change the dimension (for example after switching embedding
models), delete the collection in Qdrant or set a new `collection`. The backend never deletes
collections itself, since with several writers that could lose another writer's data.
Keys are matched exactly, and a repeated key keeps the last value. A `set` is a single Qdrant
request, so a rejected `set` writes nothing. `NaN` and `Inf` components are rejected.
| Option | Default | Description |
|--------|---------|-------------|
| `addr` | `localhost:6334` | Qdrant gRPC address (`host:port`; the port defaults to `6334`). Not a URL — use `tls:true` for HTTPS endpoints. |
| `api_key` | *(empty)* | Qdrant API key (plaintext in config). |
| `api_key_env` | *(empty)* | Name of an env var that holds the API key. Preferred over `api_key` — keeps the secret out of the model YAML. |
| `tls` | `false` | Enable TLS (required by Qdrant Cloud). |
| `tls_ca_cert` | *(empty)* | Path to a PEM CA bundle used to verify the server certificate (self-signed / private CA). Turns on `tls`. |
| `tls_skip_verify` | `false` | Skip TLS certificate verification. Insecure — for testing only. Turns on `tls`. |
| `collection` | *(derived)* | Use this exact collection name instead of the derived one. |
| `distance_metric` | `COSINE` | `COSINE`, `EUCLID`, `DOT` or `MANHATTAN` (case-insensitive). |
| `request_timeout_ms` | `5000` | Per-request timeout in milliseconds. |
For `COSINE` the returned `similarities` follow the local store's convention (`1.0` = identical,
`-1.0` = opposite). For `DOT` they are dot products (larger is closer). For `EUCLID` and `MANHATTAN`
they are distances, so smaller is closer, which is the opposite ordering to `COSINE`. In every case
results come back nearest-first. If a collection already exists with a different distance than
`distance_metric`, loading the store (or, if the collection appeared later, the first `find`) fails
rather than returning scores with the wrong meaning.
{{% notice note %}}
The backend creates collections with Qdrant's default settings. For custom settings (sharding and
replication on a cluster, HNSW parameters, on-disk vectors, quantization), create the collection
in Qdrant yourself and point `collection` at it: an existing collection is used as long as it has a
single unnamed vector with the configured `distance_metric`.
{{% /notice %}}
{{% notice note %}}
Small collections are searched exactly. Once Qdrant builds the HNSW index (when a segment passes
its indexing threshold, 10 MB of vectors per segment by default, so typically tens of thousands of
embeddings), `find` is approximate, with recall set by the collection's HNSW settings.
{{% /notice %}}
{{% notice warning %}}
`tls` defaults to `false` (plaintext). Set `tls:true` whenever Qdrant is not on `localhost` or an
API key is configured, otherwise the key and the stored vectors travel the network unencrypted.
{{% /notice %}}
## Set
To set some keys you can do
@@ -129,6 +129,7 @@ All backends listed here can be installed on demand from the [Backend Gallery]({
| [privacy-filter.cpp](https://github.com/localai-org/privacy-filter.cpp) | Standalone GGML engine for the openai-privacy-filter PII/NER token-classification model family (powers LocalAI's PII redaction tier) | CPU, CUDA 13, Vulkan |
| [local-store](https://github.com/mudler/LocalAI) | Local-first vector database for embeddings | CPU, Metal |
| [valkey-store](https://github.com/mudler/LocalAI) | Durable vector store for embeddings backed by Valkey Search (FLAT or HNSW) | CPU, Metal |
| [qdrant-store](https://github.com/mudler/LocalAI) | Durable vector store for embeddings backed by Qdrant (HNSW; self-hosted, clustered or Qdrant Cloud) | CPU, Metal |
| [TRL](https://github.com/huggingface/trl) | Fine-tuning (SFT, DPO, GRPO, RLOO, KTO, ORPO) | CPU, CUDA 12/13 |
| [llama.cpp quantization](https://github.com/ggml-org/llama.cpp) | HuggingFace → GGUF model conversion and quantization | CPU, Metal |
+2 -1
View File
@@ -53,6 +53,7 @@ require (
github.com/otiai10/openaigo v1.7.0
github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5
github.com/prometheus/client_golang v1.23.2
github.com/qdrant/go-client v1.17.1
github.com/robfig/cron/v3 v3.0.1
github.com/russross/blackfriday v1.6.0
github.com/sashabaranov/go-openai v1.41.2
@@ -454,7 +455,7 @@ require (
github.com/libp2p/go-reuseport v0.4.0 // indirect
github.com/libp2p/zeroconf/v2 v2.2.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
+4
View File
@@ -893,6 +893,8 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
@@ -1219,6 +1221,8 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/qdrant/go-client v1.17.1 h1:7QmPwDddrHL3hC4NfycwtQlraVKRLcRi++BX6TTm+3g=
github.com/qdrant/go-client v1.17.1/go.mod h1:n1h6GhkdAzcohoXt/5Z19I2yxbCkMA6Jejob3S6NZT8=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
+2
View File
@@ -30,6 +30,7 @@ var Aliases = map[string]string{
"ik-llama": IKLLamaCPP,
"embedded-store": LocalStoreBackend,
"valkey": ValkeyStoreBackend,
"qdrant": QdrantStoreBackend,
"huggingface-embeddings": TransformersBackend,
"transformers-musicgen": TransformersBackend,
"sentencetransformers": TransformersBackend,
@@ -60,6 +61,7 @@ const (
TransformersBackend = "transformers"
LocalStoreBackend = "local-store"
ValkeyStoreBackend = "valkey-store"
QdrantStoreBackend = "qdrant-store"
// Proxy backends serve a model by forwarding to another server instead
// of loading weights. Core special-cases both (credentials, failover
+198
View File
@@ -0,0 +1,198 @@
package integration_test
import (
"context"
"fmt"
"net"
"os"
"strconv"
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/qdrant/go-client/qdrant"
"github.com/mudler/LocalAI/core/gallery"
"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"
"github.com/mudler/LocalAI/pkg/system"
)
var _ = Describe("Integration tests for the qdrant-store backend", Label("stores"), Label("qdrant"), func() {
Context("Qdrant get, set, delete and find", func() {
var sl *model.ModelLoader
var sc grpc.Backend
var tmpdir string
var namespace string
var qdrantAddr string
var backendsPath string
tryLoadStore := func(ns string, extra ...string) (grpc.Backend, error) {
storeOpts := []model.Option{
model.WithBackendString(model.QdrantStoreBackend),
model.WithModelID(ns),
model.WithModel(store.NamespacePrefix + ns),
model.WithLoadGRPCLoadModelOpts(&pb.ModelOptions{
Options: append([]string{"addr:" + qdrantAddr}, extra...),
}),
}
return sl.Load(storeOpts...)
}
loadStore := func(ns string, extra ...string) grpc.Backend {
backend, err := tryLoadStore(ns, extra...)
Expect(err).ToNot(HaveOccurred())
return backend
}
initLoader := func() {
systemState, err := system.GetSystemState(
system.WithModelPath(tmpdir),
system.WithBackendPath(backendsPath),
)
Expect(err).ToNot(HaveOccurred())
sl = model.NewModelLoader(systemState)
Expect(gallery.RegisterBackends(systemState, sl)).To(Succeed())
}
BeforeEach(func() {
qdrantAddr = os.Getenv("QDRANT_ADDR")
if qdrantAddr == "" {
Skip("QDRANT_ADDR is not set; skipping Qdrant integration tests")
}
backendsPath = os.Getenv("BACKENDS_PATH")
if backendsPath == "" {
Skip("BACKENDS_PATH is not set; build the backend and point BACKENDS_PATH at it (see make test-qdrant-store)")
}
var err error
tmpdir, err = os.MkdirTemp("", "")
Expect(err).ToNot(HaveOccurred())
namespace = fmt.Sprintf("it-%d-%d", GinkgoRandomSeed(), namespaceCounter.Add(1))
initLoader()
sc = loadStore(namespace)
})
AfterEach(func() {
if sl != nil {
Expect(sl.StopAllGRPC()).To(Succeed())
}
if tmpdir != "" {
_ = os.RemoveAll(tmpdir)
}
dropNamespaceCollections(qdrantAddr, namespace)
})
It("should set, get and delete keys", func() {
err := store.SetCols(context.Background(), sc, [][]float32{{0.1, 0.2, 0.3}, {0.4, 0.5, 0.6}, {0.7, 0.8, 0.9}}, [][]byte{[]byte("test1"), []byte("test2"), []byte("test3")})
Expect(err).ToNot(HaveOccurred())
keys, vals, err := store.GetCols(context.Background(), sc, [][]float32{{0.7, 0.8, 0.9}, {9, 9, 9}, {0.1, 0.2, 0.3}})
Expect(err).ToNot(HaveOccurred())
Expect(keys).To(Equal([][]float32{{0.7, 0.8, 0.9}, {0.1, 0.2, 0.3}}))
Expect(vals).To(Equal([][]byte{[]byte("test3"), []byte("test1")}))
err = store.DeleteCols(context.Background(), sc, [][]float32{{0.1, 0.2, 0.3}, {0.7, 0.8, 0.9}, {5, 5, 5}})
Expect(err).ToNot(HaveOccurred())
keys, vals, err = store.GetCols(context.Background(), sc, [][]float32{{0.1, 0.2, 0.3}, {0.4, 0.5, 0.6}, {0.7, 0.8, 0.9}})
Expect(err).ToNot(HaveOccurred())
Expect(keys).To(Equal([][]float32{{0.4, 0.5, 0.6}}))
Expect(vals).To(Equal([][]byte{[]byte("test2")}))
})
It("should find similar keys, returning the exact unnormalised keys", func() {
err := store.SetCols(context.Background(), sc, [][]float32{{0.5, 0.5, 0.5}, {0.6, 0.6, -0.6}, {0.7, -0.7, -0.7}}, [][]byte{[]byte("test1"), []byte("test2"), []byte("test3")})
Expect(err).ToNot(HaveOccurred())
keys, vals, sims, err := store.Find(context.Background(), sc, []float32{0.1, 0.3, 0.5}, 2)
Expect(err).ToNot(HaveOccurred())
Expect(keys).To(HaveLen(2))
Expect(sims).To(HaveLen(2))
Expect(keys[0]).To(Equal([]float32{0.5, 0.5, 0.5}))
Expect(vals[0]).To(Equal([]byte("test1")))
Expect(keys[1]).To(Equal([]float32{0.6, 0.6, -0.6}))
})
It("persists data across a backend restart", func() {
err := store.SetCols(context.Background(), sc,
[][]float32{{0.1, 0.2, 0.3}, {0.4, 0.5, 0.6}},
[][]byte{[]byte("persisted1"), []byte("persisted2")})
Expect(err).ToNot(HaveOccurred())
Expect(sl.StopAllGRPC()).To(Succeed())
initLoader()
sc = loadStore(namespace)
val, err := store.GetSingle(context.Background(), sc, []float32{0.1, 0.2, 0.3})
Expect(err).ToNot(HaveOccurred())
Expect(val).To(Equal([]byte("persisted1")))
keys, _, _, err := store.Find(context.Background(), sc, []float32{0.1, 0.2, 0.3}, 2)
Expect(err).ToNot(HaveOccurred())
Expect(keys).To(HaveLen(2))
Expect(store.SetSingle(context.Background(), sc, []float32{0.1, 0.2}, []byte("bad"))).ToNot(Succeed())
})
It("creates, recreates and checks a named collection on the server", func() {
Expect(sl.StopAllGRPC()).To(Succeed())
initLoader()
collection := "localai_it_" + namespace
sc = loadStore(namespace, "collection:"+collection, "distance_metric:DOT")
Expect(store.SetSingle(context.Background(), sc, []float32{1, 2, 3, 4}, []byte("a"))).To(Succeed())
host, port := splitAddr(qdrantAddr)
client, err := qdrant.NewClient(&qdrant.Config{Host: host, Port: port, SkipCompatibilityCheck: true})
Expect(err).ToNot(HaveOccurred())
DeferCleanup(client.Close)
DeferCleanup(func() { _ = client.DeleteCollection(context.Background(), collection) })
info, err := client.GetCollectionInfo(context.Background(), collection)
Expect(err).ToNot(HaveOccurred())
params := info.GetConfig().GetParams().GetVectorsConfig().GetParams()
Expect(params.GetSize()).To(Equal(uint64(4)))
Expect(params.GetDistance()).To(Equal(qdrant.Distance_Dot))
Expect(info.GetPointsCount()).To(Equal(uint64(1)))
Expect(client.DeleteCollection(context.Background(), collection)).To(Succeed())
Expect(store.SetSingle(context.Background(), sc, []float32{1, 2}, []byte("b"))).To(Succeed())
Expect(sl.StopAllGRPC()).To(Succeed())
initLoader()
_, err = tryLoadStore(namespace, "collection:"+collection, "distance_metric:COSINE")
Expect(err).To(MatchError(ContainSubstring("distance_metric is Cosine")))
})
})
})
func splitAddr(addr string) (string, int) {
host, portStr, err := net.SplitHostPort(addr)
Expect(err).ToNot(HaveOccurred())
port, err := strconv.Atoi(portStr)
Expect(err).ToNot(HaveOccurred())
return host, port
}
func dropNamespaceCollections(addr, namespace string) {
if addr == "" || namespace == "" {
return
}
host, port := splitAddr(addr)
client, err := qdrant.NewClient(&qdrant.Config{Host: host, Port: port, SkipCompatibilityCheck: true})
Expect(err).ToNot(HaveOccurred())
defer func() { _ = client.Close() }()
names, err := client.ListCollections(context.Background())
Expect(err).ToNot(HaveOccurred())
for _, n := range names {
if strings.HasPrefix(n, "localai_"+namespace+"-") {
Expect(client.DeleteCollection(context.Background(), n)).To(Succeed())
}
}
}
+1 -1
View File
@@ -267,7 +267,7 @@ engines:
- Ships in-tree and is the default, so embeddings work on a fresh install
- Exact cosine similarity, zero configuration
- Backs RAG, the face and voice registries and the semantic router cache
- Swap in valkey-store per request when you need durability
- Swap in valkey-store or qdrant-store per request when you need durability
- name: apex-quant
tagline: Fit a 35B mixture-of-experts model on a card you already own, and watch it run faster than the full-size build.