mirror of
https://github.com/redis/go-redis.git
synced 2026-10-22 19:49:00 +03:00
feat: sq compression hnsw (#4077)
This commit is contained in:
@@ -115,6 +115,16 @@ type FTHNSWOptions struct {
|
||||
// false can be distinguished from unset (omitted).
|
||||
Rerank bool
|
||||
HasRerank bool
|
||||
// Compression selects the vector quantization scheme for HNSW indexes
|
||||
// (Redis 8.12+), e.g. "SQ8". Emitted as COMPRESSION when non-empty.
|
||||
Compression string
|
||||
// TrainingThreshold is the number of vectors the index collects before
|
||||
// training the compression model. A positive value emits
|
||||
// TRAINING_THRESHOLD on its own; to emit an explicit TRAINING_THRESHOLD 0,
|
||||
// set HasTrainingThreshold=true with TrainingThreshold=0, so that an
|
||||
// explicit zero can be distinguished from unset (omitted).
|
||||
TrainingThreshold int
|
||||
HasTrainingThreshold bool
|
||||
}
|
||||
|
||||
type FTVamanaOptions struct {
|
||||
@@ -622,12 +632,14 @@ type FTAttribute struct {
|
||||
WithSuffixtrie bool
|
||||
|
||||
// Vector specific attributes
|
||||
Algorithm string
|
||||
DataType string
|
||||
Dim int
|
||||
DistanceMetric string
|
||||
M int
|
||||
EFConstruction int
|
||||
Algorithm string
|
||||
DataType string
|
||||
Dim int
|
||||
DistanceMetric string
|
||||
M int
|
||||
EFConstruction int
|
||||
Compression string
|
||||
TrainingThreshold int
|
||||
}
|
||||
|
||||
type CursorStats struct {
|
||||
@@ -1586,6 +1598,13 @@ func (c cmdable) FTCreate(ctx context.Context, index string, options *FTCreateOp
|
||||
}
|
||||
hnswArgs = append(hnswArgs, "RERANK", rerank)
|
||||
}
|
||||
if schema.VectorArgs.HNSWOptions.Compression != "" {
|
||||
hnswArgs = append(hnswArgs, "COMPRESSION", schema.VectorArgs.HNSWOptions.Compression)
|
||||
}
|
||||
if schema.VectorArgs.HNSWOptions.TrainingThreshold > 0 || schema.VectorArgs.HNSWOptions.HasTrainingThreshold {
|
||||
hnswArgs = append(hnswArgs, "TRAINING_THRESHOLD", schema.VectorArgs.HNSWOptions.TrainingThreshold)
|
||||
}
|
||||
|
||||
args = append(args, len(hnswArgs))
|
||||
args = append(args, hnswArgs...)
|
||||
}
|
||||
@@ -1818,6 +1837,10 @@ func parseFTAttributeFromMap(attrMap map[interface{}]interface{}) FTAttribute {
|
||||
att.M = internal.ToInteger(v)
|
||||
case "ef_construction":
|
||||
att.EFConstruction = internal.ToInteger(v)
|
||||
case "compression":
|
||||
att.Compression = internal.ToString(v)
|
||||
case "training_threshold":
|
||||
att.TrainingThreshold = internal.ToInteger(v)
|
||||
case "flags":
|
||||
// flags is an array of strings like ["SORTABLE", "NOSTEM"]
|
||||
if flags, ok := v.([]interface{}); ok {
|
||||
@@ -2060,6 +2083,16 @@ func parseFTInfo(data map[string]interface{}) (FTInfoResult, error) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if internal.ToLower(internal.ToString(attrSlice[i])) == "compression" && i+1 < attrLen {
|
||||
att.Compression = internal.ToString(attrSlice[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if internal.ToLower(internal.ToString(attrSlice[i])) == "training_threshold" && i+1 < attrLen {
|
||||
att.TrainingThreshold = internal.ToInteger(attrSlice[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
}
|
||||
ftInfo.Attributes = append(ftInfo.Attributes, att)
|
||||
} else if attrMap, ok := attr.(map[interface{}]interface{}); ok {
|
||||
|
||||
@@ -524,6 +524,127 @@ func TestFTCreateHNSWRerank_Args(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFTCreateHNSWCompression_Args verifies the COMPRESSION and
|
||||
// TRAINING_THRESHOLD attributes (Redis 8.12+, scalar-quantized HNSW) are
|
||||
// emitted as key-value pairs counted in the attribute-count token.
|
||||
// TRAINING_THRESHOLD is emitted for a positive value, or for an explicit zero
|
||||
// when HasTrainingThreshold is set, and omitted otherwise.
|
||||
func TestFTCreateHNSWCompression_Args(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts *FTHNSWOptions
|
||||
want []any
|
||||
}{
|
||||
{
|
||||
name: "unset",
|
||||
opts: &FTHNSWOptions{Type: "FLOAT32", Dim: 2, DistanceMetric: "L2"},
|
||||
want: []any{
|
||||
"FT.CREATE", "idx", "SCHEMA", "v", "VECTOR", "HNSW", 6,
|
||||
"TYPE", "FLOAT32", "DIM", 2, "DISTANCE_METRIC", "L2",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "compression_only",
|
||||
opts: &FTHNSWOptions{Type: "FLOAT32", Dim: 2, DistanceMetric: "L2", Compression: "SQ8"},
|
||||
want: []any{
|
||||
"FT.CREATE", "idx", "SCHEMA", "v", "VECTOR", "HNSW", 8,
|
||||
"TYPE", "FLOAT32", "DIM", 2, "DISTANCE_METRIC", "L2", "COMPRESSION", "SQ8",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "compression_and_training_threshold",
|
||||
opts: &FTHNSWOptions{Type: "FLOAT32", Dim: 2, DistanceMetric: "L2", Compression: "SQ8", TrainingThreshold: 1024},
|
||||
want: []any{
|
||||
"FT.CREATE", "idx", "SCHEMA", "v", "VECTOR", "HNSW", 10,
|
||||
"TYPE", "FLOAT32", "DIM", 2, "DISTANCE_METRIC", "L2", "COMPRESSION", "SQ8", "TRAINING_THRESHOLD", 1024,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "training_threshold_explicit_zero",
|
||||
opts: &FTHNSWOptions{Type: "FLOAT32", Dim: 2, DistanceMetric: "L2", Compression: "SQ8", HasTrainingThreshold: true},
|
||||
want: []any{
|
||||
"FT.CREATE", "idx", "SCHEMA", "v", "VECTOR", "HNSW", 10,
|
||||
"TYPE", "FLOAT32", "DIM", 2, "DISTANCE_METRIC", "L2", "COMPRESSION", "SQ8", "TRAINING_THRESHOLD", 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "training_threshold_zero_without_has_flag_omitted",
|
||||
opts: &FTHNSWOptions{Type: "FLOAT32", Dim: 2, DistanceMetric: "L2", Compression: "SQ8", TrainingThreshold: 0},
|
||||
want: []any{
|
||||
"FT.CREATE", "idx", "SCHEMA", "v", "VECTOR", "HNSW", 8,
|
||||
"TYPE", "FLOAT32", "DIM", 2, "DISTANCE_METRIC", "L2", "COMPRESSION", "SQ8",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := &mockCmdable{}
|
||||
c := m.asCmdable()
|
||||
cmd := c.FTCreate(context.Background(), "idx", nil, &FieldSchema{
|
||||
FieldName: "v",
|
||||
FieldType: SearchFieldTypeVector,
|
||||
VectorArgs: &FTVectorArgs{HNSWOptions: tt.opts},
|
||||
})
|
||||
if cmd.Err() != nil {
|
||||
t.Fatalf("unexpected error: %v", cmd.Err())
|
||||
}
|
||||
if !reflect.DeepEqual(cmd.Args(), tt.want) {
|
||||
t.Errorf("args mismatch\n got: %#v\nwant: %#v", cmd.Args(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseFTInfoHNSWCompression checks that FT.INFO populates
|
||||
// FTAttribute.Compression and FTAttribute.TrainingThreshold from both the
|
||||
// RESP2 flat-slice attribute shape and the RESP3 map attribute shape.
|
||||
func TestParseFTInfoHNSWCompression(t *testing.T) {
|
||||
t.Run("resp2 slice", func(t *testing.T) {
|
||||
data := map[string]any{
|
||||
"attributes": []any{
|
||||
[]any{
|
||||
"identifier", "v", "attribute", "v", "type", "VECTOR",
|
||||
"algorithm", "HNSW", "data_type", "FLOAT32", "dim", int64(2),
|
||||
"distance_metric", "L2", "M", int64(16), "ef_construction", int64(200),
|
||||
"compression", "SQ8", "training_threshold", int64(1024),
|
||||
},
|
||||
},
|
||||
}
|
||||
info, err := parseFTInfo(data)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(info.Attributes) != 1 {
|
||||
t.Fatalf("expected 1 attribute, got %d", len(info.Attributes))
|
||||
}
|
||||
att := info.Attributes[0]
|
||||
if att.Compression != "SQ8" {
|
||||
t.Errorf("Compression = %q, want %q", att.Compression, "SQ8")
|
||||
}
|
||||
if att.TrainingThreshold != 1024 {
|
||||
t.Errorf("TrainingThreshold = %d, want %d", att.TrainingThreshold, 1024)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resp3 map", func(t *testing.T) {
|
||||
att := parseFTAttributeFromMap(map[any]any{
|
||||
"identifier": "v",
|
||||
"attribute": "v",
|
||||
"type": "VECTOR",
|
||||
"algorithm": "HNSW",
|
||||
"compression": "SQ8",
|
||||
"training_threshold": int64(1024),
|
||||
})
|
||||
if att.Compression != "SQ8" {
|
||||
t.Errorf("Compression = %q, want %q", att.Compression, "SQ8")
|
||||
}
|
||||
if att.TrainingThreshold != 1024 {
|
||||
t.Errorf("TrainingThreshold = %d, want %d", att.TrainingThreshold, 1024)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestParseFTHybridWarnings checks that FT.HYBRID warnings populate from either
|
||||
// the "warning" or "warnings" key.
|
||||
func TestParseFTHybridWarnings(t *testing.T) {
|
||||
|
||||
@@ -2185,6 +2185,30 @@ var _ = Describe("RediSearch commands Resp 2", Label("search"), func() {
|
||||
WaitForIndexing(rawClient, "idx1")
|
||||
})
|
||||
|
||||
It("should FTCreate VECTOR with HNSW compression and read it back from FTInfo", Label("search", "ftcreate", "ftinfo"), func() {
|
||||
SkipBeforeRedisVersion("8.12", "HNSW COMPRESSION requires Redis 8.12+")
|
||||
hnswOptions := &redis.FTHNSWOptions{
|
||||
Type: "FLOAT32",
|
||||
Dim: 4,
|
||||
DistanceMetric: "L2",
|
||||
Compression: "SQ8",
|
||||
TrainingThreshold: 1024,
|
||||
}
|
||||
val, err := client.FTCreate(ctx, "idx1",
|
||||
&redis.FTCreateOptions{},
|
||||
&redis.FieldSchema{FieldName: "v", FieldType: redis.SearchFieldTypeVector, VectorArgs: &redis.FTVectorArgs{HNSWOptions: hnswOptions}}).Result()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(val).To(BeEquivalentTo("OK"))
|
||||
WaitForIndexing(rawClient, "idx1")
|
||||
|
||||
resInfo, err := client.FTInfo(ctx, "idx1").Result()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(resInfo.Attributes).To(HaveLen(1))
|
||||
Expect(resInfo.Attributes[0].Algorithm).To(BeEquivalentTo("HNSW"))
|
||||
Expect(resInfo.Attributes[0].Compression).To(BeEquivalentTo("SQ8"))
|
||||
Expect(resInfo.Attributes[0].TrainingThreshold).To(BeEquivalentTo(1024))
|
||||
})
|
||||
|
||||
It("should FTCreate VECTOR with VAMANA algorithm - advanced parameters", Label("search", "ftcreate"), func() {
|
||||
SkipBeforeRedisVersion("8.2", "VAMANA requires Redis 8.2+")
|
||||
vamanaOptions := &redis.FTVamanaOptions{
|
||||
@@ -4871,4 +4895,31 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() {
|
||||
Expect(res2).ToNot(BeEmpty())
|
||||
}).ShouldNot(Panic())
|
||||
})
|
||||
|
||||
It("should read HNSW compression back from FTInfo with RESP3", Label("search", "ftcreate", "ftinfo"), func() {
|
||||
SkipBeforeRedisVersion("8.12", "HNSW COMPRESSION requires Redis 8.12+")
|
||||
hnswOptions := &redis.FTHNSWOptions{
|
||||
Type: "FLOAT32",
|
||||
Dim: 4,
|
||||
DistanceMetric: "L2",
|
||||
Compression: "SQ8",
|
||||
TrainingThreshold: 1024,
|
||||
}
|
||||
val, err := client.FTCreate(ctx, "idx1",
|
||||
&redis.FTCreateOptions{},
|
||||
&redis.FieldSchema{FieldName: "v", FieldType: redis.SearchFieldTypeVector, VectorArgs: &redis.FTVectorArgs{HNSWOptions: hnswOptions}}).Result()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(val).To(BeEquivalentTo("OK"))
|
||||
WaitForIndexing(rawClient, "idx1")
|
||||
|
||||
// RESP3 attributes arrive as maps and go through parseFTAttributeFromMap.
|
||||
for _, c := range []redis.Cmdable{client, client2} {
|
||||
resInfo, err := c.FTInfo(ctx, "idx1").Result()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(resInfo.Attributes).To(HaveLen(1))
|
||||
Expect(resInfo.Attributes[0].Algorithm).To(BeEquivalentTo("HNSW"))
|
||||
Expect(resInfo.Attributes[0].Compression).To(BeEquivalentTo("SQ8"))
|
||||
Expect(resInfo.Attributes[0].TrainingThreshold).To(BeEquivalentTo(1024))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user