Skip to content

Commit 96a8a84

Browse files
feat(search): add hybrid search command (#3119)
1 parent 9c9a973 commit 96a8a84

File tree

3 files changed

+759
-0
lines changed

3 files changed

+759
-0
lines changed
Lines changed: 379 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
1+
import { strict as assert } from 'node:assert';
2+
import testUtils, { GLOBAL } from '../test-utils';
3+
import HYBRID from './HYBRID';
4+
import { BasicCommandParser } from '@redis/client/lib/client/parser';
5+
6+
describe('FT.HYBRID', () => {
7+
describe('parseCommand', () => {
8+
it('minimal command', () => {
9+
const parser = new BasicCommandParser();
10+
HYBRID.parseCommand(parser, 'index');
11+
assert.deepEqual(
12+
parser.redisArgs,
13+
['FT.HYBRID', 'index', '2', 'DIALECT', '2']
14+
);
15+
});
16+
17+
it('with count expressions', () => {
18+
const parser = new BasicCommandParser();
19+
HYBRID.parseCommand(parser, 'index', {
20+
countExpressions: 3
21+
});
22+
assert.deepEqual(
23+
parser.redisArgs,
24+
['FT.HYBRID', 'index', '3', 'DIALECT', '2']
25+
);
26+
});
27+
28+
it('with SEARCH expression', () => {
29+
const parser = new BasicCommandParser();
30+
HYBRID.parseCommand(parser, 'index', {
31+
SEARCH: {
32+
query: '@description: bikes'
33+
}
34+
});
35+
assert.deepEqual(
36+
parser.redisArgs,
37+
['FT.HYBRID', 'index', '2', 'SEARCH', '@description: bikes', 'DIALECT', '2']
38+
);
39+
});
40+
41+
it('with SEARCH expression and SCORER', () => {
42+
const parser = new BasicCommandParser();
43+
HYBRID.parseCommand(parser, 'index', {
44+
SEARCH: {
45+
query: '@description: bikes',
46+
SCORER: {
47+
algorithm: 'TFIDF.DOCNORM',
48+
params: ['param1', 'param2']
49+
},
50+
YIELD_SCORE_AS: 'search_score'
51+
}
52+
});
53+
assert.deepEqual(
54+
parser.redisArgs,
55+
[
56+
'FT.HYBRID', 'index', '2', 'SEARCH', '@description: bikes',
57+
'SCORER', 'TFIDF.DOCNORM', 'param1', 'param2',
58+
'YIELD_SCORE_AS', 'search_score', 'DIALECT', '2'
59+
]
60+
);
61+
});
62+
63+
it('with VSIM expression and KNN method', () => {
64+
const parser = new BasicCommandParser();
65+
HYBRID.parseCommand(parser, 'index', {
66+
VSIM: {
67+
field: '@vector_field',
68+
vectorData: 'BLOB_DATA',
69+
method: {
70+
KNN: {
71+
K: 10,
72+
EF_RUNTIME: 50,
73+
YIELD_DISTANCE_AS: 'vector_dist'
74+
}
75+
}
76+
}
77+
});
78+
assert.deepEqual(
79+
parser.redisArgs,
80+
[
81+
'FT.HYBRID', 'index', '2', 'VSIM', '@vector_field', 'BLOB_DATA',
82+
'KNN', '1', 'K', '10', 'EF_RUNTIME', '50', 'YIELD_DISTANCE_AS', 'vector_dist',
83+
'DIALECT', '2'
84+
]
85+
);
86+
});
87+
88+
it('with VSIM expression and RANGE method', () => {
89+
const parser = new BasicCommandParser();
90+
HYBRID.parseCommand(parser, 'index', {
91+
VSIM: {
92+
field: '@vector_field',
93+
vectorData: 'BLOB_DATA',
94+
method: {
95+
RANGE: {
96+
RADIUS: 0.5,
97+
EPSILON: 0.01,
98+
YIELD_DISTANCE_AS: 'vector_dist'
99+
}
100+
}
101+
}
102+
});
103+
assert.deepEqual(
104+
parser.redisArgs,
105+
[
106+
'FT.HYBRID', 'index', '2', 'VSIM', '@vector_field', 'BLOB_DATA',
107+
'RANGE', '1', 'RADIUS', '0.5', 'EPSILON', '0.01', 'YIELD_DISTANCE_AS', 'vector_dist',
108+
'DIALECT', '2'
109+
]
110+
);
111+
});
112+
113+
it('with VSIM expression and FILTER', () => {
114+
const parser = new BasicCommandParser();
115+
HYBRID.parseCommand(parser, 'index', {
116+
VSIM: {
117+
field: '@vector_field',
118+
vectorData: 'BLOB_DATA',
119+
FILTER: {
120+
expression: '@category:{bikes}',
121+
POLICY: 'BATCHES',
122+
BATCHES: {
123+
BATCH_SIZE: 100
124+
}
125+
},
126+
YIELD_SCORE_AS: 'vsim_score'
127+
}
128+
});
129+
assert.deepEqual(
130+
parser.redisArgs,
131+
[
132+
'FT.HYBRID', 'index', '2', 'VSIM', '@vector_field', 'BLOB_DATA',
133+
'FILTER', '@category:{bikes}', 'POLICY', 'BATCHES', 'BATCHES', 'BATCH_SIZE', '100',
134+
'YIELD_SCORE_AS', 'vsim_score', 'DIALECT', '2'
135+
]
136+
);
137+
});
138+
139+
it('with RRF COMBINE method', () => {
140+
const parser = new BasicCommandParser();
141+
HYBRID.parseCommand(parser, 'index', {
142+
COMBINE: {
143+
method: {
144+
RRF: {
145+
count: 2,
146+
WINDOW: 10,
147+
CONSTANT: 60
148+
}
149+
},
150+
YIELD_SCORE_AS: 'combined_score'
151+
}
152+
});
153+
assert.deepEqual(
154+
parser.redisArgs,
155+
[
156+
'FT.HYBRID', 'index', '2', 'COMBINE', 'RRF', '2', 'WINDOW', '10', 'CONSTANT', '60',
157+
'YIELD_SCORE_AS', 'combined_score', 'DIALECT', '2'
158+
]
159+
);
160+
});
161+
162+
it('with LINEAR COMBINE method', () => {
163+
const parser = new BasicCommandParser();
164+
HYBRID.parseCommand(parser, 'index', {
165+
COMBINE: {
166+
method: {
167+
LINEAR: {
168+
count: 2,
169+
ALPHA: 0.7,
170+
BETA: 0.3
171+
}
172+
}
173+
}
174+
});
175+
assert.deepEqual(
176+
parser.redisArgs,
177+
[
178+
'FT.HYBRID', 'index', '2', 'COMBINE', 'LINEAR', '2', 'ALPHA', '0.7', 'BETA', '0.3',
179+
'DIALECT', '2'
180+
]
181+
);
182+
});
183+
184+
it('with LOAD, SORTBY, and LIMIT', () => {
185+
const parser = new BasicCommandParser();
186+
HYBRID.parseCommand(parser, 'index', {
187+
LOAD: ['field1', 'field2'],
188+
SORTBY: {
189+
count: 1,
190+
fields: [
191+
{ field: 'score', direction: 'DESC' }
192+
]
193+
},
194+
LIMIT: {
195+
offset: 0,
196+
num: 10
197+
}
198+
});
199+
assert.deepEqual(
200+
parser.redisArgs,
201+
[
202+
'FT.HYBRID', 'index', '2', 'LOAD', '2', 'field1', 'field2',
203+
'SORTBY', '1', 'score', 'DESC', 'LIMIT', '0', '10', 'DIALECT', '2'
204+
]
205+
);
206+
});
207+
208+
it('with GROUPBY and REDUCE', () => {
209+
const parser = new BasicCommandParser();
210+
HYBRID.parseCommand(parser, 'index', {
211+
GROUPBY: {
212+
fields: ['@category'],
213+
REDUCE: {
214+
function: 'COUNT',
215+
count: 0,
216+
args: []
217+
}
218+
}
219+
});
220+
assert.deepEqual(
221+
parser.redisArgs,
222+
[
223+
'FT.HYBRID', 'index', '2', 'GROUPBY', '1', '@category', 'REDUCE', 'COUNT', '0',
224+
'DIALECT', '2'
225+
]
226+
);
227+
});
228+
229+
it('with APPLY', () => {
230+
const parser = new BasicCommandParser();
231+
HYBRID.parseCommand(parser, 'index', {
232+
APPLY: {
233+
expression: '@score * 2',
234+
AS: 'double_score'
235+
}
236+
});
237+
assert.deepEqual(
238+
parser.redisArgs,
239+
['FT.HYBRID', 'index', '2', 'APPLY', '@score * 2', 'AS', 'double_score', 'DIALECT', '2']
240+
);
241+
});
242+
243+
it('with FILTER and post-processing', () => {
244+
const parser = new BasicCommandParser();
245+
HYBRID.parseCommand(parser, 'index', {
246+
FILTER: '@price:[100 500]'
247+
});
248+
assert.deepEqual(
249+
parser.redisArgs,
250+
['FT.HYBRID', 'index', '2', 'FILTER', '@price:[100 500]', 'DIALECT', '2']
251+
);
252+
});
253+
254+
it('with PARAMS', () => {
255+
const parser = new BasicCommandParser();
256+
HYBRID.parseCommand(parser, 'index', {
257+
PARAMS: {
258+
query_vector: 'BLOB_DATA',
259+
min_price: 100
260+
}
261+
});
262+
assert.deepEqual(
263+
parser.redisArgs,
264+
[
265+
'FT.HYBRID', 'index', '2', 'PARAMS', '4', 'query_vector', 'BLOB_DATA', 'min_price', '100',
266+
'DIALECT', '2'
267+
]
268+
);
269+
});
270+
271+
it('with EXPLAINSCORE and TIMEOUT', () => {
272+
const parser = new BasicCommandParser();
273+
HYBRID.parseCommand(parser, 'index', {
274+
EXPLAINSCORE: true,
275+
TIMEOUT: 5000
276+
});
277+
assert.deepEqual(
278+
parser.redisArgs,
279+
['FT.HYBRID', 'index', '2', 'EXPLAINSCORE', 'TIMEOUT', '5000', 'DIALECT', '2']
280+
);
281+
});
282+
283+
it('with WITHCURSOR', () => {
284+
const parser = new BasicCommandParser();
285+
HYBRID.parseCommand(parser, 'index', {
286+
WITHCURSOR: {
287+
COUNT: 100,
288+
MAXIDLE: 300000
289+
}
290+
});
291+
assert.deepEqual(
292+
parser.redisArgs,
293+
[
294+
'FT.HYBRID', 'index', '2', 'WITHCURSOR', 'COUNT', '100', 'MAXIDLE', '300000',
295+
'DIALECT', '2'
296+
]
297+
);
298+
});
299+
300+
it('complete example with all options', () => {
301+
const parser = new BasicCommandParser();
302+
HYBRID.parseCommand(parser, 'index', {
303+
countExpressions: 2,
304+
SEARCH: {
305+
query: '@description: bikes',
306+
SCORER: {
307+
algorithm: 'TFIDF.DOCNORM'
308+
},
309+
YIELD_SCORE_AS: 'text_score'
310+
},
311+
VSIM: {
312+
field: '@vector_field',
313+
vectorData: '$query_vector',
314+
method: {
315+
KNN: {
316+
K: 5
317+
}
318+
},
319+
YIELD_SCORE_AS: 'vector_score'
320+
},
321+
COMBINE: {
322+
method: {
323+
RRF: {
324+
count: 2,
325+
CONSTANT: 60
326+
}
327+
},
328+
YIELD_SCORE_AS: 'final_score'
329+
},
330+
LOAD: ['description', 'price'],
331+
SORTBY: {
332+
count: 1,
333+
fields: [{ field: 'final_score', direction: 'DESC' }]
334+
},
335+
LIMIT: {
336+
offset: 0,
337+
num: 10
338+
},
339+
PARAMS: {
340+
query_vector: 'BLOB_DATA'
341+
}
342+
});
343+
assert.deepEqual(
344+
parser.redisArgs,
345+
[
346+
'FT.HYBRID', 'index', '2',
347+
'SEARCH', '@description: bikes', 'SCORER', 'TFIDF.DOCNORM', 'YIELD_SCORE_AS', 'text_score',
348+
'VSIM', '@vector_field', '$query_vector', 'KNN', '1', 'K', '5', 'YIELD_SCORE_AS', 'vector_score',
349+
'COMBINE', 'RRF', '2', 'CONSTANT', '60', 'YIELD_SCORE_AS', 'final_score',
350+
'LOAD', '2', 'description', 'price',
351+
'SORTBY', '1', 'final_score', 'DESC',
352+
'LIMIT', '0', '10',
353+
'PARAMS', '2', 'query_vector', 'BLOB_DATA',
354+
'DIALECT', '2'
355+
]
356+
);
357+
});
358+
359+
it('with custom DIALECT', () => {
360+
const parser = new BasicCommandParser();
361+
HYBRID.parseCommand(parser, 'index', {
362+
DIALECT: 3
363+
});
364+
assert.deepEqual(
365+
parser.redisArgs,
366+
['FT.HYBRID', 'index', '2', 'DIALECT', '3']
367+
);
368+
});
369+
});
370+
371+
// Integration tests would need to be added when RediSearch supports FT.HYBRID
372+
// For now, we'll skip them as this is a new command that may not be available yet
373+
describe.skip('client.ft.hybrid', () => {
374+
testUtils.testWithClient('basic hybrid search', async client => {
375+
// This would require a test index and data setup
376+
// similar to how other FT commands are tested
377+
}, GLOBAL.SERVERS.OPEN);
378+
});
379+
});

0 commit comments

Comments
 (0)