diff --git a/examples/README.md b/examples/README.md index 37341e5fcf0..c74c09cb572 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,6 +23,7 @@ This folder contains example scripts showing how to use Node Redis in different | `search-hashes.js` | Uses [RediSearch](https://redisearch.io) to index and search data in hashes. | | `search-json.js` | Uses [RediSearch](https://redisearch.io/) and [RedisJSON](https://redisjson.io/) to index and search JSON data. | | `search-knn.js` | Uses [RediSearch vector similarity]([https://redisearch.io/](https://redis.io/docs/stack/search/reference/vectors/)) to index and run KNN queries. | +| `search-hybrid.js` | Uses [RediSearch](https://redisearch.io) hybrid search to combine text search with vector similarity search. | | `set-scan.js` | An example script that shows how to use the SSCAN iterator functionality. | | `sorted-set.js` | Add members with scores to a Sorted Set and retrieve them using the ZSCAN iteractor functionality. | | `stream-producer.js` | Adds entries to a [Redis Stream](https://redis.io/topics/streams-intro) using the `XADD` command. | diff --git a/examples/search-hybrid.js b/examples/search-hybrid.js new file mode 100644 index 00000000000..72fc8722fd5 --- /dev/null +++ b/examples/search-hybrid.js @@ -0,0 +1,173 @@ +// This example demonstrates how to use RediSearch hybrid search (FT.HYBRID). +// Hybrid search combines text search with vector similarity search for more +// comprehensive and relevant results. + +import { + createClient, + SCHEMA_FIELD_TYPE, + SCHEMA_VECTOR_FIELD_ALGORITHM, +} from "redis"; + +const client = createClient(); + +await client.connect(); + +// Helper function to create a Float32Array vector as a Buffer +const createVectorBuffer = (values) => { + return Buffer.from(new Float32Array(values).buffer); +}; + +// Create an index with text, tag, numeric, and vector fields... +const indexName = "idx:products"; +try { + // Documentation: https://redis.io/commands/ft.create/ + await client.ft.create( + indexName, + { + description: SCHEMA_FIELD_TYPE.TEXT, + category: SCHEMA_FIELD_TYPE.TAG, + price: SCHEMA_FIELD_TYPE.NUMERIC, + embedding: { + type: SCHEMA_FIELD_TYPE.VECTOR, + ALGORITHM: SCHEMA_VECTOR_FIELD_ALGORITHM.FLAT, + TYPE: "FLOAT32", + DIM: 4, + DISTANCE_METRIC: "L2", + }, + }, + { + ON: "HASH", + PREFIX: "noderedis:products", + }, + ); +} catch (e) { + if (e.message === "Index already exists") { + console.log("Index exists already, skipped creation."); + } else { + console.error(e); + process.exit(1); + } +} + +// Add some sample product data with embeddings... +await Promise.all([ + client.hSet("noderedis:products:1", { + description: "comfortable red running shoes", + category: "footwear", + price: "79", + embedding: createVectorBuffer([1, 2, 7, 8]), + }), + client.hSet("noderedis:products:2", { + description: "stylish blue sneakers", + category: "footwear", + price: "89", + embedding: createVectorBuffer([1, 4, 7, 8]), + }), + client.hSet("noderedis:products:3", { + description: "elegant red dress", + category: "clothing", + price: "129", + embedding: createVectorBuffer([1, 2, 6, 5]), + }), + client.hSet("noderedis:products:4", { + description: "warm winter jacket", + category: "clothing", + price: "199", + embedding: createVectorBuffer([5, 6, 7, 8]), + }), +]); + +// Perform a hybrid search combining text search with vector similarity +// Documentation: https://redis.io/commands/ft.hybrid/ +const results = await client.ft.hybrid(indexName, { + // Text search component - full-text search on TEXT fields + SEARCH: { + query: "@description:red", + YIELD_SCORE_AS: "text_score", + }, + // Vector similarity component + VSIM: { + field: "@embedding", + // Reference to the vector parameter (must match a key in PARAMS, prefixed with '$') + vector: "$query_vector", + YIELD_SCORE_AS: "vector_score", + // Search method configuration - KNN or RANGE + method: { + type: "KNN", + K: 10, + }, + }, + // Combine method: RRF (Reciprocal Rank Fusion) or LINEAR + COMBINE: { + method: { type: "RRF", CONSTANT: 60 }, + YIELD_SCORE_AS: "combined_score", + }, + // Fields to load from the documents + // - Use `'*'` to load all fields from documents + LOAD: ["@__key", "@description", "@category", "@price"], + // Sort by combined score + SORTBY: { + fields: [{ field: "@combined_score", direction: "DESC" }], + }, + // Limit results + LIMIT: { offset: 0, count: 10 }, + // Query parameters - the param name must match the vector reference in VSIM + // (e.g., '$query_vector' in VSIM.vector corresponds to 'query_vector' here) + PARAMS: { + query_vector: createVectorBuffer([1, 2, 6, 5]), + }, +}); + +// results: +// { +// totalResults: 4, +// executionTime: 0.879, +// warnings: [], +// results: [ +// { +// text_score: '0.0404949945054', +// __key: 'noderedis:products:3', +// description: 'elegant red dress', +// category: 'clothing', +// price: '129', +// vector_score: '1', +// combined_score: '0.0327868852459' +// }, +// { +// text_score: '0.0358374231755', +// __key: 'noderedis:products:1', +// description: 'comfortable red running shoes', +// category: 'footwear', +// price: '79', +// vector_score: '0.0909090909091', +// combined_score: '0.0322580645161' +// }, +// { +// __key: 'noderedis:products:2', +// description: 'stylish blue sneakers', +// category: 'footwear', +// price: '89', +// vector_score: '0.0666666666667', +// combined_score: '0.015873015873' +// }, +// { +// __key: 'noderedis:products:4', +// description: 'warm winter jacket', +// category: 'clothing', +// price: '199', +// vector_score: '0.0232558139535', +// combined_score: '0.015625' +// } +// ] +// } + +console.log(`Results found: ${results.totalResults}`); +console.log(`Execution time: ${results.executionTime}ms`); + +for (const doc of results.results) { + console.log(`${doc.__key} - ${doc.description} ($${doc.price})`); + console.log(` Category: ${doc.category}`); + console.log(` Combined score: ${doc.combined_score}`); +} + +client.destroy(); diff --git a/packages/search/lib/commands/AGGREGATE.ts b/packages/search/lib/commands/AGGREGATE.ts index 9e8fb7810d6..ea3e3aa18ff 100644 --- a/packages/search/lib/commands/AGGREGATE.ts +++ b/packages/search/lib/commands/AGGREGATE.ts @@ -87,7 +87,7 @@ interface RandomSampleReducer extends GroupByReducerWithProperty { properties?: RediSearchProperty | Array; @@ -284,7 +284,7 @@ function pushLoadField(args: Array, toLoad: LoadField) { } } -function parseGroupByReducer(parser: CommandParser, reducer: GroupByReducers) { +export function parseGroupByReducer(parser: CommandParser, reducer: GroupByReducers) { parser.push('REDUCE', reducer.type); switch (reducer.type) { diff --git a/packages/search/lib/commands/HYBRID.spec.ts b/packages/search/lib/commands/HYBRID.spec.ts index 12f1ded6dcc..81a80d7d688 100644 --- a/packages/search/lib/commands/HYBRID.spec.ts +++ b/packages/search/lib/commands/HYBRID.spec.ts @@ -1,350 +1,1878 @@ -import { strict as assert } from 'node:assert'; -import testUtils, { GLOBAL } from '../test-utils'; -import HYBRID from './HYBRID'; -import { BasicCommandParser } from '@redis/client/lib/client/parser'; - -describe('FT.HYBRID', () => { - describe('parseCommand', () => { - it('minimal command', () => { - const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index'); - assert.deepEqual( - parser.redisArgs, - ['FT.HYBRID', 'index'] - ); - }); +import { strict as assert } from "node:assert"; +import HYBRID, { FT_HYBRID_VECTOR_METHOD, FT_HYBRID_COMBINE_METHOD } from "./HYBRID"; +import { BasicCommandParser } from "@redis/client/lib/client/parser"; +import testUtils, { GLOBAL } from "../test-utils"; +import { SCHEMA_VECTOR_FIELD_ALGORITHM } from "./CREATE"; +import { FT_AGGREGATE_GROUP_BY_REDUCERS } from "./AGGREGATE"; + +/** + * Helper function to create a Float32Array vector as a Buffer + */ +const createVectorBuffer = (values: number[]): Buffer => { + return Buffer.from(new Float32Array(values).buffer); +}; + +/** + * Helper function to generate random vector data + */ +const generateRandomVector = (dim: number): number[] => { + return Array.from({ length: dim }, () => Math.random()); +}; + +/** + * Helper function to generate random string data (for vector as string) + */ +const generateRandomStrData = (dim: number): string => { + const chars = "abcdefgh12345678"; + return Array.from( + { length: dim }, + () => chars[Math.floor(Math.random() * chars.length)], + ).join(""); +}; + +/** + * Items to be added to the index for testing + */ +const FT_HYBRID_ITEMS = [ + { vector: [1, 2, 7, 8], description: "red shoes" }, + { vector: [1, 4, 7, 8], description: "green shoes with red laces" }, + { vector: [1, 2, 6, 5], description: "red dress" }, + { vector: [2, 3, 6, 5], description: "orange dress" }, + { vector: [5, 6, 7, 8], description: "black shoes" }, +]; + +/** + * Helper to create the index for hybrid search tests + */ +const createHybridSearchIndex = async ( + client: any, + indexName: string, + dim = 4, +) => { + await client.ft.create( + indexName, + { + description: { type: "TEXT" }, + price: { type: "NUMERIC" }, + color: { type: "TAG" }, + itemType: { type: "TAG" }, + size: { type: "NUMERIC" }, + embedding: { + type: "VECTOR", + ALGORITHM: SCHEMA_VECTOR_FIELD_ALGORITHM.FLAT, + TYPE: "FLOAT32", + DIM: dim, + DISTANCE_METRIC: "L2", + }, + embeddingHNSW: { + type: "VECTOR", + ALGORITHM: SCHEMA_VECTOR_FIELD_ALGORITHM.HNSW, + TYPE: "FLOAT32", + DIM: dim, + DISTANCE_METRIC: "L2", + }, + }, + { + ON: "HASH", + PREFIX: "item:", + }, + ); +}; + +/** + * Helper to add data to the index for hybrid search tests + */ +const addDataForHybridSearch = async ( + client: any, + itemsSets = 1, + options: { + randomizeData?: boolean; + dimForRandomData?: number; + useRandomStrData?: boolean; + } = {}, +) => { + const { + randomizeData = false, + dimForRandomData = 4, + useRandomStrData = false, + } = options; + + let items: Array<{ vector: number[] | string; description: string }>; + + if (randomizeData || useRandomStrData) { + const actualDim = useRandomStrData + ? dimForRandomData * 4 + : dimForRandomData; + const generateDataFunc = useRandomStrData + ? () => generateRandomStrData(actualDim) + : () => generateRandomVector(actualDim); + + items = [ + { vector: generateDataFunc() as any, description: "red shoes" }, + { + vector: generateDataFunc() as any, + description: "green shoes with red laces", + }, + { vector: generateDataFunc() as any, description: "red dress" }, + { vector: generateDataFunc() as any, description: "orange dress" }, + { vector: generateDataFunc() as any, description: "black shoes" }, + ]; + } else { + items = FT_HYBRID_ITEMS; + } + // Multiply items by itemsSets + const allItems: typeof items = []; + for (let s = 0; s < itemsSets; s++) { + allItems.push(...items); + } - it('with SEARCH expression', () => { + const promises: Promise[] = []; + for (let i = 0; i < allItems.length; i++) { + const { vector, description } = allItems[i]; + const embeddingData = + typeof vector === "string" + ? vector + : createVectorBuffer(vector as number[]); + + promises.push( + client.hSet(`item:${i}`, { + description, + embedding: embeddingData, + embeddingHNSW: embeddingData, + price: String(15 + (i % 4)), + color: description.split(" ")[0], + itemType: description.split(" ")[1], + size: String(10 + (i % 3)), + }), + ); + } + await Promise.all(promises); +}; + +describe("FT.HYBRID", () => { + describe("transformArguments", () => { + it("minimal command with SEARCH, VSIM, and PARAMS", () => { const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { + HYBRID.parseCommand(parser, "index", { SEARCH: { - query: '@description: bikes' - } + query: "*", + }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + PARAMS: { + vec: "BLOB_DATA", + }, }); - assert.deepEqual( - parser.redisArgs, - ['FT.HYBRID', 'index', 'SEARCH', '@description: bikes'] - ); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "SEARCH", + "*", + "VSIM", + "@embedding", + "$vec", + "PARAMS", + "2", + "vec", + "BLOB_DATA", + ]); }); - it('with SEARCH expression and SCORER', () => { + it("with SEARCH expression and SCORER", () => { const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { + HYBRID.parseCommand(parser, "index", { SEARCH: { - query: '@description: bikes', - SCORER: { - algorithm: 'TFIDF.DOCNORM', - params: ['param1', 'param2'] - }, - YIELD_SCORE_AS: 'search_score' - } + query: "@description: bikes", + SCORER: "TFIDF.DOCNORM", + YIELD_SCORE_AS: "search_score", + }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + PARAMS: { + vec: "BLOB_DATA", + }, }); - assert.deepEqual( - parser.redisArgs, - [ - 'FT.HYBRID', 'index', 'SEARCH', '@description: bikes', - 'SCORER', 'TFIDF.DOCNORM', 'param1', 'param2', - 'YIELD_SCORE_AS', 'search_score' - ] - ); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "SEARCH", + "@description: bikes", + "SCORER", + "TFIDF.DOCNORM", + "YIELD_SCORE_AS", + "search_score", + "VSIM", + "@embedding", + "$vec", + "PARAMS", + "2", + "vec", + "BLOB_DATA", + ]); }); - it('with VSIM expression and KNN method', () => { + it("with VSIM expression and KNN method", () => { const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { + HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + }, VSIM: { - field: '@vector_field', - vectorData: 'BLOB_DATA', + field: "@vector_field", + vector: "$vec", method: { - KNN: { - K: 10, - EF_RUNTIME: 50, - YIELD_DISTANCE_AS: 'vector_dist' - } - } - } + type: FT_HYBRID_VECTOR_METHOD.KNN, + K: 10, + EF_RUNTIME: 50, + }, + }, + PARAMS: { + vec: "BLOB_DATA", + }, }); - assert.deepEqual( - parser.redisArgs, - [ - 'FT.HYBRID', 'index', 'VSIM', '@vector_field', 'BLOB_DATA', - 'KNN', '1', 'K', '10', 'EF_RUNTIME', '50', 'YIELD_DISTANCE_AS', 'vector_dist' - ] - ); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "SEARCH", + "@description: bikes", + "VSIM", + "@vector_field", + "$vec", + "KNN", + "4", + "K", + "10", + "EF_RUNTIME", + "50", + "PARAMS", + "2", + "vec", + "BLOB_DATA", + ]); }); - it('with VSIM expression and RANGE method', () => { + it("with VSIM expression and RANGE method", () => { const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { + HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + }, VSIM: { - field: '@vector_field', - vectorData: 'BLOB_DATA', + field: "@vector_field", + vector: "$vec", method: { - RANGE: { - RADIUS: 0.5, - EPSILON: 0.01, - YIELD_DISTANCE_AS: 'vector_dist' - } - } - } + type: FT_HYBRID_VECTOR_METHOD.RANGE, + RADIUS: 0.5, + EPSILON: 0.01, + }, + }, + PARAMS: { + vec: "BLOB_DATA", + }, }); - assert.deepEqual( - parser.redisArgs, - [ - 'FT.HYBRID', 'index', 'VSIM', '@vector_field', 'BLOB_DATA', - 'RANGE', '1', 'RADIUS', '0.5', 'EPSILON', '0.01', 'YIELD_DISTANCE_AS', 'vector_dist' - ] - ); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "SEARCH", + "@description: bikes", + "VSIM", + "@vector_field", + "$vec", + "RANGE", + "4", + "RADIUS", + "0.5", + "EPSILON", + "0.01", + "PARAMS", + "2", + "vec", + "BLOB_DATA", + ]); }); - it('with VSIM expression and FILTER', () => { + it("with VSIM expression and FILTER", () => { const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { + HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + }, VSIM: { - field: '@vector_field', - vectorData: 'BLOB_DATA', - FILTER: { - expression: '@category:{bikes}', - POLICY: 'BATCHES', - BATCHES: { - BATCH_SIZE: 100 - } - }, - YIELD_SCORE_AS: 'vsim_score' - } + field: "@vector_field", + vector: "$vec", + FILTER: "@category:{bikes}", + YIELD_SCORE_AS: "vsim_score", + }, + PARAMS: { + vec: "BLOB_DATA", + }, }); - assert.deepEqual( - parser.redisArgs, - [ - 'FT.HYBRID', 'index', 'VSIM', '@vector_field', 'BLOB_DATA', - 'FILTER', '@category:{bikes}', 'POLICY', 'BATCHES', 'BATCHES', 'BATCH_SIZE', '100', - 'YIELD_SCORE_AS', 'vsim_score' - ] - ); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "SEARCH", + "@description: bikes", + "VSIM", + "@vector_field", + "$vec", + "FILTER", + "@category:{bikes}", + "YIELD_SCORE_AS", + "vsim_score", + "PARAMS", + "2", + "vec", + "BLOB_DATA", + ]); }); - it('with RRF COMBINE method', () => { + it("with RRF COMBINE method", () => { const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { + HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, COMBINE: { method: { - RRF: { - count: 2, - WINDOW: 10, - CONSTANT: 60 - } + type: FT_HYBRID_COMBINE_METHOD.RRF, + WINDOW: 10, + CONSTANT: 60, }, - YIELD_SCORE_AS: 'combined_score' - } + YIELD_SCORE_AS: "combined_score", + }, + PARAMS: { + vec: "BLOB_DATA", + }, }); - assert.deepEqual( - parser.redisArgs, - [ - 'FT.HYBRID', 'index', 'COMBINE', 'RRF', '2', 'WINDOW', '10', 'CONSTANT', '60', - 'YIELD_SCORE_AS', 'combined_score' - ] - ); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "SEARCH", + "@description: bikes", + "VSIM", + "@embedding", + "$vec", + "COMBINE", + "RRF", + "6", + "WINDOW", + "10", + "CONSTANT", + "60", + "YIELD_SCORE_AS", + "combined_score", + "PARAMS", + "2", + "vec", + "BLOB_DATA", + ]); }); - it('with LINEAR COMBINE method', () => { + it("with LINEAR COMBINE method", () => { const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { + HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, COMBINE: { method: { - LINEAR: { - count: 2, - ALPHA: 0.7, - BETA: 0.3 - } - } - } + type: FT_HYBRID_COMBINE_METHOD.LINEAR, + ALPHA: 0.7, + BETA: 0.3, + }, + }, + PARAMS: { + vec: "BLOB_DATA", + }, }); - assert.deepEqual( - parser.redisArgs, - [ - 'FT.HYBRID', 'index', 'COMBINE', 'LINEAR', '2', 'ALPHA', '0.7', 'BETA', '0.3' - ] - ); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "SEARCH", + "@description: bikes", + "VSIM", + "@embedding", + "$vec", + "COMBINE", + "LINEAR", + "4", + "ALPHA", + "0.7", + "BETA", + "0.3", + "PARAMS", + "2", + "vec", + "BLOB_DATA", + ]); }); - it('with LOAD, SORTBY, and LIMIT', () => { + it("with LOAD, SORTBY, and LIMIT", () => { const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { - LOAD: ['field1', 'field2'], + HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + LOAD: ["field1", "field2"], SORTBY: { - count: 1, - fields: [ - { field: 'score', direction: 'DESC' } - ] + fields: [{ field: "score", direction: "DESC" }], }, LIMIT: { offset: 0, - num: 10 - } + count: 10, + }, + PARAMS: { + vec: "BLOB_DATA", + }, }); - assert.deepEqual( - parser.redisArgs, - [ - 'FT.HYBRID', 'index', 'LOAD', '2', 'field1', 'field2', - 'SORTBY', '1', 'score', 'DESC', 'LIMIT', '0', '10' - ] - ); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "SEARCH", + "@description: bikes", + "VSIM", + "@embedding", + "$vec", + "LOAD", + "2", + "field1", + "field2", + "SORTBY", + "2", + "score", + "DESC", + "LIMIT", + "0", + "10", + "PARAMS", + "2", + "vec", + "BLOB_DATA", + ]); }); - it('with GROUPBY and REDUCE', () => { + it("with GROUPBY and REDUCE", () => { const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { + HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, GROUPBY: { - fields: ['@category'], + fields: ["@category"], REDUCE: { - function: 'COUNT', - count: 0, - args: [] - } - } + type: FT_AGGREGATE_GROUP_BY_REDUCERS.COUNT, + }, + }, + PARAMS: { + vec: "BLOB_DATA", + }, }); - assert.deepEqual( - parser.redisArgs, - [ - 'FT.HYBRID', 'index', 'GROUPBY', '1', '@category', 'REDUCE', 'COUNT', '0' - ] - ); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "SEARCH", + "@description: bikes", + "VSIM", + "@embedding", + "$vec", + "GROUPBY", + "1", + "@category", + "REDUCE", + "COUNT", + "0", + "PARAMS", + "2", + "vec", + "BLOB_DATA", + ]); }); - it('with APPLY', () => { + it("with APPLY", () => { const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { + HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, APPLY: { - expression: '@score * 2', - AS: 'double_score' - } + expression: "@score * 2", + AS: "double_score", + }, + PARAMS: { + vec: "BLOB_DATA", + }, }); - assert.deepEqual( - parser.redisArgs, - ['FT.HYBRID', 'index', 'APPLY', '@score * 2', 'AS', 'double_score'] - ); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "SEARCH", + "@description: bikes", + "VSIM", + "@embedding", + "$vec", + "APPLY", + "@score * 2", + "AS", + "double_score", + "PARAMS", + "2", + "vec", + "BLOB_DATA", + ]); }); - it('with FILTER and post-processing', () => { + it("with FILTER and post-processing", () => { const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { - FILTER: '@price:[100 500]' + HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + FILTER: "@price:[100 500]", + PARAMS: { + vec: "BLOB_DATA", + }, }); - assert.deepEqual( - parser.redisArgs, - ['FT.HYBRID', 'index', 'FILTER', '@price:[100 500]'] - ); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "SEARCH", + "@description: bikes", + "VSIM", + "@embedding", + "$vec", + "FILTER", + "@price:[100 500]", + "PARAMS", + "2", + "vec", + "BLOB_DATA", + ]); }); - it('with PARAMS', () => { + it("with additional PARAMS", () => { const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { + HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, PARAMS: { - query_vector: 'BLOB_DATA', - min_price: 100 - } + vec: "BLOB_DATA", + query_vector: "BLOB_DATA", + min_price: 100, + }, }); - assert.deepEqual( - parser.redisArgs, - [ - 'FT.HYBRID', 'index', 'PARAMS', '4', 'query_vector', 'BLOB_DATA', 'min_price', '100' - ] - ); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "SEARCH", + "@description: bikes", + "VSIM", + "@embedding", + "$vec", + "PARAMS", + "6", + "vec", + "BLOB_DATA", + "query_vector", + "BLOB_DATA", + "min_price", + "100", + ]); }); - it('with EXPLAINSCORE and TIMEOUT', () => { + it("with TIMEOUT", () => { const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { - EXPLAINSCORE: true, - TIMEOUT: 5000 + HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + PARAMS: { + vec: "BLOB_DATA", + }, + TIMEOUT: 5000, }); - assert.deepEqual( - parser.redisArgs, - ['FT.HYBRID', 'index', 'EXPLAINSCORE', 'TIMEOUT', '5000'] - ); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "SEARCH", + "@description: bikes", + "VSIM", + "@embedding", + "$vec", + "PARAMS", + "2", + "vec", + "BLOB_DATA", + "TIMEOUT", + "5000", + ]); }); - it('with WITHCURSOR', () => { + it("with SEARCH YIELD_SCORE_AS", () => { const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { - WITHCURSOR: { - COUNT: 100, - MAXIDLE: 300000 - } + HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "shoes", + YIELD_SCORE_AS: "search_score", + }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + PARAMS: { + vec: "BLOB_DATA", + }, }); - assert.deepEqual( - parser.redisArgs, - [ - 'FT.HYBRID', 'index', 'WITHCURSOR', 'COUNT', '100', 'MAXIDLE', '300000' - ] - ); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "SEARCH", + "shoes", + "YIELD_SCORE_AS", + "search_score", + "VSIM", + "@embedding", + "$vec", + "PARAMS", + "2", + "vec", + "BLOB_DATA", + ]); }); - it('complete example with all options', () => { + it("with VSIM YIELD_SCORE_AS", () => { const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { + HYBRID.parseCommand(parser, "index", { SEARCH: { - query: '@description: bikes', - SCORER: { - algorithm: 'TFIDF.DOCNORM' - }, - YIELD_SCORE_AS: 'text_score' + query: "shoes", }, VSIM: { - field: '@vector_field', - vectorData: '$query_vector', + field: "@embedding", + vector: "$vec", + YIELD_SCORE_AS: "vsim_score", + }, + PARAMS: { + vec: "BLOB_DATA", + }, + }); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "SEARCH", + "shoes", + "VSIM", + "@embedding", + "$vec", + "YIELD_SCORE_AS", + "vsim_score", + "PARAMS", + "2", + "vec", + "BLOB_DATA", + ]); + }); + + it("with multiple APPLY expressions", () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + APPLY: [ + { expression: "@price - (@price * 0.1)", AS: "price_discount" }, + { expression: "@price_discount * 0.2", AS: "tax_discount" }, + ], + PARAMS: { + vec: "BLOB_DATA", + }, + }); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "SEARCH", + "@description: bikes", + "VSIM", + "@embedding", + "$vec", + "APPLY", + "@price - (@price * 0.1)", + "AS", + "price_discount", + "APPLY", + "@price_discount * 0.2", + "AS", + "tax_discount", + "PARAMS", + "2", + "vec", + "BLOB_DATA", + ]); + }); + + it("with GROUPBY and multiple REDUCE functions", () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + GROUPBY: { + fields: ["@itemType", "@price"], + REDUCE: [ + { + type: FT_AGGREGATE_GROUP_BY_REDUCERS.COUNT_DISTINCT, + property: "@color", + AS: "colors_count", + }, + { + type: FT_AGGREGATE_GROUP_BY_REDUCERS.MIN, + property: "@size", + }, + ], + }, + PARAMS: { + vec: "BLOB_DATA", + }, + }); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "SEARCH", + "@description: bikes", + "VSIM", + "@embedding", + "$vec", + "GROUPBY", + "2", + "@itemType", + "@price", + "REDUCE", + "COUNT_DISTINCT", + "1", + "@color", + "AS", + "colors_count", + "REDUCE", + "MIN", + "1", + "@size", + "PARAMS", + "2", + "vec", + "BLOB_DATA", + ]); + }); + + it("with multiple SORTBY fields", () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + SORTBY: { + fields: [ + { field: "@price_discount", direction: "DESC" }, + { field: "@color", direction: "ASC" }, + ], + }, + PARAMS: { + vec: "BLOB_DATA", + }, + }); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "SEARCH", + "@description: bikes", + "VSIM", + "@embedding", + "$vec", + "SORTBY", + "4", + "@price_discount", + "DESC", + "@color", + "ASC", + "PARAMS", + "2", + "vec", + "BLOB_DATA", + ]); + }); + + it("complete example with all options", () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + SCORER: "TFIDF.DOCNORM", + YIELD_SCORE_AS: "text_score", + }, + VSIM: { + field: "@vector_field", + vector: "$query_vector", method: { - KNN: { - K: 5 - } + type: FT_HYBRID_VECTOR_METHOD.KNN, + K: 5, }, - YIELD_SCORE_AS: 'vector_score' + YIELD_SCORE_AS: "vector_score", }, COMBINE: { method: { - RRF: { - count: 2, - CONSTANT: 60 - } + type: FT_HYBRID_COMBINE_METHOD.RRF, + CONSTANT: 60, }, - YIELD_SCORE_AS: 'final_score' + YIELD_SCORE_AS: "final_score", }, - LOAD: ['description', 'price'], + LOAD: ["description", "price"], SORTBY: { - count: 1, - fields: [{ field: 'final_score', direction: 'DESC' }] + fields: [{ field: "final_score", direction: "DESC" }], }, LIMIT: { offset: 0, - num: 10 + count: 10, }, PARAMS: { - query_vector: 'BLOB_DATA' - } + query_vector: "BLOB_DATA", + }, }); - assert.deepEqual( - parser.redisArgs, - [ - 'FT.HYBRID', 'index', - 'SEARCH', '@description: bikes', 'SCORER', 'TFIDF.DOCNORM', 'YIELD_SCORE_AS', 'text_score', - 'VSIM', '@vector_field', '$query_vector', 'KNN', '1', 'K', '5', 'YIELD_SCORE_AS', 'vector_score', - 'COMBINE', 'RRF', '2', 'CONSTANT', '60', 'YIELD_SCORE_AS', 'final_score', - 'LOAD', '2', 'description', 'price', - 'SORTBY', '1', 'final_score', 'DESC', - 'LIMIT', '0', '10', - 'PARAMS', '2', 'query_vector', 'BLOB_DATA' - ] - ); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "SEARCH", + "@description: bikes", + "SCORER", + "TFIDF.DOCNORM", + "YIELD_SCORE_AS", + "text_score", + "VSIM", + "@vector_field", + "$query_vector", + "KNN", + "2", + "K", + "5", + "YIELD_SCORE_AS", + "vector_score", + "COMBINE", + "RRF", + "4", + "CONSTANT", + "60", + "YIELD_SCORE_AS", + "final_score", + "LOAD", + "2", + "description", + "price", + "SORTBY", + "2", + "final_score", + "DESC", + "LIMIT", + "0", + "10", + "PARAMS", + "2", + "query_vector", + "BLOB_DATA", + ]); }); }); - // Integration tests would need to be added when RediSearch supports FT.HYBRID - // For now, we'll skip them as this is a new command that may not be available yet - describe.skip('client.ft.hybrid', () => { - testUtils.testWithClient('basic hybrid search', async client => { - // This would require a test index and data setup - // similar to how other FT commands are tested - }, GLOBAL.SERVERS.OPEN); + describe("client.ft.create", () => { + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "basic hybrid search", + async (client) => { + const indexName = "idx_basic_hybrid"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 5); + + const result = await client.ft.hybrid(indexName, { + SEARCH: { query: "@color:{red} @color:{green}" }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + PARAMS: { + vec: createVectorBuffer([-100, -200, -200, -300]), + }, + }); + + // Default results count limit is 10 + assert.strictEqual(result.totalResults, 10); + assert.strictEqual(result.results.length, 10); + assert.deepStrictEqual(result.warnings, []); + assert.ok(result.executionTime > 0); + }, + GLOBAL.SERVERS.OPEN, + ); + + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with scorer", + async (client) => { + const indexName = "idx_scorer"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 10); + + // Test with TFIDF scorer + const resultTfidf = await client.ft.hybrid(indexName, { + SEARCH: { + query: "shoes", + SCORER: "TFIDF", + }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + COMBINE: { + method: { type: FT_HYBRID_COMBINE_METHOD.LINEAR, ALPHA: 1, BETA: 0 }, + }, + LOAD: [ + "@description", + "@color", + "@price", + "@size", + "@__score", + "@__item", + ], + LIMIT: { offset: 0, count: 2 }, + PARAMS: { + vec: createVectorBuffer([1, 2, 2, 3]), + }, + }); + + assert.ok(resultTfidf.totalResults >= 2); + assert.strictEqual(resultTfidf.results.length, 2); + assert.deepStrictEqual(resultTfidf.warnings, []); + + // Test with BM25 scorer + const resultBm25 = await client.ft.hybrid(indexName, { + SEARCH: { + query: "shoes", + SCORER: "BM25", + }, + VSIM: { + field: "@embedding", + vector: "$vec2", + }, + COMBINE: { + method: { type: FT_HYBRID_COMBINE_METHOD.LINEAR, ALPHA: 1, BETA: 0 }, + }, + LOAD: [ + "@description", + "@color", + "@price", + "@size", + "@__score", + "@__item", + ], + LIMIT: { offset: 0, count: 2 }, + PARAMS: { + vec2: createVectorBuffer([1, 2, 2, 3]), + }, + }); + + assert.ok(resultBm25.totalResults >= 2); + assert.strictEqual(resultBm25.results.length, 2); + assert.deepStrictEqual(resultBm25.warnings, []); + }, + GLOBAL.SERVERS.OPEN, + ); + + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with vsim explicit method", + async (client) => { + const indexName = "idx_vsim_method"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 5, { useRandomStrData: true }); + + const result = await client.ft.hybrid(indexName, { + SEARCH: { query: "shoes" }, + VSIM: { + field: "@embeddingHNSW", + vector: "$vec", + method: { + type: FT_HYBRID_VECTOR_METHOD.KNN, + K: 3, + EF_RUNTIME: 1, + }, + }, + TIMEOUT: 10000, + PARAMS: { + vec: "abcd1234efgh5678", + }, + }); + + assert.ok(result.results.length > 0); + assert.deepStrictEqual(result.warnings, []); + }, + GLOBAL.SERVERS.OPEN, + ); + + // Test: Hybrid search with VSIM filter + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with vsim filter", + async (client) => { + const indexName = "idx_vsim_filter"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 5, { useRandomStrData: true }); + + const result = await client.ft.hybrid(indexName, { + SEARCH: { query: "@color:{missing}" }, + VSIM: { + field: "@embedding", + vector: "$vec", + FILTER: "@price:[15 16] @size:[10 11]", + }, + LOAD: ["@price", "@size"], + TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 2, 3]), + }, + }); + + assert.ok(result.results.length > 0); + assert.deepStrictEqual(result.warnings, []); + + for (const item of result.results) { + assert.ok(["15", "16"].includes(item.price)); + assert.ok(["10", "11"].includes(item.size)); + } + }, + GLOBAL.SERVERS.OPEN, + ); + + // Test: Hybrid search with search score aliases + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with search score aliases", + async (client) => { + const indexName = "idx_search_score_alias"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 1, { useRandomStrData: true }); + + const result = await client.ft.hybrid(indexName, { + SEARCH: { + query: "shoes", + YIELD_SCORE_AS: "search_score", + }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + TIMEOUT: 10000, + PARAMS: { + vec: "abcd1234efgh5678", + }, + }); + + assert.ok(result.results.length > 0); + assert.deepStrictEqual(result.warnings, []); + + assert.ok( + result.results.some((item) => item.search_score !== undefined), + ); + }, + GLOBAL.SERVERS.OPEN, + ); + + // Test: Hybrid search with VSIM score aliases + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with vsim score aliases", + async (client) => { + const indexName = "idx_vsim_score_alias"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 1, { useRandomStrData: true }); + + const result = await client.ft.hybrid(indexName, { + SEARCH: { query: "shoes" }, + VSIM: { + field: "@embeddingHNSW", + vector: "$vec", + method: { + type: FT_HYBRID_VECTOR_METHOD.KNN, + K: 3, + EF_RUNTIME: 1, + }, + YIELD_SCORE_AS: "vsim_score", + }, + TIMEOUT: 10000, + PARAMS: { + vec: "abcd1234efgh5678", + }, + }); + + assert.ok(result.results.length > 0); + assert.deepStrictEqual(result.warnings, []); + + assert.ok(result.results.some((item) => item.vsim_score !== undefined)); + }, + GLOBAL.SERVERS.OPEN, + ); + + // Test: Hybrid search with combine score aliases + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with combine score aliases", + async (client) => { + const indexName = "idx_combine_score_alias"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 1, { useRandomStrData: true }); + + const result = await client.ft.hybrid(indexName, { + SEARCH: { query: "shoes" }, + VSIM: { + field: "@embeddingHNSW", + vector: "$vec", + }, + COMBINE: { + method: { type: FT_HYBRID_COMBINE_METHOD.LINEAR, ALPHA: 0.5, BETA: 0.5 }, + YIELD_SCORE_AS: "combined_score", + }, + TIMEOUT: 10000, + PARAMS: { + vec: "abcd1234efgh5678", + }, + }); + + assert.ok(result.results.length > 0); + assert.deepStrictEqual(result.warnings, []); + + for (const item of result.results) { + assert.ok(item.combined_score !== undefined); + } + }, + GLOBAL.SERVERS.OPEN, + ); + + // Test: Hybrid search with all score aliases + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with all score aliases", + async (client) => { + const indexName = "idx_all_score_alias"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 1, { useRandomStrData: true }); + + const result = await client.ft.hybrid(indexName, { + SEARCH: { + query: "shoes", + YIELD_SCORE_AS: "search_score", + }, + VSIM: { + field: "@embeddingHNSW", + vector: "$vec", + method: { + type: FT_HYBRID_VECTOR_METHOD.KNN, + K: 3, + EF_RUNTIME: 1, + }, + YIELD_SCORE_AS: "vsim_score", + }, + COMBINE: { + method: { type: FT_HYBRID_COMBINE_METHOD.LINEAR, ALPHA: 0.5, BETA: 0.5 }, + YIELD_SCORE_AS: "combined_score", + }, + TIMEOUT: 10000, + PARAMS: { + vec: "abcd1234efgh5678", + }, + }); + + assert.ok(result.results.length > 0); + assert.deepStrictEqual(result.warnings, []); + + for (const item of result.results) { + assert.ok(item.combined_score !== undefined); + } + }, + GLOBAL.SERVERS.OPEN, + ); + + // Test: Hybrid search with VSIM KNN + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with vsim knn", + async (client) => { + const indexName = "idx_vsim_knn"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 10); + + // Query that won't have results to validate VSIM results + const result = await client.ft.hybrid(indexName, { + SEARCH: { query: "@color:{none}" }, + VSIM: { + field: "@embedding", + vector: "$vec", + method: { + type: FT_HYBRID_VECTOR_METHOD.KNN, + K: 3, + }, + }, + TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 2, 3]), + }, + }); + + assert.strictEqual(result.totalResults, 3); // KNN top-k value + assert.strictEqual(result.results.length, 3); + assert.deepStrictEqual(result.warnings, []); + assert.ok(result.executionTime > 0); + + // Test with HNSW vector field + const result2 = await client.ft.hybrid(indexName, { + SEARCH: { query: "@color:{none}" }, + VSIM: { + field: "@embeddingHNSW", + vector: "$vec", + method: { + type: FT_HYBRID_VECTOR_METHOD.KNN, + K: 3, + EF_RUNTIME: 1, + }, + }, + TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 2, 3]), + }, + }); + + assert.strictEqual(result2.totalResults, 3); + assert.strictEqual(result2.results.length, 3); + assert.deepStrictEqual(result2.warnings, []); + assert.ok(result2.executionTime > 0); + }, + GLOBAL.SERVERS.OPEN, + ); + + // Test: Hybrid search with VSIM RANGE + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with vsim range", + async (client) => { + const indexName = "idx_vsim_range"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 10); + + // Query that won't have results to validate VSIM results + const result = await client.ft.hybrid(indexName, { + SEARCH: { query: "@color:{none}" }, + VSIM: { + field: "@embedding", + vector: "$vec", + method: { + type: FT_HYBRID_VECTOR_METHOD.RANGE, + RADIUS: 2, + }, + }, + LIMIT: { offset: 0, count: 3 }, + TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 7, 6]), + }, + }); + + assert.ok(result.totalResults >= 3); + assert.strictEqual(result.results.length, 3); + assert.deepStrictEqual(result.warnings, []); + assert.ok(result.executionTime > 0); + + // Test with HNSW and EPSILON + const result2 = await client.ft.hybrid(indexName, { + SEARCH: { query: "@color:{none}" }, + VSIM: { + field: "@embeddingHNSW", + vector: "$vec", + method: { + type: FT_HYBRID_VECTOR_METHOD.RANGE, + RADIUS: 2, + EPSILON: 0.5, + }, + }, + LIMIT: { offset: 0, count: 3 }, + TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 7, 6]), + }, + }); + + assert.ok(result2.totalResults >= 3); + assert.strictEqual(result2.results.length, 3); + assert.deepStrictEqual(result2.warnings, []); + assert.ok(result2.executionTime > 0); + }, + GLOBAL.SERVERS.OPEN, + ); + + // Test: Hybrid search with combine methods (LINEAR and RRF) + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with combine methods", + async (client) => { + const indexName = "idx_combine"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 10); + + // Test with LINEAR combine method + const resultLinear = await client.ft.hybrid(indexName, { + SEARCH: { query: "@color:{red}" }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + COMBINE: { + method: { type: FT_HYBRID_COMBINE_METHOD.LINEAR, ALPHA: 0.5, BETA: 0.5 }, + }, + LIMIT: { offset: 0, count: 3 }, + TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 7, 6]), + }, + }); + + assert.ok(resultLinear.totalResults >= 3); + assert.strictEqual(resultLinear.results.length, 3); + assert.deepStrictEqual(resultLinear.warnings, []); + assert.ok(resultLinear.executionTime > 0); + + // Test with RRF combine method with WINDOW and CONSTANT + const resultRrf = await client.ft.hybrid(indexName, { + SEARCH: { query: "@color:{red}" }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + COMBINE: { + method: { type: FT_HYBRID_COMBINE_METHOD.RRF, WINDOW: 3, CONSTANT: 0.5 }, + }, + LIMIT: { offset: 0, count: 3 }, + TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 7, 6]), + }, + }); + + assert.ok(resultRrf.totalResults >= 3); + assert.strictEqual(resultRrf.results.length, 3); + assert.deepStrictEqual(resultRrf.warnings, []); + assert.ok(resultRrf.executionTime > 0); + + // Test with RRF without all params + const resultRrf2 = await client.ft.hybrid(indexName, { + SEARCH: { query: "@color:{red}" }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + COMBINE: { + method: { type: FT_HYBRID_COMBINE_METHOD.RRF, WINDOW: 3 }, + }, + LIMIT: { offset: 0, count: 3 }, + TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 7, 6]), + }, + }); + + assert.ok(resultRrf2.totalResults >= 3); + assert.strictEqual(resultRrf2.results.length, 3); + assert.deepStrictEqual(resultRrf2.warnings, []); + assert.ok(resultRrf2.executionTime > 0); + }, + GLOBAL.SERVERS.OPEN, + ); + + // Test: Hybrid search with LOAD + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with load", + async (client) => { + const indexName = "idx_load"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 10); + + const result = await client.ft.hybrid(indexName, { + SEARCH: { query: "@color:{red|green|black}" }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + COMBINE: { + method: { type: FT_HYBRID_COMBINE_METHOD.LINEAR, ALPHA: 0.5, BETA: 0.5 }, + }, + LOAD: ["@description", "@color", "@price", "@size"], + LIMIT: { offset: 0, count: 1 }, + TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 7, 6]), + }, + }); + + assert.ok(result.totalResults >= 1); + assert.strictEqual(result.results.length, 1); + assert.deepStrictEqual(result.warnings, []); + assert.ok(result.executionTime > 0); + + // Check that loaded fields exist + const doc = result.results[0]; + assert.ok(doc.description !== undefined); + assert.ok(doc.color !== undefined); + assert.ok(doc.price !== undefined); + assert.ok(doc.size !== undefined); + }, + GLOBAL.SERVERS.OPEN, + ); + + // Test: Hybrid search with LOAD and APPLY + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with load and apply", + async (client) => { + const indexName = "idx_load_apply"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 10); + + const result = await client.ft.hybrid(indexName, { + SEARCH: { query: "@color:{red}" }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + LOAD: ["@color", "@price", "@size"], + APPLY: [ + { expression: "@price - (@price * 0.1)", AS: "price_discount" }, + { expression: "@price_discount * 0.2", AS: "tax_discount" }, + ], + LIMIT: { offset: 0, count: 3 }, + TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 7, 6]), + }, + }); + + assert.strictEqual(result.results.length, 3); + assert.deepStrictEqual(result.warnings, []); + assert.ok(result.executionTime > 0); + + // Check that applied fields exist + for (const doc of result.results) { + assert.ok(doc.color !== undefined); + assert.ok(doc.price !== undefined); + assert.ok(doc.size !== undefined); + assert.ok(doc.price_discount !== undefined); + assert.ok(doc.tax_discount !== undefined); + } + }, + GLOBAL.SERVERS.OPEN, + ); + + // Test: Hybrid search with LOAD and FILTER + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with load and filter", + async (client) => { + const indexName = "idx_load_filter"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 10); + + const result = await client.ft.hybrid(indexName, { + SEARCH: { query: "@color:{red|green|black}" }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + LOAD: ["@description", "@color", "@price", "@size"], + FILTER: '@price=="15"', + LIMIT: { offset: 0, count: 3 }, + TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 7, 6]), + }, + }); + + assert.strictEqual(result.results.length, 3); + assert.deepStrictEqual(result.warnings, []); + assert.ok(result.executionTime > 0); + + for (const item of result.results) { + assert.strictEqual(item.price, "15"); + } + }, + GLOBAL.SERVERS.OPEN, + ); + + // Test: Hybrid search with LOAD, APPLY, and PARAMS + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with load apply and params", + async (client) => { + const indexName = "idx_load_apply_params"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 5, { useRandomStrData: true }); + + const result = await client.ft.hybrid(indexName, { + SEARCH: { query: "@color:{$color_criteria}" }, + VSIM: { + field: "@embedding", + vector: "$vector", + }, + LOAD: ["@description", "@color", "@price"], + APPLY: [ + { expression: "@price - (@price * 0.1)", AS: "price_discount" }, + ], + LIMIT: { offset: 0, count: 3 }, + PARAMS: { + vector: "abcd1234abcd5678", + color_criteria: "red", + }, + TIMEOUT: 10000, + }); + + assert.strictEqual(result.results.length, 3); + assert.deepStrictEqual(result.warnings, []); + assert.ok(result.executionTime > 0); + + for (const doc of result.results) { + assert.ok(doc.description !== undefined); + assert.ok(doc.color !== undefined); + assert.ok(doc.price !== undefined); + assert.ok(doc.price_discount !== undefined); + } + }, + GLOBAL.SERVERS.OPEN, + ); + + // Test: Hybrid search with LIMIT + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with limit", + async (client) => { + const indexName = "idx_limit"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 10); + + const result = await client.ft.hybrid(indexName, { + SEARCH: { query: "@color:{red}" }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + LIMIT: { offset: 0, count: 3 }, + TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 7, 6]), + }, + }); + + assert.strictEqual(result.results.length, 3); + assert.deepStrictEqual(result.warnings, []); + }, + GLOBAL.SERVERS.OPEN, + ); + + // Test: Hybrid search with LOAD, APPLY, and SORTBY + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with load apply and sortby", + async (client) => { + const indexName = "idx_sortby"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 1); + + const result = await client.ft.hybrid(indexName, { + SEARCH: { query: "@color:{red|green}" }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + LOAD: ["@color", "@price"], + APPLY: [ + { expression: "@price - (@price * 0.1)", AS: "price_discount" }, + ], + SORTBY: { + fields: [ + { field: "@price_discount", direction: "DESC" }, + { field: "@color", direction: "ASC" }, + ], + }, + LIMIT: { offset: 0, count: 5 }, + TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 7, 6]), + }, + }); + + assert.ok(result.totalResults >= 5); + assert.strictEqual(result.results.length, 5); + assert.deepStrictEqual(result.warnings, []); + assert.ok(result.executionTime > 0); + assert.ok( + result.results[0].price_discount > + result.results.at(-1)?.price_discount, + ); + }, + GLOBAL.SERVERS.OPEN, + ); + + // Test: Hybrid search with timeout + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with timeout", + async (client) => { + const dim = 128; + const indexName = "idx_timeout"; + await createHybridSearchIndex(client, indexName, dim); + await addDataForHybridSearch(client, 1000, { + dimForRandomData: dim, + useRandomStrData: true, + }); + + // Normal timeout should succeed + const timeout = 5000; // 5 second timeout + const result = await client.ft.hybrid(indexName, { + SEARCH: { query: "*" }, + VSIM: { + field: "@embeddingHNSW", + vector: "$vec", + method: { + type: FT_HYBRID_VECTOR_METHOD.KNN, + K: 1000, + }, + FILTER: + "((@price:[15 16] @size:[10 11]) | (@price:[13 15] @size:[11 12])) @description:(shoes) -@description:(green)", + }, + COMBINE: { + method: { type: FT_HYBRID_COMBINE_METHOD.RRF, WINDOW: 1000 }, + }, + TIMEOUT: timeout, + PARAMS: { + vec: "abcd".repeat(dim), + }, + }); + + assert.ok(result.results.length > 0); + assert.deepStrictEqual(result.warnings, []); + assert.ok(result.executionTime > 0 && result.executionTime < timeout); + + // Very short timeout may cause warnings + const result2 = await client.ft.hybrid(indexName, { + SEARCH: { query: "*" }, + VSIM: { + field: "@embeddingHNSW", + vector: "$vec", + method: { + type: FT_HYBRID_VECTOR_METHOD.KNN, + K: 1000, + }, + }, + TIMEOUT: 1, // 1ms timeout - likely to timeout + PARAMS: { + vec: "abcd".repeat(dim), + }, + }); + + // May have timeout warnings + // Note: This is timing-dependent, so we just check it returns + assert.ok(result2 !== undefined); + }, + GLOBAL.SERVERS.OPEN, + ); + + // Test: Hybrid search with LOAD and GROUPBY + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with load and groupby", + async (client) => { + const indexName = "idx_groupby"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 10); + + const result = await client.ft.hybrid(indexName, { + SEARCH: { query: "@color:{red|green}" }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + LOAD: ["@color", "@price", "@size", "@itemType"], + GROUPBY: { + fields: ["@itemType", "@price"], + REDUCE: [ + { + type: FT_AGGREGATE_GROUP_BY_REDUCERS.COUNT_DISTINCT, + property: "@color", + AS: "colors_count", + }, + { + type: FT_AGGREGATE_GROUP_BY_REDUCERS.MIN, + property: "@size", + }, + ], + }, + SORTBY: { + fields: [{ field: "@price", direction: "ASC" }], + }, + LIMIT: { offset: 0, count: 4 }, + TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 7, 6]), + }, + }); + + assert.strictEqual(result.results.length, 4); + assert.deepStrictEqual(result.warnings, []); + for (const item of result.results) { + assert.ok(item.colors_count !== undefined); + } + }, + GLOBAL.SERVERS.OPEN, + ); + + // Test: Hybrid search with multiple LOADs and APPLYs + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with multiple loads and applies", + async (client) => { + const indexName = "idx_multi_load_apply"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 1); + + const result = await client.ft.hybrid(indexName, { + SEARCH: { query: "@color:{red|green}" }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + LOAD: ["@color", "@price", "@description"], + APPLY: [ + { + expression: "@price - (@price * 0.1)", + AS: "discount_10_percents", + }, + { + expression: + "@discount_10_percents - (@discount_10_percents * 0.1)", + AS: "additional_discount", + }, + ], + FILTER: '@price=="15"', + SORTBY: { + fields: [ + { field: "@discount_10_percents", direction: "DESC" }, + { field: "@color", direction: "ASC" }, + ], + }, + LIMIT: { offset: 0, count: 5 }, + TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 7, 6]), + }, + }); + + assert.strictEqual(result.results.length, 2); + for (const item of result.results) { + assert.ok(item.color !== undefined); + assert.ok(item.price !== undefined); + assert.ok(item.description !== undefined); + assert.ok(item.discount_10_percents !== undefined); + assert.ok(item.additional_discount !== undefined); + } + }, + GLOBAL.SERVERS.OPEN, + ); + + // Test: Hybrid search with LOAD: "*" loads all fields + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with load all fields using LOAD *", + async (client) => { + const indexName = "idx_load_all"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 1); + + const result = await client.ft.hybrid(indexName, { + SEARCH: { query: "@color:{red}" }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + LOAD: "*", + LIMIT: { offset: 0, count: 1 }, + TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 7, 6]), + }, + }); + + assert.strictEqual(result.results.length, 1); + assert.deepStrictEqual(result.warnings, []); + + // Check that all fields are loaded when using LOAD: "*" + const doc = result.results[0]; + assert.ok(doc.description !== undefined, "description should be loaded"); + assert.ok(doc.price !== undefined, "price should be loaded"); + assert.ok(doc.color !== undefined, "color should be loaded"); + assert.ok(doc.itemType !== undefined, "itemType should be loaded"); + assert.ok(doc.size !== undefined, "size should be loaded"); + // embedding and embeddingHNSW are binary vector fields + assert.ok(doc.embedding !== undefined, "embedding should be loaded"); + assert.ok(doc.embeddingHNSW !== undefined, "embeddingHNSW should be loaded"); + }, + GLOBAL.SERVERS.OPEN, + ); }); -}); \ No newline at end of file +}); diff --git a/packages/search/lib/commands/HYBRID.ts b/packages/search/lib/commands/HYBRID.ts index 3773659de56..f2eddccd8cf 100644 --- a/packages/search/lib/commands/HYBRID.ts +++ b/packages/search/lib/commands/HYBRID.ts @@ -1,234 +1,361 @@ -import { CommandParser } from '@redis/client/dist/lib/client/parser'; -import { RedisArgument, Command, ReplyUnion } from '@redis/client/dist/lib/RESP/types'; -import { RedisVariadicArgument, parseOptionalVariadicArgument } from '@redis/client/dist/lib/commands/generic-transformers'; -import { FtSearchParams, parseParamsArgument } from './SEARCH'; - +import { CommandParser } from "@redis/client/dist/lib/client/parser"; +import { + RedisArgument, + Command, + ReplyUnion, +} from "@redis/client/dist/lib/RESP/types"; +import { + RedisVariadicArgument, + parseOptionalVariadicArgument, +} from "@redis/client/dist/lib/commands/generic-transformers"; +import { parseParamsArgument } from "./SEARCH"; +import { GroupByReducers, parseGroupByReducer } from "./AGGREGATE"; + +/** + * Text search expression configuration for hybrid search. + */ export interface FtHybridSearchExpression { + /** Search query string or parameter reference (e.g., "$q") */ query: RedisArgument; - SCORER?: { - algorithm: RedisArgument; - params?: Array; - }; + /** Scoring algorithm configuration */ + SCORER?: RedisArgument; + /** Alias for the text search score in results */ YIELD_SCORE_AS?: RedisArgument; } -export interface FtHybridVectorMethod { - KNN?: { - K: number; - EF_RUNTIME?: number; - YIELD_DISTANCE_AS?: RedisArgument; - }; - RANGE?: { - RADIUS: number; - EPSILON?: number; - YIELD_DISTANCE_AS?: RedisArgument; - }; +/** + * Vector search method configuration - either KNN or RANGE. + */ +export const FT_HYBRID_VECTOR_METHOD = { + /** K-Nearest Neighbors search configuration */ + KNN: "KNN", + /** Range-based vector search configuration */ + RANGE: "RANGE", +} as const; + +/** Vector search method type */ +export type FtHybridVectorMethodType = + (typeof FT_HYBRID_VECTOR_METHOD)[keyof typeof FT_HYBRID_VECTOR_METHOD]; + +interface FtHybridVectorMethodKNN { + type: (typeof FT_HYBRID_VECTOR_METHOD)["KNN"]; + /** Number of nearest neighbors to find */ + K: number; + /** Controls the search accuracy vs. speed tradeoff */ + EF_RUNTIME?: number; } +interface FtHybridVectorMethodRange { + type: (typeof FT_HYBRID_VECTOR_METHOD)["RANGE"]; + /** Maximum distance for matches */ + RADIUS: number; + /** Provides additional precision control */ + EPSILON?: number; +} + +/** + * Vector similarity search expression configuration. + */ export interface FtHybridVectorExpression { + /** Vector field name (e.g., "@embedding") */ field: RedisArgument; - vectorData: RedisArgument; - method?: FtHybridVectorMethod; - FILTER?: { - expression: RedisArgument; - POLICY?: 'ADHOC' | 'BATCHES' | 'ACORN'; - BATCHES?: { - BATCH_SIZE: number; - }; - }; + /** Vector parameter reference (e.g., "$v") */ + vector: string; + /** Search method configuration - KNN or RANGE */ + method?: FtHybridVectorMethodKNN | FtHybridVectorMethodRange; + /** Pre-filter expression applied before vector search (e.g., "@tag:{foo}") */ + FILTER?: RedisArgument; + /** Alias for the vector score in results */ YIELD_SCORE_AS?: RedisArgument; } -export interface FtHybridCombineMethod { - RRF?: { - count: number; - WINDOW?: number; - CONSTANT?: number; - }; - LINEAR?: { - count: number; - ALPHA?: number; - BETA?: number; - }; - FUNCTION?: RedisArgument; +/** + * Score fusion method type constants for combining search results. + */ +export const FT_HYBRID_COMBINE_METHOD = { + /** Reciprocal Rank Fusion */ + RRF: "RRF", + /** Linear combination with ALPHA and BETA weights */ + LINEAR: "LINEAR", +} as const; + +/** Combine method type */ +export type FtHybridCombineMethodType = + (typeof FT_HYBRID_COMBINE_METHOD)[keyof typeof FT_HYBRID_COMBINE_METHOD]; + +interface FtHybridCombineMethodRRF { + type: (typeof FT_HYBRID_COMBINE_METHOD)["RRF"]; + /** RRF constant for score calculation */ + CONSTANT?: number; + /** Window size for score normalization */ + WINDOW?: number; +} + +interface FtHybridCombineMethodLinear { + type: (typeof FT_HYBRID_COMBINE_METHOD)["LINEAR"]; + /** Weight for text search score */ + ALPHA?: number; + /** Weight for vector search score */ + BETA?: number; + /** Window size for score normalization */ + WINDOW?: number; +} + +/** + * Apply expression for result transformation. + */ +export interface FtHybridApply { + /** Transformation expression to apply */ + expression: RedisArgument; + /** Alias for the computed value in output */ + AS?: RedisArgument; } +/** + * Options for the FT.HYBRID command. + */ export interface FtHybridOptions { - SEARCH?: FtHybridSearchExpression; - VSIM?: FtHybridVectorExpression; + /** Text search expression configuration */ + SEARCH: FtHybridSearchExpression; + /** Vector similarity search expression configuration */ + VSIM: FtHybridVectorExpression; + /** Score fusion configuration for combining SEARCH and VSIM results */ COMBINE?: { - method: FtHybridCombineMethod; + /** Fusion method: RRF or LINEAR */ + method: FtHybridCombineMethodRRF | FtHybridCombineMethodLinear; + /** Alias for the combined score in results */ YIELD_SCORE_AS?: RedisArgument; }; + /** + * Fields to load and return in results (LOAD clause). + * - Use `"*"` to load all fields from documents + * - Use a field name or array of field names to load specific fields + */ LOAD?: RedisVariadicArgument; + /** Group by configuration for aggregation */ GROUPBY?: { + /** Fields to group by */ fields: RedisVariadicArgument; - REDUCE?: { - function: RedisArgument; - count: number; - args: Array; - }; - }; - APPLY?: { - expression: RedisArgument; - AS: RedisArgument; + /** Reducer(s) to apply to each group */ + REDUCE?: GroupByReducers | Array; }; + /** Apply expression(s) for result transformation */ + APPLY?: FtHybridApply | Array; + /** Sort configuration for results */ SORTBY?: { - count: number; + /** Fields to sort by with optional direction */ fields: Array<{ + /** Field name to sort by */ field: RedisArgument; - direction?: 'ASC' | 'DESC'; + /** Sort direction: "ASC" (ascending) or "DESC" (descending) */ + direction?: "ASC" | "DESC"; }>; }; + /** Disable sorting - returns results in arbitrary order */ + NOSORT?: boolean; + /** Post-filter expression applied after scoring */ FILTER?: RedisArgument; + /** Pagination configuration */ LIMIT?: { + /** Number of results to skip */ offset: number | RedisArgument; - num: number | RedisArgument; + /** Number of results to return */ + count: number | RedisArgument; }; - PARAMS?: FtSearchParams; - EXPLAINSCORE?: boolean; + /** Query parameters for parameterized queries */ + PARAMS?: Record; + /** Query timeout in milliseconds */ TIMEOUT?: number; - WITHCURSOR?: { - COUNT?: number; - MAXIDLE?: number; - }; } -function parseSearchExpression(parser: CommandParser, search: FtHybridSearchExpression) { - parser.push('SEARCH', search.query); +function parseSearchExpression( + parser: CommandParser, + search: FtHybridSearchExpression, +) { + parser.push("SEARCH", search.query); if (search.SCORER) { - parser.push('SCORER', search.SCORER.algorithm); - if (search.SCORER.params) { - parser.push(...search.SCORER.params); - } + parser.push("SCORER", search.SCORER); } if (search.YIELD_SCORE_AS) { - parser.push('YIELD_SCORE_AS', search.YIELD_SCORE_AS); + parser.push("YIELD_SCORE_AS", search.YIELD_SCORE_AS); } } -function parseVectorExpression(parser: CommandParser, vsim: FtHybridVectorExpression) { - parser.push('VSIM', vsim.field, vsim.vectorData); +function parseVectorExpression( + parser: CommandParser, + vsim: FtHybridVectorExpression, +) { + parser.push("VSIM", vsim.field, vsim.vector); if (vsim.method) { - if (vsim.method.KNN) { - const knn = vsim.method.KNN; - parser.push('KNN', '1', 'K', knn.K.toString()); - - if (knn.EF_RUNTIME !== undefined) { - parser.push('EF_RUNTIME', knn.EF_RUNTIME.toString()); + if (vsim.method.type === FT_HYBRID_VECTOR_METHOD.KNN) { + let argsCount = 2; + if (vsim.method.EF_RUNTIME !== undefined) { + argsCount += 2; } - if (knn.YIELD_DISTANCE_AS) { - parser.push('YIELD_DISTANCE_AS', knn.YIELD_DISTANCE_AS); + parser.push("KNN", argsCount.toString(), "K", vsim.method.K.toString()); + + if (vsim.method.EF_RUNTIME !== undefined) { + parser.push("EF_RUNTIME", vsim.method.EF_RUNTIME.toString()); } } - if (vsim.method.RANGE) { - const range = vsim.method.RANGE; - parser.push('RANGE', '1', 'RADIUS', range.RADIUS.toString()); - - if (range.EPSILON !== undefined) { - parser.push('EPSILON', range.EPSILON.toString()); + if (vsim.method.type === FT_HYBRID_VECTOR_METHOD.RANGE) { + let argsCount = 2; + if (vsim.method.EPSILON !== undefined) { + argsCount += 2; } - if (range.YIELD_DISTANCE_AS) { - parser.push('YIELD_DISTANCE_AS', range.YIELD_DISTANCE_AS); + parser.push( + "RANGE", + argsCount.toString(), + "RADIUS", + vsim.method.RADIUS.toString(), + ); + + if (vsim.method.EPSILON !== undefined) { + parser.push("EPSILON", vsim.method.EPSILON.toString()); } } } if (vsim.FILTER) { - parser.push('FILTER', vsim.FILTER.expression); - - if (vsim.FILTER.POLICY) { - parser.push('POLICY', vsim.FILTER.POLICY); - - if (vsim.FILTER.POLICY === 'BATCHES' && vsim.FILTER.BATCHES) { - parser.push('BATCHES', 'BATCH_SIZE', vsim.FILTER.BATCHES.BATCH_SIZE.toString()); - } - } + parser.push("FILTER", vsim.FILTER); } if (vsim.YIELD_SCORE_AS) { - parser.push('YIELD_SCORE_AS', vsim.YIELD_SCORE_AS); + parser.push("YIELD_SCORE_AS", vsim.YIELD_SCORE_AS); } } -function parseCombineMethod(parser: CommandParser, combine: FtHybridOptions['COMBINE']) { +function parseCombineMethod( + parser: CommandParser, + combine: FtHybridOptions["COMBINE"], +) { if (!combine) return; - parser.push('COMBINE'); + parser.push("COMBINE"); - if (combine.method.RRF) { - const rrf = combine.method.RRF; - parser.push('RRF', rrf.count.toString()); + if (combine.method.type === FT_HYBRID_COMBINE_METHOD.RRF) { + // Calculate argsCount: 2 per optional (WINDOW, CONSTANT, YIELD_SCORE_AS) + let argsCount = 0; + if (combine.method.WINDOW !== undefined) { + argsCount += 2; + } + if (combine.method.CONSTANT !== undefined) { + argsCount += 2; + } + if (combine.YIELD_SCORE_AS) { + argsCount += 2; + } + + parser.push("RRF", argsCount.toString()); - if (rrf.WINDOW !== undefined) { - parser.push('WINDOW', rrf.WINDOW.toString()); + if (combine.method.WINDOW !== undefined) { + parser.push("WINDOW", combine.method.WINDOW.toString()); } - if (rrf.CONSTANT !== undefined) { - parser.push('CONSTANT', rrf.CONSTANT.toString()); + if (combine.method.CONSTANT !== undefined) { + parser.push("CONSTANT", combine.method.CONSTANT.toString()); } } - if (combine.method.LINEAR) { - const linear = combine.method.LINEAR; - parser.push('LINEAR', linear.count.toString()); + if (combine.method.type === FT_HYBRID_COMBINE_METHOD.LINEAR) { + // Calculate argsCount: 2 per optional (ALPHA, BETA, WINDOW, YIELD_SCORE_AS) + let argsCount = 0; + if (combine.method.ALPHA !== undefined) { + argsCount += 2; + } + if (combine.method.BETA !== undefined) { + argsCount += 2; + } + if (combine.method.WINDOW !== undefined) { + argsCount += 2; + } + if (combine.YIELD_SCORE_AS) { + argsCount += 2; + } + + parser.push("LINEAR", argsCount.toString()); - if (linear.ALPHA !== undefined) { - parser.push('ALPHA', linear.ALPHA.toString()); + if (combine.method.ALPHA !== undefined) { + parser.push("ALPHA", combine.method.ALPHA.toString()); } - if (linear.BETA !== undefined) { - parser.push('BETA', linear.BETA.toString()); + if (combine.method.BETA !== undefined) { + parser.push("BETA", combine.method.BETA.toString()); } - } - if (combine.method.FUNCTION) { - parser.push('FUNCTION', combine.method.FUNCTION); + if (combine.method.WINDOW !== undefined) { + parser.push("WINDOW", combine.method.WINDOW.toString()); + } } if (combine.YIELD_SCORE_AS) { - parser.push('YIELD_SCORE_AS', combine.YIELD_SCORE_AS); + parser.push("YIELD_SCORE_AS", combine.YIELD_SCORE_AS); } } -function parseHybridOptions(parser: CommandParser, options?: FtHybridOptions) { - if (!options) return; - - if (options.SEARCH) { - parseSearchExpression(parser, options.SEARCH); +function parseApply(parser: CommandParser, apply: FtHybridApply) { + parser.push("APPLY", apply.expression); + if (apply.AS) { + parser.push("AS", apply.AS); } +} - if (options.VSIM) { - parseVectorExpression(parser, options.VSIM); - } +function parseHybridOptions(parser: CommandParser, options: FtHybridOptions) { + parseSearchExpression(parser, options.SEARCH); + parseVectorExpression(parser, options.VSIM); if (options.COMBINE) { parseCombineMethod(parser, options.COMBINE); } - parseOptionalVariadicArgument(parser, 'LOAD', options.LOAD); + if (options.LOAD) { + if (options.LOAD === "*") { + parser.push("LOAD", "*"); + } else { + parseOptionalVariadicArgument(parser, "LOAD", options.LOAD); + } + } + if (options.GROUPBY) { - parseOptionalVariadicArgument(parser, 'GROUPBY', options.GROUPBY.fields); + parseOptionalVariadicArgument(parser, "GROUPBY", options.GROUPBY.fields); if (options.GROUPBY.REDUCE) { - parser.push('REDUCE', options.GROUPBY.REDUCE.function, options.GROUPBY.REDUCE.count.toString()); - parser.push(...options.GROUPBY.REDUCE.args); + const reducers = Array.isArray(options.GROUPBY.REDUCE) + ? options.GROUPBY.REDUCE + : [options.GROUPBY.REDUCE]; + + for (const reducer of reducers) { + parseGroupByReducer(parser, reducer); + } } } if (options.APPLY) { - parser.push('APPLY', options.APPLY.expression, 'AS', options.APPLY.AS); + const applies = Array.isArray(options.APPLY) + ? options.APPLY + : [options.APPLY]; + + for (const apply of applies) { + parseApply(parser, apply); + } } if (options.SORTBY) { - parser.push('SORTBY', options.SORTBY.count.toString()); + const sortByArgsCount = options.SORTBY.fields.reduce((acc, field) => { + if (field.direction) { + return acc + 2; + } + return acc + 1; + }, 0); + + parser.push("SORTBY", sortByArgsCount.toString()); for (const sortField of options.SORTBY.fields) { parser.push(sortField.field); if (sortField.direction) { @@ -237,34 +364,28 @@ function parseHybridOptions(parser: CommandParser, options?: FtHybridOptions) { } } + if (options.NOSORT) { + parser.push("NOSORT"); + } + if (options.FILTER) { - parser.push('FILTER', options.FILTER); + parser.push("FILTER", options.FILTER); } if (options.LIMIT) { - parser.push('LIMIT', options.LIMIT.offset.toString(), options.LIMIT.num.toString()); + parser.push( + "LIMIT", + options.LIMIT.offset.toString(), + options.LIMIT.count.toString(), + ); } - parseParamsArgument(parser, options.PARAMS); + const hasParams = options.PARAMS && Object.keys(options.PARAMS).length > 0; - if (options.EXPLAINSCORE) { - parser.push('EXPLAINSCORE'); - } + parseParamsArgument(parser, hasParams ? options.PARAMS : undefined); if (options.TIMEOUT !== undefined) { - parser.push('TIMEOUT', options.TIMEOUT.toString()); - } - - if (options.WITHCURSOR) { - parser.push('WITHCURSOR'); - - if (options.WITHCURSOR.COUNT !== undefined) { - parser.push('COUNT', options.WITHCURSOR.COUNT.toString()); - } - - if (options.WITHCURSOR.MAXIDLE !== undefined) { - parser.push('MAXIDLE', options.WITHCURSOR.MAXIDLE.toString()); - } + parser.push("TIMEOUT", options.TIMEOUT.toString()); } } @@ -284,80 +405,93 @@ export default { * @param options - Hybrid search options including: * - SEARCH: Text search expression with optional scoring * - VSIM: Vector similarity expression with KNN/RANGE methods - * - COMBINE: Fusion method (RRF, LINEAR, FUNCTION) + * - COMBINE: Fusion method (RRF, LINEAR) * - Post-processing operations: LOAD, GROUPBY, APPLY, SORTBY, FILTER - * - Tunable options: LIMIT, PARAMS, EXPLAINSCORE, TIMEOUT, WITHCURSOR + * - Tunable options: LIMIT, PARAMS, TIMEOUT */ - parseCommand(parser: CommandParser, index: RedisArgument, options?: FtHybridOptions) { - parser.push('FT.HYBRID', index); + parseCommand( + parser: CommandParser, + index: RedisArgument, + options: FtHybridOptions, + ) { + parser.push("FT.HYBRID", index); parseHybridOptions(parser, options); - }, transformReply: { - 2: (reply: any): any => { - // Check if this is a cursor reply: [[results...], cursorId] - if (Array.isArray(reply) && reply.length === 2 && typeof reply[1] === 'number') { - // This is a cursor reply - const [searchResults, cursor] = reply; - const transformedResults = transformHybridSearchResults(searchResults); - - return { - ...transformedResults, - cursor - }; - } else { - // Normal reply without cursor - return transformHybridSearchResults(reply); - } + 2: (reply: any): HybridSearchResult => { + return transformHybridSearchResults(reply); }, - 3: undefined as unknown as () => ReplyUnion + 3: undefined as unknown as () => ReplyUnion, }, - unstableResp3: true + unstableResp3: true, } as const satisfies Command; -function transformHybridSearchResults(reply: any) { - // Similar structure to FT.SEARCH reply transformation - const withoutDocuments = reply.length > 2 && !Array.isArray(reply[2]); - - const documents = []; - let i = 1; - while (i < reply.length) { - documents.push({ - id: reply[i++], - value: withoutDocuments ? Object.create(null) : documentValue(reply[i++]) - }); +export interface HybridSearchResult { + totalResults: number; + executionTime: number; + warnings: string[]; + results: Record[]; +} + +function transformHybridSearchResults(reply: any): HybridSearchResult { + // FT.HYBRID returns a map-like structure as flat array: + // ['total_results', N, 'results', [...], 'warnings', [...], 'execution_time', 'X.XXX'] + const replyMap = parseReplyMap(reply); + + const totalResults = replyMap["total_results"] ?? 0; + const rawResults = replyMap["results"] ?? []; + const warnings = replyMap["warnings"] ?? []; + const executionTime = replyMap["execution_time"] + ? Number.parseFloat(replyMap["execution_time"]) + : 0; + + const results: Record[] = []; + for (const result of rawResults) { + // Each result is a flat key-value array like FT.AGGREGATE: ['field1', 'value1', 'field2', 'value2', ...] + const resultMap = parseReplyMap(result); + + const doc = Object.create(null); + + // Add all other fields from the result + for (const [key, value] of Object.entries(resultMap)) { + if (key === "$") { + // JSON document - parse and merge + try { + Object.assign(doc, JSON.parse(value as string)); + } catch { + doc[key] = value; + } + } else { + doc[key] = value; + } + } + + results.push(doc); } return { - total: reply[0], - documents + totalResults, + executionTime, + warnings, + results, }; } -function documentValue(tuples: any) { - const message = Object.create(null); +function parseReplyMap(reply: any): Record { + const map: Record = {}; - if (!tuples) { - return message; + if (!Array.isArray(reply)) { + return map; } - let i = 0; - while (i < tuples.length) { - const key = tuples[i++]; - const value = tuples[i++]; - - if (key === '$') { // might be a JSON reply - try { - Object.assign(message, JSON.parse(value)); - continue; - } catch { - // set as a regular property if not a valid JSON - } + for (let i = 0; i < reply.length; i += 2) { + const key = reply[i]; + const value = reply[i + 1]; + if (typeof key === "string") { + map[key] = value; } - - message[key] = value; } - return message; + return map; }