Repository navigation
Description
Summary
On a client created with protocol=3, every RedisVL feature built on FT.AGGREGATE or FT.HYBRID raises AttributeError. redis-py returns the RESP3 reply as a plain dict (or, for a cursor, a list) instead of an AggregateResult or HybridResult, and RedisVL reads .rows, .cursor or .results from it as if it were the parsed object. This affects AggregateHybridQuery, MultiVectorQuery, HybridQuery, SemanticRouter route matching, and the cursor that update_by_filter and drop_by_filter use to collect keys. The sync and async paths fail the same way.
This is the aggregation counterpart of #825, which covers FT.SEARCH. As there, clients that RedisVL creates itself are unaffected, because RedisConnectionFactory sets protocol=2 unless the caller asks otherwise. The failure needs a client passed in with protocol=3, or a URL with ?protocol=3.
Reproduction
Measured on main at 87d5c177 against Redis 8.4.8, with redis-py 6.4.0, 7.4.1 and 8.1.0, on a two-document hash index with a tag, a text and a three-dimensional FLAT vector field:
import redis
from redis.commands.search.aggregation import AggregateRequest
from redisvl.index import SearchIndex
from redisvl.query import AggregateHybridQuery
index = SearchIndex.from_dict(schema, redis_client=redis.Redis(protocol=3))
index.create(overwrite=True, drop=True)
index.load(docs)
index.aggregate(AggregateRequest("*").load("@t"))
# returns a dict, not an AggregateResult
index.query(AggregateHybridQuery("hello", "txt", [0.1, 0.2, 0.3], "v"))
# AttributeError: 'dict' object has no attribute 'rows'| Call | protocol 2 | protocol 3 |
|---|---|---|
index.aggregate(AggregateRequest(...)) |
AggregateResult |
raw dict |
query(AggregateHybridQuery(...)) |
results | 'dict' object has no attribute 'rows' |
query(MultiVectorQuery(...)) |
results | 'dict' object has no attribute 'rows' |
query(HybridQuery(...)), redis-py 7.4.1 and 8.1.0 |
results | 'dict' object has no attribute 'results' |
SemanticRouter.__call__ and aroute |
RouteMatch |
'dict' object has no attribute 'rows' |
update_by_filter, drop_by_filter |
BulkResult |
'dict' object has no attribute 'total' |
_iter_keys_by_filter (the bulk key cursor) |
keys | 'list' object has no attribute 'cursor' |
Every row held on all three redis-py versions, for SearchIndex with redis.Redis and AsyncSearchIndex with redis.asyncio.Redis alike. HybridQuery needs redis-py 7.1 or later, so it was not measured on 6.4.0.
update_by_filter and drop_by_filter fail first on the CountQuery they run to count matches, which is #825. Once that is fixed they will fail on the aggregate cursor instead, as the last row shows when the cursor is called directly.
The migration executor's key enumeration is not affected: it reads the raw FT.AGGREGATE reply and already handles the RESP3 map (#714).
Cause
SearchIndex.aggregate (redisvl/index/index.py:2016) and AsyncSearchIndex.aggregate (index.py:3319) return whatever ft().aggregate() gives back, and are typed as returning AggregateResult. On RESP3 redis-py skips its RESP2 parser and returns the reply map unchanged. The callers then assume the parsed shape:
process_aggregate_resultsreadsresults.rows(index.py:590), which everyAggregationQuerygoes through.SemanticRouter._route_matches_from_resultreadsaggregation_result.rows(redisvl/extensions/router/semantic.py:521), for both the sync and async routes._iter_keys_by_filterreadsresult.cursorandresult.rows(index.py:1850andindex.py:3165). With a cursor, the RESP3 reply comes back as a[map, cursor_id]list._hybrid_searchreadsresults.resultsfromhybrid_search()(index.py:2187andindex.py:3497).
Expected
aggregate()returns anAggregateResulton aprotocol=3client, as it does onprotocol=2, so that every caller sees one shape.AggregateHybridQuery,MultiVectorQuery,HybridQuery,SemanticRouterroute matching and the bulk filter operations return the same results onprotocol=3as onprotocol=2, sync and async.- A regression test that fails before the fix, covering at least one
AggregationQuery, a cursor read andHybridQueryon aprotocol=3client.
Activity
Bhavy-Ranka commentedon Nov 9, 2026
I'd like to work on this
fix: normalize RESP3 aggregate/hybrid replies (redis#834)