From b1a8e4a4267e31033bcca52cf35e0821804f8ee4 Mon Sep 17 00:00:00 2001 From: Patrick Ribbsaeter Date: Thu, 6 Aug 2026 21:29:46 +0200 Subject: [PATCH 1/3] fix(python/redis): update connector for redisvl>=0.5 API break and guard KeyError on include_vectors=False Two independent bugs made vector search completely unusable: 1. **process_results() API break (redisvl >= 0.5)** `redisvl` 0.5.0 changed the third argument of `process_results()` from `StorageType` (an enum value) to `IndexSchema` (an object). The SK `pyproject.toml` pin `redisvl ~= 0.4` resolves via PEP 440 to `>=0.4, <1.0`, so `uv.lock` picks up redisvl 0.15.x where the old API no longer exists. Every call to `collection.search()` raised: AttributeError: 'StorageType' object has no attribute 'index' Fix: detect the redisvl API version at runtime using `inspect.signature` and call `process_results()` with either the legacy `StorageType` enum or the new `IndexSchema` object accordingly, with a fallback swap if the initial detection is wrong. 2. **KeyError in RedisHashsetCollection._deserialize_store_models_to_dicts** The deserializer unconditionally called `buffer_to_array(rec[field.name], dtype)` for every vector field. When `include_vectors=False` (the default for search), the vector field is absent from the result dict, producing: KeyError: 'vector' Fix: check `if storage_name in rec` before decoding. When the field is absent (not returned by Redis), set the model attribute to `None` rather than raising. Together these two defects blocked all FT.SEARCH usage regardless of collection type or include_vectors setting. Fixes #13896 --- python/semantic_kernel/connectors/redis.py | 69 +++++++++++++++++++++- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/python/semantic_kernel/connectors/redis.py b/python/semantic_kernel/connectors/redis.py index 575624895aca..32850d7a908d 100644 --- a/python/semantic_kernel/connectors/redis.py +++ b/python/semantic_kernel/connectors/redis.py @@ -321,7 +321,63 @@ async def _inner_search( results = await self.redis_database.ft(self.collection_name).search( # type: ignore query=query.query, query_params=query.params ) - processed = process_results(results, query, STORAGE_TYPE_MAP[self.collection_type]) + # redisvl >= 0.5.0 changed the third argument of process_results() from + # StorageType (an enum) to IndexSchema (an object). Detect which API is + # present at runtime so the connector works with both the old (<0.5) and + # current (>=0.5) redisvl versions. + # + # New API: process_results(results, query, schema: IndexSchema) + # Old API: process_results(results, query, storage_type: StorageType) + import inspect + + sig = inspect.signature(process_results) + params = list(sig.parameters.values()) + if len(params) >= 3 and params[2].annotation is not inspect.Parameter.empty: + annotation_name = getattr(params[2].annotation, "__name__", str(params[2].annotation)) + uses_schema_api = "IndexSchema" in annotation_name or "Schema" in annotation_name + else: + # Fall back to duck-typing: try the new API first, then the old one + uses_schema_api = not hasattr(params[2].default if len(params) >= 3 else None, "value") + + try: + if uses_schema_api: + # redisvl >= 0.5: fetch the live IndexSchema and pass it + from redisvl.schema import IndexSchema + + index_info = await self.redis_database.ft(self.collection_name).info() + schema = IndexSchema.from_dict( + { + "index": { + "name": self.collection_name, + "storage_type": STORAGE_TYPE_MAP[self.collection_type].value, + }, + "fields": {}, + } + ) + processed = process_results(results, query, schema) + else: + # redisvl < 0.5: pass the StorageType enum directly + processed = process_results(results, query, STORAGE_TYPE_MAP[self.collection_type]) + except Exception as e: + # If the version sniffing produced the wrong branch, try the other + try: + if uses_schema_api: + processed = process_results(results, query, STORAGE_TYPE_MAP[self.collection_type]) + else: + from redisvl.schema import IndexSchema + + schema = IndexSchema.from_dict( + { + "index": { + "name": self.collection_name, + "storage_type": STORAGE_TYPE_MAP[self.collection_type].value, + }, + "fields": {}, + } + ) + processed = process_results(results, query, schema) + except Exception: + raise VectorSearchExecutionException(f"An error occurred during the search: {e}") from e return KernelSearchResults( results=self._get_vector_search_results_from_results(desync_list(processed)), total_count=results.total, @@ -616,8 +672,15 @@ def _deserialize_store_models_to_dicts( case FieldTypes.KEY: rec[field.name] = self._unget_redis_key(rec[field.name]) case "vector": - dtype = DATATYPE_MAP_VECTOR[field.type_ or "default"] - rec[field.name] = buffer_to_array(rec[field.name], dtype) + # When include_vectors=False (the default for search), the vector + # field is not returned by Redis and will be absent from `rec`. + # Guard against KeyError before attempting to decode the buffer. + storage_name = field.storage_name or field.name + if storage_name in rec: + dtype = DATATYPE_MAP_VECTOR[field.type_ or "default"] + rec[field.name] = buffer_to_array(rec[storage_name], dtype) + else: + rec[field.name] = None results.append(rec) return results From e0e1f784d1da6d5210670d4e6b97f1654823a2cd Mon Sep 17 00:00:00 2001 From: Patrick Ribbsaeter Date: Sun, 9 Aug 2026 05:24:04 +0200 Subject: [PATCH 2/3] test(redis): cover redisvl compatibility and omitted vectors --- .../connectors/memory/test_redis_store.py | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/python/tests/unit/connectors/memory/test_redis_store.py b/python/tests/unit/connectors/memory/test_redis_store.py index e779ad945a97..8d7c556c6f99 100644 --- a/python/tests/unit/connectors/memory/test_redis_store.py +++ b/python/tests/unit/connectors/memory/test_redis_store.py @@ -2,6 +2,8 @@ from unittest.mock import AsyncMock, patch +import semantic_kernel.connectors.redis as redis_module + import numpy as np from pytest import fixture, mark, raises from redis.asyncio.client import Redis @@ -306,3 +308,72 @@ async def test_create_index_manual(collection_hash, mock_ensure_collection_exist async def test_create_index_fail(collection_hash, mock_ensure_collection_exists): with raises(VectorStoreOperationException, match="Invalid index type supplied."): await collection_hash.ensure_collection_exists(index_definition="index_definition", fields="fields") + + +async def test_process_search_results_with_legacy_redisvl_api(): + results = object() + query = object() + + def legacy_process_results(results_arg, query_arg, storage_type): + assert results_arg is results + assert query_arg is query + assert storage_type is redis_module.StorageType.HASH + return [{"id": "legacy"}] + + with patch.object( + redis_module, "process_results", new=legacy_process_results + ): + processed = await redis_module._process_search_results( + results, + query, + "test", + object(), + redis_module.RedisCollectionTypes.HASHSET, + ) + + assert processed == [{"id": "legacy"}] + + +async def test_process_search_results_with_current_redisvl_api(): + results = object() + query = object() + schema = object() + index = type("Index", (), {"schema": schema})() + redis_database = object() + + def current_process_results(results_arg, query_arg, schema_arg): + assert results_arg is results + assert query_arg is query + assert schema_arg is schema + return [{"id": "current"}] + + with ( + patch.object( + redis_module, "process_results", new=current_process_results + ), + patch.object( + redis_module.AsyncSearchIndex, + "from_existing", + new=AsyncMock(return_value=index), + ) as from_existing, + ): + processed = await redis_module._process_search_results( + results, + query, + "test", + redis_database, + redis_module.RedisCollectionTypes.HASHSET, + ) + + from_existing.assert_awaited_once_with( + name="test", redis_client=redis_database + ) + assert processed == [{"id": "current"}] + + +def test_hash_deserialization_handles_omitted_vector(collection_hash): + records = collection_hash._deserialize_store_models_to_dicts( + [{"id": "id1", "content": "content"}] + ) + + assert records[0]["vector"] is None From 3ec1d012e08e9b7f039075ffe9a4d558fa4bf3ef Mon Sep 17 00:00:00 2001 From: Patrick Ribbsaeter Date: Sun, 9 Aug 2026 05:24:05 +0200 Subject: [PATCH 3/3] fix(redis): support both redisvl result APIs safely --- python/semantic_kernel/connectors/redis.py | 101 +++++++++------------ 1 file changed, 45 insertions(+), 56 deletions(-) diff --git a/python/semantic_kernel/connectors/redis.py b/python/semantic_kernel/connectors/redis.py index 32850d7a908d..5f32e98d16d7 100644 --- a/python/semantic_kernel/connectors/redis.py +++ b/python/semantic_kernel/connectors/redis.py @@ -3,6 +3,7 @@ import ast import asyncio import contextlib +import inspect import json import logging import sys @@ -17,6 +18,7 @@ from redis.commands.search.field import Field as RedisField from redis.commands.search.field import NumericField, TagField, TextField, VectorField from redis.commands.search.index_definition import IndexDefinition, IndexType +from redisvl.index import AsyncSearchIndex from redisvl.index.index import process_results from redisvl.query.filter import FilterExpression, Num, Tag, Text from redisvl.query.query import BaseQuery, VectorQuery @@ -166,6 +168,36 @@ def _definition_to_redis_fields( return fields +async def _process_search_results( + results: Any, + query: BaseQuery, + collection_name: str, + redis_database: Redis, + collection_type: RedisCollectionTypes, +) -> Any: + """Process RedisVL results across its old and current APIs.""" + parameters = list(inspect.signature(process_results).parameters.values()) + if len(parameters) < 3: + raise VectorSearchExecutionException( + "Unsupported redisvl process_results() signature." + ) + + third_parameter = parameters[2].name + if third_parameter == "storage_type": + return process_results(results, query, STORAGE_TYPE_MAP[collection_type]) + + if third_parameter == "schema": + index = await AsyncSearchIndex.from_existing( + name=collection_name, + redis_client=redis_database, + ) + return process_results(results, query, index.schema) + + raise VectorSearchExecutionException( + f"Unsupported redisvl process_results() parameter: {third_parameter}." + ) + + @release_candidate class RedisSettings(KernelBaseSettings): """Redis model settings. @@ -321,63 +353,20 @@ async def _inner_search( results = await self.redis_database.ft(self.collection_name).search( # type: ignore query=query.query, query_params=query.params ) - # redisvl >= 0.5.0 changed the third argument of process_results() from - # StorageType (an enum) to IndexSchema (an object). Detect which API is - # present at runtime so the connector works with both the old (<0.5) and - # current (>=0.5) redisvl versions. - # - # New API: process_results(results, query, schema: IndexSchema) - # Old API: process_results(results, query, storage_type: StorageType) - import inspect - - sig = inspect.signature(process_results) - params = list(sig.parameters.values()) - if len(params) >= 3 and params[2].annotation is not inspect.Parameter.empty: - annotation_name = getattr(params[2].annotation, "__name__", str(params[2].annotation)) - uses_schema_api = "IndexSchema" in annotation_name or "Schema" in annotation_name - else: - # Fall back to duck-typing: try the new API first, then the old one - uses_schema_api = not hasattr(params[2].default if len(params) >= 3 else None, "value") - try: - if uses_schema_api: - # redisvl >= 0.5: fetch the live IndexSchema and pass it - from redisvl.schema import IndexSchema - - index_info = await self.redis_database.ft(self.collection_name).info() - schema = IndexSchema.from_dict( - { - "index": { - "name": self.collection_name, - "storage_type": STORAGE_TYPE_MAP[self.collection_type].value, - }, - "fields": {}, - } - ) - processed = process_results(results, query, schema) - else: - # redisvl < 0.5: pass the StorageType enum directly - processed = process_results(results, query, STORAGE_TYPE_MAP[self.collection_type]) - except Exception as e: - # If the version sniffing produced the wrong branch, try the other - try: - if uses_schema_api: - processed = process_results(results, query, STORAGE_TYPE_MAP[self.collection_type]) - else: - from redisvl.schema import IndexSchema - - schema = IndexSchema.from_dict( - { - "index": { - "name": self.collection_name, - "storage_type": STORAGE_TYPE_MAP[self.collection_type].value, - }, - "fields": {}, - } - ) - processed = process_results(results, query, schema) - except Exception: - raise VectorSearchExecutionException(f"An error occurred during the search: {e}") from e + processed = await _process_search_results( + results, + query, + self.collection_name, + self.redis_database, + self.collection_type, + ) + except VectorSearchExecutionException: + raise + except Exception as exc: + raise VectorSearchExecutionException( + f"An error occurred during the search: {exc}" + ) from exc return KernelSearchResults( results=self._get_vector_search_results_from_results(desync_list(processed)), total_count=results.total,