Skip to content

Commit a43aacf

Browse files
authored
[MOD-12263] Enhance FT.PROFILE with vector search execution details (#7408)
* add vector search mode to profule add VecSimSearchMode_ToString * ADD 'Largest batch size',and 'Largest batch iteration (zero based) * reset maxBatchSize and maxBatchIteration
1 parent 759c7e9 commit a43aacf

7 files changed

Lines changed: 131 additions & 8 deletions

File tree

src/iterators/hybrid_reader.c

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,8 @@ static VecSimQueryReply_Code prepareResults(HybridIterator *hr) {
243243
child_num_estimated = VecSimIndex_IndexSize(hr->index);
244244
}
245245
size_t child_upper_bound = child_num_estimated;
246+
// Track maximum batch size
247+
hr->maxBatchSize = hr->runtimeParams.batchSize;
246248
while (VecSimBatchIterator_HasNext(batch_it)) {
247249
hr->numIterations++;
248250
size_t vec_index_size = VecSimIndex_IndexSize(hr->index);
@@ -252,6 +254,11 @@ static VecSimQueryReply_Code prepareResults(HybridIterator *hr) {
252254
size_t batch_size = hr->runtimeParams.batchSize;
253255
if (batch_size == 0) {
254256
batch_size = n_res_left * ((float)vec_index_size / child_num_estimated) + 1;
257+
// If given by the user, it's constant, otherwise update the maximum batch size.
258+
if (batch_size > hr->maxBatchSize) {
259+
hr->maxBatchSize = batch_size;
260+
hr->maxBatchIteration = hr->numIterations - 1; // Zero-based
261+
}
255262
}
256263
VecSimQueryReply_Free(hr->reply);
257264
VecSimQueryReply_IteratorFree(hr->iter);
@@ -379,6 +386,8 @@ static void HR_Rewind(QueryIterator *ctx) {
379386
HybridIterator *hr = (HybridIterator *)ctx;
380387
hr->resultsPrepared = false;
381388
hr->numIterations = 0;
389+
hr->maxBatchSize = 0;
390+
hr->maxBatchIteration = 0;
382391
VecSimQueryReply_Free(hr->reply);
383392
VecSimQueryReply_IteratorFree(hr->iter);
384393
hr->reply = NULL;
@@ -473,6 +482,8 @@ QueryIterator *NewHybridVectorIterator(HybridIteratorParams hParams, QueryError
473482
hi->iter = NULL;
474483
hi->topResults = NULL;
475484
hi->numIterations = 0;
485+
hi->maxBatchSize = 0;
486+
hi->maxBatchIteration = 0;
476487
hi->canTrimDeepResults = hParams.canTrimDeepResults;
477488
hi->timeoutCtx = (TimeoutCtx){ .timeout = hParams.timeout, .counter = 0 };
478489
hi->runtimeParams.timeoutCtx = &hi->timeoutCtx;

src/iterators/hybrid_reader.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ typedef struct {
4949
char *scoreField; // To use by the sorter, for distinguishing between different vector fields.
5050
mm_heap_t *topResults; // Sorted by score (min-max heap).
5151
size_t numIterations;
52+
size_t maxBatchSize; // Maximum batch size used during batches mode
53+
size_t maxBatchIteration; // Iteration (zero-based) where the maximum batch size occurred
5254
bool canTrimDeepResults; // Ignore the document scores, only vector score matters. No need to deep copy the results from the child iterator.
5355
TimeoutCtx timeoutCtx; // Timeout parameters
5456
FieldFilterContext filterCtx;

src/profile.c

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,10 @@ PRINT_PROFILE_FUNC(printMetricIt) {
408408

409409
printProfileCounters(counters);
410410

411+
if (it->type == VECTOR_DISTANCE) {
412+
printProfileVectorSearchMode(VECSIM_RANGE_QUERY);
413+
}
414+
411415
RedisModule_Reply_MapEnd(reply);
412416
}
413417

@@ -423,9 +427,12 @@ void PrintIteratorChildProfile(RedisModule_Reply *reply, QueryIterator *root, Pr
423427

424428
if (root->type == HYBRID_ITERATOR) {
425429
HybridIterator *hi = (HybridIterator *)root;
430+
printProfileVectorSearchMode(hi->searchMode);
426431
if (hi->searchMode == VECSIM_HYBRID_BATCHES ||
427432
hi->searchMode == VECSIM_HYBRID_BATCHES_TO_ADHOC_BF) {
428433
printProfileNumBatches(hi);
434+
printProfileMaxBatchSize(hi);
435+
printProfileMaxBatchIteration(hi);
429436
}
430437
}
431438

src/profile.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,14 @@
2424
#define printProfileGILTime(vtime) RedisModule_ReplyKV_Double(reply, "GIL-Time", (rs_timer_ms(&(vtime))))
2525
#define printProfileNumBatches(hybrid_reader) \
2626
RedisModule_ReplyKV_LongLong(reply, "Batches number", (hybrid_reader)->numIterations)
27+
#define printProfileMaxBatchSize(hybrid_reader) \
28+
RedisModule_ReplyKV_LongLong(reply, "Largest batch size", (hybrid_reader)->maxBatchSize)
29+
#define printProfileMaxBatchIteration(hybrid_reader) \
30+
RedisModule_ReplyKV_LongLong(reply, "Largest batch iteration (zero based)", (hybrid_reader)->maxBatchIteration)
2731
#define printProfileOptimizationType(oi) \
2832
RedisModule_ReplyKV_SimpleString(reply, "Optimizer mode", QOptimizer_PrintType((oi)->optim))
33+
#define printProfileVectorSearchMode(searchMode) \
34+
RedisModule_ReplyKV_SimpleString(reply, "Vector search mode", VecSimSearchMode_ToString(searchMode))
2935

3036
/**
3137
* @brief Add profile iterators to all nodes in the iterator tree

src/vector_index.c

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,23 @@ const char *VecSimAlgorithm_ToString(VecSimAlgo algo) {
296296
}
297297
return NULL;
298298
}
299+
const char *VecSimSearchMode_ToString(VecSearchMode vecsimSearchMode) {
300+
switch (vecsimSearchMode) {
301+
case EMPTY_MODE:
302+
return "EMPTY_MODE";
303+
case STANDARD_KNN:
304+
return "STANDARD_KNN";
305+
case HYBRID_ADHOC_BF:
306+
return "HYBRID_ADHOC_BF";
307+
case HYBRID_BATCHES:
308+
return "HYBRID_BATCHES";
309+
case HYBRID_BATCHES_TO_ADHOC_BF:
310+
return "HYBRID_BATCHES_TO_ADHOC_BF";
311+
case RANGE_QUERY:
312+
return "RANGE_QUERY";
313+
}
314+
return NULL;
315+
}
299316

300317
bool VecSim_IsLeanVecCompressionType(VecSimSvsQuantBits quantBits) {
301318
return quantBits == VecSimSvsQuant_4x8_LeanVec || quantBits == VecSimSvsQuant_8x8_LeanVec;

src/vector_index.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ size_t VecSimType_sizeof(VecSimType type);
162162
const char *VecSimType_ToString(VecSimType type);
163163
const char *VecSimMetric_ToString(VecSimMetric metric);
164164
const char *VecSimAlgorithm_ToString(VecSimAlgo algo);
165+
const char *VecSimSearchMode_ToString(VecSearchMode vecsimSearchMode);
165166
const char *VecSimSvsCompression_ToString(VecSimSvsQuantBits quantBits);
166167
const char *VecSimSearchHistory_ToString(VecSimOptionMode option);
167168
bool VecSim_IsLeanVecCompressionType(VecSimSvsQuantBits quantBits);

tests/pytests/test_profile.py

Lines changed: 87 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ def testProfileVector(env):
290290

291291
actual_res = conn.execute_command('ft.profile', 'idx', 'search', 'query', '*=>[KNN 3 @v $vec]',
292292
'SORTBY', '__v_score', 'PARAMS', '2', 'vec', 'aaaaaaaa', 'nocontent')
293-
expected_iterators_res = ['Type', 'VECTOR', 'Number of reading operations', 3]
293+
expected_iterators_res = ['Type', 'VECTOR', 'Number of reading operations', 3, 'Vector search mode', 'STANDARD_KNN']
294294
expected_vecsim_rp_res = ['Type', 'Metrics Applier', 'Results processed', 3]
295295
env.assertEqual(actual_res[0], [3, '4', '2', '1'])
296296
actual_profile = to_dict(actual_res[1][1][0])
@@ -301,7 +301,7 @@ def testProfileVector(env):
301301
# Range query - uses metric iterator. Radius is set so that the closest 2 vectors will be in the range
302302
actual_res = conn.execute_command('ft.profile', 'idx', 'search', 'query', '@v:[VECTOR_RANGE 3e36 $vec]=>{$yield_distance_as:dist}',
303303
'SORTBY', 'dist', 'PARAMS', '2', 'vec', 'aaaaaaaa', 'nocontent')
304-
expected_iterators_res = ['Type', 'METRIC - VECTOR DISTANCE', 'Number of reading operations', 2]
304+
expected_iterators_res = ['Type', 'METRIC - VECTOR DISTANCE', 'Number of reading operations', 2, 'Vector search mode', 'RANGE_QUERY']
305305
expected_vecsim_rp_res = ['Type', 'Metrics Applier', 'Results processed', 2]
306306
env.assertEqual(actual_res[0], [2, '4', '2'])
307307
actual_profile = to_dict(actual_res[1][1][0])
@@ -313,7 +313,7 @@ def testProfileVector(env):
313313
# Expect ad-hoc BF to take place - going over child iterator exactly once (reading 2 results)
314314
actual_res = conn.execute_command('ft.profile', 'idx', 'search', 'query', '(@t:hello world)=>[KNN 3 @v $vec]',
315315
'SORTBY', '__v_score', 'PARAMS', '2', 'vec', 'aaaaaaaa', 'nocontent')
316-
expected_iterators_res = ['Type', 'VECTOR', 'Number of reading operations', 2, 'Child iterator',
316+
expected_iterators_res = ['Type', 'VECTOR', 'Number of reading operations', 2, 'Vector search mode', 'HYBRID_ADHOC_BF', 'Child iterator',
317317
['Type', 'INTERSECT', 'Number of reading operations', 2, 'Child iterators', [
318318
['Type', 'TEXT', 'Term', 'world', 'Number of reading operations', 2, 'Estimated number of matches', 2],
319319
['Type', 'TEXT', 'Term', 'hello', 'Number of reading operations', 2, 'Estimated number of matches', 5]]]]
@@ -332,7 +332,7 @@ def testProfileVector(env):
332332
actual_res = conn.execute_command('ft.profile', 'idx', 'search', 'query', '(@t:hello world)=>[KNN 3 @v $vec]',
333333
'SORTBY', '__v_score', 'PARAMS', '2', 'vec', 'aaaaaaaa', 'nocontent')
334334
env.assertEqual(actual_res[0], [3, '4', '6', '7'])
335-
expected_iterators_res = ['Type', 'VECTOR', 'Number of reading operations', 3, 'Batches number', 2, 'Child iterator',
335+
expected_iterators_res = ['Type', 'VECTOR', 'Number of reading operations', 3, 'Vector search mode', 'HYBRID_BATCHES', 'Batches number', 2, 'Largest batch size', 4, 'Largest batch iteration (zero based)', 0, 'Child iterator',
336336
['Type', 'INTERSECT', 'Number of reading operations', 8, 'Child iterators', [
337337
['Type', 'TEXT', 'Term', 'world', 'Number of reading operations', 8, 'Estimated number of matches', 9997],
338338
['Type', 'TEXT', 'Term', 'hello', 'Number of reading operations', 8, 'Estimated number of matches', 10000]]]]
@@ -348,7 +348,7 @@ def testProfileVector(env):
348348

349349
# expected results that pass the filter is index_size/2. after two iterations with no results,
350350
# we should move ad-hoc BF.
351-
expected_iterators_res = ['Type', 'VECTOR', 'Number of reading operations', 0, 'Batches number', 2, 'Child iterator',
351+
expected_iterators_res = ['Type', 'VECTOR', 'Number of reading operations', 0, 'Vector search mode', 'HYBRID_BATCHES_TO_ADHOC_BF', 'Batches number', 2, 'Largest batch size', 13, 'Largest batch iteration (zero based)', 1, 'Child iterator',
352352
['Type', 'INTERSECT', 'Number of reading operations', 2, 'Child iterators', [
353353
['Type', 'TEXT', 'Term', 'hello', 'Number of reading operations', 5, 'Estimated number of matches', 10000],
354354
['Type', 'TEXT', 'Term', 'other', 'Number of reading operations', 3, 'Estimated number of matches', 10000]]]]
@@ -363,7 +363,7 @@ def testProfileVector(env):
363363
# index after the 13th batch.
364364
actual_res = conn.execute_command('ft.profile', 'idx', 'search', 'query', '(@t:hello other)=>[KNN 2 @v $vec HYBRID_POLICY BATCHES]',
365365
'SORTBY', '__v_score', 'PARAMS', '2', 'vec', '????????', 'nocontent')
366-
expected_iterators_res = ['Type', 'VECTOR', 'Number of reading operations', 0, 'Batches number', 13, 'Child iterator',
366+
expected_iterators_res = ['Type', 'VECTOR', 'Number of reading operations', 0, 'Vector search mode', 'HYBRID_BATCHES', 'Batches number', 13, 'Largest batch size', 20001, 'Largest batch iteration (zero based)', 12, 'Child iterator',
367367
['Type', 'INTERSECT', 'Number of reading operations', 12, 'Child iterators', [
368368
['Type', 'TEXT', 'Term', 'hello', 'Number of reading operations', 25, 'Estimated number of matches', 10000],
369369
['Type', 'TEXT', 'Term', 'other', 'Number of reading operations', 13, 'Estimated number of matches', 10000]]]]
@@ -375,7 +375,7 @@ def testProfileVector(env):
375375
# After 200 iterations, we should go over the entire index.
376376
actual_res = conn.execute_command('ft.profile', 'idx', 'search', 'query', '(@t:hello other)=>[KNN 2 @v $vec HYBRID_POLICY BATCHES BATCH_SIZE 100]',
377377
'SORTBY', '__v_score', 'PARAMS', '2', 'vec', '????????', 'nocontent', 'timeout', '100000')
378-
expected_iterators_res = ['Type', 'VECTOR', 'Number of reading operations', 0, 'Batches number', 200, 'Child iterator',
378+
expected_iterators_res = ['Type', 'VECTOR', 'Number of reading operations', 0, 'Vector search mode', 'HYBRID_BATCHES', 'Batches number', 200, 'Largest batch size', 100, 'Largest batch iteration (zero based)', 0, 'Child iterator',
379379
['Type', 'INTERSECT', 'Number of reading operations', 199, 'Child iterators', [
380380
['Type', 'TEXT', 'Term', 'hello', 'Number of reading operations', 399, 'Estimated number of matches', 10000],
381381
['Type', 'TEXT', 'Term', 'other', 'Number of reading operations', 200, 'Estimated number of matches', 10000]]]]
@@ -389,7 +389,7 @@ def testProfileVector(env):
389389
# every iteration that returned 0 results.
390390
actual_res = conn.execute_command('ft.profile', 'idx', 'search', 'query', '(@t:hello other)=>[KNN 2 @v $vec BATCH_SIZE 100]',
391391
'SORTBY', '__v_score', 'PARAMS', '2', 'vec', '????????', 'nocontent')
392-
expected_iterators_res = ['Type', 'VECTOR', 'Number of reading operations', 0, 'Batches number', 2, 'Child iterator',
392+
expected_iterators_res = ['Type', 'VECTOR', 'Number of reading operations', 0, 'Vector search mode', 'HYBRID_BATCHES_TO_ADHOC_BF', 'Batches number', 2, 'Largest batch size', 100, 'Largest batch iteration (zero based)', 0, 'Child iterator',
393393
['Type', 'INTERSECT', 'Number of reading operations', 2, 'Child iterators', [
394394
['Type', 'TEXT', 'Term', 'hello', 'Number of reading operations', 5, 'Estimated number of matches', 10000],
395395
['Type', 'TEXT', 'Term', 'other', 'Number of reading operations', 3, 'Estimated number of matches', 10000]]]]
@@ -693,3 +693,82 @@ def testProfileBM25NormMax(env):
693693
env.assertTrue(recursive_contains(aggregate_response, "Score Max Normalizer"))
694694
search_response = env.cmd('FT.PROFILE', 'idx', 'SEARCH', 'query', 'hello', 'WITHSCORES', 'SCORER', 'BM25STD.NORM')
695695
env.assertTrue(recursive_contains(search_response, "Score Max Normalizer"))
696+
697+
def testProfileVectorSearchMode():
698+
"""Test Vector search mode field in FT.PROFILE for both SEARCH and AGGREGATE"""
699+
env = Env(moduleArgs='DEFAULT_DIALECT 2', protocol=3) # Use RESP3 for easier dict access
700+
conn = getConnectionByEnv(env)
701+
702+
env.expect('FT.CREATE', 'idx', 'SCHEMA', 'v', 'VECTOR', 'FLAT', '6', 'TYPE', 'FLOAT32', 'DIM', '2', 'DISTANCE_METRIC', 'L2', 't', 'TEXT').ok()
703+
704+
conn.execute_command('hset', '1', 'v', 'bababaca', 't', "hello")
705+
conn.execute_command('hset', '2', 'v', 'babababa', 't', "hello")
706+
conn.execute_command('hset', '3', 'v', 'aabbaabb', 't', "hello")
707+
conn.execute_command('hset', '4', 'v', 'bbaabbaa', 't', "hello world")
708+
conn.execute_command('hset', '5', 'v', 'aaaabbbb', 't', "hello world")
709+
710+
# Helper function to test both SEARCH and AGGREGATE
711+
def verify_search_mode(query_type, query, params, expected_mode, expected_iterator_type='VECTOR'):
712+
scenario_message = f"query_type: {query_type}, query: {query}, params: {params}, expected_mode: {expected_mode}"
713+
"""
714+
Verify that Vector search mode appears in profile for both SEARCH and AGGREGATE
715+
query_type: 'SEARCH' or 'AGGREGATE'
716+
query: the query string
717+
params: list of params (e.g., ['vec', 'aaaaaaaa'])
718+
expected_mode: expected search mode string
719+
expected_iterator_type: 'VECTOR' or 'METRIC - VECTOR DISTANCE'
720+
"""
721+
cmd = ['FT.PROFILE', 'idx', query_type, 'QUERY', query]
722+
cmd.extend(['PARAMS'] + [str(len(params))] + params)
723+
724+
res = env.cmd(*cmd)
725+
726+
# Navigate to iterator profile (RESP3 dict structure)
727+
shards = res['Profile']['Shards']
728+
env.assertGreater(len(shards), 0, message=scenario_message)
729+
730+
# Check at least one shard has the expected search mode
731+
# res['Profile']['Shards'][0]['Iterators profile']['Vector search mode']
732+
found = False
733+
for shard in shards:
734+
iter_profile = shard['Iterators profile']
735+
if iter_profile['Type'] == expected_iterator_type:
736+
env.assertEqual(iter_profile['Vector search mode'], expected_mode, message=scenario_message)
737+
found = True
738+
break
739+
env.assertTrue(found, message=f"{scenario_message}: Expected iterator type {expected_iterator_type} not found")
740+
741+
# Test 1: STANDARD_KNN
742+
verify_search_mode('SEARCH', '*=>[KNN 3 @v $vec]', ['vec', 'aaaaaaaa'], 'STANDARD_KNN')
743+
verify_search_mode('AGGREGATE', '*=>[KNN 3 @v $vec]', ['vec', 'aaaaaaaa'], 'STANDARD_KNN')
744+
745+
# Test 2: HYBRID_ADHOC_BF
746+
verify_search_mode('SEARCH', '(@t:hello world)=>[KNN 3 @v $vec]', ['vec', 'aaaaaaaa'], 'HYBRID_ADHOC_BF')
747+
verify_search_mode('AGGREGATE', '(@t:hello world)=>[KNN 3 @v $vec]', ['vec', 'aaaaaaaa'], 'HYBRID_ADHOC_BF')
748+
749+
# Test 3: RANGE_QUERY (uses METRIC_ITERATOR)
750+
verify_search_mode('SEARCH', '@v:[VECTOR_RANGE 3e36 $vec]=>{$yield_distance_as:dist}',
751+
['vec', 'aaaaaaaa'], 'RANGE_QUERY', 'METRIC - VECTOR DISTANCE')
752+
verify_search_mode('AGGREGATE', '@v:[VECTOR_RANGE 3e36 $vec]=>{$yield_distance_as:dist}',
753+
['vec', 'aaaaaaaa'], 'RANGE_QUERY', 'METRIC - VECTOR DISTANCE')
754+
755+
# Test 4: HYBRID_BATCHES
756+
verify_search_mode('SEARCH', '(@t:hello world)=>[KNN 3 @v $vec HYBRID_POLICY BATCHES BATCH_SIZE 100]', ['vec', 'aaaaaaaa'], 'HYBRID_BATCHES')
757+
verify_search_mode('AGGREGATE', '(@t:hello world)=>[KNN 3 @v $vec HYBRID_POLICY BATCHES BATCH_SIZE 100]', ['vec', 'aaaaaaaa'], 'HYBRID_BATCHES')
758+
759+
# Running HYBRID_BATCHES_TO_ADHOC_BF on cluster requires much more data and doesn't add a significant value
760+
if env.isCluster():
761+
return
762+
763+
for i in range(6, 5000):
764+
conn.execute_command('hset', str(i), 'v', 'bababada', 't', "hello")
765+
766+
# Add another 10K docs with "other" tag for HYBRID_BATCHES_TO_ADHOC_BF test
767+
for i in range(5000, 10001):
768+
conn.execute_command('hset', str(i), 'v', '????????', 't', "other")
769+
770+
# Test 5: HYBRID_BATCHES_TO_ADHOC_BF
771+
# Query: "hello" (10K docs) AND "other" (10K docs) → intersection is 0 (disjoint sets)
772+
# High estimated results → starts BATCHES, but 0 actual results → switches to ADHOC_BF
773+
verify_search_mode('SEARCH', '(@t:hello other)=>[KNN 3 @v $vec BATCH_SIZE 100]', ['vec', '????????'], 'HYBRID_BATCHES_TO_ADHOC_BF')
774+
verify_search_mode('AGGREGATE', '(@t:hello other)=>[KNN 3 @v $vec BATCH_SIZE 100]', ['vec', '????????'], 'HYBRID_BATCHES_TO_ADHOC_BF')

0 commit comments

Comments
 (0)