Skip to content

Commit 96b9b9e

Browse files
authored
fix(search): improve FT.HYBRID command implementation (#3171)
* fix(search): improve FT.HYBRID command implementation * fix: add jsdoc for ft.hybrid options * refactor(search): simplify FT.HYBRID command API * refactor(ft.hybrid): use discriminated union for HYBRID vector method * refactor(ft.hybrid): use discriminated union for HYBRID combine method * refactor(ft.hybrid): reuse AGGREGATE reducer types in HYBRID command * fix: improve FT.HYBRID command interface and parsing * fix: support LOAD '*' to load all fields in FT.HYBRID * docs(examples): add RediSearch hybrid search example
1 parent be230f2 commit 96b9b9e

File tree

5 files changed

+2292
-456
lines changed

5 files changed

+2292
-456
lines changed

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ This folder contains example scripts showing how to use Node Redis in different
2323
| `search-hashes.js` | Uses [RediSearch](https://redisearch.io) to index and search data in hashes. |
2424
| `search-json.js` | Uses [RediSearch](https://redisearch.io/) and [RedisJSON](https://redisjson.io/) to index and search JSON data. |
2525
| `search-knn.js` | Uses [RediSearch vector similarity]([https://redisearch.io/](https://redis.io/docs/stack/search/reference/vectors/)) to index and run KNN queries. |
26+
| `search-hybrid.js` | Uses [RediSearch](https://redisearch.io) hybrid search to combine text search with vector similarity search. |
2627
| `set-scan.js` | An example script that shows how to use the SSCAN iterator functionality. |
2728
| `sorted-set.js` | Add members with scores to a Sorted Set and retrieve them using the ZSCAN iteractor functionality. |
2829
| `stream-producer.js` | Adds entries to a [Redis Stream](https://redis.io/topics/streams-intro) using the `XADD` command. |

examples/search-hybrid.js

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// This example demonstrates how to use RediSearch hybrid search (FT.HYBRID).
2+
// Hybrid search combines text search with vector similarity search for more
3+
// comprehensive and relevant results.
4+
5+
import {
6+
createClient,
7+
SCHEMA_FIELD_TYPE,
8+
SCHEMA_VECTOR_FIELD_ALGORITHM,
9+
} from "redis";
10+
11+
const client = createClient();
12+
13+
await client.connect();
14+
15+
// Helper function to create a Float32Array vector as a Buffer
16+
const createVectorBuffer = (values) => {
17+
return Buffer.from(new Float32Array(values).buffer);
18+
};
19+
20+
// Create an index with text, tag, numeric, and vector fields...
21+
const indexName = "idx:products";
22+
try {
23+
// Documentation: https://redis.io/commands/ft.create/
24+
await client.ft.create(
25+
indexName,
26+
{
27+
description: SCHEMA_FIELD_TYPE.TEXT,
28+
category: SCHEMA_FIELD_TYPE.TAG,
29+
price: SCHEMA_FIELD_TYPE.NUMERIC,
30+
embedding: {
31+
type: SCHEMA_FIELD_TYPE.VECTOR,
32+
ALGORITHM: SCHEMA_VECTOR_FIELD_ALGORITHM.FLAT,
33+
TYPE: "FLOAT32",
34+
DIM: 4,
35+
DISTANCE_METRIC: "L2",
36+
},
37+
},
38+
{
39+
ON: "HASH",
40+
PREFIX: "noderedis:products",
41+
},
42+
);
43+
} catch (e) {
44+
if (e.message === "Index already exists") {
45+
console.log("Index exists already, skipped creation.");
46+
} else {
47+
console.error(e);
48+
process.exit(1);
49+
}
50+
}
51+
52+
// Add some sample product data with embeddings...
53+
await Promise.all([
54+
client.hSet("noderedis:products:1", {
55+
description: "comfortable red running shoes",
56+
category: "footwear",
57+
price: "79",
58+
embedding: createVectorBuffer([1, 2, 7, 8]),
59+
}),
60+
client.hSet("noderedis:products:2", {
61+
description: "stylish blue sneakers",
62+
category: "footwear",
63+
price: "89",
64+
embedding: createVectorBuffer([1, 4, 7, 8]),
65+
}),
66+
client.hSet("noderedis:products:3", {
67+
description: "elegant red dress",
68+
category: "clothing",
69+
price: "129",
70+
embedding: createVectorBuffer([1, 2, 6, 5]),
71+
}),
72+
client.hSet("noderedis:products:4", {
73+
description: "warm winter jacket",
74+
category: "clothing",
75+
price: "199",
76+
embedding: createVectorBuffer([5, 6, 7, 8]),
77+
}),
78+
]);
79+
80+
// Perform a hybrid search combining text search with vector similarity
81+
// Documentation: https://redis.io/commands/ft.hybrid/
82+
const results = await client.ft.hybrid(indexName, {
83+
// Text search component - full-text search on TEXT fields
84+
SEARCH: {
85+
query: "@description:red",
86+
YIELD_SCORE_AS: "text_score",
87+
},
88+
// Vector similarity component
89+
VSIM: {
90+
field: "@embedding",
91+
// Reference to the vector parameter (must match a key in PARAMS, prefixed with '$')
92+
vector: "$query_vector",
93+
YIELD_SCORE_AS: "vector_score",
94+
// Search method configuration - KNN or RANGE
95+
method: {
96+
type: "KNN",
97+
K: 10,
98+
},
99+
},
100+
// Combine method: RRF (Reciprocal Rank Fusion) or LINEAR
101+
COMBINE: {
102+
method: { type: "RRF", CONSTANT: 60 },
103+
YIELD_SCORE_AS: "combined_score",
104+
},
105+
// Fields to load from the documents
106+
// - Use `'*'` to load all fields from documents
107+
LOAD: ["@__key", "@description", "@category", "@price"],
108+
// Sort by combined score
109+
SORTBY: {
110+
fields: [{ field: "@combined_score", direction: "DESC" }],
111+
},
112+
// Limit results
113+
LIMIT: { offset: 0, count: 10 },
114+
// Query parameters - the param name must match the vector reference in VSIM
115+
// (e.g., '$query_vector' in VSIM.vector corresponds to 'query_vector' here)
116+
PARAMS: {
117+
query_vector: createVectorBuffer([1, 2, 6, 5]),
118+
},
119+
});
120+
121+
// results:
122+
// {
123+
// totalResults: 4,
124+
// executionTime: 0.879,
125+
// warnings: [],
126+
// results: [
127+
// {
128+
// text_score: '0.0404949945054',
129+
// __key: 'noderedis:products:3',
130+
// description: 'elegant red dress',
131+
// category: 'clothing',
132+
// price: '129',
133+
// vector_score: '1',
134+
// combined_score: '0.0327868852459'
135+
// },
136+
// {
137+
// text_score: '0.0358374231755',
138+
// __key: 'noderedis:products:1',
139+
// description: 'comfortable red running shoes',
140+
// category: 'footwear',
141+
// price: '79',
142+
// vector_score: '0.0909090909091',
143+
// combined_score: '0.0322580645161'
144+
// },
145+
// {
146+
// __key: 'noderedis:products:2',
147+
// description: 'stylish blue sneakers',
148+
// category: 'footwear',
149+
// price: '89',
150+
// vector_score: '0.0666666666667',
151+
// combined_score: '0.015873015873'
152+
// },
153+
// {
154+
// __key: 'noderedis:products:4',
155+
// description: 'warm winter jacket',
156+
// category: 'clothing',
157+
// price: '199',
158+
// vector_score: '0.0232558139535',
159+
// combined_score: '0.015625'
160+
// }
161+
// ]
162+
// }
163+
164+
console.log(`Results found: ${results.totalResults}`);
165+
console.log(`Execution time: ${results.executionTime}ms`);
166+
167+
for (const doc of results.results) {
168+
console.log(`${doc.__key} - ${doc.description} ($${doc.price})`);
169+
console.log(` Category: ${doc.category}`);
170+
console.log(` Combined score: ${doc.combined_score}`);
171+
}
172+
173+
client.destroy();

packages/search/lib/commands/AGGREGATE.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ interface RandomSampleReducer extends GroupByReducerWithProperty<FT_AGGREGATE_GR
8787
sampleSize: number;
8888
}
8989

90-
type GroupByReducers = CountReducer | CountDistinctReducer | CountDistinctishReducer | SumReducer | MinReducer | MaxReducer | AvgReducer | StdDevReducer | QuantileReducer | ToListReducer | FirstValueReducer | RandomSampleReducer;
90+
export type GroupByReducers = CountReducer | CountDistinctReducer | CountDistinctishReducer | SumReducer | MinReducer | MaxReducer | AvgReducer | StdDevReducer | QuantileReducer | ToListReducer | FirstValueReducer | RandomSampleReducer;
9191

9292
interface GroupByStep extends AggregateStep<FT_AGGREGATE_STEPS['GROUPBY']> {
9393
properties?: RediSearchProperty | Array<RediSearchProperty>;
@@ -284,7 +284,7 @@ function pushLoadField(args: Array<RedisArgument>, toLoad: LoadField) {
284284
}
285285
}
286286

287-
function parseGroupByReducer(parser: CommandParser, reducer: GroupByReducers) {
287+
export function parseGroupByReducer(parser: CommandParser, reducer: GroupByReducers) {
288288
parser.push('REDUCE', reducer.type);
289289

290290
switch (reducer.type) {

0 commit comments

Comments
 (0)