From defffed85e5d98840468120d82d6ef5ae630d1d7 Mon Sep 17 00:00:00 2001 From: Pavel Pashov Date: Mon, 2 Feb 2026 16:13:11 +0200 Subject: [PATCH 1/9] fix(search): improve FT.HYBRID command implementation --- packages/search/lib/commands/HYBRID.spec.ts | 1872 +++++++++++++++-- packages/search/lib/commands/HYBRID.ts | 414 ++-- .../search/lib/commands/HYBRID_WITHCURSOR.ts | 85 + packages/search/lib/commands/index.ts | 3 + 4 files changed, 2003 insertions(+), 371 deletions(-) create mode 100644 packages/search/lib/commands/HYBRID_WITHCURSOR.ts diff --git a/packages/search/lib/commands/HYBRID.spec.ts b/packages/search/lib/commands/HYBRID.spec.ts index 12f1ded6dcc..16c98b7dc81 100644 --- a/packages/search/lib/commands/HYBRID.spec.ts +++ b/packages/search/lib/commands/HYBRID.spec.ts @@ -1,350 +1,1758 @@ -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', () => { +import { strict as assert } from "node:assert"; +import HYBRID from "./HYBRID"; +import { BasicCommandParser } from "@redis/client/lib/client/parser"; +import testUtils, { GLOBAL } from "../test-utils"; +import { SCHEMA_VECTOR_FIELD_ALGORITHM } from "./CREATE"; +import HYBRID_WITHCURSOR from "./HYBRID_WITHCURSOR"; + +/** + * 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); + } + + 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", () => { const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index'); - assert.deepEqual( - parser.redisArgs, - ['FT.HYBRID', 'index'] - ); + HYBRID.parseCommand(parser, "index"); + assert.deepEqual(parser.redisArgs, ["FT.HYBRID", "index"]); }); - - it('with SEARCH expression', () => { + it("with SEARCH expression", () => { const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { + HYBRID.parseCommand(parser, "index", { SEARCH: { - query: '@description: bikes' - } + query: "@description: bikes", + }, }); - assert.deepEqual( - parser.redisArgs, - ['FT.HYBRID', 'index', 'SEARCH', '@description: bikes'] - ); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "SEARCH", + "@description: bikes", + ]); }); - 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', + query: "@description: bikes", SCORER: { - algorithm: 'TFIDF.DOCNORM', - params: ['param1', 'param2'] + algorithm: "TFIDF.DOCNORM", + params: ["param1", "param2"], }, - YIELD_SCORE_AS: 'search_score' - } + YIELD_SCORE_AS: "search_score", + }, }); - 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", + "param1", + "param2", + "YIELD_SCORE_AS", + "search_score", + ]); }); - 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", { VSIM: { - field: '@vector_field', - vectorData: 'BLOB_DATA', + field: "@vector_field", + vectorData: "BLOB_DATA", method: { KNN: { K: 10, EF_RUNTIME: 50, - YIELD_DISTANCE_AS: 'vector_dist' - } - } - } + YIELD_DISTANCE_AS: "vector_dist", + }, + }, + }, }); - 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", + "VSIM", + "@vector_field", + "$v", + "KNN", + "6", + "K", + "10", + "EF_RUNTIME", + "50", + "YIELD_DISTANCE_AS", + "vector_dist", + "PARAMS", + "2", + "v", + "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", { VSIM: { - field: '@vector_field', - vectorData: 'BLOB_DATA', + field: "@vector_field", + vectorData: "BLOB_DATA", method: { RANGE: { RADIUS: 0.5, EPSILON: 0.01, - YIELD_DISTANCE_AS: 'vector_dist' - } - } - } + YIELD_DISTANCE_AS: "vector_dist", + }, + }, + }, }); - 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", + "VSIM", + "@vector_field", + "$v", + "RANGE", + "6", + "RADIUS", + "0.5", + "EPSILON", + "0.01", + "YIELD_DISTANCE_AS", + "vector_dist", + "PARAMS", + "2", + "v", + "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", { 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", + vectorData: "BLOB_DATA", + FILTER: "@category:{bikes}", + YIELD_SCORE_AS: "vsim_score", + }, }); - 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", + "VSIM", + "@vector_field", + "$v", + "FILTER", + "@category:{bikes}", + "YIELD_SCORE_AS", + "vsim_score", + "PARAMS", + "2", + "v", + "BLOB_DATA", + ]); }); - it('with RRF COMBINE method', () => { + it("with RRF COMBINE method", () => { const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { + HYBRID.parseCommand(parser, "index", { COMBINE: { method: { RRF: { - count: 2, WINDOW: 10, - CONSTANT: 60 - } + CONSTANT: 60, + }, }, - YIELD_SCORE_AS: 'combined_score' - } + YIELD_SCORE_AS: "combined_score", + }, }); - 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", + "COMBINE", + "RRF", + "6", + "WINDOW", + "10", + "CONSTANT", + "60", + "YIELD_SCORE_AS", + "combined_score", + ]); }); - it('with LINEAR COMBINE method', () => { + it("with LINEAR COMBINE method", () => { const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { + HYBRID.parseCommand(parser, "index", { COMBINE: { method: { LINEAR: { - count: 2, ALPHA: 0.7, - BETA: 0.3 - } - } - } + BETA: 0.3, + }, + }, + }, }); - assert.deepEqual( - parser.redisArgs, - [ - 'FT.HYBRID', 'index', 'COMBINE', 'LINEAR', '2', 'ALPHA', '0.7', 'BETA', '0.3' - ] - ); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "COMBINE", + "LINEAR", + "4", + "ALPHA", + "0.7", + "BETA", + "0.3", + ]); }); - 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", { + LOAD: ["field1", "field2"], SORTBY: { - count: 1, - fields: [ - { field: 'score', direction: 'DESC' } - ] + fields: [{ field: "score", direction: "DESC" }], }, LIMIT: { offset: 0, - num: 10 - } + count: 10, + }, }); - 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", + "LOAD", + "2", + "field1", + "field2", + "SORTBY", + "2", + "score", + "DESC", + "LIMIT", + "0", + "10", + ]); }); - it('with GROUPBY and REDUCE', () => { + it("with GROUPBY and REDUCE", () => { const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { + HYBRID.parseCommand(parser, "index", { GROUPBY: { - fields: ['@category'], + fields: ["@category"], REDUCE: { - function: 'COUNT', - count: 0, - args: [] - } - } + function: "COUNT", + nargs: 0, + args: [], + }, + }, }); - assert.deepEqual( - parser.redisArgs, - [ - 'FT.HYBRID', 'index', 'GROUPBY', '1', '@category', 'REDUCE', 'COUNT', '0' - ] - ); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "GROUPBY", + "1", + "@category", + "REDUCE", + "COUNT", + "0", + ]); }); - it('with APPLY', () => { + it("with APPLY", () => { const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { + HYBRID.parseCommand(parser, "index", { APPLY: { - expression: '@score * 2', - AS: 'double_score' - } + expression: "@score * 2", + AS: "double_score", + }, }); - assert.deepEqual( - parser.redisArgs, - ['FT.HYBRID', 'index', 'APPLY', '@score * 2', 'AS', 'double_score'] - ); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "APPLY", + "@score * 2", + "AS", + "double_score", + ]); }); - 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", { + FILTER: "@price:[100 500]", }); - assert.deepEqual( - parser.redisArgs, - ['FT.HYBRID', 'index', 'FILTER', '@price:[100 500]'] - ); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "FILTER", + "@price:[100 500]", + ]); }); - it('with PARAMS', () => { + it("with PARAMS", () => { const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { + HYBRID.parseCommand(parser, "index", { PARAMS: { - query_vector: 'BLOB_DATA', - min_price: 100 - } + 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", + "PARAMS", + "4", + "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", { + TIMEOUT: 5000, }); - assert.deepEqual( - parser.redisArgs, - ['FT.HYBRID', 'index', 'EXPLAINSCORE', 'TIMEOUT', '5000'] - ); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "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", + }, + }); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "SEARCH", + "shoes", + "YIELD_SCORE_AS", + "search_score", + ]); + }); + + it("with VSIM YIELD_SCORE_AS", () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, "index", { + VSIM: { + field: "@embedding", + vectorData: "BLOB_DATA", + YIELD_SCORE_AS: "vsim_score", + }, + }); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "VSIM", + "@embedding", + "$v", + "YIELD_SCORE_AS", + "vsim_score", + "PARAMS", + "2", + "v", + "BLOB_DATA", + ]); + }); + + it("with multiple APPLY expressions", () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, "index", { + APPLY: [ + { expression: "@price - (@price * 0.1)", AS: "price_discount" }, + { expression: "@price_discount * 0.2", AS: "tax_discount" }, + ], + }); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "APPLY", + "@price - (@price * 0.1)", + "AS", + "price_discount", + "APPLY", + "@price_discount * 0.2", + "AS", + "tax_discount", + ]); + }); + + it("with GROUPBY and multiple REDUCE functions", () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, "index", { + GROUPBY: { + fields: ["@itemType", "@price"], + REDUCE: [ + { + function: "COUNT_DISTINCT", + nargs: 1, + args: ["@color"], + AS: "colors_count", + }, + { + function: "MIN", + nargs: 1, + args: ["@size"], + }, + ], + }, + }); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "GROUPBY", + "2", + "@itemType", + "@price", + "REDUCE", + "COUNT_DISTINCT", + "1", + "@color", + "AS", + "colors_count", + "REDUCE", + "MIN", + "1", + "@size", + ]); + }); + + it("with multiple SORTBY fields", () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, "index", { + SORTBY: { + fields: [ + { field: "@price_discount", direction: "DESC" }, + { field: "@color", direction: "ASC" }, + ], + }, }); - assert.deepEqual( - parser.redisArgs, - [ - 'FT.HYBRID', 'index', 'WITHCURSOR', 'COUNT', '100', 'MAXIDLE', '300000' - ] - ); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "SORTBY", + "4", + "@price_discount", + "DESC", + "@color", + "ASC", + ]); }); - it('complete example with all options', () => { + it("complete example with all options", () => { const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { + HYBRID.parseCommand(parser, "index", { SEARCH: { - query: '@description: bikes', + query: "@description: bikes", SCORER: { - algorithm: 'TFIDF.DOCNORM' + algorithm: "TFIDF.DOCNORM", }, - YIELD_SCORE_AS: 'text_score' + YIELD_SCORE_AS: "text_score", }, VSIM: { - field: '@vector_field', - vectorData: '$query_vector', + field: "@vector_field", + vectorData: "$query_vector", method: { KNN: { - K: 5 - } + K: 5, + }, }, - YIELD_SCORE_AS: 'vector_score' + YIELD_SCORE_AS: "vector_score", }, COMBINE: { method: { RRF: { - count: 2, - CONSTANT: 60 - } + 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", + "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", + ]); + }); + }); + + 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", + vectorData: 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: { algorithm: "TFIDF" }, + }, + VSIM: { + field: "@embedding", + vectorData: createVectorBuffer([1, 2, 2, 3]), + }, + COMBINE: { + method: { LINEAR: { ALPHA: 1, BETA: 0 } }, + }, + LOAD: [ + "@description", + "@color", + "@price", + "@size", + "@__score", + "@__item", + ], + LIMIT: { offset: 0, count: 2 }, + }); + + 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: { algorithm: "BM25" }, + }, + VSIM: { + field: "@embedding", + vectorData: createVectorBuffer([1, 2, 2, 3]), + }, + COMBINE: { + method: { LINEAR: { ALPHA: 1, BETA: 0 } }, + }, + LOAD: [ + "@description", + "@color", + "@price", + "@size", + "@__score", + "@__item", + ], + LIMIT: { offset: 0, count: 2 }, + }); + + 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 method defined in query init", + 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", + vectorData: "abcd1234efgh5678", + method: { + KNN: { K: 3, EF_RUNTIME: 1 }, + }, + }, + TIMEOUT: 10000, + }); + + 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", + vectorData: createVectorBuffer([1, 2, 2, 3]), + FILTER: "@price:[15 16] @size:[10 11]", + }, + LOAD: ["@price", "@size"], + TIMEOUT: 10000, + }); + + 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", + vectorData: "abcd1234efgh5678", + }, + TIMEOUT: 10000, + }); + + assert.ok(result.results.length > 0); + assert.deepStrictEqual(result.warnings, []); + }, + 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", + vectorData: "abcd1234efgh5678", + method: { + KNN: { K: 3, EF_RUNTIME: 1 }, + }, + YIELD_SCORE_AS: "vsim_score", + }, + TIMEOUT: 10000, + }); + + assert.ok(result.results.length > 0); + assert.deepStrictEqual(result.warnings, []); + }, + 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", + vectorData: "abcd1234efgh5678", + }, + COMBINE: { + method: { LINEAR: { ALPHA: 0.5, BETA: 0.5 } }, + YIELD_SCORE_AS: "combined_score", + }, + TIMEOUT: 10000, + }); + + 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", + vectorData: "abcd1234efgh5678", + method: { + KNN: { K: 3, EF_RUNTIME: 1 }, + }, + YIELD_SCORE_AS: "vsim_score", + }, + COMBINE: { + method: { LINEAR: { ALPHA: 0.5, BETA: 0.5 } }, + YIELD_SCORE_AS: "combined_score", + }, + TIMEOUT: 10000, + }); + + 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", + vectorData: createVectorBuffer([1, 2, 2, 3]), + method: { + KNN: { K: 3 }, + }, + }, + TIMEOUT: 10000, + }); + + 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", + vectorData: createVectorBuffer([1, 2, 2, 3]), + method: { + KNN: { K: 3, EF_RUNTIME: 1 }, + }, + }, + TIMEOUT: 10000, + }); + + 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", + vectorData: createVectorBuffer([1, 2, 7, 6]), + method: { + RANGE: { RADIUS: 2 }, + }, + }, + LIMIT: { offset: 0, count: 3 }, + TIMEOUT: 10000, + }); + + 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", + vectorData: createVectorBuffer([1, 2, 7, 6]), + method: { + RANGE: { RADIUS: 2, EPSILON: 0.5 }, + }, + }, + LIMIT: { offset: 0, count: 3 }, + TIMEOUT: 10000, + }); + + 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", + vectorData: createVectorBuffer([1, 2, 7, 6]), + }, + COMBINE: { + method: { LINEAR: { ALPHA: 0.5, BETA: 0.5 } }, + }, + LIMIT: { offset: 0, count: 3 }, + TIMEOUT: 10000, + }); + + 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", + vectorData: createVectorBuffer([1, 2, 7, 6]), + }, + COMBINE: { + method: { RRF: { WINDOW: 3, CONSTANT: 0.5 } }, + }, + LIMIT: { offset: 0, count: 3 }, + TIMEOUT: 10000, + }); + + 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", + vectorData: createVectorBuffer([1, 2, 7, 6]), + }, + COMBINE: { + method: { RRF: { WINDOW: 3 } }, + }, + LIMIT: { offset: 0, count: 3 }, + TIMEOUT: 10000, + }); + + 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", + vectorData: createVectorBuffer([1, 2, 7, 6]), + }, + COMBINE: { + method: { LINEAR: { ALPHA: 0.5, BETA: 0.5 } }, + }, + LOAD: [ + "@description", + "@color", + "@price", + "@size", + "@__key AS item_key", + ], + LIMIT: { offset: 0, count: 1 }, + TIMEOUT: 10000, + }); + + 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", + vectorData: createVectorBuffer([1, 2, 7, 6]), + }, + 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, + }); + + 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", + vectorData: createVectorBuffer([1, 2, 7, 6]), + }, + LOAD: ["@description", "@color", "@price", "@size"], + FILTER: '@price=="15"', + LIMIT: { offset: 0, count: 3 }, + TIMEOUT: 10000, + }); + + 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", + vectorData: "$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", + vectorData: createVectorBuffer([1, 2, 7, 6]), + }, + LIMIT: { offset: 0, count: 3 }, + TIMEOUT: 10000, + }); + + 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", + vectorData: createVectorBuffer([1, 2, 7, 6]), + }, + 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, + }); + + assert.ok(result.totalResults >= 5); + assert.strictEqual(result.results.length, 5); + assert.deepStrictEqual(result.warnings, []); + assert.ok(result.executionTime > 0); + }, + 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", + vectorData: "abcd".repeat(dim), + method: { + KNN: { K: 1000 }, + }, + FILTER: + "((@price:[15 16] @size:[10 11]) | (@price:[13 15] @size:[11 12])) @description:(shoes) -@description:(green)", + }, + COMBINE: { + method: { RRF: { WINDOW: 1000 } }, + }, + TIMEOUT: timeout, + }); + + 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", + vectorData: "abcd".repeat(dim), + method: { + KNN: { K: 1000 }, + }, + }, + TIMEOUT: 1, // 1ms timeout - likely to timeout + }); + + // 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", + vectorData: createVectorBuffer([1, 2, 7, 6]), + }, + LOAD: ["@color", "@price", "@size", "@itemType"], + GROUPBY: { + fields: ["@itemType", "@price"], + REDUCE: [ + { + function: "COUNT_DISTINCT", + nargs: 1, + args: ["@color"], + AS: "colors_count", + }, + { + function: "MIN", + nargs: 1, + args: ["@size"], + }, + ], + }, + SORTBY: { + fields: [{ field: "@price", direction: "ASC" }], + }, + LIMIT: { offset: 0, count: 4 }, + TIMEOUT: 10000, + }); + + assert.strictEqual(result.results.length, 4); + assert.deepStrictEqual(result.warnings, []); + }, + 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", + vectorData: createVectorBuffer([1, 2, 7, 6]), + }, + 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, + }); + + 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, + ); + }); +}); + + +describe("FT.HYBRID_WITHCURSOR", () => { + describe("transformArguments", () => { + it("minimal command with WITHCURSOR", () => { + const parser = new BasicCommandParser(); + HYBRID_WITHCURSOR.parseCommand(parser, "index"); + assert.deepEqual(parser.redisArgs, ["FT.HYBRID", "index", "WITHCURSOR"]); + }); + + it("with COUNT", () => { + const parser = new BasicCommandParser(); + HYBRID_WITHCURSOR.parseCommand(parser, "index", { + COUNT: 10, + }); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "WITHCURSOR", + "COUNT", + "10", + ]); + }); + + it("with MAXIDLE", () => { + const parser = new BasicCommandParser(); + HYBRID_WITHCURSOR.parseCommand(parser, "index", { + MAXIDLE: 5000, + }); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "WITHCURSOR", + "MAXIDLE", + "5000", + ]); + }); + + it("with COUNT and MAXIDLE", () => { + const parser = new BasicCommandParser(); + HYBRID_WITHCURSOR.parseCommand(parser, "index", { + COUNT: 10, + MAXIDLE: 5000, + }); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "WITHCURSOR", + "COUNT", + "10", + "MAXIDLE", + "5000", + ]); + }); + + it("with SEARCH and VSIM options plus cursor options", () => { + const parser = new BasicCommandParser(); + HYBRID_WITHCURSOR.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + }, + VSIM: { + field: "@vector_field", + vectorData: "BLOB_DATA", + }, + COUNT: 5, + MAXIDLE: 1000, + }); + assert.deepEqual(parser.redisArgs, [ + "FT.HYBRID", + "index", + "SEARCH", + "@description: bikes", + "VSIM", + "@vector_field", + "$v", + "PARAMS", + "2", + "v", + "BLOB_DATA", + "WITHCURSOR", + "COUNT", + "5", + "MAXIDLE", + "1000", + ]); + }); + + it("with LIMIT and cursor options", () => { + const parser = new BasicCommandParser(); + HYBRID_WITHCURSOR.parseCommand(parser, "index", { + SEARCH: { query: "shoes" }, + LIMIT: { offset: 0, count: 10 }, + COUNT: 5, }); - 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", + "shoes", + "LIMIT", + "0", + "10", + "WITHCURSOR", + "COUNT", + "5", + ]); }); }); - // 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.hybridWithCursor", () => { + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "basic hybrid search with cursor", + async (client) => { + const indexName = "idx_cursor_basic"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 10); + + const result = await client.ft.hybridWithCursor(indexName, { + SEARCH: { query: "@color:{red|green}" }, + VSIM: { + field: "@embedding", + vectorData: createVectorBuffer([1, 2, 7, 6]), + }, + COUNT: 5, + TIMEOUT: 10000, + }); + assert.ok(result !== undefined); + assert.ok(!Number.isNaN(result.searchCursor)); + assert.ok(!Number.isNaN(result.vsimCursor)); + assert.ok(typeof result.searchCursor === "number"); + assert.ok(typeof result.vsimCursor === "number"); + assert.ok(Array.isArray(result.warnings)); + }, + GLOBAL.SERVERS.OPEN, + ); + + // NOTE: FT.HYBRID requires both SEARCH and VSIM subqueries. + // Using only SEARCH or only VSIM results in Redis errors: + // - SEARCH only: "Unknown argument `TIMEOUT` in SEARCH" + // - VSIM only: "Invalid subqueries count: expected an unsigned integer" + + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with cursor and MAXIDLE", + async (client) => { + const indexName = "idx_cursor_maxidle"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 10); + + const result = await client.ft.hybridWithCursor(indexName, { + SEARCH: { query: "@color:{red|green}" }, + VSIM: { + field: "@embedding", + vectorData: createVectorBuffer([1, 2, 7, 6]), + }, + COUNT: 5, + MAXIDLE: 10000, + TIMEOUT: 10000, + }); + + assert.ok(result !== undefined); + assert.ok(!Number.isNaN(result.searchCursor)); + assert.ok(!Number.isNaN(result.vsimCursor)); + assert.ok(typeof result.searchCursor === "number"); + assert.ok(typeof result.vsimCursor === "number"); + }, + GLOBAL.SERVERS.OPEN, + ); + + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with cursor iteration", + async (client) => { + const indexName = "idx_cursor_iterate"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 20); + + // First request with cursor + const result1 = await client.ft.hybridWithCursor(indexName, { + SEARCH: { query: "@color:{red|green|black|orange}" }, + VSIM: { + field: "@embedding", + vectorData: createVectorBuffer([1, 2, 7, 6]), + }, + COUNT: 10, + MAXIDLE: 30000, + TIMEOUT: 10000, + }); + + assert.ok(result1 !== undefined); + assert.ok(!Number.isNaN(result1.searchCursor)); + assert.ok(!Number.isNaN(result1.vsimCursor)); + assert.ok(typeof result1.searchCursor === "number"); + assert.ok(typeof result1.vsimCursor === "number"); + + // NOTE: HYBRID cursors work differently from AGGREGATE cursors. + // They have separate cursor IDs for SEARCH and VSIM components. + // Further cursor reading would need a different mechanism. + }, + GLOBAL.SERVERS.OPEN, + ); + + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with cursor and LOAD", + async (client) => { + const indexName = "idx_cursor_load"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 10); + + const result = await client.ft.hybridWithCursor(indexName, { + SEARCH: { query: "@color:{red|green}" }, + VSIM: { + field: "@embedding", + vectorData: createVectorBuffer([1, 2, 7, 6]), + }, + LOAD: ["@description", "@color", "@price"], + COUNT: 3, + TIMEOUT: 10000, + }); + + assert.ok(result !== undefined); + assert.ok(!Number.isNaN(result.searchCursor)); + assert.ok(!Number.isNaN(result.vsimCursor)); + assert.ok(Array.isArray(result.warnings)); + }, + GLOBAL.SERVERS.OPEN, + ); + + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with cursor and COMBINE", + async (client) => { + const indexName = "idx_cursor_combine"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 10); + + const result = await client.ft.hybridWithCursor(indexName, { + SEARCH: { query: "@color:{red}" }, + VSIM: { + field: "@embedding", + vectorData: createVectorBuffer([1, 2, 7, 6]), + }, + COMBINE: { + method: { LINEAR: { ALPHA: 0.5, BETA: 0.5 } }, + YIELD_SCORE_AS: "combined_score", + }, + COUNT: 5, + TIMEOUT: 10000, + }); + + assert.ok(result !== undefined); + assert.ok(!Number.isNaN(result.searchCursor)); + assert.ok(!Number.isNaN(result.vsimCursor)); + assert.ok(typeof result.searchCursor === "number"); + assert.ok(typeof result.vsimCursor === "number"); + assert.deepStrictEqual(result.warnings, []); + }, + 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..431f450d6f5 100644 --- a/packages/search/lib/commands/HYBRID.ts +++ b/packages/search/lib/commands/HYBRID.ts @@ -1,7 +1,14 @@ -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 { FtSearchParams, parseParamsArgument } from "./SEARCH"; export interface FtHybridSearchExpression { query: RedisArgument; @@ -29,30 +36,35 @@ export interface FtHybridVectorExpression { field: RedisArgument; vectorData: RedisArgument; method?: FtHybridVectorMethod; - FILTER?: { - expression: RedisArgument; - POLICY?: 'ADHOC' | 'BATCHES' | 'ACORN'; - BATCHES?: { - BATCH_SIZE: number; - }; - }; + FILTER?: RedisArgument; YIELD_SCORE_AS?: RedisArgument; } export interface FtHybridCombineMethod { RRF?: { - count: number; WINDOW?: number; CONSTANT?: number; }; LINEAR?: { - count: number; ALPHA?: number; BETA?: number; + WINDOW?: number; }; FUNCTION?: RedisArgument; } +export interface FtHybridReducer { + function: RedisArgument; + nargs: number; + args: Array; + AS?: RedisArgument; +} + +export interface FtHybridApply { + expression: RedisArgument; + AS?: RedisArgument; +} + export interface FtHybridOptions { SEARCH?: FtHybridSearchExpression; VSIM?: FtHybridVectorExpression; @@ -63,137 +75,184 @@ export interface FtHybridOptions { LOAD?: RedisVariadicArgument; GROUPBY?: { fields: RedisVariadicArgument; - REDUCE?: { - function: RedisArgument; - count: number; - args: Array; - }; - }; - APPLY?: { - expression: RedisArgument; - AS: RedisArgument; + REDUCE?: FtHybridReducer | Array; }; + APPLY?: FtHybridApply | Array; SORTBY?: { - count: number; fields: Array<{ field: RedisArgument; - direction?: 'ASC' | 'DESC'; + direction?: "ASC" | "DESC"; }>; }; + NOSORT?: boolean; FILTER?: RedisArgument; LIMIT?: { offset: number | RedisArgument; - num: number | RedisArgument; + count: number | RedisArgument; }; PARAMS?: FtSearchParams; EXPLAINSCORE?: boolean; 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); + parser.push("SCORER", search.SCORER.algorithm); if (search.SCORER.params) { parser.push(...search.SCORER.params); } } if (search.YIELD_SCORE_AS) { - parser.push('YIELD_SCORE_AS', search.YIELD_SCORE_AS); + parser.push("YIELD_SCORE_AS", search.YIELD_SCORE_AS); + } +} + +function isParameterReference(value: RedisArgument): boolean { + if (typeof value === "string" && value.startsWith("$")) { + return true; } + return false; } -function parseVectorExpression(parser: CommandParser, vsim: FtHybridVectorExpression) { - parser.push('VSIM', vsim.field, vsim.vectorData); +function parseVectorExpression( + parser: CommandParser, + vsim: FtHybridVectorExpression, + isParamRef: boolean, +) { + // If vectorData is a parameter reference (starts with $), use it directly + // Otherwise, use the auto-generated $v parameter reference + const vectorRef = isParamRef ? vsim.vectorData : "$v"; + parser.push("VSIM", vsim.field, vectorRef); if (vsim.method) { if (vsim.method.KNN) { const knn = vsim.method.KNN; - parser.push('KNN', '1', 'K', knn.K.toString()); + // Calculate nargs: 2 base (K + value) + 2 per optional (EF_RUNTIME, YIELD_DISTANCE_AS) + let nargs = 2; + if (knn.EF_RUNTIME !== undefined) nargs += 2; + if (knn.YIELD_DISTANCE_AS) nargs += 2; + + parser.push("KNN", nargs.toString(), "K", knn.K.toString()); if (knn.EF_RUNTIME !== undefined) { - parser.push('EF_RUNTIME', knn.EF_RUNTIME.toString()); + parser.push("EF_RUNTIME", knn.EF_RUNTIME.toString()); } if (knn.YIELD_DISTANCE_AS) { - parser.push('YIELD_DISTANCE_AS', knn.YIELD_DISTANCE_AS); + parser.push("YIELD_DISTANCE_AS", knn.YIELD_DISTANCE_AS); } } if (vsim.method.RANGE) { const range = vsim.method.RANGE; - parser.push('RANGE', '1', 'RADIUS', range.RADIUS.toString()); + // Calculate nargs: 2 base (RADIUS + value) + 2 per optional (EPSILON, YIELD_DISTANCE_AS) + let nargs = 2; + if (range.EPSILON !== undefined) nargs += 2; + if (range.YIELD_DISTANCE_AS) nargs += 2; + + parser.push("RANGE", nargs.toString(), "RADIUS", range.RADIUS.toString()); if (range.EPSILON !== undefined) { - parser.push('EPSILON', range.EPSILON.toString()); + parser.push("EPSILON", range.EPSILON.toString()); } if (range.YIELD_DISTANCE_AS) { - parser.push('YIELD_DISTANCE_AS', range.YIELD_DISTANCE_AS); + parser.push("YIELD_DISTANCE_AS", range.YIELD_DISTANCE_AS); } } } 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()); + // Calculate nargs: 2 per optional (WINDOW, CONSTANT, YIELD_SCORE_AS) + let nargs = 0; + if (rrf.WINDOW !== undefined) nargs += 2; + if (rrf.CONSTANT !== undefined) nargs += 2; + if (combine.YIELD_SCORE_AS) nargs += 2; + + parser.push("RRF", nargs.toString()); if (rrf.WINDOW !== undefined) { - parser.push('WINDOW', rrf.WINDOW.toString()); + parser.push("WINDOW", rrf.WINDOW.toString()); } if (rrf.CONSTANT !== undefined) { - parser.push('CONSTANT', rrf.CONSTANT.toString()); + parser.push("CONSTANT", rrf.CONSTANT.toString()); + } + + if (combine.YIELD_SCORE_AS) { + parser.push("YIELD_SCORE_AS", combine.YIELD_SCORE_AS); } } if (combine.method.LINEAR) { const linear = combine.method.LINEAR; - parser.push('LINEAR', linear.count.toString()); + // Calculate nargs: 2 per optional (ALPHA, BETA, WINDOW, YIELD_SCORE_AS) + let nargs = 0; + if (linear.ALPHA !== undefined) nargs += 2; + if (linear.BETA !== undefined) nargs += 2; + if (linear.WINDOW !== undefined) nargs += 2; + if (combine.YIELD_SCORE_AS) nargs += 2; + + parser.push("LINEAR", nargs.toString()); if (linear.ALPHA !== undefined) { - parser.push('ALPHA', linear.ALPHA.toString()); + parser.push("ALPHA", linear.ALPHA.toString()); } if (linear.BETA !== undefined) { - parser.push('BETA', linear.BETA.toString()); + parser.push("BETA", linear.BETA.toString()); + } + + if (linear.WINDOW !== undefined) { + parser.push("WINDOW", linear.WINDOW.toString()); + } + + if (combine.YIELD_SCORE_AS) { + parser.push("YIELD_SCORE_AS", combine.YIELD_SCORE_AS); } } if (combine.method.FUNCTION) { - parser.push('FUNCTION', combine.method.FUNCTION); + parser.push("FUNCTION", combine.method.FUNCTION); } +} + +function parseReducer(parser: CommandParser, reducer: FtHybridReducer) { + parser.push("REDUCE", reducer.function, reducer.nargs.toString()); + parser.push(...reducer.args); + if (reducer.AS) { + parser.push("AS", reducer.AS); + } +} - if (combine.YIELD_SCORE_AS) { - parser.push('YIELD_SCORE_AS', combine.YIELD_SCORE_AS); +function parseApply(parser: CommandParser, apply: FtHybridApply) { + parser.push("APPLY", apply.expression); + if (apply.AS) { + parser.push("AS", apply.AS); } } @@ -204,31 +263,54 @@ function parseHybridOptions(parser: CommandParser, options?: FtHybridOptions) { parseSearchExpression(parser, options.SEARCH); } + // Check if vectorData is a parameter reference (starts with $) + const isVectorParamRef = options.VSIM + ? isParameterReference(options.VSIM.vectorData) + : false; + if (options.VSIM) { - parseVectorExpression(parser, options.VSIM); + parseVectorExpression(parser, options.VSIM, isVectorParamRef); } if (options.COMBINE) { parseCombineMethod(parser, options.COMBINE); } - parseOptionalVariadicArgument(parser, 'LOAD', options.LOAD); + 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) { + parseReducer(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()); + // nargs is just the count of fields + const sortByNargs = options.SORTBY.fields.reduce((acc, field) => { + if (field.direction) { + return acc + 2; + } + return acc + 1; + }, 0); + parser.push("SORTBY", sortByNargs.toString()); for (const sortField of options.SORTBY.fields) { parser.push(sortField.field); if (sortField.direction) { @@ -237,34 +319,40 @@ 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); + // Merge vector data into PARAMS - vector must be passed as parameter 'v' + // Only add 'v' to params if vectorData is NOT a parameter reference (e.g., "$vector") + // When vectorData is a parameter reference, the user provides the actual vector in PARAMS + const params: FtSearchParams = { ...options.PARAMS }; + if (options.VSIM && !isVectorParamRef) { + params["v"] = options.VSIM.vectorData; + } + parseParamsArgument( + parser, + Object.keys(params).length > 0 ? params : undefined, + ); if (options.EXPLAINSCORE) { - parser.push('EXPLAINSCORE'); + parser.push("EXPLAINSCORE"); } 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()); } } @@ -286,78 +374,126 @@ export default { * - VSIM: Vector similarity expression with KNN/RANGE methods * - COMBINE: Fusion method (RRF, LINEAR, FUNCTION) * - Post-processing operations: LOAD, GROUPBY, APPLY, SORTBY, FILTER - * - Tunable options: LIMIT, PARAMS, EXPLAINSCORE, TIMEOUT, WITHCURSOR + * - Tunable options: LIMIT, PARAMS, EXPLAINSCORE, 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: HybridSearchDocument[]; +} + +export interface HybridSearchDocument { + id: string; + score?: number | undefined; + [field: string]: any; +} + +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: HybridSearchDocument[] = []; + for (const result of rawResults) { + // Each result is a flat key-value array like FT.AGGREGATE: ['field1', 'value1', 'field2', 'value2', ...] + const resultMap = parseReplyMap(result); + + // Document ID comes from @__key field if loaded + const docId = resultMap["__key"] ?? ""; + + // The hybrid score field name is user-defined via COMBINE's YIELD_SCORE_AS + // Common conventions are __hybrid_score, combined_score, etc. + // We check for these common names but users should use YIELD_SCORE_AS and access the field directly + // __score is the default score field returned by Redis when no custom YIELD_SCORE_AS is specified + const doc: HybridSearchDocument = { + id: docId, + ...(resultMap["__score"] && { score: parseScore(resultMap["__score"]) }), + }; + + // Add all other fields from the result + for (const [key, value] of Object.entries(resultMap)) { + if (key === "__key") { + continue; // Already handled as id and score + } + 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 parseScore(value: any): number | undefined { + if (value === undefined || value === null) { + return undefined; + } - if (!tuples) { - return message; + if (typeof value === "number") { + return value; } - 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 - } - } + if (typeof value === "string") { + return Number.parseFloat(value); + } + + return undefined; +} - message[key] = value; +function parseReplyMap(reply: any): Record { + const map: Record = {}; + + if (!Array.isArray(reply)) { + return map; + } + + 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; + } } - return message; + return map; } diff --git a/packages/search/lib/commands/HYBRID_WITHCURSOR.ts b/packages/search/lib/commands/HYBRID_WITHCURSOR.ts new file mode 100644 index 00000000000..bedc2947422 --- /dev/null +++ b/packages/search/lib/commands/HYBRID_WITHCURSOR.ts @@ -0,0 +1,85 @@ +import { CommandParser } from "@redis/client/dist/lib/client/parser"; +import { + RedisArgument, + Command, + ReplyUnion, +} from "@redis/client/dist/lib/RESP/types"; +import HYBRID, { FtHybridOptions } from "./HYBRID"; + +export interface FtHybridWithCursorOptions extends FtHybridOptions { + COUNT?: number; + MAXIDLE?: number; +} + +export interface HybridWithCursorReply { + warnings: string[]; + searchCursor: number; + vsimCursor: number; +} + +function parseReplyMap(reply: any): Record { + const map: Record = {}; + + if (!Array.isArray(reply)) { + return map; + } + + 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; + } + } + + return map; +} + +export default { + NOT_KEYED_COMMAND: HYBRID.NOT_KEYED_COMMAND, + IS_READ_ONLY: HYBRID.IS_READ_ONLY, + /** + * Performs a hybrid search with a cursor for retrieving large result sets. + * + * @experimental + * NOTE: FT.Hybrid is still in experimental state + * It's behaviour and function signature may change + * + * @param parser - The command parser + * @param index - Name of the index to query + * @param options - Optional parameters: + * - All options supported by FT.HYBRID + * - COUNT: Number of results to return per cursor fetch + * - MAXIDLE: Maximum idle time for cursor in milliseconds + */ + parseCommand( + parser: CommandParser, + index: RedisArgument, + options?: FtHybridWithCursorOptions, + ) { + HYBRID.parseCommand(parser, index, options); + parser.push("WITHCURSOR"); + + if (options?.COUNT !== undefined) { + parser.push("COUNT", options.COUNT.toString()); + } + + if (options?.MAXIDLE !== undefined) { + parser.push("MAXIDLE", options.MAXIDLE.toString()); + } + }, + transformReply: { + 2: (reply: any): HybridWithCursorReply => { + // Parse flat array reply: ['SEARCH', cursor_id, 'VSIM', cursor_id, 'warnings', [...]] + const replyMap = parseReplyMap(reply); + + return { + warnings: replyMap["warnings"] ?? [], + searchCursor: replyMap["SEARCH"] ?? Number.NaN, + vsimCursor: replyMap["VSIM"] ?? Number.NaN, + }; + }, + 3: undefined as unknown as () => ReplyUnion, + }, + unstableResp3: true, +} as const satisfies Command; diff --git a/packages/search/lib/commands/index.ts b/packages/search/lib/commands/index.ts index 53030be1ef6..60e22af38cd 100644 --- a/packages/search/lib/commands/index.ts +++ b/packages/search/lib/commands/index.ts @@ -17,6 +17,7 @@ import DROPINDEX from './DROPINDEX'; import EXPLAIN from './EXPLAIN'; import EXPLAINCLI from './EXPLAINCLI'; import HYBRID from './HYBRID'; +import HYBRID_WITHCURSOR from './HYBRID_WITHCURSOR'; import INFO from './INFO'; import PROFILESEARCH from './PROFILE_SEARCH'; import PROFILEAGGREGATE from './PROFILE_AGGREGATE'; @@ -85,6 +86,8 @@ export default { explainCli: EXPLAINCLI, HYBRID, hybrid: HYBRID, + HYBRID_WITHCURSOR, + hybridWithCursor: HYBRID_WITHCURSOR, INFO, info: INFO, PROFILESEARCH, From 64324039d3548633ab43b3faf087ac43ac6a6986 Mon Sep 17 00:00:00 2001 From: Pavel Pashov Date: Tue, 3 Feb 2026 13:00:21 +0200 Subject: [PATCH 2/9] fix: add jsdoc for ft.hybrid options --- packages/search/lib/commands/HYBRID.ts | 78 ++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/packages/search/lib/commands/HYBRID.ts b/packages/search/lib/commands/HYBRID.ts index 431f450d6f5..7bb9fd41f9b 100644 --- a/packages/search/lib/commands/HYBRID.ts +++ b/packages/search/lib/commands/HYBRID.ts @@ -10,88 +10,165 @@ import { } from "@redis/client/dist/lib/commands/generic-transformers"; import { FtSearchParams, parseParamsArgument } from "./SEARCH"; +/** + * Text search expression configuration for hybrid search. + */ export interface FtHybridSearchExpression { + /** Search query string or parameter reference (e.g., "$q") */ query: RedisArgument; + /** Scoring algorithm configuration */ SCORER?: { + /** Scoring algorithm name (e.g., "BM25", "TFIDF") */ algorithm: RedisArgument; + /** Optional parameters for the scoring algorithm */ params?: Array; }; + /** Alias for the text search score in results */ YIELD_SCORE_AS?: RedisArgument; } +/** + * Vector search method configuration - either KNN or RANGE. + * Only one method should be specified. + */ export interface FtHybridVectorMethod { + /** K-Nearest Neighbors search configuration */ KNN?: { + /** Number of nearest neighbors to return (default: 10) */ K: number; + /** HNSW ef_runtime parameter for search accuracy/speed tradeoff */ EF_RUNTIME?: number; + /** Alias for the vector distance in results */ YIELD_DISTANCE_AS?: RedisArgument; }; + /** Range-based vector search configuration */ RANGE?: { + /** Search radius - maximum distance from query vector */ RADIUS: number; + /** Range search epsilon for accuracy tuning */ EPSILON?: number; + /** Alias for the vector distance in results */ YIELD_DISTANCE_AS?: RedisArgument; }; } +/** + * Vector similarity search expression configuration. + */ export interface FtHybridVectorExpression { + /** Vector field name (e.g., "@embedding") */ field: RedisArgument; + /** Vector data as Buffer or parameter reference (e.g., "$v") */ vectorData: RedisArgument; + /** Search method configuration - KNN or RANGE */ method?: FtHybridVectorMethod; + /** Pre-filter expression applied before vector search (e.g., "@tag:{foo}") */ FILTER?: RedisArgument; + /** Alias for the vector score in results */ YIELD_SCORE_AS?: RedisArgument; } +/** + * Score fusion method configuration for combining search results. + * Only one method should be specified: RRF, LINEAR, or FUNCTION. + */ export interface FtHybridCombineMethod { + /** Reciprocal Rank Fusion configuration */ RRF?: { + /** RRF window size (default: 20) */ WINDOW?: number; + /** RRF constant (default: 60) */ CONSTANT?: number; }; + /** Linear weighted combination configuration */ LINEAR?: { + /** Weight for text search score (default: 0.3) */ ALPHA?: number; + /** Weight for vector search score (default: 0.7) */ BETA?: number; + /** Window size for score normalization */ WINDOW?: number; }; + /** Custom scoring function expression */ FUNCTION?: RedisArgument; } +/** + * Reducer configuration for GROUPBY aggregation. + */ export interface FtHybridReducer { + /** Reducer function name (e.g., "COUNT", "SUM", "AVG") */ function: RedisArgument; + /** Number of arguments for the reducer */ nargs: number; + /** Arguments for the reducer function */ args: Array; + /** Alias for the reducer result in output */ AS?: RedisArgument; } +/** + * 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 { + /** Text search expression configuration */ SEARCH?: FtHybridSearchExpression; + /** Vector similarity search expression configuration */ VSIM?: FtHybridVectorExpression; + /** Score fusion configuration for combining SEARCH and VSIM results */ COMBINE?: { + /** Fusion method: RRF, LINEAR, or FUNCTION */ method: FtHybridCombineMethod; + /** Alias for the combined score in results */ YIELD_SCORE_AS?: RedisArgument; }; + /** Fields to load and return in results (LOAD clause) */ LOAD?: RedisVariadicArgument; + /** Group by configuration for aggregation */ GROUPBY?: { + /** Fields to group by */ fields: RedisVariadicArgument; + /** Reducer(s) to apply to each group */ REDUCE?: FtHybridReducer | Array; }; + /** Apply expression(s) for result transformation */ APPLY?: FtHybridApply | Array; + /** Sort configuration for results */ SORTBY?: { + /** Fields to sort by with optional direction */ fields: Array<{ + /** Field name to sort by */ field: RedisArgument; + /** 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; + /** Number of results to return */ count: number | RedisArgument; }; + /** Query parameters for parameterized queries */ PARAMS?: FtSearchParams; + /** Enable score explanation in results for debugging */ EXPLAINSCORE?: boolean; + /** Query timeout in milliseconds */ TIMEOUT?: number; } @@ -408,6 +485,7 @@ export interface HybridSearchDocument { } function transformHybridSearchResults(reply: any): HybridSearchResult { + // console.log('reply', reply); // FT.HYBRID returns a map-like structure as flat array: // ['total_results', N, 'results', [...], 'warnings', [...], 'execution_time', 'X.XXX'] const replyMap = parseReplyMap(reply); From 7cf7dfd0f837a913767bdf4b1c9d6935689c6e88 Mon Sep 17 00:00:00 2001 From: Pavel Pashov Date: Tue, 3 Feb 2026 18:54:14 +0200 Subject: [PATCH 3/9] refactor(search): simplify FT.HYBRID command API --- packages/search/lib/commands/HYBRID.spec.ts | 749 ++++++++++-------- packages/search/lib/commands/HYBRID.ts | 183 ++--- .../search/lib/commands/HYBRID_WITHCURSOR.ts | 85 -- packages/search/lib/commands/index.ts | 3 - 4 files changed, 476 insertions(+), 544 deletions(-) delete mode 100644 packages/search/lib/commands/HYBRID_WITHCURSOR.ts diff --git a/packages/search/lib/commands/HYBRID.spec.ts b/packages/search/lib/commands/HYBRID.spec.ts index 16c98b7dc81..9d2c2de23e9 100644 --- a/packages/search/lib/commands/HYBRID.spec.ts +++ b/packages/search/lib/commands/HYBRID.spec.ts @@ -3,7 +3,6 @@ import HYBRID from "./HYBRID"; import { BasicCommandParser } from "@redis/client/lib/client/parser"; import testUtils, { GLOBAL } from "../test-utils"; import { SCHEMA_VECTOR_FIELD_ALGORITHM } from "./CREATE"; -import HYBRID_WITHCURSOR from "./HYBRID_WITHCURSOR"; /** * Helper function to create a Float32Array vector as a Buffer @@ -152,24 +151,32 @@ const addDataForHybridSearch = async ( describe("FT.HYBRID", () => { describe("transformArguments", () => { - it("minimal command", () => { - const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, "index"); - assert.deepEqual(parser.redisArgs, ["FT.HYBRID", "index"]); - }); - - it("with SEARCH expression", () => { + it("minimal command with SEARCH, VSIM, and PARAMS", () => { const parser = new BasicCommandParser(); 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", + "*", + "VSIM", + "@embedding", + "$vec", + "PARAMS", + "2", + "vec", + "BLOB_DATA", ]); }); @@ -178,12 +185,16 @@ describe("FT.HYBRID", () => { HYBRID.parseCommand(parser, "index", { SEARCH: { query: "@description: bikes", - SCORER: { - algorithm: "TFIDF.DOCNORM", - params: ["param1", "param2"], - }, + SCORER: "TFIDF.DOCNORM", YIELD_SCORE_AS: "search_score", }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + PARAMS: { + vec: "BLOB_DATA", + }, }); assert.deepEqual(parser.redisArgs, [ "FT.HYBRID", @@ -192,45 +203,55 @@ describe("FT.HYBRID", () => { "@description: bikes", "SCORER", "TFIDF.DOCNORM", - "param1", - "param2", "YIELD_SCORE_AS", "search_score", + "VSIM", + "@embedding", + "$vec", + "PARAMS", + "2", + "vec", + "BLOB_DATA", ]); }); it("with VSIM expression and KNN method", () => { const parser = new BasicCommandParser(); HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + }, VSIM: { field: "@vector_field", - vectorData: "BLOB_DATA", + vector: "$vec", method: { KNN: { K: 10, EF_RUNTIME: 50, - YIELD_DISTANCE_AS: "vector_dist", }, }, }, + PARAMS: { + vec: "BLOB_DATA", + }, }); assert.deepEqual(parser.redisArgs, [ "FT.HYBRID", "index", + "SEARCH", + "@description: bikes", "VSIM", "@vector_field", - "$v", + "$vec", "KNN", - "6", + "4", "K", "10", "EF_RUNTIME", "50", - "YIELD_DISTANCE_AS", - "vector_dist", "PARAMS", "2", - "v", + "vec", "BLOB_DATA", ]); }); @@ -238,35 +259,40 @@ describe("FT.HYBRID", () => { it("with VSIM expression and RANGE method", () => { const parser = new BasicCommandParser(); HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + }, VSIM: { field: "@vector_field", - vectorData: "BLOB_DATA", + vector: "$vec", method: { RANGE: { RADIUS: 0.5, EPSILON: 0.01, - YIELD_DISTANCE_AS: "vector_dist", }, }, }, + PARAMS: { + vec: "BLOB_DATA", + }, }); assert.deepEqual(parser.redisArgs, [ "FT.HYBRID", "index", + "SEARCH", + "@description: bikes", "VSIM", "@vector_field", - "$v", + "$vec", "RANGE", - "6", + "4", "RADIUS", "0.5", "EPSILON", "0.01", - "YIELD_DISTANCE_AS", - "vector_dist", "PARAMS", "2", - "v", + "vec", "BLOB_DATA", ]); }); @@ -274,26 +300,34 @@ describe("FT.HYBRID", () => { it("with VSIM expression and FILTER", () => { const parser = new BasicCommandParser(); HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + }, VSIM: { field: "@vector_field", - vectorData: "BLOB_DATA", + vector: "$vec", FILTER: "@category:{bikes}", YIELD_SCORE_AS: "vsim_score", }, + PARAMS: { + vec: "BLOB_DATA", + }, }); assert.deepEqual(parser.redisArgs, [ "FT.HYBRID", "index", + "SEARCH", + "@description: bikes", "VSIM", "@vector_field", - "$v", + "$vec", "FILTER", "@category:{bikes}", "YIELD_SCORE_AS", "vsim_score", "PARAMS", "2", - "v", + "vec", "BLOB_DATA", ]); }); @@ -301,6 +335,13 @@ describe("FT.HYBRID", () => { it("with RRF COMBINE method", () => { const parser = new BasicCommandParser(); HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, COMBINE: { method: { RRF: { @@ -310,10 +351,18 @@ describe("FT.HYBRID", () => { }, YIELD_SCORE_AS: "combined_score", }, + PARAMS: { + vec: "BLOB_DATA", + }, }); assert.deepEqual(parser.redisArgs, [ "FT.HYBRID", "index", + "SEARCH", + "@description: bikes", + "VSIM", + "@embedding", + "$vec", "COMBINE", "RRF", "6", @@ -323,12 +372,23 @@ describe("FT.HYBRID", () => { "60", "YIELD_SCORE_AS", "combined_score", + "PARAMS", + "2", + "vec", + "BLOB_DATA", ]); }); it("with LINEAR COMBINE method", () => { const parser = new BasicCommandParser(); HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, COMBINE: { method: { LINEAR: { @@ -337,10 +397,18 @@ describe("FT.HYBRID", () => { }, }, }, + PARAMS: { + vec: "BLOB_DATA", + }, }); assert.deepEqual(parser.redisArgs, [ "FT.HYBRID", "index", + "SEARCH", + "@description: bikes", + "VSIM", + "@embedding", + "$vec", "COMBINE", "LINEAR", "4", @@ -348,12 +416,23 @@ describe("FT.HYBRID", () => { "0.7", "BETA", "0.3", + "PARAMS", + "2", + "vec", + "BLOB_DATA", ]); }); it("with LOAD, SORTBY, and LIMIT", () => { const parser = new BasicCommandParser(); HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, LOAD: ["field1", "field2"], SORTBY: { fields: [{ field: "score", direction: "DESC" }], @@ -362,10 +441,18 @@ describe("FT.HYBRID", () => { offset: 0, count: 10, }, + PARAMS: { + vec: "BLOB_DATA", + }, }); assert.deepEqual(parser.redisArgs, [ "FT.HYBRID", "index", + "SEARCH", + "@description: bikes", + "VSIM", + "@embedding", + "$vec", "LOAD", "2", "field1", @@ -377,12 +464,23 @@ describe("FT.HYBRID", () => { "LIMIT", "0", "10", + "PARAMS", + "2", + "vec", + "BLOB_DATA", ]); }); it("with GROUPBY and REDUCE", () => { const parser = new BasicCommandParser(); HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, GROUPBY: { fields: ["@category"], REDUCE: { @@ -391,54 +489,112 @@ describe("FT.HYBRID", () => { args: [], }, }, + PARAMS: { + vec: "BLOB_DATA", + }, }); 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", () => { const parser = new BasicCommandParser(); HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "@description: bikes", + }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, APPLY: { expression: "@score * 2", AS: "double_score", }, + PARAMS: { + vec: "BLOB_DATA", + }, }); 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", () => { const parser = new BasicCommandParser(); 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", + "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", { + SEARCH: { + query: "@description: bikes", + }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, PARAMS: { + vec: "BLOB_DATA", query_vector: "BLOB_DATA", min_price: 100, }, @@ -446,8 +602,15 @@ describe("FT.HYBRID", () => { assert.deepEqual(parser.redisArgs, [ "FT.HYBRID", "index", + "SEARCH", + "@description: bikes", + "VSIM", + "@embedding", + "$vec", "PARAMS", - "4", + "6", + "vec", + "BLOB_DATA", "query_vector", "BLOB_DATA", "min_price", @@ -458,11 +621,30 @@ describe("FT.HYBRID", () => { it("with TIMEOUT", () => { const parser = new BasicCommandParser(); 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", + "SEARCH", + "@description: bikes", + "VSIM", + "@embedding", + "$vec", + "PARAMS", + "2", + "vec", + "BLOB_DATA", "TIMEOUT", "5000", ]); @@ -475,6 +657,13 @@ describe("FT.HYBRID", () => { query: "shoes", YIELD_SCORE_AS: "search_score", }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + PARAMS: { + vec: "BLOB_DATA", + }, }); assert.deepEqual(parser.redisArgs, [ "FT.HYBRID", @@ -483,29 +672,44 @@ describe("FT.HYBRID", () => { "shoes", "YIELD_SCORE_AS", "search_score", + "VSIM", + "@embedding", + "$vec", + "PARAMS", + "2", + "vec", + "BLOB_DATA", ]); }); it("with VSIM YIELD_SCORE_AS", () => { const parser = new BasicCommandParser(); HYBRID.parseCommand(parser, "index", { + SEARCH: { + query: "shoes", + }, VSIM: { field: "@embedding", - vectorData: "BLOB_DATA", + vector: "$vec", YIELD_SCORE_AS: "vsim_score", }, + PARAMS: { + vec: "BLOB_DATA", + }, }); assert.deepEqual(parser.redisArgs, [ "FT.HYBRID", "index", + "SEARCH", + "shoes", "VSIM", "@embedding", - "$v", + "$vec", "YIELD_SCORE_AS", "vsim_score", "PARAMS", "2", - "v", + "vec", "BLOB_DATA", ]); }); @@ -513,14 +717,29 @@ describe("FT.HYBRID", () => { 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", @@ -529,12 +748,23 @@ describe("FT.HYBRID", () => { "@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: [ @@ -551,10 +781,18 @@ describe("FT.HYBRID", () => { }, ], }, + PARAMS: { + vec: "BLOB_DATA", + }, }); assert.deepEqual(parser.redisArgs, [ "FT.HYBRID", "index", + "SEARCH", + "@description: bikes", + "VSIM", + "@embedding", + "$vec", "GROUPBY", "2", "@itemType", @@ -569,28 +807,51 @@ describe("FT.HYBRID", () => { "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", ]); }); @@ -599,14 +860,12 @@ describe("FT.HYBRID", () => { HYBRID.parseCommand(parser, "index", { SEARCH: { query: "@description: bikes", - SCORER: { - algorithm: "TFIDF.DOCNORM", - }, + SCORER: "TFIDF.DOCNORM", YIELD_SCORE_AS: "text_score", }, VSIM: { field: "@vector_field", - vectorData: "$query_vector", + vector: "$query_vector", method: { KNN: { K: 5, @@ -691,7 +950,10 @@ describe("FT.HYBRID", () => { SEARCH: { query: "@color:{red} @color:{green}" }, VSIM: { field: "@embedding", - vectorData: createVectorBuffer([-100, -200, -200, -300]), + vector: "$vec", + }, + PARAMS: { + vec: createVectorBuffer([-100, -200, -200, -300]), }, }); @@ -716,11 +978,11 @@ describe("FT.HYBRID", () => { const resultTfidf = await client.ft.hybrid(indexName, { SEARCH: { query: "shoes", - SCORER: { algorithm: "TFIDF" }, + SCORER: "TFIDF", }, VSIM: { field: "@embedding", - vectorData: createVectorBuffer([1, 2, 2, 3]), + vector: "$vec", }, COMBINE: { method: { LINEAR: { ALPHA: 1, BETA: 0 } }, @@ -734,6 +996,9 @@ describe("FT.HYBRID", () => { "@__item", ], LIMIT: { offset: 0, count: 2 }, + PARAMS: { + vec: createVectorBuffer([1, 2, 2, 3]), + }, }); assert.ok(resultTfidf.totalResults >= 2); @@ -744,11 +1009,11 @@ describe("FT.HYBRID", () => { const resultBm25 = await client.ft.hybrid(indexName, { SEARCH: { query: "shoes", - SCORER: { algorithm: "BM25" }, + SCORER: "BM25", }, VSIM: { field: "@embedding", - vectorData: createVectorBuffer([1, 2, 2, 3]), + vector: "$vec2", }, COMBINE: { method: { LINEAR: { ALPHA: 1, BETA: 0 } }, @@ -762,6 +1027,9 @@ describe("FT.HYBRID", () => { "@__item", ], LIMIT: { offset: 0, count: 2 }, + PARAMS: { + vec2: createVectorBuffer([1, 2, 2, 3]), + } }); assert.ok(resultBm25.totalResults >= 2); @@ -773,7 +1041,7 @@ describe("FT.HYBRID", () => { testUtils.testWithClientIfVersionWithinRange( [[8, 6], "LATEST"], - "hybrid search with vsim method defined in query init", + "hybrid search with vsim explicit method", async (client) => { const indexName = "idx_vsim_method"; await createHybridSearchIndex(client, indexName); @@ -783,12 +1051,15 @@ describe("FT.HYBRID", () => { SEARCH: { query: "shoes" }, VSIM: { field: "@embeddingHNSW", - vectorData: "abcd1234efgh5678", + vector: "$vec", method: { KNN: { K: 3, EF_RUNTIME: 1 }, }, }, TIMEOUT: 10000, + PARAMS: { + vec: "abcd1234efgh5678", + }, }); assert.ok(result.results.length > 0); @@ -810,11 +1081,14 @@ describe("FT.HYBRID", () => { SEARCH: { query: "@color:{missing}" }, VSIM: { field: "@embedding", - vectorData: createVectorBuffer([1, 2, 2, 3]), + 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); @@ -844,13 +1118,18 @@ describe("FT.HYBRID", () => { }, VSIM: { field: "@embedding", - vectorData: "abcd1234efgh5678", + 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, ); @@ -868,17 +1147,22 @@ describe("FT.HYBRID", () => { SEARCH: { query: "shoes" }, VSIM: { field: "@embeddingHNSW", - vectorData: "abcd1234efgh5678", + vector: "$vec", 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, ); @@ -896,13 +1180,16 @@ describe("FT.HYBRID", () => { SEARCH: { query: "shoes" }, VSIM: { field: "@embeddingHNSW", - vectorData: "abcd1234efgh5678", + vector: "$vec", }, 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); @@ -931,7 +1218,7 @@ describe("FT.HYBRID", () => { }, VSIM: { field: "@embeddingHNSW", - vectorData: "abcd1234efgh5678", + vector: "$vec", method: { KNN: { K: 3, EF_RUNTIME: 1 }, }, @@ -942,6 +1229,9 @@ describe("FT.HYBRID", () => { YIELD_SCORE_AS: "combined_score", }, TIMEOUT: 10000, + PARAMS: { + vec: "abcd1234efgh5678", + }, }); assert.ok(result.results.length > 0); @@ -968,12 +1258,15 @@ describe("FT.HYBRID", () => { SEARCH: { query: "@color:{none}" }, VSIM: { field: "@embedding", - vectorData: createVectorBuffer([1, 2, 2, 3]), + vector: "$vec", method: { KNN: { K: 3 }, }, }, TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 2, 3]), + }, }); assert.strictEqual(result.totalResults, 3); // KNN top-k value @@ -986,12 +1279,15 @@ describe("FT.HYBRID", () => { SEARCH: { query: "@color:{none}" }, VSIM: { field: "@embeddingHNSW", - vectorData: createVectorBuffer([1, 2, 2, 3]), + vector: "$vec", method: { KNN: { K: 3, EF_RUNTIME: 1 }, }, }, TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 2, 3]), + }, }); assert.strictEqual(result2.totalResults, 3); @@ -1016,13 +1312,16 @@ describe("FT.HYBRID", () => { SEARCH: { query: "@color:{none}" }, VSIM: { field: "@embedding", - vectorData: createVectorBuffer([1, 2, 7, 6]), + vector: "$vec", method: { RANGE: { RADIUS: 2 }, }, }, LIMIT: { offset: 0, count: 3 }, TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 7, 6]), + }, }); assert.ok(result.totalResults >= 3); @@ -1035,13 +1334,16 @@ describe("FT.HYBRID", () => { SEARCH: { query: "@color:{none}" }, VSIM: { field: "@embeddingHNSW", - vectorData: createVectorBuffer([1, 2, 7, 6]), + vector: "$vec", 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); @@ -1066,13 +1368,16 @@ describe("FT.HYBRID", () => { SEARCH: { query: "@color:{red}" }, VSIM: { field: "@embedding", - vectorData: createVectorBuffer([1, 2, 7, 6]), + vector: "$vec", }, 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); @@ -1085,13 +1390,16 @@ describe("FT.HYBRID", () => { SEARCH: { query: "@color:{red}" }, VSIM: { field: "@embedding", - vectorData: createVectorBuffer([1, 2, 7, 6]), + vector: "$vec", }, 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); @@ -1104,13 +1412,16 @@ describe("FT.HYBRID", () => { SEARCH: { query: "@color:{red}" }, VSIM: { field: "@embedding", - vectorData: createVectorBuffer([1, 2, 7, 6]), + vector: "$vec", }, COMBINE: { method: { RRF: { WINDOW: 3 } }, }, LIMIT: { offset: 0, count: 3 }, TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 7, 6]), + }, }); assert.ok(resultRrf2.totalResults >= 3); @@ -1134,7 +1445,7 @@ describe("FT.HYBRID", () => { SEARCH: { query: "@color:{red|green|black}" }, VSIM: { field: "@embedding", - vectorData: createVectorBuffer([1, 2, 7, 6]), + vector: "$vec", }, COMBINE: { method: { LINEAR: { ALPHA: 0.5, BETA: 0.5 } }, @@ -1144,10 +1455,12 @@ describe("FT.HYBRID", () => { "@color", "@price", "@size", - "@__key AS item_key", ], LIMIT: { offset: 0, count: 1 }, TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 7, 6]), + }, }); assert.ok(result.totalResults >= 1); @@ -1178,7 +1491,7 @@ describe("FT.HYBRID", () => { SEARCH: { query: "@color:{red}" }, VSIM: { field: "@embedding", - vectorData: createVectorBuffer([1, 2, 7, 6]), + vector: "$vec", }, LOAD: ["@color", "@price", "@size"], APPLY: [ @@ -1187,6 +1500,9 @@ describe("FT.HYBRID", () => { ], LIMIT: { offset: 0, count: 3 }, TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 7, 6]), + }, }); assert.strictEqual(result.results.length, 3); @@ -1218,12 +1534,15 @@ describe("FT.HYBRID", () => { SEARCH: { query: "@color:{red|green|black}" }, VSIM: { field: "@embedding", - vectorData: createVectorBuffer([1, 2, 7, 6]), + 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); @@ -1250,7 +1569,7 @@ describe("FT.HYBRID", () => { SEARCH: { query: "@color:{$color_criteria}" }, VSIM: { field: "@embedding", - vectorData: "$vector", + vector: "$vector", }, LOAD: ["@description", "@color", "@price"], APPLY: [ @@ -1291,10 +1610,13 @@ describe("FT.HYBRID", () => { SEARCH: { query: "@color:{red}" }, VSIM: { field: "@embedding", - vectorData: createVectorBuffer([1, 2, 7, 6]), + vector: "$vec", }, LIMIT: { offset: 0, count: 3 }, TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 7, 6]), + }, }); assert.strictEqual(result.results.length, 3); @@ -1316,7 +1638,7 @@ describe("FT.HYBRID", () => { SEARCH: { query: "@color:{red|green}" }, VSIM: { field: "@embedding", - vectorData: createVectorBuffer([1, 2, 7, 6]), + vector: "$vec", }, LOAD: ["@color", "@price"], APPLY: [ @@ -1330,12 +1652,16 @@ describe("FT.HYBRID", () => { }, 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, ); @@ -1359,7 +1685,7 @@ describe("FT.HYBRID", () => { SEARCH: { query: "*" }, VSIM: { field: "@embeddingHNSW", - vectorData: "abcd".repeat(dim), + vector: "$vec", method: { KNN: { K: 1000 }, }, @@ -1370,6 +1696,9 @@ describe("FT.HYBRID", () => { method: { RRF: { WINDOW: 1000 } }, }, TIMEOUT: timeout, + PARAMS: { + vec: "abcd".repeat(dim), + }, }); assert.ok(result.results.length > 0); @@ -1381,12 +1710,15 @@ describe("FT.HYBRID", () => { SEARCH: { query: "*" }, VSIM: { field: "@embeddingHNSW", - vectorData: "abcd".repeat(dim), + vector: "$vec", method: { KNN: { K: 1000 }, }, }, TIMEOUT: 1, // 1ms timeout - likely to timeout + PARAMS: { + vec: "abcd".repeat(dim), + }, }); // May have timeout warnings @@ -1409,7 +1741,7 @@ describe("FT.HYBRID", () => { SEARCH: { query: "@color:{red|green}" }, VSIM: { field: "@embedding", - vectorData: createVectorBuffer([1, 2, 7, 6]), + vector: "$vec", }, LOAD: ["@color", "@price", "@size", "@itemType"], GROUPBY: { @@ -1433,10 +1765,16 @@ describe("FT.HYBRID", () => { }, 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, ); @@ -1454,7 +1792,7 @@ describe("FT.HYBRID", () => { SEARCH: { query: "@color:{red|green}" }, VSIM: { field: "@embedding", - vectorData: createVectorBuffer([1, 2, 7, 6]), + vector: "$vec", }, LOAD: ["@color", "@price", "@description"], APPLY: [ @@ -1477,6 +1815,9 @@ describe("FT.HYBRID", () => { }, LIMIT: { offset: 0, count: 5 }, TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 7, 6]), + }, }); assert.strictEqual(result.results.length, 2); @@ -1492,267 +1833,3 @@ describe("FT.HYBRID", () => { ); }); }); - - -describe("FT.HYBRID_WITHCURSOR", () => { - describe("transformArguments", () => { - it("minimal command with WITHCURSOR", () => { - const parser = new BasicCommandParser(); - HYBRID_WITHCURSOR.parseCommand(parser, "index"); - assert.deepEqual(parser.redisArgs, ["FT.HYBRID", "index", "WITHCURSOR"]); - }); - - it("with COUNT", () => { - const parser = new BasicCommandParser(); - HYBRID_WITHCURSOR.parseCommand(parser, "index", { - COUNT: 10, - }); - assert.deepEqual(parser.redisArgs, [ - "FT.HYBRID", - "index", - "WITHCURSOR", - "COUNT", - "10", - ]); - }); - - it("with MAXIDLE", () => { - const parser = new BasicCommandParser(); - HYBRID_WITHCURSOR.parseCommand(parser, "index", { - MAXIDLE: 5000, - }); - assert.deepEqual(parser.redisArgs, [ - "FT.HYBRID", - "index", - "WITHCURSOR", - "MAXIDLE", - "5000", - ]); - }); - - it("with COUNT and MAXIDLE", () => { - const parser = new BasicCommandParser(); - HYBRID_WITHCURSOR.parseCommand(parser, "index", { - COUNT: 10, - MAXIDLE: 5000, - }); - assert.deepEqual(parser.redisArgs, [ - "FT.HYBRID", - "index", - "WITHCURSOR", - "COUNT", - "10", - "MAXIDLE", - "5000", - ]); - }); - - it("with SEARCH and VSIM options plus cursor options", () => { - const parser = new BasicCommandParser(); - HYBRID_WITHCURSOR.parseCommand(parser, "index", { - SEARCH: { - query: "@description: bikes", - }, - VSIM: { - field: "@vector_field", - vectorData: "BLOB_DATA", - }, - COUNT: 5, - MAXIDLE: 1000, - }); - assert.deepEqual(parser.redisArgs, [ - "FT.HYBRID", - "index", - "SEARCH", - "@description: bikes", - "VSIM", - "@vector_field", - "$v", - "PARAMS", - "2", - "v", - "BLOB_DATA", - "WITHCURSOR", - "COUNT", - "5", - "MAXIDLE", - "1000", - ]); - }); - - it("with LIMIT and cursor options", () => { - const parser = new BasicCommandParser(); - HYBRID_WITHCURSOR.parseCommand(parser, "index", { - SEARCH: { query: "shoes" }, - LIMIT: { offset: 0, count: 10 }, - COUNT: 5, - }); - assert.deepEqual(parser.redisArgs, [ - "FT.HYBRID", - "index", - "SEARCH", - "shoes", - "LIMIT", - "0", - "10", - "WITHCURSOR", - "COUNT", - "5", - ]); - }); - }); - - describe("client.ft.hybridWithCursor", () => { - testUtils.testWithClientIfVersionWithinRange( - [[8, 6], "LATEST"], - "basic hybrid search with cursor", - async (client) => { - const indexName = "idx_cursor_basic"; - await createHybridSearchIndex(client, indexName); - await addDataForHybridSearch(client, 10); - - const result = await client.ft.hybridWithCursor(indexName, { - SEARCH: { query: "@color:{red|green}" }, - VSIM: { - field: "@embedding", - vectorData: createVectorBuffer([1, 2, 7, 6]), - }, - COUNT: 5, - TIMEOUT: 10000, - }); - assert.ok(result !== undefined); - assert.ok(!Number.isNaN(result.searchCursor)); - assert.ok(!Number.isNaN(result.vsimCursor)); - assert.ok(typeof result.searchCursor === "number"); - assert.ok(typeof result.vsimCursor === "number"); - assert.ok(Array.isArray(result.warnings)); - }, - GLOBAL.SERVERS.OPEN, - ); - - // NOTE: FT.HYBRID requires both SEARCH and VSIM subqueries. - // Using only SEARCH or only VSIM results in Redis errors: - // - SEARCH only: "Unknown argument `TIMEOUT` in SEARCH" - // - VSIM only: "Invalid subqueries count: expected an unsigned integer" - - testUtils.testWithClientIfVersionWithinRange( - [[8, 6], "LATEST"], - "hybrid search with cursor and MAXIDLE", - async (client) => { - const indexName = "idx_cursor_maxidle"; - await createHybridSearchIndex(client, indexName); - await addDataForHybridSearch(client, 10); - - const result = await client.ft.hybridWithCursor(indexName, { - SEARCH: { query: "@color:{red|green}" }, - VSIM: { - field: "@embedding", - vectorData: createVectorBuffer([1, 2, 7, 6]), - }, - COUNT: 5, - MAXIDLE: 10000, - TIMEOUT: 10000, - }); - - assert.ok(result !== undefined); - assert.ok(!Number.isNaN(result.searchCursor)); - assert.ok(!Number.isNaN(result.vsimCursor)); - assert.ok(typeof result.searchCursor === "number"); - assert.ok(typeof result.vsimCursor === "number"); - }, - GLOBAL.SERVERS.OPEN, - ); - - testUtils.testWithClientIfVersionWithinRange( - [[8, 6], "LATEST"], - "hybrid search with cursor iteration", - async (client) => { - const indexName = "idx_cursor_iterate"; - await createHybridSearchIndex(client, indexName); - await addDataForHybridSearch(client, 20); - - // First request with cursor - const result1 = await client.ft.hybridWithCursor(indexName, { - SEARCH: { query: "@color:{red|green|black|orange}" }, - VSIM: { - field: "@embedding", - vectorData: createVectorBuffer([1, 2, 7, 6]), - }, - COUNT: 10, - MAXIDLE: 30000, - TIMEOUT: 10000, - }); - - assert.ok(result1 !== undefined); - assert.ok(!Number.isNaN(result1.searchCursor)); - assert.ok(!Number.isNaN(result1.vsimCursor)); - assert.ok(typeof result1.searchCursor === "number"); - assert.ok(typeof result1.vsimCursor === "number"); - - // NOTE: HYBRID cursors work differently from AGGREGATE cursors. - // They have separate cursor IDs for SEARCH and VSIM components. - // Further cursor reading would need a different mechanism. - }, - GLOBAL.SERVERS.OPEN, - ); - - testUtils.testWithClientIfVersionWithinRange( - [[8, 6], "LATEST"], - "hybrid search with cursor and LOAD", - async (client) => { - const indexName = "idx_cursor_load"; - await createHybridSearchIndex(client, indexName); - await addDataForHybridSearch(client, 10); - - const result = await client.ft.hybridWithCursor(indexName, { - SEARCH: { query: "@color:{red|green}" }, - VSIM: { - field: "@embedding", - vectorData: createVectorBuffer([1, 2, 7, 6]), - }, - LOAD: ["@description", "@color", "@price"], - COUNT: 3, - TIMEOUT: 10000, - }); - - assert.ok(result !== undefined); - assert.ok(!Number.isNaN(result.searchCursor)); - assert.ok(!Number.isNaN(result.vsimCursor)); - assert.ok(Array.isArray(result.warnings)); - }, - GLOBAL.SERVERS.OPEN, - ); - - testUtils.testWithClientIfVersionWithinRange( - [[8, 6], "LATEST"], - "hybrid search with cursor and COMBINE", - async (client) => { - const indexName = "idx_cursor_combine"; - await createHybridSearchIndex(client, indexName); - await addDataForHybridSearch(client, 10); - - const result = await client.ft.hybridWithCursor(indexName, { - SEARCH: { query: "@color:{red}" }, - VSIM: { - field: "@embedding", - vectorData: createVectorBuffer([1, 2, 7, 6]), - }, - COMBINE: { - method: { LINEAR: { ALPHA: 0.5, BETA: 0.5 } }, - YIELD_SCORE_AS: "combined_score", - }, - COUNT: 5, - TIMEOUT: 10000, - }); - - assert.ok(result !== undefined); - assert.ok(!Number.isNaN(result.searchCursor)); - assert.ok(!Number.isNaN(result.vsimCursor)); - assert.ok(typeof result.searchCursor === "number"); - assert.ok(typeof result.vsimCursor === "number"); - assert.deepStrictEqual(result.warnings, []); - }, - GLOBAL.SERVERS.OPEN, - ); - }); -}); diff --git a/packages/search/lib/commands/HYBRID.ts b/packages/search/lib/commands/HYBRID.ts index 7bb9fd41f9b..ef35d6c83a6 100644 --- a/packages/search/lib/commands/HYBRID.ts +++ b/packages/search/lib/commands/HYBRID.ts @@ -8,7 +8,7 @@ import { RedisVariadicArgument, parseOptionalVariadicArgument, } from "@redis/client/dist/lib/commands/generic-transformers"; -import { FtSearchParams, parseParamsArgument } from "./SEARCH"; +import { parseParamsArgument } from "./SEARCH"; /** * Text search expression configuration for hybrid search. @@ -17,12 +17,7 @@ export interface FtHybridSearchExpression { /** Search query string or parameter reference (e.g., "$q") */ query: RedisArgument; /** Scoring algorithm configuration */ - SCORER?: { - /** Scoring algorithm name (e.g., "BM25", "TFIDF") */ - algorithm: RedisArgument; - /** Optional parameters for the scoring algorithm */ - params?: Array; - }; + SCORER?: RedisArgument; /** Alias for the text search score in results */ YIELD_SCORE_AS?: RedisArgument; } @@ -38,8 +33,6 @@ export interface FtHybridVectorMethod { K: number; /** HNSW ef_runtime parameter for search accuracy/speed tradeoff */ EF_RUNTIME?: number; - /** Alias for the vector distance in results */ - YIELD_DISTANCE_AS?: RedisArgument; }; /** Range-based vector search configuration */ RANGE?: { @@ -47,8 +40,6 @@ export interface FtHybridVectorMethod { RADIUS: number; /** Range search epsilon for accuracy tuning */ EPSILON?: number; - /** Alias for the vector distance in results */ - YIELD_DISTANCE_AS?: RedisArgument; }; } @@ -58,8 +49,8 @@ export interface FtHybridVectorMethod { export interface FtHybridVectorExpression { /** Vector field name (e.g., "@embedding") */ field: RedisArgument; - /** Vector data as Buffer or parameter reference (e.g., "$v") */ - vectorData: RedisArgument; + /** Vector parameter reference (e.g., "$v") */ + vector: string; /** Search method configuration - KNN or RANGE */ method?: FtHybridVectorMethod; /** Pre-filter expression applied before vector search (e.g., "@tag:{foo}") */ @@ -122,9 +113,9 @@ export interface FtHybridApply { */ export interface FtHybridOptions { /** Text search expression configuration */ - SEARCH?: FtHybridSearchExpression; + SEARCH: FtHybridSearchExpression; /** Vector similarity search expression configuration */ - VSIM?: FtHybridVectorExpression; + VSIM: FtHybridVectorExpression; /** Score fusion configuration for combining SEARCH and VSIM results */ COMBINE?: { /** Fusion method: RRF, LINEAR, or FUNCTION */ @@ -165,9 +156,7 @@ export interface FtHybridOptions { count: number | RedisArgument; }; /** Query parameters for parameterized queries */ - PARAMS?: FtSearchParams; - /** Enable score explanation in results for debugging */ - EXPLAINSCORE?: boolean; + PARAMS: Record; /** Query timeout in milliseconds */ TIMEOUT?: number; } @@ -179,10 +168,7 @@ function parseSearchExpression( 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) { @@ -190,57 +176,45 @@ function parseSearchExpression( } } -function isParameterReference(value: RedisArgument): boolean { - if (typeof value === "string" && value.startsWith("$")) { - return true; - } - return false; -} - function parseVectorExpression( parser: CommandParser, vsim: FtHybridVectorExpression, - isParamRef: boolean, ) { - // If vectorData is a parameter reference (starts with $), use it directly - // Otherwise, use the auto-generated $v parameter reference - const vectorRef = isParamRef ? vsim.vectorData : "$v"; - parser.push("VSIM", vsim.field, vectorRef); + parser.push("VSIM", vsim.field, vsim.vector); if (vsim.method) { if (vsim.method.KNN) { const knn = vsim.method.KNN; - // Calculate nargs: 2 base (K + value) + 2 per optional (EF_RUNTIME, YIELD_DISTANCE_AS) - let nargs = 2; - if (knn.EF_RUNTIME !== undefined) nargs += 2; - if (knn.YIELD_DISTANCE_AS) nargs += 2; - - parser.push("KNN", nargs.toString(), "K", knn.K.toString()); + let argsCount = 2; if (knn.EF_RUNTIME !== undefined) { - parser.push("EF_RUNTIME", knn.EF_RUNTIME.toString()); + argsCount += 2; } - if (knn.YIELD_DISTANCE_AS) { - parser.push("YIELD_DISTANCE_AS", knn.YIELD_DISTANCE_AS); + parser.push("KNN", argsCount.toString(), "K", knn.K.toString()); + + if (knn.EF_RUNTIME !== undefined) { + parser.push("EF_RUNTIME", knn.EF_RUNTIME.toString()); } } if (vsim.method.RANGE) { const range = vsim.method.RANGE; - // Calculate nargs: 2 base (RADIUS + value) + 2 per optional (EPSILON, YIELD_DISTANCE_AS) - let nargs = 2; - if (range.EPSILON !== undefined) nargs += 2; - if (range.YIELD_DISTANCE_AS) nargs += 2; - - parser.push("RANGE", nargs.toString(), "RADIUS", range.RADIUS.toString()); + let argsCount = 2; if (range.EPSILON !== undefined) { - parser.push("EPSILON", range.EPSILON.toString()); + argsCount += 2; } - if (range.YIELD_DISTANCE_AS) { - parser.push("YIELD_DISTANCE_AS", range.YIELD_DISTANCE_AS); + parser.push( + "RANGE", + argsCount.toString(), + "RADIUS", + range.RADIUS.toString(), + ); + + if (range.EPSILON !== undefined) { + parser.push("EPSILON", range.EPSILON.toString()); } } } @@ -264,13 +238,20 @@ function parseCombineMethod( if (combine.method.RRF) { const rrf = combine.method.RRF; - // Calculate nargs: 2 per optional (WINDOW, CONSTANT, YIELD_SCORE_AS) - let nargs = 0; - if (rrf.WINDOW !== undefined) nargs += 2; - if (rrf.CONSTANT !== undefined) nargs += 2; - if (combine.YIELD_SCORE_AS) nargs += 2; - parser.push("RRF", nargs.toString()); + // Calculate argsCount: 2 per optional (WINDOW, CONSTANT, YIELD_SCORE_AS) + let argsCount = 0; + if (rrf.WINDOW !== undefined) { + argsCount += 2; + } + if (rrf.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()); @@ -287,14 +268,23 @@ function parseCombineMethod( if (combine.method.LINEAR) { const linear = combine.method.LINEAR; - // Calculate nargs: 2 per optional (ALPHA, BETA, WINDOW, YIELD_SCORE_AS) - let nargs = 0; - if (linear.ALPHA !== undefined) nargs += 2; - if (linear.BETA !== undefined) nargs += 2; - if (linear.WINDOW !== undefined) nargs += 2; - if (combine.YIELD_SCORE_AS) nargs += 2; - parser.push("LINEAR", nargs.toString()); + // Calculate argsCount: 2 per optional (ALPHA, BETA, WINDOW, YIELD_SCORE_AS) + let argsCount = 0; + if (linear.ALPHA !== undefined) { + argsCount += 2; + } + if (linear.BETA !== undefined) { + argsCount += 2; + } + if (linear.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()); @@ -340,13 +330,8 @@ function parseHybridOptions(parser: CommandParser, options?: FtHybridOptions) { parseSearchExpression(parser, options.SEARCH); } - // Check if vectorData is a parameter reference (starts with $) - const isVectorParamRef = options.VSIM - ? isParameterReference(options.VSIM.vectorData) - : false; - if (options.VSIM) { - parseVectorExpression(parser, options.VSIM, isVectorParamRef); + parseVectorExpression(parser, options.VSIM); } if (options.COMBINE) { @@ -380,14 +365,14 @@ function parseHybridOptions(parser: CommandParser, options?: FtHybridOptions) { } if (options.SORTBY) { - // nargs is just the count of fields - const sortByNargs = options.SORTBY.fields.reduce((acc, field) => { + const sortByArgsCount = options.SORTBY.fields.reduce((acc, field) => { if (field.direction) { return acc + 2; } return acc + 1; }, 0); - parser.push("SORTBY", sortByNargs.toString()); + + parser.push("SORTBY", sortByArgsCount.toString()); for (const sortField of options.SORTBY.fields) { parser.push(sortField.field); if (sortField.direction) { @@ -412,21 +397,9 @@ function parseHybridOptions(parser: CommandParser, options?: FtHybridOptions) { ); } - // Merge vector data into PARAMS - vector must be passed as parameter 'v' - // Only add 'v' to params if vectorData is NOT a parameter reference (e.g., "$vector") - // When vectorData is a parameter reference, the user provides the actual vector in PARAMS - const params: FtSearchParams = { ...options.PARAMS }; - if (options.VSIM && !isVectorParamRef) { - params["v"] = options.VSIM.vectorData; - } - parseParamsArgument( - parser, - Object.keys(params).length > 0 ? params : undefined, - ); + 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()); @@ -451,7 +424,7 @@ export default { * - VSIM: Vector similarity expression with KNN/RANGE methods * - COMBINE: Fusion method (RRF, LINEAR, FUNCTION) * - Post-processing operations: LOAD, GROUPBY, APPLY, SORTBY, FILTER - * - Tunable options: LIMIT, PARAMS, EXPLAINSCORE, TIMEOUT + * - Tunable options: LIMIT, PARAMS, TIMEOUT */ parseCommand( parser: CommandParser, @@ -485,7 +458,6 @@ export interface HybridSearchDocument { } function transformHybridSearchResults(reply: any): HybridSearchResult { - // console.log('reply', reply); // FT.HYBRID returns a map-like structure as flat array: // ['total_results', N, 'results', [...], 'warnings', [...], 'execution_time', 'X.XXX'] const replyMap = parseReplyMap(reply); @@ -502,23 +474,10 @@ function transformHybridSearchResults(reply: any): HybridSearchResult { // Each result is a flat key-value array like FT.AGGREGATE: ['field1', 'value1', 'field2', 'value2', ...] const resultMap = parseReplyMap(result); - // Document ID comes from @__key field if loaded - const docId = resultMap["__key"] ?? ""; - - // The hybrid score field name is user-defined via COMBINE's YIELD_SCORE_AS - // Common conventions are __hybrid_score, combined_score, etc. - // We check for these common names but users should use YIELD_SCORE_AS and access the field directly - // __score is the default score field returned by Redis when no custom YIELD_SCORE_AS is specified - const doc: HybridSearchDocument = { - id: docId, - ...(resultMap["__score"] && { score: parseScore(resultMap["__score"]) }), - }; + const doc = Object.create(null); // Add all other fields from the result for (const [key, value] of Object.entries(resultMap)) { - if (key === "__key") { - continue; // Already handled as id and score - } if (key === "$") { // JSON document - parse and merge try { @@ -542,22 +501,6 @@ function transformHybridSearchResults(reply: any): HybridSearchResult { }; } -function parseScore(value: any): number | undefined { - if (value === undefined || value === null) { - return undefined; - } - - if (typeof value === "number") { - return value; - } - - if (typeof value === "string") { - return Number.parseFloat(value); - } - - return undefined; -} - function parseReplyMap(reply: any): Record { const map: Record = {}; diff --git a/packages/search/lib/commands/HYBRID_WITHCURSOR.ts b/packages/search/lib/commands/HYBRID_WITHCURSOR.ts deleted file mode 100644 index bedc2947422..00000000000 --- a/packages/search/lib/commands/HYBRID_WITHCURSOR.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { CommandParser } from "@redis/client/dist/lib/client/parser"; -import { - RedisArgument, - Command, - ReplyUnion, -} from "@redis/client/dist/lib/RESP/types"; -import HYBRID, { FtHybridOptions } from "./HYBRID"; - -export interface FtHybridWithCursorOptions extends FtHybridOptions { - COUNT?: number; - MAXIDLE?: number; -} - -export interface HybridWithCursorReply { - warnings: string[]; - searchCursor: number; - vsimCursor: number; -} - -function parseReplyMap(reply: any): Record { - const map: Record = {}; - - if (!Array.isArray(reply)) { - return map; - } - - 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; - } - } - - return map; -} - -export default { - NOT_KEYED_COMMAND: HYBRID.NOT_KEYED_COMMAND, - IS_READ_ONLY: HYBRID.IS_READ_ONLY, - /** - * Performs a hybrid search with a cursor for retrieving large result sets. - * - * @experimental - * NOTE: FT.Hybrid is still in experimental state - * It's behaviour and function signature may change - * - * @param parser - The command parser - * @param index - Name of the index to query - * @param options - Optional parameters: - * - All options supported by FT.HYBRID - * - COUNT: Number of results to return per cursor fetch - * - MAXIDLE: Maximum idle time for cursor in milliseconds - */ - parseCommand( - parser: CommandParser, - index: RedisArgument, - options?: FtHybridWithCursorOptions, - ) { - HYBRID.parseCommand(parser, index, options); - parser.push("WITHCURSOR"); - - if (options?.COUNT !== undefined) { - parser.push("COUNT", options.COUNT.toString()); - } - - if (options?.MAXIDLE !== undefined) { - parser.push("MAXIDLE", options.MAXIDLE.toString()); - } - }, - transformReply: { - 2: (reply: any): HybridWithCursorReply => { - // Parse flat array reply: ['SEARCH', cursor_id, 'VSIM', cursor_id, 'warnings', [...]] - const replyMap = parseReplyMap(reply); - - return { - warnings: replyMap["warnings"] ?? [], - searchCursor: replyMap["SEARCH"] ?? Number.NaN, - vsimCursor: replyMap["VSIM"] ?? Number.NaN, - }; - }, - 3: undefined as unknown as () => ReplyUnion, - }, - unstableResp3: true, -} as const satisfies Command; diff --git a/packages/search/lib/commands/index.ts b/packages/search/lib/commands/index.ts index 60e22af38cd..53030be1ef6 100644 --- a/packages/search/lib/commands/index.ts +++ b/packages/search/lib/commands/index.ts @@ -17,7 +17,6 @@ import DROPINDEX from './DROPINDEX'; import EXPLAIN from './EXPLAIN'; import EXPLAINCLI from './EXPLAINCLI'; import HYBRID from './HYBRID'; -import HYBRID_WITHCURSOR from './HYBRID_WITHCURSOR'; import INFO from './INFO'; import PROFILESEARCH from './PROFILE_SEARCH'; import PROFILEAGGREGATE from './PROFILE_AGGREGATE'; @@ -86,8 +85,6 @@ export default { explainCli: EXPLAINCLI, HYBRID, hybrid: HYBRID, - HYBRID_WITHCURSOR, - hybridWithCursor: HYBRID_WITHCURSOR, INFO, info: INFO, PROFILESEARCH, From 23f4d44073f43ad310b8b96371b58981df452bef Mon Sep 17 00:00:00 2001 From: Pavel Pashov Date: Thu, 5 Feb 2026 12:37:10 +0200 Subject: [PATCH 4/9] refactor(ft.hybrid): use discriminated union for HYBRID vector method --- packages/search/lib/commands/HYBRID.spec.ts | 75 ++++++++++++--------- packages/search/lib/commands/HYBRID.ts | 63 +++++++++-------- 2 files changed, 77 insertions(+), 61 deletions(-) diff --git a/packages/search/lib/commands/HYBRID.spec.ts b/packages/search/lib/commands/HYBRID.spec.ts index 9d2c2de23e9..349d9562a98 100644 --- a/packages/search/lib/commands/HYBRID.spec.ts +++ b/packages/search/lib/commands/HYBRID.spec.ts @@ -1,5 +1,5 @@ import { strict as assert } from "node:assert"; -import HYBRID from "./HYBRID"; +import HYBRID, { FT_HYBRID_VECTOR_METHOD } from "./HYBRID"; import { BasicCommandParser } from "@redis/client/lib/client/parser"; import testUtils, { GLOBAL } from "../test-utils"; import { SCHEMA_VECTOR_FIELD_ALGORITHM } from "./CREATE"; @@ -225,10 +225,9 @@ describe("FT.HYBRID", () => { field: "@vector_field", vector: "$vec", method: { - KNN: { - K: 10, - EF_RUNTIME: 50, - }, + type: FT_HYBRID_VECTOR_METHOD.KNN, + K: 10, + EF_RUNTIME: 50, }, }, PARAMS: { @@ -266,10 +265,9 @@ describe("FT.HYBRID", () => { field: "@vector_field", vector: "$vec", method: { - RANGE: { - RADIUS: 0.5, - EPSILON: 0.01, - }, + type: FT_HYBRID_VECTOR_METHOD.RANGE, + RADIUS: 0.5, + EPSILON: 0.01, }, }, PARAMS: { @@ -867,9 +865,8 @@ describe("FT.HYBRID", () => { field: "@vector_field", vector: "$query_vector", method: { - KNN: { - K: 5, - }, + type: FT_HYBRID_VECTOR_METHOD.KNN, + K: 5, }, YIELD_SCORE_AS: "vector_score", }, @@ -1029,7 +1026,7 @@ describe("FT.HYBRID", () => { LIMIT: { offset: 0, count: 2 }, PARAMS: { vec2: createVectorBuffer([1, 2, 2, 3]), - } + }, }); assert.ok(resultBm25.totalResults >= 2); @@ -1053,7 +1050,9 @@ describe("FT.HYBRID", () => { field: "@embeddingHNSW", vector: "$vec", method: { - KNN: { K: 3, EF_RUNTIME: 1 }, + type: FT_HYBRID_VECTOR_METHOD.KNN, + K: 3, + EF_RUNTIME: 1, }, }, TIMEOUT: 10000, @@ -1088,7 +1087,7 @@ describe("FT.HYBRID", () => { TIMEOUT: 10000, PARAMS: { vec: createVectorBuffer([1, 2, 2, 3]), - } + }, }); assert.ok(result.results.length > 0); @@ -1129,7 +1128,9 @@ describe("FT.HYBRID", () => { assert.ok(result.results.length > 0); assert.deepStrictEqual(result.warnings, []); - assert.ok(result.results.some(item => item.search_score !== undefined)); + assert.ok( + result.results.some((item) => item.search_score !== undefined), + ); }, GLOBAL.SERVERS.OPEN, ); @@ -1149,7 +1150,9 @@ describe("FT.HYBRID", () => { field: "@embeddingHNSW", vector: "$vec", method: { - KNN: { K: 3, EF_RUNTIME: 1 }, + type: FT_HYBRID_VECTOR_METHOD.KNN, + K: 3, + EF_RUNTIME: 1, }, YIELD_SCORE_AS: "vsim_score", }, @@ -1162,7 +1165,7 @@ describe("FT.HYBRID", () => { assert.ok(result.results.length > 0); assert.deepStrictEqual(result.warnings, []); - assert.ok(result.results.some(item => item.vsim_score !== undefined)); + assert.ok(result.results.some((item) => item.vsim_score !== undefined)); }, GLOBAL.SERVERS.OPEN, ); @@ -1220,7 +1223,9 @@ describe("FT.HYBRID", () => { field: "@embeddingHNSW", vector: "$vec", method: { - KNN: { K: 3, EF_RUNTIME: 1 }, + type: FT_HYBRID_VECTOR_METHOD.KNN, + K: 3, + EF_RUNTIME: 1, }, YIELD_SCORE_AS: "vsim_score", }, @@ -1260,7 +1265,8 @@ describe("FT.HYBRID", () => { field: "@embedding", vector: "$vec", method: { - KNN: { K: 3 }, + type: FT_HYBRID_VECTOR_METHOD.KNN, + K: 3, }, }, TIMEOUT: 10000, @@ -1281,7 +1287,9 @@ describe("FT.HYBRID", () => { field: "@embeddingHNSW", vector: "$vec", method: { - KNN: { K: 3, EF_RUNTIME: 1 }, + type: FT_HYBRID_VECTOR_METHOD.KNN, + K: 3, + EF_RUNTIME: 1, }, }, TIMEOUT: 10000, @@ -1314,7 +1322,8 @@ describe("FT.HYBRID", () => { field: "@embedding", vector: "$vec", method: { - RANGE: { RADIUS: 2 }, + type: FT_HYBRID_VECTOR_METHOD.RANGE, + RADIUS: 2, }, }, LIMIT: { offset: 0, count: 3 }, @@ -1336,7 +1345,9 @@ describe("FT.HYBRID", () => { field: "@embeddingHNSW", vector: "$vec", method: { - RANGE: { RADIUS: 2, EPSILON: 0.5 }, + type: FT_HYBRID_VECTOR_METHOD.RANGE, + RADIUS: 2, + EPSILON: 0.5, }, }, LIMIT: { offset: 0, count: 3 }, @@ -1450,12 +1461,7 @@ describe("FT.HYBRID", () => { COMBINE: { method: { LINEAR: { ALPHA: 0.5, BETA: 0.5 } }, }, - LOAD: [ - "@description", - "@color", - "@price", - "@size", - ], + LOAD: ["@description", "@color", "@price", "@size"], LIMIT: { offset: 0, count: 1 }, TIMEOUT: 10000, PARAMS: { @@ -1661,7 +1667,10 @@ describe("FT.HYBRID", () => { 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); + assert.ok( + result.results[0].price_discount > + result.results.at(-1)?.price_discount, + ); }, GLOBAL.SERVERS.OPEN, ); @@ -1687,7 +1696,8 @@ describe("FT.HYBRID", () => { field: "@embeddingHNSW", vector: "$vec", method: { - KNN: { K: 1000 }, + 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)", @@ -1712,7 +1722,8 @@ describe("FT.HYBRID", () => { field: "@embeddingHNSW", vector: "$vec", method: { - KNN: { K: 1000 }, + type: FT_HYBRID_VECTOR_METHOD.KNN, + K: 1000, }, }, TIMEOUT: 1, // 1ms timeout - likely to timeout diff --git a/packages/search/lib/commands/HYBRID.ts b/packages/search/lib/commands/HYBRID.ts index ef35d6c83a6..6aaffbda17b 100644 --- a/packages/search/lib/commands/HYBRID.ts +++ b/packages/search/lib/commands/HYBRID.ts @@ -24,23 +24,32 @@ export interface FtHybridSearchExpression { /** * Vector search method configuration - either KNN or RANGE. - * Only one method should be specified. */ -export interface FtHybridVectorMethod { +export const FT_HYBRID_VECTOR_METHOD = { /** K-Nearest Neighbors search configuration */ - KNN?: { - /** Number of nearest neighbors to return (default: 10) */ - K: number; - /** HNSW ef_runtime parameter for search accuracy/speed tradeoff */ - EF_RUNTIME?: number; - }; + KNN: "KNN", /** Range-based vector search configuration */ - RANGE?: { - /** Search radius - maximum distance from query vector */ - RADIUS: number; - /** Range search epsilon for accuracy tuning */ - EPSILON?: number; - }; + 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; } /** @@ -52,7 +61,7 @@ export interface FtHybridVectorExpression { /** Vector parameter reference (e.g., "$v") */ vector: string; /** Search method configuration - KNN or RANGE */ - method?: FtHybridVectorMethod; + method?: FtHybridVectorMethodKNN | FtHybridVectorMethodRange; /** Pre-filter expression applied before vector search (e.g., "@tag:{foo}") */ FILTER?: RedisArgument; /** Alias for the vector score in results */ @@ -183,26 +192,22 @@ function parseVectorExpression( parser.push("VSIM", vsim.field, vsim.vector); if (vsim.method) { - if (vsim.method.KNN) { - const knn = vsim.method.KNN; - + if (vsim.method.type === FT_HYBRID_VECTOR_METHOD.KNN) { let argsCount = 2; - if (knn.EF_RUNTIME !== undefined) { + if (vsim.method.EF_RUNTIME !== undefined) { argsCount += 2; } - parser.push("KNN", argsCount.toString(), "K", knn.K.toString()); + parser.push("KNN", argsCount.toString(), "K", vsim.method.K.toString()); - if (knn.EF_RUNTIME !== undefined) { - parser.push("EF_RUNTIME", knn.EF_RUNTIME.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; - + if (vsim.method.type === FT_HYBRID_VECTOR_METHOD.RANGE) { let argsCount = 2; - if (range.EPSILON !== undefined) { + if (vsim.method.EPSILON !== undefined) { argsCount += 2; } @@ -210,11 +215,11 @@ function parseVectorExpression( "RANGE", argsCount.toString(), "RADIUS", - range.RADIUS.toString(), + vsim.method.RADIUS.toString(), ); - if (range.EPSILON !== undefined) { - parser.push("EPSILON", range.EPSILON.toString()); + if (vsim.method.EPSILON !== undefined) { + parser.push("EPSILON", vsim.method.EPSILON.toString()); } } } From 0b221d664b47c33c62920c46979825b291daa85b Mon Sep 17 00:00:00 2001 From: Pavel Pashov Date: Thu, 5 Feb 2026 13:10:20 +0200 Subject: [PATCH 5/9] refactor(ft.hybrid): use discriminated union for HYBRID combine method --- packages/search/lib/commands/HYBRID.spec.ts | 39 ++++----- packages/search/lib/commands/HYBRID.ts | 97 ++++++++++----------- 2 files changed, 66 insertions(+), 70 deletions(-) diff --git a/packages/search/lib/commands/HYBRID.spec.ts b/packages/search/lib/commands/HYBRID.spec.ts index 349d9562a98..1c800b6c03b 100644 --- a/packages/search/lib/commands/HYBRID.spec.ts +++ b/packages/search/lib/commands/HYBRID.spec.ts @@ -1,5 +1,5 @@ import { strict as assert } from "node:assert"; -import HYBRID, { FT_HYBRID_VECTOR_METHOD } from "./HYBRID"; +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"; @@ -342,10 +342,9 @@ describe("FT.HYBRID", () => { }, COMBINE: { method: { - RRF: { - WINDOW: 10, - CONSTANT: 60, - }, + type: FT_HYBRID_COMBINE_METHOD.RRF, + WINDOW: 10, + CONSTANT: 60, }, YIELD_SCORE_AS: "combined_score", }, @@ -389,10 +388,9 @@ describe("FT.HYBRID", () => { }, COMBINE: { method: { - LINEAR: { - ALPHA: 0.7, - BETA: 0.3, - }, + type: FT_HYBRID_COMBINE_METHOD.LINEAR, + ALPHA: 0.7, + BETA: 0.3, }, }, PARAMS: { @@ -872,9 +870,8 @@ describe("FT.HYBRID", () => { }, COMBINE: { method: { - RRF: { - CONSTANT: 60, - }, + type: FT_HYBRID_COMBINE_METHOD.RRF, + CONSTANT: 60, }, YIELD_SCORE_AS: "final_score", }, @@ -982,7 +979,7 @@ describe("FT.HYBRID", () => { vector: "$vec", }, COMBINE: { - method: { LINEAR: { ALPHA: 1, BETA: 0 } }, + method: { type: FT_HYBRID_COMBINE_METHOD.LINEAR, ALPHA: 1, BETA: 0 }, }, LOAD: [ "@description", @@ -1013,7 +1010,7 @@ describe("FT.HYBRID", () => { vector: "$vec2", }, COMBINE: { - method: { LINEAR: { ALPHA: 1, BETA: 0 } }, + method: { type: FT_HYBRID_COMBINE_METHOD.LINEAR, ALPHA: 1, BETA: 0 }, }, LOAD: [ "@description", @@ -1186,7 +1183,7 @@ describe("FT.HYBRID", () => { vector: "$vec", }, COMBINE: { - method: { LINEAR: { ALPHA: 0.5, BETA: 0.5 } }, + method: { type: FT_HYBRID_COMBINE_METHOD.LINEAR, ALPHA: 0.5, BETA: 0.5 }, YIELD_SCORE_AS: "combined_score", }, TIMEOUT: 10000, @@ -1230,7 +1227,7 @@ describe("FT.HYBRID", () => { YIELD_SCORE_AS: "vsim_score", }, COMBINE: { - method: { LINEAR: { ALPHA: 0.5, BETA: 0.5 } }, + method: { type: FT_HYBRID_COMBINE_METHOD.LINEAR, ALPHA: 0.5, BETA: 0.5 }, YIELD_SCORE_AS: "combined_score", }, TIMEOUT: 10000, @@ -1382,7 +1379,7 @@ describe("FT.HYBRID", () => { vector: "$vec", }, COMBINE: { - method: { LINEAR: { ALPHA: 0.5, BETA: 0.5 } }, + method: { type: FT_HYBRID_COMBINE_METHOD.LINEAR, ALPHA: 0.5, BETA: 0.5 }, }, LIMIT: { offset: 0, count: 3 }, TIMEOUT: 10000, @@ -1404,7 +1401,7 @@ describe("FT.HYBRID", () => { vector: "$vec", }, COMBINE: { - method: { RRF: { WINDOW: 3, CONSTANT: 0.5 } }, + method: { type: FT_HYBRID_COMBINE_METHOD.RRF, WINDOW: 3, CONSTANT: 0.5 }, }, LIMIT: { offset: 0, count: 3 }, TIMEOUT: 10000, @@ -1426,7 +1423,7 @@ describe("FT.HYBRID", () => { vector: "$vec", }, COMBINE: { - method: { RRF: { WINDOW: 3 } }, + method: { type: FT_HYBRID_COMBINE_METHOD.RRF, WINDOW: 3 }, }, LIMIT: { offset: 0, count: 3 }, TIMEOUT: 10000, @@ -1459,7 +1456,7 @@ describe("FT.HYBRID", () => { vector: "$vec", }, COMBINE: { - method: { LINEAR: { ALPHA: 0.5, BETA: 0.5 } }, + method: { type: FT_HYBRID_COMBINE_METHOD.LINEAR, ALPHA: 0.5, BETA: 0.5 }, }, LOAD: ["@description", "@color", "@price", "@size"], LIMIT: { offset: 0, count: 1 }, @@ -1703,7 +1700,7 @@ describe("FT.HYBRID", () => { "((@price:[15 16] @size:[10 11]) | (@price:[13 15] @size:[11 12])) @description:(shoes) -@description:(green)", }, COMBINE: { - method: { RRF: { WINDOW: 1000 } }, + method: { type: FT_HYBRID_COMBINE_METHOD.RRF, WINDOW: 1000 }, }, TIMEOUT: timeout, PARAMS: { diff --git a/packages/search/lib/commands/HYBRID.ts b/packages/search/lib/commands/HYBRID.ts index 6aaffbda17b..883c230ab64 100644 --- a/packages/search/lib/commands/HYBRID.ts +++ b/packages/search/lib/commands/HYBRID.ts @@ -69,28 +69,35 @@ export interface FtHybridVectorExpression { } /** - * Score fusion method configuration for combining search results. - * Only one method should be specified: RRF, LINEAR, or FUNCTION. + * Score fusion method type constants for combining search results. */ -export interface FtHybridCombineMethod { - /** Reciprocal Rank Fusion configuration */ - RRF?: { - /** RRF window size (default: 20) */ - WINDOW?: number; - /** RRF constant (default: 60) */ - CONSTANT?: number; - }; - /** Linear weighted combination configuration */ - LINEAR?: { - /** Weight for text search score (default: 0.3) */ - ALPHA?: number; - /** Weight for vector search score (default: 0.7) */ - BETA?: number; - /** Window size for score normalization */ - WINDOW?: number; - }; - /** Custom scoring function expression */ - FUNCTION?: RedisArgument; +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; } /** @@ -127,8 +134,8 @@ export interface FtHybridOptions { VSIM: FtHybridVectorExpression; /** Score fusion configuration for combining SEARCH and VSIM results */ COMBINE?: { - /** Fusion method: RRF, LINEAR, or FUNCTION */ - method: FtHybridCombineMethod; + /** Fusion method: RRF or LINEAR */ + method: FtHybridCombineMethodRRF | FtHybridCombineMethodLinear; /** Alias for the combined score in results */ YIELD_SCORE_AS?: RedisArgument; }; @@ -241,15 +248,13 @@ function parseCombineMethod( parser.push("COMBINE"); - if (combine.method.RRF) { - const rrf = combine.method.RRF; - + if (combine.method.type === FT_HYBRID_COMBINE_METHOD.RRF) { // Calculate argsCount: 2 per optional (WINDOW, CONSTANT, YIELD_SCORE_AS) let argsCount = 0; - if (rrf.WINDOW !== undefined) { + if (combine.method.WINDOW !== undefined) { argsCount += 2; } - if (rrf.CONSTANT !== undefined) { + if (combine.method.CONSTANT !== undefined) { argsCount += 2; } if (combine.YIELD_SCORE_AS) { @@ -258,12 +263,12 @@ function parseCombineMethod( 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.YIELD_SCORE_AS) { @@ -271,18 +276,16 @@ function parseCombineMethod( } } - if (combine.method.LINEAR) { - const linear = combine.method.LINEAR; - + if (combine.method.type === FT_HYBRID_COMBINE_METHOD.LINEAR) { // Calculate argsCount: 2 per optional (ALPHA, BETA, WINDOW, YIELD_SCORE_AS) let argsCount = 0; - if (linear.ALPHA !== undefined) { + if (combine.method.ALPHA !== undefined) { argsCount += 2; } - if (linear.BETA !== undefined) { + if (combine.method.BETA !== undefined) { argsCount += 2; } - if (linear.WINDOW !== undefined) { + if (combine.method.WINDOW !== undefined) { argsCount += 2; } if (combine.YIELD_SCORE_AS) { @@ -291,26 +294,22 @@ function parseCombineMethod( 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 (linear.WINDOW !== undefined) { - parser.push("WINDOW", linear.WINDOW.toString()); + 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); } } - - if (combine.method.FUNCTION) { - parser.push("FUNCTION", combine.method.FUNCTION); - } } function parseReducer(parser: CommandParser, reducer: FtHybridReducer) { @@ -427,7 +426,7 @@ 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, TIMEOUT */ From cf3d6ed895df11b131e4760ead337fc77ca1256e Mon Sep 17 00:00:00 2001 From: Pavel Pashov Date: Thu, 5 Feb 2026 13:36:19 +0200 Subject: [PATCH 6/9] refactor(ft.hybrid): reuse AGGREGATE reducer types in HYBRID command --- packages/search/lib/commands/AGGREGATE.ts | 4 +-- packages/search/lib/commands/HYBRID.spec.ts | 25 ++++++++----------- packages/search/lib/commands/HYBRID.ts | 27 +++------------------ 3 files changed, 16 insertions(+), 40 deletions(-) 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 1c800b6c03b..96b8e504f24 100644 --- a/packages/search/lib/commands/HYBRID.spec.ts +++ b/packages/search/lib/commands/HYBRID.spec.ts @@ -3,6 +3,7 @@ import HYBRID, { FT_HYBRID_VECTOR_METHOD, FT_HYBRID_COMBINE_METHOD } from "./HYB 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 @@ -480,9 +481,7 @@ describe("FT.HYBRID", () => { GROUPBY: { fields: ["@category"], REDUCE: { - function: "COUNT", - nargs: 0, - args: [], + type: FT_AGGREGATE_GROUP_BY_REDUCERS.COUNT, }, }, PARAMS: { @@ -765,15 +764,13 @@ describe("FT.HYBRID", () => { fields: ["@itemType", "@price"], REDUCE: [ { - function: "COUNT_DISTINCT", - nargs: 1, - args: ["@color"], + type: FT_AGGREGATE_GROUP_BY_REDUCERS.COUNT_DISTINCT, + property: "@color", AS: "colors_count", }, { - function: "MIN", - nargs: 1, - args: ["@size"], + type: FT_AGGREGATE_GROUP_BY_REDUCERS.MIN, + property: "@size", }, ], }, @@ -1756,15 +1753,13 @@ describe("FT.HYBRID", () => { fields: ["@itemType", "@price"], REDUCE: [ { - function: "COUNT_DISTINCT", - nargs: 1, - args: ["@color"], + type: FT_AGGREGATE_GROUP_BY_REDUCERS.COUNT_DISTINCT, + property: "@color", AS: "colors_count", }, { - function: "MIN", - nargs: 1, - args: ["@size"], + type: FT_AGGREGATE_GROUP_BY_REDUCERS.MIN, + property: "@size", }, ], }, diff --git a/packages/search/lib/commands/HYBRID.ts b/packages/search/lib/commands/HYBRID.ts index 883c230ab64..51a489797d1 100644 --- a/packages/search/lib/commands/HYBRID.ts +++ b/packages/search/lib/commands/HYBRID.ts @@ -9,6 +9,7 @@ import { 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. @@ -100,20 +101,6 @@ interface FtHybridCombineMethodLinear { WINDOW?: number; } -/** - * Reducer configuration for GROUPBY aggregation. - */ -export interface FtHybridReducer { - /** Reducer function name (e.g., "COUNT", "SUM", "AVG") */ - function: RedisArgument; - /** Number of arguments for the reducer */ - nargs: number; - /** Arguments for the reducer function */ - args: Array; - /** Alias for the reducer result in output */ - AS?: RedisArgument; -} - /** * Apply expression for result transformation. */ @@ -146,7 +133,7 @@ export interface FtHybridOptions { /** Fields to group by */ fields: RedisVariadicArgument; /** Reducer(s) to apply to each group */ - REDUCE?: FtHybridReducer | Array; + REDUCE?: GroupByReducers | Array; }; /** Apply expression(s) for result transformation */ APPLY?: FtHybridApply | Array; @@ -312,13 +299,7 @@ function parseCombineMethod( } } -function parseReducer(parser: CommandParser, reducer: FtHybridReducer) { - parser.push("REDUCE", reducer.function, reducer.nargs.toString()); - parser.push(...reducer.args); - if (reducer.AS) { - parser.push("AS", reducer.AS); - } -} + function parseApply(parser: CommandParser, apply: FtHybridApply) { parser.push("APPLY", apply.expression); @@ -353,7 +334,7 @@ function parseHybridOptions(parser: CommandParser, options?: FtHybridOptions) { : [options.GROUPBY.REDUCE]; for (const reducer of reducers) { - parseReducer(parser, reducer); + parseGroupByReducer(parser, reducer); } } } From d0c5b83c9a900db9d6ca3d08ee57b8e5cebb0219 Mon Sep 17 00:00:00 2001 From: Pavel Pashov Date: Thu, 5 Feb 2026 14:28:14 +0200 Subject: [PATCH 7/9] fix: improve FT.HYBRID command interface and parsing --- packages/search/lib/commands/HYBRID.ts | 39 +++++++------------------- 1 file changed, 10 insertions(+), 29 deletions(-) diff --git a/packages/search/lib/commands/HYBRID.ts b/packages/search/lib/commands/HYBRID.ts index 51a489797d1..50d29bf5909 100644 --- a/packages/search/lib/commands/HYBRID.ts +++ b/packages/search/lib/commands/HYBRID.ts @@ -159,7 +159,7 @@ export interface FtHybridOptions { count: number | RedisArgument; }; /** Query parameters for parameterized queries */ - PARAMS: Record; + PARAMS?: Record; /** Query timeout in milliseconds */ TIMEOUT?: number; } @@ -257,10 +257,6 @@ function parseCombineMethod( if (combine.method.CONSTANT !== undefined) { parser.push("CONSTANT", combine.method.CONSTANT.toString()); } - - if (combine.YIELD_SCORE_AS) { - parser.push("YIELD_SCORE_AS", combine.YIELD_SCORE_AS); - } } if (combine.method.type === FT_HYBRID_COMBINE_METHOD.LINEAR) { @@ -292,15 +288,13 @@ function parseCombineMethod( 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); - } + if (combine.YIELD_SCORE_AS) { + parser.push("YIELD_SCORE_AS", combine.YIELD_SCORE_AS); } } - - function parseApply(parser: CommandParser, apply: FtHybridApply) { parser.push("APPLY", apply.expression); if (apply.AS) { @@ -308,16 +302,9 @@ function parseApply(parser: CommandParser, apply: FtHybridApply) { } } -function parseHybridOptions(parser: CommandParser, options?: FtHybridOptions) { - if (!options) return; - - if (options.SEARCH) { - parseSearchExpression(parser, options.SEARCH); - } - - 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); @@ -414,7 +401,7 @@ export default { parseCommand( parser: CommandParser, index: RedisArgument, - options?: FtHybridOptions, + options: FtHybridOptions, ) { parser.push("FT.HYBRID", index); @@ -433,13 +420,7 @@ export interface HybridSearchResult { totalResults: number; executionTime: number; warnings: string[]; - results: HybridSearchDocument[]; -} - -export interface HybridSearchDocument { - id: string; - score?: number | undefined; - [field: string]: any; + results: Record[]; } function transformHybridSearchResults(reply: any): HybridSearchResult { @@ -454,7 +435,7 @@ function transformHybridSearchResults(reply: any): HybridSearchResult { ? Number.parseFloat(replyMap["execution_time"]) : 0; - const results: HybridSearchDocument[] = []; + 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); From b6f161e083b8ae2eadb2385316c84f2a12d37f48 Mon Sep 17 00:00:00 2001 From: Pavel Pashov Date: Thu, 5 Feb 2026 15:11:23 +0200 Subject: [PATCH 8/9] fix: support LOAD '*' to load all fields in FT.HYBRID --- packages/search/lib/commands/HYBRID.spec.ts | 40 +++++++++++++++++++++ packages/search/lib/commands/HYBRID.ts | 15 ++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/packages/search/lib/commands/HYBRID.spec.ts b/packages/search/lib/commands/HYBRID.spec.ts index 96b8e504f24..81a80d7d688 100644 --- a/packages/search/lib/commands/HYBRID.spec.ts +++ b/packages/search/lib/commands/HYBRID.spec.ts @@ -1834,5 +1834,45 @@ describe("FT.HYBRID", () => { }, 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, + ); }); }); diff --git a/packages/search/lib/commands/HYBRID.ts b/packages/search/lib/commands/HYBRID.ts index 50d29bf5909..f2eddccd8cf 100644 --- a/packages/search/lib/commands/HYBRID.ts +++ b/packages/search/lib/commands/HYBRID.ts @@ -126,7 +126,11 @@ export interface FtHybridOptions { /** Alias for the combined score in results */ YIELD_SCORE_AS?: RedisArgument; }; - /** Fields to load and return in results (LOAD clause) */ + /** + * 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?: { @@ -310,7 +314,14 @@ function parseHybridOptions(parser: CommandParser, options: FtHybridOptions) { 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); From 13414954c7f23b8cddc2ccae72859adf8d3d12f0 Mon Sep 17 00:00:00 2001 From: Pavel Pashov Date: Fri, 6 Feb 2026 15:52:51 +0200 Subject: [PATCH 9/9] docs(examples): add RediSearch hybrid search example --- examples/README.md | 1 + examples/search-hybrid.js | 173 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 examples/search-hybrid.js 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();