From 8d9115b072fc696d489bebd7e1bb4bc9b4b9fcdd Mon Sep 17 00:00:00 2001 From: Hristo Temelski Date: Wed, 29 Oct 2025 10:15:11 +0200 Subject: [PATCH 1/3] Added hybrid search command --- search_commands.go | 332 +++++++++++++++++++++++++++++++++++++ search_test.go | 403 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 735 insertions(+) diff --git a/search_commands.go b/search_commands.go index f0ca1bfede..2e9496455e 100644 --- a/search_commands.go +++ b/search_commands.go @@ -29,6 +29,8 @@ type SearchCmdable interface { FTDropIndexWithArgs(ctx context.Context, index string, options *FTDropIndexOptions) *StatusCmd FTExplain(ctx context.Context, index string, query string) *StringCmd FTExplainWithArgs(ctx context.Context, index string, query string, options *FTExplainOptions) *StringCmd + FTHybrid(ctx context.Context, index string, searchExpr string, vectorField string, vectorData Vector) *FTHybridCmd + FTHybridWithArgs(ctx context.Context, index string, options *FTHybridOptions) *FTHybridCmd FTInfo(ctx context.Context, index string) *FTInfoCmd FTSpellCheck(ctx context.Context, index string, query string) *FTSpellCheckCmd FTSpellCheckWithArgs(ctx context.Context, index string, query string, options *FTSpellCheckOptions) *FTSpellCheckCmd @@ -344,6 +346,85 @@ type FTSearchOptions struct { DialectVersion int } +// FTHybridCombineMethod represents the fusion method for combining search and vector results +type FTHybridCombineMethod string + +const ( + FTHybridCombineRRF FTHybridCombineMethod = "RRF" + FTHybridCombineLinear FTHybridCombineMethod = "LINEAR" + FTHybridCombineFunction FTHybridCombineMethod = "FUNCTION" +) + +// FTHybridSearchExpression represents a search expression in hybrid search +type FTHybridSearchExpression struct { + Query string + Scorer string + ScorerParams []interface{} + YieldScoreAs string +} + +// FTHybridVectorExpression represents a vector expression in hybrid search +type FTHybridVectorExpression struct { + VectorField string + VectorData Vector + Method string // KNN or RANGE + MethodParams []interface{} + Filter string + YieldScoreAs string +} + +// FTHybridCombineOptions represents options for result fusion +type FTHybridCombineOptions struct { + Method FTHybridCombineMethod + Count int + Window int // For RRF + Constant float64 // For RRF + Alpha float64 // For LINEAR + Beta float64 // For LINEAR + YieldScoreAs string +} + +// FTHybridGroupBy represents GROUP BY functionality +type FTHybridGroupBy struct { + Count int + Fields []string + ReduceFunc string + ReduceCount int + ReduceParams []interface{} +} + +// FTHybridApply represents APPLY functionality +type FTHybridApply struct { + Expression string + AsField string +} + +// FTHybridWithCursor represents cursor configuration for hybrid search +type FTHybridWithCursor struct { + Count int // Number of results to return per cursor read + MaxIdle int // Maximum idle time in milliseconds before cursor is automatically deleted +} + +// FTHybridOptions hold options that can be passed to the FT.HYBRID command +type FTHybridOptions struct { + CountExpressions int // Number of search/vector expressions + SearchExpressions []FTHybridSearchExpression // Multiple search expressions + VectorExpressions []FTHybridVectorExpression // Multiple vector expressions + Combine *FTHybridCombineOptions // Fusion step options + Load []string // Projected fields + GroupBy *FTHybridGroupBy // Aggregation grouping + Apply []FTHybridApply // Field transformations + SortBy []FTSearchSortBy // Reuse from FTSearch + Filter string // Post-filter expression + LimitOffset int // Result limiting + Limit int + Params map[string]interface{} // Parameter substitution + ExplainScore bool // Include score explanations + Timeout int // Runtime timeout + WithCursor bool // Enable cursor support for large result sets + WithCursorOptions *FTHybridWithCursor // Cursor configuration options +} + type FTSynDumpResult struct { Term string Synonyms []string @@ -1819,6 +1900,70 @@ func (cmd *FTSearchCmd) readReply(rd *proto.Reader) (err error) { return nil } +// FTHybridResult represents the result of a hybrid search operation +type FTHybridResult struct { + Total int + Docs []Document +} + +type FTHybridCmd struct { + baseCmd + val FTHybridResult + options *FTHybridOptions +} + +func newFTHybridCmd(ctx context.Context, options *FTHybridOptions, args ...interface{}) *FTHybridCmd { + return &FTHybridCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + }, + options: options, + } +} + +func (cmd *FTHybridCmd) String() string { + return cmdString(cmd, cmd.val) +} + +func (cmd *FTHybridCmd) SetVal(val FTHybridResult) { + cmd.val = val +} + +func (cmd *FTHybridCmd) Result() (FTHybridResult, error) { + return cmd.val, cmd.err +} + +func (cmd *FTHybridCmd) Val() FTHybridResult { + return cmd.val +} + +func (cmd *FTHybridCmd) RawVal() interface{} { + return cmd.rawVal +} + +func (cmd *FTHybridCmd) RawResult() (interface{}, error) { + return cmd.rawVal, cmd.err +} + +func (cmd *FTHybridCmd) readReply(rd *proto.Reader) (err error) { + data, err := rd.ReadSlice() + if err != nil { + return err + } + // Parse hybrid search results similarly to FT.SEARCH + // We can reuse the FTSearch parser since the result format should be similar + searchResult, err := parseFTSearch(data, false, true, false, false) + if err != nil { + return err + } + cmd.val = FTHybridResult{ + Total: searchResult.Total, + Docs: searchResult.Docs, + } + return nil +} + // FTSearch - Executes a search query on an index. // The 'index' parameter specifies the index to search, and the 'query' parameter specifies the search query. // For more information, please refer to the Redis documentation about [FT.SEARCH]. @@ -2191,3 +2336,190 @@ func (c cmdable) FTTagVals(ctx context.Context, index string, field string) *Str _ = c(ctx, cmd) return cmd } + +// FTHybrid - Executes a hybrid search combining full-text search and vector similarity +// The 'index' parameter specifies the index to search, 'searchExpr' is the search query, +// 'vectorField' is the name of the vector field, and 'vectorData' is the vector to search with. +func (c cmdable) FTHybrid(ctx context.Context, index string, searchExpr string, vectorField string, vectorData Vector) *FTHybridCmd { + options := &FTHybridOptions{ + CountExpressions: 2, + SearchExpressions: []FTHybridSearchExpression{ + {Query: searchExpr}, + }, + VectorExpressions: []FTHybridVectorExpression{ + {VectorField: vectorField, VectorData: vectorData}, + }, + } + return c.FTHybridWithArgs(ctx, index, options) +} + +// FTHybridWithArgs - Executes a hybrid search with advanced options +func (c cmdable) FTHybridWithArgs(ctx context.Context, index string, options *FTHybridOptions) *FTHybridCmd { + args := []interface{}{"FT.HYBRID", index} + + if options != nil { + // Add count expressions if specified + if options.CountExpressions > 0 { + args = append(args, options.CountExpressions) + } else { + // Default to 2 expressions (1 search + 1 vector) + args = append(args, 2) + } + + // Add search expressions + for _, searchExpr := range options.SearchExpressions { + args = append(args, "SEARCH", searchExpr.Query) + + if searchExpr.Scorer != "" { + args = append(args, "SCORER", searchExpr.Scorer) + if len(searchExpr.ScorerParams) > 0 { + args = append(args, searchExpr.ScorerParams...) + } + } + + if searchExpr.YieldScoreAs != "" { + args = append(args, "YIELD_SCORE_AS", searchExpr.YieldScoreAs) + } + } + + // Add vector expressions + for _, vectorExpr := range options.VectorExpressions { + args = append(args, "VSIM", "@"+vectorExpr.VectorField) + args = append(args, vectorExpr.VectorData.Value()...) + + if vectorExpr.Method != "" { + args = append(args, vectorExpr.Method) + if len(vectorExpr.MethodParams) > 0 { + args = append(args, vectorExpr.MethodParams...) + } + } + + if vectorExpr.Filter != "" { + args = append(args, "FILTER", vectorExpr.Filter) + } + + if vectorExpr.YieldScoreAs != "" { + args = append(args, "YIELD_SCORE_AS", vectorExpr.YieldScoreAs) + } + } + + // Add combine/fusion options + if options.Combine != nil { + args = append(args, "COMBINE", string(options.Combine.Method)) + + if options.Combine.Count > 0 { + args = append(args, options.Combine.Count) + } + + switch options.Combine.Method { + case FTHybridCombineRRF: + if options.Combine.Window > 0 { + args = append(args, "WINDOW", options.Combine.Window) + } + if options.Combine.Constant > 0 { + args = append(args, "CONSTANT", options.Combine.Constant) + } + case FTHybridCombineLinear: + if options.Combine.Alpha > 0 { + args = append(args, "ALPHA", options.Combine.Alpha) + } + if options.Combine.Beta > 0 { + args = append(args, "BETA", options.Combine.Beta) + } + } + + if options.Combine.YieldScoreAs != "" { + args = append(args, "YIELD_SCORE_AS", options.Combine.YieldScoreAs) + } + } + + // Add LOAD (projected fields) + if len(options.Load) > 0 { + args = append(args, "LOAD", len(options.Load)) + for _, field := range options.Load { + args = append(args, field) + } + } + + // Add GROUPBY + if options.GroupBy != nil { + args = append(args, "GROUPBY", options.GroupBy.Count) + for _, field := range options.GroupBy.Fields { + args = append(args, field) + } + if options.GroupBy.ReduceFunc != "" { + args = append(args, "REDUCE", options.GroupBy.ReduceFunc, options.GroupBy.ReduceCount) + args = append(args, options.GroupBy.ReduceParams...) + } + } + + // Add APPLY transformations + for _, apply := range options.Apply { + args = append(args, "APPLY", apply.Expression, "AS", apply.AsField) + } + + // Add SORTBY + if len(options.SortBy) > 0 { + args = append(args, "SORTBY", len(options.SortBy)) + for _, sortBy := range options.SortBy { + args = append(args, sortBy.FieldName) + if sortBy.Asc && sortBy.Desc { + cmd := newFTHybridCmd(ctx, options, args...) + cmd.SetErr(fmt.Errorf("FT.HYBRID: ASC and DESC are mutually exclusive")) + return cmd + } + if sortBy.Asc { + args = append(args, "ASC") + } + if sortBy.Desc { + args = append(args, "DESC") + } + } + } + + // Add FILTER (post-filter) + if options.Filter != "" { + args = append(args, "FILTER", options.Filter) + } + + // Add LIMIT + if options.LimitOffset >= 0 && options.Limit > 0 || options.LimitOffset > 0 && options.Limit == 0 { + args = append(args, "LIMIT", options.LimitOffset, options.Limit) + } + + // Add PARAMS + if len(options.Params) > 0 { + args = append(args, "PARAMS", len(options.Params)*2) + for key, value := range options.Params { + args = append(args, key, value) + } + } + + // Add EXPLAINSCORE + if options.ExplainScore { + args = append(args, "EXPLAINSCORE") + } + + // Add TIMEOUT + if options.Timeout > 0 { + args = append(args, "TIMEOUT", options.Timeout) + } + + // Add WITHCURSOR support + if options.WithCursor { + args = append(args, "WITHCURSOR") + if options.WithCursorOptions != nil { + if options.WithCursorOptions.Count > 0 { + args = append(args, "COUNT", options.WithCursorOptions.Count) + } + if options.WithCursorOptions.MaxIdle > 0 { + args = append(args, "MAXIDLE", options.WithCursorOptions.MaxIdle) + } + } + } + } + + cmd := newFTHybridCmd(ctx, options, args...) + _ = c(ctx, cmd) + return cmd +} diff --git a/search_test.go b/search_test.go index a939a585e6..4c9b55cc20 100644 --- a/search_test.go +++ b/search_test.go @@ -3561,4 +3561,407 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() { Expect(res2).ToNot(BeEmpty()) }).ShouldNot(Panic()) }) + + // Hybrid Search Tests + Describe("FT.HYBRID Commands", func() { + BeforeEach(func() { + // Create index with text, numeric, tag fields and vector fields + err := client.FTCreate(ctx, "hybrid_idx", &redis.FTCreateOptions{}, + &redis.FieldSchema{FieldName: "description", FieldType: redis.SearchFieldTypeText}, + &redis.FieldSchema{FieldName: "price", FieldType: redis.SearchFieldTypeNumeric}, + &redis.FieldSchema{FieldName: "color", FieldType: redis.SearchFieldTypeTag}, + &redis.FieldSchema{FieldName: "item_type", FieldType: redis.SearchFieldTypeTag}, + &redis.FieldSchema{FieldName: "size", FieldType: redis.SearchFieldTypeNumeric}, + &redis.FieldSchema{ + FieldName: "embedding", + FieldType: redis.SearchFieldTypeVector, + VectorArgs: &redis.FTVectorArgs{ + FlatOptions: &redis.FTFlatOptions{ + Type: "FLOAT32", + Dim: 4, + DistanceMetric: "L2", + }, + }, + }, + &redis.FieldSchema{ + FieldName: "embedding_hnsw", + FieldType: redis.SearchFieldTypeVector, + VectorArgs: &redis.FTVectorArgs{ + HNSWOptions: &redis.FTHNSWOptions{ + Type: "FLOAT32", + Dim: 4, + DistanceMetric: "L2", + }, + }, + }).Err() + Expect(err).NotTo(HaveOccurred()) + WaitForIndexing(client, "hybrid_idx") + + // Add test data + items := []struct { + key string + description string + price int + color string + itemType string + size int + embedding []float32 + }{ + {"item:0", "red shoes", 15, "red", "shoes", 10, []float32{1.0, 2.0, 7.0, 8.0}}, + {"item:1", "green shoes with red laces", 16, "green", "shoes", 11, []float32{1.0, 4.0, 7.0, 8.0}}, + {"item:2", "red dress", 17, "red", "dress", 12, []float32{1.0, 2.0, 6.0, 5.0}}, + {"item:3", "orange dress", 18, "orange", "dress", 10, []float32{2.0, 3.0, 6.0, 5.0}}, + {"item:4", "black shoes", 19, "black", "shoes", 11, []float32{5.0, 6.0, 7.0, 8.0}}, + } + + for _, item := range items { + client.HSet(ctx, item.key, map[string]interface{}{ + "description": item.description, + "price": item.price, + "color": item.color, + "item_type": item.itemType, + "size": item.size, + "embedding": encodeFloat32Vector(item.embedding), + "embedding_hnsw": encodeFloat32Vector(item.embedding), + }) + } + }) + + It("should perform basic hybrid search", Label("search", "fthybrid"), func() { + // Basic hybrid search combining text and vector search + searchQuery := "@color:{red} OR @color:{green}" + vectorData := encodeFloat32Vector([]float32{-100, -200, -200, -300}) + + res, err := client.FTHybrid(ctx, "hybrid_idx", searchQuery, "embedding", &redis.VectorFP32{Val: vectorData}).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Total).To(BeNumerically(">", 0)) + Expect(len(res.Docs)).To(BeNumerically(">", 0)) + + // Check that results contain expected fields + for _, doc := range res.Docs { + Expect(doc.ID).ToNot(BeEmpty()) + Expect(doc.Fields).To(HaveKey("__score")) + Expect(doc.Fields).To(HaveKey("__key")) + } + }) + + It("should perform hybrid search with scorer", Label("search", "fthybrid", "scorer"), func() { + // Test with TFIDF scorer + options := &redis.FTHybridOptions{ + CountExpressions: 2, + SearchExpressions: []redis.FTHybridSearchExpression{ + { + Query: "@color:{red}", + Scorer: "TFIDF", + }, + }, + VectorExpressions: []redis.FTHybridVectorExpression{ + { + VectorField: "embedding", + VectorData: &redis.VectorFP32{Val: encodeFloat32Vector([]float32{1, 2, 2, 3})}, + }, + }, + Load: []string{"description", "color", "price", "size", "__score"}, + LimitOffset: 0, + Limit: 3, + } + + res, err := client.FTHybridWithArgs(ctx, "hybrid_idx", options).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Total).To(BeNumerically(">", 0)) + Expect(len(res.Docs)).To(BeNumerically("<=", 3)) + + // Verify that we get red items + for _, doc := range res.Docs { + if color, exists := doc.Fields["color"]; exists { + Expect(color).To(Equal("red")) + } + } + }) + + It("should perform hybrid search with vector filter", Label("search", "fthybrid", "filter"), func() { + // This query won't have results from search, so we can validate vector filter + options := &redis.FTHybridOptions{ + CountExpressions: 2, + SearchExpressions: []redis.FTHybridSearchExpression{ + {Query: "@color:{none}"}, // This won't match anything + }, + VectorExpressions: []redis.FTHybridVectorExpression{ + { + VectorField: "embedding", + VectorData: &redis.VectorFP32{Val: encodeFloat32Vector([]float32{1, 2, 2, 3})}, + Filter: "@price:[15 16] @size:[10 11]", + }, + }, + Load: []string{"description", "color", "price", "size", "__score"}, + } + + res, err := client.FTHybridWithArgs(ctx, "hybrid_idx", options).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Total).To(BeNumerically(">", 0)) + + // Verify that all results match the filter criteria + for _, doc := range res.Docs { + if price, exists := doc.Fields["price"]; exists { + priceStr := fmt.Sprintf("%v", price) + priceFloat, err := helper.ParseFloat(priceStr) + Expect(err).NotTo(HaveOccurred()) + Expect(priceFloat).To(BeNumerically(">=", 15)) + Expect(priceFloat).To(BeNumerically("<=", 16)) + } + if size, exists := doc.Fields["size"]; exists { + sizeStr := fmt.Sprintf("%v", size) + sizeFloat, err := helper.ParseFloat(sizeStr) + Expect(err).NotTo(HaveOccurred()) + Expect(sizeFloat).To(BeNumerically(">=", 10)) + Expect(sizeFloat).To(BeNumerically("<=", 11)) + } + } + }) + + It("should perform hybrid search with KNN method", Label("search", "fthybrid", "knn"), func() { + options := &redis.FTHybridOptions{ + CountExpressions: 2, + SearchExpressions: []redis.FTHybridSearchExpression{ + {Query: "@color:{none}"}, // This won't match anything + }, + VectorExpressions: []redis.FTHybridVectorExpression{ + { + VectorField: "embedding", + VectorData: &redis.VectorFP32{Val: encodeFloat32Vector([]float32{1, 2, 2, 3})}, + Method: "KNN", + MethodParams: []interface{}{3}, // K=3 + }, + }, + } + + res, err := client.FTHybridWithArgs(ctx, "hybrid_idx", options).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Total).To(Equal(3)) // Should return exactly K=3 results + Expect(len(res.Docs)).To(Equal(3)) + }) + + It("should perform hybrid search with RANGE method", Label("search", "fthybrid", "range"), func() { + options := &redis.FTHybridOptions{ + CountExpressions: 2, + SearchExpressions: []redis.FTHybridSearchExpression{ + {Query: "@color:{none}"}, // This won't match anything + }, + VectorExpressions: []redis.FTHybridVectorExpression{ + { + VectorField: "embedding", + VectorData: &redis.VectorFP32{Val: encodeFloat32Vector([]float32{1, 2, 7, 6})}, + Method: "RANGE", + MethodParams: []interface{}{2}, // RADIUS=2 + }, + }, + LimitOffset: 0, + Limit: 3, + } + + res, err := client.FTHybridWithArgs(ctx, "hybrid_idx", options).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Total).To(BeNumerically(">", 0)) + Expect(len(res.Docs)).To(BeNumerically("<=", 3)) + }) + + It("should perform hybrid search with LINEAR combine method", Label("search", "fthybrid", "combine"), func() { + options := &redis.FTHybridOptions{ + CountExpressions: 2, + SearchExpressions: []redis.FTHybridSearchExpression{ + {Query: "@color:{red}"}, + }, + VectorExpressions: []redis.FTHybridVectorExpression{ + { + VectorField: "embedding", + VectorData: &redis.VectorFP32{Val: encodeFloat32Vector([]float32{1, 2, 7, 6})}, + }, + }, + Combine: &redis.FTHybridCombineOptions{ + Method: redis.FTHybridCombineLinear, + Alpha: 0.5, + Beta: 0.5, + }, + LimitOffset: 0, + Limit: 3, + } + + res, err := client.FTHybridWithArgs(ctx, "hybrid_idx", options).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Total).To(BeNumerically(">", 0)) + Expect(len(res.Docs)).To(BeNumerically("<=", 3)) + }) + + It("should perform hybrid search with RRF combine method", Label("search", "fthybrid", "rrf"), func() { + options := &redis.FTHybridOptions{ + CountExpressions: 2, + SearchExpressions: []redis.FTHybridSearchExpression{ + {Query: "@color:{red}"}, + }, + VectorExpressions: []redis.FTHybridVectorExpression{ + { + VectorField: "embedding", + VectorData: &redis.VectorFP32{Val: encodeFloat32Vector([]float32{1, 2, 7, 6})}, + }, + }, + Combine: &redis.FTHybridCombineOptions{ + Method: redis.FTHybridCombineRRF, + Window: 3, + Constant: 0.5, + }, + LimitOffset: 0, + Limit: 3, + } + + res, err := client.FTHybridWithArgs(ctx, "hybrid_idx", options).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Total).To(BeNumerically(">", 0)) + Expect(len(res.Docs)).To(BeNumerically("<=", 3)) + }) + + It("should perform hybrid search with LOAD and APPLY", Label("search", "fthybrid", "load", "apply"), func() { + options := &redis.FTHybridOptions{ + CountExpressions: 2, + SearchExpressions: []redis.FTHybridSearchExpression{ + {Query: "@color:{red}"}, + }, + VectorExpressions: []redis.FTHybridVectorExpression{ + { + VectorField: "embedding", + VectorData: &redis.VectorFP32{Val: encodeFloat32Vector([]float32{1, 2, 7, 6})}, + }, + }, + Load: []string{"description", "color", "price", "size", "__score"}, + Apply: []redis.FTHybridApply{ + { + Expression: "@price - (@price * 0.1)", + AsField: "price_discount", + }, + { + Expression: "@price_discount * 0.2", + AsField: "tax_discount", + }, + }, + LimitOffset: 0, + Limit: 3, + } + + res, err := client.FTHybridWithArgs(ctx, "hybrid_idx", options).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Total).To(BeNumerically(">", 0)) + Expect(len(res.Docs)).To(BeNumerically("<=", 3)) + + // Verify that applied fields exist + for _, doc := range res.Docs { + Expect(doc.Fields).To(HaveKey("price_discount")) + Expect(doc.Fields).To(HaveKey("tax_discount")) + } + }) + + It("should perform hybrid search with parameters", Label("search", "fthybrid", "params"), func() { + params := map[string]interface{}{ + "$vector": encodeFloat32Vector([]float32{1, 2, 7, 6}), + "$discount": 0.1, + } + + options := &redis.FTHybridOptions{ + CountExpressions: 2, + SearchExpressions: []redis.FTHybridSearchExpression{ + {Query: "@color:{red}"}, + }, + VectorExpressions: []redis.FTHybridVectorExpression{ + { + VectorField: "embedding", + VectorData: &redis.VectorRef{Name: "$vector"}, + }, + }, + Load: []string{"description", "color", "price"}, + Apply: []redis.FTHybridApply{ + { + Expression: "@price - (@price * $discount)", + AsField: "price_discount", + }, + }, + Params: params, + LimitOffset: 0, + Limit: 3, + } + + res, err := client.FTHybridWithArgs(ctx, "hybrid_idx", options).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Total).To(BeNumerically(">", 0)) + Expect(len(res.Docs)).To(BeNumerically("<=", 3)) + + // Verify that parameter substitution worked + for _, doc := range res.Docs { + Expect(doc.Fields).To(HaveKey("price_discount")) + } + }) + + It("should perform hybrid search with LIMIT", Label("search", "fthybrid", "limit"), func() { + options := &redis.FTHybridOptions{ + CountExpressions: 2, + SearchExpressions: []redis.FTHybridSearchExpression{ + {Query: "@color:{red}"}, + }, + VectorExpressions: []redis.FTHybridVectorExpression{ + { + VectorField: "embedding", + VectorData: &redis.VectorFP32{Val: encodeFloat32Vector([]float32{1, 2, 7, 6})}, + }, + }, + LimitOffset: 0, + Limit: 2, + } + + res, err := client.FTHybridWithArgs(ctx, "hybrid_idx", options).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(len(res.Docs)).To(BeNumerically("<=", 2)) + }) + + It("should perform hybrid search with SORTBY", Label("search", "fthybrid", "sortby"), func() { + options := &redis.FTHybridOptions{ + CountExpressions: 2, + SearchExpressions: []redis.FTHybridSearchExpression{ + {Query: "@color:{red|green}"}, + }, + VectorExpressions: []redis.FTHybridVectorExpression{ + { + VectorField: "embedding", + VectorData: &redis.VectorFP32{Val: encodeFloat32Vector([]float32{1, 2, 7, 6})}, + }, + }, + Load: []string{"color", "price"}, + Apply: []redis.FTHybridApply{ + { + Expression: "@price - (@price * 0.1)", + AsField: "price_discount", + }, + }, + SortBy: []redis.FTSearchSortBy{ + {FieldName: "price_discount", Desc: true}, + {FieldName: "color", Asc: true}, + }, + LimitOffset: 0, + Limit: 5, + } + + res, err := client.FTHybridWithArgs(ctx, "hybrid_idx", options).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Total).To(BeNumerically(">", 0)) + Expect(len(res.Docs)).To(BeNumerically("<=", 5)) + + // Check that results are sorted - first result should have higher price_discount + if len(res.Docs) > 1 { + firstPriceStr := fmt.Sprintf("%v", res.Docs[0].Fields["price_discount"]) + secondPriceStr := fmt.Sprintf("%v", res.Docs[1].Fields["price_discount"]) + firstPrice, err1 := helper.ParseFloat(firstPriceStr) + secondPrice, err2 := helper.ParseFloat(secondPriceStr) + + if err1 == nil && err2 == nil && firstPrice != secondPrice { + Expect(firstPrice).To(BeNumerically(">=", secondPrice)) + } + } + }) + }) }) From 78b26ac69bb7309f46a9d018e30e1e98016b0a07 Mon Sep 17 00:00:00 2001 From: Hristo Temelski Date: Wed, 29 Oct 2025 10:35:00 +0200 Subject: [PATCH 2/3] fixed lint, fixed some tests --- search_commands.go | 6 ++---- search_test.go | 11 +++++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/search_commands.go b/search_commands.go index 2e9496455e..54c776252e 100644 --- a/search_commands.go +++ b/search_commands.go @@ -1901,10 +1901,7 @@ func (cmd *FTSearchCmd) readReply(rd *proto.Reader) (err error) { } // FTHybridResult represents the result of a hybrid search operation -type FTHybridResult struct { - Total int - Docs []Document -} +type FTHybridResult = FTSearchResult type FTHybridCmd struct { baseCmd @@ -1957,6 +1954,7 @@ func (cmd *FTHybridCmd) readReply(rd *proto.Reader) (err error) { if err != nil { return err } + cmd.val = FTHybridResult{ Total: searchResult.Total, Docs: searchResult.Docs, diff --git a/search_test.go b/search_test.go index 4c9b55cc20..f4afcc0896 100644 --- a/search_test.go +++ b/search_test.go @@ -3628,6 +3628,7 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() { }) It("should perform basic hybrid search", Label("search", "fthybrid"), func() { + SkipBeforeRedisVersion(8.4, "no support") // Basic hybrid search combining text and vector search searchQuery := "@color:{red} OR @color:{green}" vectorData := encodeFloat32Vector([]float32{-100, -200, -200, -300}) @@ -3646,6 +3647,7 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() { }) It("should perform hybrid search with scorer", Label("search", "fthybrid", "scorer"), func() { + SkipBeforeRedisVersion(8.4, "no support") // Test with TFIDF scorer options := &redis.FTHybridOptions{ CountExpressions: 2, @@ -3680,6 +3682,7 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() { }) It("should perform hybrid search with vector filter", Label("search", "fthybrid", "filter"), func() { + SkipBeforeRedisVersion(8.4, "no support") // This query won't have results from search, so we can validate vector filter options := &redis.FTHybridOptions{ CountExpressions: 2, @@ -3720,6 +3723,7 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() { }) It("should perform hybrid search with KNN method", Label("search", "fthybrid", "knn"), func() { + SkipBeforeRedisVersion(8.4, "no support") options := &redis.FTHybridOptions{ CountExpressions: 2, SearchExpressions: []redis.FTHybridSearchExpression{ @@ -3742,6 +3746,7 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() { }) It("should perform hybrid search with RANGE method", Label("search", "fthybrid", "range"), func() { + SkipBeforeRedisVersion(8.4, "no support") options := &redis.FTHybridOptions{ CountExpressions: 2, SearchExpressions: []redis.FTHybridSearchExpression{ @@ -3766,6 +3771,7 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() { }) It("should perform hybrid search with LINEAR combine method", Label("search", "fthybrid", "combine"), func() { + SkipBeforeRedisVersion(8.4, "no support") options := &redis.FTHybridOptions{ CountExpressions: 2, SearchExpressions: []redis.FTHybridSearchExpression{ @@ -3793,6 +3799,7 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() { }) It("should perform hybrid search with RRF combine method", Label("search", "fthybrid", "rrf"), func() { + SkipBeforeRedisVersion(8.4, "no support") options := &redis.FTHybridOptions{ CountExpressions: 2, SearchExpressions: []redis.FTHybridSearchExpression{ @@ -3820,6 +3827,7 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() { }) It("should perform hybrid search with LOAD and APPLY", Label("search", "fthybrid", "load", "apply"), func() { + SkipBeforeRedisVersion(8.4, "no support") options := &redis.FTHybridOptions{ CountExpressions: 2, SearchExpressions: []redis.FTHybridSearchExpression{ @@ -3859,6 +3867,7 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() { }) It("should perform hybrid search with parameters", Label("search", "fthybrid", "params"), func() { + SkipBeforeRedisVersion(8.4, "no support") params := map[string]interface{}{ "$vector": encodeFloat32Vector([]float32{1, 2, 7, 6}), "$discount": 0.1, @@ -3899,6 +3908,7 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() { }) It("should perform hybrid search with LIMIT", Label("search", "fthybrid", "limit"), func() { + SkipBeforeRedisVersion(8.4, "no support") options := &redis.FTHybridOptions{ CountExpressions: 2, SearchExpressions: []redis.FTHybridSearchExpression{ @@ -3920,6 +3930,7 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() { }) It("should perform hybrid search with SORTBY", Label("search", "fthybrid", "sortby"), func() { + SkipBeforeRedisVersion(8.4, "no support") options := &redis.FTHybridOptions{ CountExpressions: 2, SearchExpressions: []redis.FTHybridSearchExpression{ From 13e725a6b501588b4da9718d906705873bf7734b Mon Sep 17 00:00:00 2001 From: Hristo Temelski Date: Wed, 29 Oct 2025 10:41:11 +0200 Subject: [PATCH 3/3] lint fix --- search_commands.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/search_commands.go b/search_commands.go index 54c776252e..1f3c3e74d8 100644 --- a/search_commands.go +++ b/search_commands.go @@ -1955,10 +1955,8 @@ func (cmd *FTHybridCmd) readReply(rd *proto.Reader) (err error) { return err } - cmd.val = FTHybridResult{ - Total: searchResult.Total, - Docs: searchResult.Docs, - } + // FTSearchResult and FTHybridResult are aliases + cmd.val = searchResult return nil }