feat(vector): validate FT.HYBRID vector input type. Add proper vector types (#3756)

* fix FT.HYBIRD vector validation

* Add missing vector types for FT.HYBRID

---------

Co-authored-by: Nedyalko Dyakov <[email protected]>
This commit is contained in:
Yi Deng
2026-05-08 11:26:32 +03:00
committed by GitHub
co-authored by Nedyalko Dyakov
parent 5a3f74cc81
commit 5e36de8b1d
4 changed files with 349 additions and 10 deletions
+41 -10
View File
@@ -3006,6 +3006,42 @@ func (c cmdable) FTHybrid(ctx context.Context, index string, searchExpr string,
return c.FTHybridWithArgs(ctx, index, options)
}
func hybridVectorBlob(v Vector) (interface{}, error) {
if v == nil {
return nil, fmt.Errorf("FT.HYBRID: vector data is required")
}
switch vector := v.(type) {
case *VectorFP32:
return hybridVectorBytes(vector.Val)
case *VectorFloat16:
return hybridVectorBytes(vector.Val)
case *VectorBFloat16:
return hybridVectorBytes(vector.Val)
case *VectorFloat64:
return hybridVectorBytes(vector.Val)
case *VectorInt8:
return hybridVectorBytes(vector.Val)
case *VectorUint8:
return hybridVectorBytes(vector.Val)
case *VectorValues, *VectorRef:
return nil, fmt.Errorf("FT.HYBRID: unsupported vector type %T", v)
default:
values := v.Value()
if len(values) < 2 {
return nil, fmt.Errorf("FT.HYBRID: vector Value must contain a blob at index 1")
}
return values[1], nil
}
}
func hybridVectorBytes(blob []byte) ([]byte, error) {
if len(blob) == 0 {
return nil, fmt.Errorf("FT.HYBRID: vector blob is required")
}
return blob, nil
}
// FTHybridWithArgs - Executes a hybrid search with advanced options
// FTHybridWithArgs is still experimental, the command behaviour and signature may change
func (c cmdable) FTHybridWithArgs(ctx context.Context, index string, options *FTHybridOptions) *FTHybridCmd {
@@ -3032,16 +3068,11 @@ func (c cmdable) FTHybridWithArgs(ctx context.Context, index string, options *FT
for _, vectorExpr := range options.VectorExpressions {
args = append(args, "VSIM", "@"+vectorExpr.VectorField)
// For FT.HYBRID, we need to send just the raw vector bytes, not the Value() format
// Value() returns [format, data] but FT.HYBRID expects just the blob
vectorValue := vectorExpr.VectorData.Value()
var vectorBlob interface{}
if len(vectorValue) >= 2 {
// vectorValue is [format, data, ...] - we only want the data part
vectorBlob = vectorValue[1]
} else {
// Fallback for unexpected format
vectorBlob = vectorValue
vectorBlob, err := hybridVectorBlob(vectorExpr.VectorData)
if err != nil {
cmd := newFTHybridCmd(ctx, options, args...)
cmd.SetErr(err)
return cmd
}
// If VectorParamName is provided, use PARAMS mechanism (required for Redis 8.6+)
+198
View File
@@ -0,0 +1,198 @@
package redis
import (
"context"
"reflect"
"strings"
"testing"
)
type legacyHybridVector struct {
value []any
}
func (v legacyHybridVector) Value() []any {
return v.value
}
func TestHybridVectorBlob(t *testing.T) {
tests := []struct {
name string
vector Vector
want interface{}
wantErr string
}{
{
name: "fp32",
vector: &VectorFP32{Val: []byte{1, 2, 3, 4}},
want: []byte{1, 2, 3, 4},
},
{
name: "float16",
vector: &VectorFloat16{Val: []byte{1, 2, 3, 4}},
want: []byte{1, 2, 3, 4},
},
{
name: "bfloat16",
vector: &VectorBFloat16{Val: []byte{1, 2, 3, 4}},
want: []byte{1, 2, 3, 4},
},
{
name: "float64",
vector: &VectorFloat64{Val: []byte{1, 2, 3, 4}},
want: []byte{1, 2, 3, 4},
},
{
name: "int8",
vector: &VectorInt8{Val: []byte{1, 2, 3, 4}},
want: []byte{1, 2, 3, 4},
},
{
name: "uint8",
vector: &VectorUint8{Val: []byte{1, 2, 3, 4}},
want: []byte{1, 2, 3, 4},
},
{
name: "custom legacy vector",
vector: legacyHybridVector{value: []any{"CUSTOM", []byte{1, 2, 3, 4}}},
want: []byte{1, 2, 3, 4},
},
{
name: "nil",
vector: nil,
wantErr: "vector data is required",
},
{
name: "empty fp32",
vector: &VectorFP32{},
wantErr: "vector blob is required",
},
{
name: "values",
vector: &VectorValues{Val: []float64{1, 2}},
wantErr: "unsupported vector type *redis.VectorValues",
},
{
name: "ref",
vector: &VectorRef{Name: "vec1"},
wantErr: "unsupported vector type *redis.VectorRef",
},
{
name: "invalid custom legacy vector",
vector: legacyHybridVector{value: []any{"CUSTOM"}},
wantErr: "vector Value must contain a blob at index 1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := hybridVectorBlob(tt.vector)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("got %v, want %v", got, tt.want)
}
})
}
}
func TestFTHybridWithArgsRejectsUnsupportedVectors(t *testing.T) {
tests := []struct {
name string
vector Vector
wantErr string
}{
{
name: "values",
vector: &VectorValues{Val: []float64{1, 2}},
wantErr: "unsupported vector type *redis.VectorValues",
},
{
name: "ref",
vector: &VectorRef{Name: "vec1"},
wantErr: "unsupported vector type *redis.VectorRef",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &mockCmdable{}
c := m.asCmdable()
cmd := c.FTHybridWithArgs(context.Background(), "idx", &FTHybridOptions{
SearchExpressions: []FTHybridSearchExpression{{Query: "*"}},
VectorExpressions: []FTHybridVectorExpression{{
VectorField: "embedding",
VectorData: tt.vector,
}},
})
if cmd.Err() == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(cmd.Err().Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %q", tt.wantErr, cmd.Err().Error())
}
if m.lastCmd != nil {
t.Fatalf("expected command not to be executed")
}
})
}
}
func TestFTHybridWithArgsAcceptsVectorFP32(t *testing.T) {
tests := []struct {
name string
vector Vector
}{
{name: "fp32", vector: &VectorFP32{Val: []byte{1, 2, 3, 4}}},
{name: "float16", vector: &VectorFloat16{Val: []byte{1, 2, 3, 4}}},
{name: "bfloat16", vector: &VectorBFloat16{Val: []byte{1, 2, 3, 4}}},
{name: "float64", vector: &VectorFloat64{Val: []byte{1, 2, 3, 4}}},
{name: "int8", vector: &VectorInt8{Val: []byte{1, 2, 3, 4}}},
{name: "uint8", vector: &VectorUint8{Val: []byte{1, 2, 3, 4}}},
{name: "custom legacy vector", vector: legacyHybridVector{value: []any{"CUSTOM", []byte{1, 2, 3, 4}}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &mockCmdable{}
c := m.asCmdable()
cmd := c.FTHybridWithArgs(context.Background(), "idx", &FTHybridOptions{
SearchExpressions: []FTHybridSearchExpression{{Query: "*"}},
VectorExpressions: []FTHybridVectorExpression{{
VectorField: "embedding",
VectorData: tt.vector,
}},
})
if cmd.Err() != nil {
t.Fatalf("unexpected error: %v", cmd.Err())
}
if m.lastCmd == nil {
t.Fatalf("expected command to be executed")
}
gotCmd, ok := m.lastCmd.(*FTHybridCmd)
if !ok {
t.Fatalf("expected FTHybridCmd, got %T", m.lastCmd)
}
foundBlob := false
for _, arg := range gotCmd.args {
if blob, ok := arg.([]byte); ok && string(blob) == string([]byte{1, 2, 3, 4}) {
foundBlob = true
break
}
}
if !foundBlob {
t.Fatalf("expected raw vector blob in args, got %v", gotCmd.args)
}
})
}
}
+65
View File
@@ -39,6 +39,11 @@ type Vector interface {
const (
vectorFormatFP32 string = "FP32"
vectorFormatValues string = "Values"
vectorFormatF16 string = "FLOAT16"
vectorFormatBF16 string = "BFLOAT16"
vectorFormatF64 string = "FLOAT64"
vectorFormatI8 string = "INT8"
vectorFormatU8 string = "UINT8"
)
type VectorFP32 struct {
@@ -51,6 +56,66 @@ func (v *VectorFP32) Value() []any {
var _ Vector = (*VectorFP32)(nil)
// VectorFloat16 represents a FLOAT16-encoded vector blob.
// note: intended for search/index query commands such as FT.HYBRID.
type VectorFloat16 struct {
Val []byte
}
func (v *VectorFloat16) Value() []any {
return []any{vectorFormatF16, v.Val}
}
var _ Vector = (*VectorFloat16)(nil)
// VectorBFloat16 represents a BFLOAT16-encoded vector blob.
// note: intended for search/index query commands such as FT.HYBRID.
type VectorBFloat16 struct {
Val []byte
}
func (v *VectorBFloat16) Value() []any {
return []any{vectorFormatBF16, v.Val}
}
var _ Vector = (*VectorBFloat16)(nil)
// VectorFloat64 represents a FLOAT64-encoded vector blob.
// note: intended for search/index query commands such as FT.HYBRID.
type VectorFloat64 struct {
Val []byte
}
func (v *VectorFloat64) Value() []any {
return []any{vectorFormatF64, v.Val}
}
var _ Vector = (*VectorFloat64)(nil)
// VectorInt8 represents an INT8-encoded vector blob.
// note: intended for search/index query commands such as FT.HYBRID.
type VectorInt8 struct {
Val []byte
}
func (v *VectorInt8) Value() []any {
return []any{vectorFormatI8, v.Val}
}
var _ Vector = (*VectorInt8)(nil)
// VectorUint8 represents a UINT8-encoded vector blob.
// note: intended for search/index query commands such as FT.HYBRID.
type VectorUint8 struct {
Val []byte
}
func (v *VectorUint8) Value() []any {
return []any{vectorFormatU8, v.Val}
}
var _ Vector = (*VectorUint8)(nil)
type VectorValues struct {
Val []float64
}
+45
View File
@@ -20,6 +20,51 @@ func TestVectorFP32_Value(t *testing.T) {
}
}
func TestVectorFloat16_Value(t *testing.T) {
v := &VectorFloat16{Val: []byte{1, 2, 3, 4}}
got := v.Value()
want := []any{"FLOAT16", []byte{1, 2, 3, 4}}
if !reflect.DeepEqual(got, want) {
t.Errorf("VectorFloat16.Value() = %v, want %v", got, want)
}
}
func TestVectorBFloat16_Value(t *testing.T) {
v := &VectorBFloat16{Val: []byte{1, 2, 3, 4}}
got := v.Value()
want := []any{"BFLOAT16", []byte{1, 2, 3, 4}}
if !reflect.DeepEqual(got, want) {
t.Errorf("VectorBFloat16.Value() = %v, want %v", got, want)
}
}
func TestVectorFloat64_Value(t *testing.T) {
v := &VectorFloat64{Val: []byte{1, 2, 3, 4}}
got := v.Value()
want := []any{"FLOAT64", []byte{1, 2, 3, 4}}
if !reflect.DeepEqual(got, want) {
t.Errorf("VectorFloat64.Value() = %v, want %v", got, want)
}
}
func TestVectorInt8_Value(t *testing.T) {
v := &VectorInt8{Val: []byte{1, 2, 3, 4}}
got := v.Value()
want := []any{"INT8", []byte{1, 2, 3, 4}}
if !reflect.DeepEqual(got, want) {
t.Errorf("VectorInt8.Value() = %v, want %v", got, want)
}
}
func TestVectorUint8_Value(t *testing.T) {
v := &VectorUint8{Val: []byte{1, 2, 3, 4}}
got := v.Value()
want := []any{"UINT8", []byte{1, 2, 3, 4}}
if !reflect.DeepEqual(got, want) {
t.Errorf("VectorUint8.Value() = %v, want %v", got, want)
}
}
func TestVectorValues_Value(t *testing.T) {
v := &VectorValues{Val: []float64{1.1, 2.2}}
got := v.Value()