diff --git a/redisvl/redis/connection.py b/redisvl/redis/connection.py index f8d53d2f..fba7f194 100644 --- a/redisvl/redis/connection.py +++ b/redisvl/redis/connection.py @@ -318,6 +318,34 @@ def normalize_index_definition(index_info: dict[str, Any]) -> dict[str, Any]: return _normalize_index_info_mapping(index_info.get("index_definition")) +# Vector parameter names that parse_vector_attrs reads from FT.INFO. In a +# RESP2 unified ``flags`` list, only these are paired with the next token, so +# an unknown bare token costs one position instead of shifting every pair. +_VECTOR_PARAM_KEYS = frozenset( + { + "algorithm", + "data_type", + "datatype", + "type", + "dim", + "dims", + "distance_metric", + "initial_cap", + "block_size", + "m", + "ef_construction", + "ef_runtime", + "epsilon", + "compression", + "reduce", + "graph_max_degree", + "construction_window_size", + "search_window_size", + "training_threshold", + } +) + + def normalize_index_fields(index_info: dict[str, Any]) -> list[dict[str, Any]]: """Return FT.INFO field entries in mapping form.""" return [ @@ -373,6 +401,18 @@ def parse_vector_attrs(attrs): for key, value in attrs.items() if key not in {"identifier", "attribute", "type", "flags"} } + # redis-py 8 on RESP2 with legacy_responses=False puts the vector + # params in "flags" as key/value pairs, possibly followed by + # valueless modifiers: ["algorithm", "FLAT", "dim", 4, "INDEXMISSING"] + flags = attrs.get("flags") or [] + i = 0 + while i < len(flags): + key = str(flags[i]).lower() + if key not in _VECTOR_PARAM_KEYS or i + 1 >= len(flags): + i += 1 + continue + vector_attrs.setdefault(key, flags[i + 1]) + i += 2 else: # Check if we have any attributes beyond the type declaration if len(attrs) <= 6: @@ -408,10 +448,18 @@ def parse_vector_attrs(attrs): normalized = {} # Handle dims/dim field - REQUIRED for vector fields - if "dim" in vector_attrs: - normalized["dims"] = int(vector_attrs.pop("dim")) - elif "dims" in vector_attrs: - normalized["dims"] = int(vector_attrs["dims"]) + if "dim" in vector_attrs or "dims" in vector_attrs: + raw_dims = ( + vector_attrs.pop("dim") + if "dim" in vector_attrs + else vector_attrs["dims"] + ) + try: + normalized["dims"] = int(raw_dims) + except (ValueError, TypeError): + # Unparseable dims: fall through to the required-dims check + # below, which skips the field with a warning. + pass else: # If dims is missing from normal parsing, try scanning the raw attrs # This handles edge cases where the format is unexpected @@ -604,6 +652,14 @@ def parse_attrs(attrs, field_type=None): if attrs is None: # Vector field attributes cannot be parsed on this Redis version # Skip this field - it cannot be properly reconstructed + logger.warning( + "Skipping vector field %r of index %r: could not parse its " + "attributes from FT.INFO. The recovered schema is incomplete, " + "and recreating the index from it (e.g. create(overwrite=True)) " + "drops this field.", + name, + index_name, + ) continue field["attrs"] = attrs else: diff --git a/tests/integration/test_async_search_index.py b/tests/integration/test_async_search_index.py index ed2109f2..3504832a 100644 --- a/tests/integration/test_async_search_index.py +++ b/tests/integration/test_async_search_index.py @@ -547,6 +547,14 @@ async def test_batch_search(async_index): reason="legacy_responses requires redis-py 8", ), ), + pytest.param( + {"protocol": 2, "legacy_responses": False}, + id="resp2-new-response-format", + marks=pytest.mark.skipif( + int(redis.__version__.split(".")[0]) < 8, + reason="legacy_responses requires redis-py 8", + ), + ), ], ) async def test_client_from_existing_and_batch_search( diff --git a/tests/integration/test_search_index.py b/tests/integration/test_search_index.py index 783d328d..38f03918 100644 --- a/tests/integration/test_search_index.py +++ b/tests/integration/test_search_index.py @@ -610,6 +610,14 @@ def test_batch_search(index): reason="legacy_responses requires redis-py 8", ), ), + pytest.param( + {"protocol": 2, "legacy_responses": False}, + id="resp2-new-response-format", + marks=pytest.mark.skipif( + int(redis.__version__.split(".")[0]) < 8, + reason="legacy_responses requires redis-py 8", + ), + ), ], ) def test_client_from_existing_and_batch_search( diff --git a/tests/unit/test_convert_index_info.py b/tests/unit/test_convert_index_info.py index 53f0eeba..b08b7b44 100644 --- a/tests/unit/test_convert_index_info.py +++ b/tests/unit/test_convert_index_info.py @@ -241,3 +241,123 @@ def test_convert_index_info_default_stopwords(): assert result["index"]["name"] == "test_default_stopwords" assert "stopwords" not in result["index"] # Should not be present + + +def test_convert_index_info_resp2_unified_vector_flags(): + """Vector params packed into ``flags`` (redis-py 8, RESP2, legacy_responses=False).""" + index_info = { + "index_name": "test_index", + "index_definition": {"key_type": "HASH", "prefixes": ["doc"]}, + "attributes": [ + { + "identifier": "emb", + "attribute": "emb", + "type": "VECTOR", + "flags": [ + "algorithm", + "HNSW", + "data_type", + "FLOAT32", + "dim", + 8, + "distance_metric", + "L2", + "M", + 24, + "ef_construction", + 300, + "INDEXMISSING", + ], + } + ], + } + + result = convert_index_info_to_schema(index_info) + + # INDEXMISSING is deliberately not recovered as ``index_missing``: a vector + # field can't be recreated with it yet, so restoring it here would turn a + # quiet loss into a failure on ``create()``. + assert result["fields"] == [ + { + "name": "emb", + "type": "vector", + "attrs": { + "dims": 8, + "distance_metric": "l2", + "algorithm": "hnsw", + "datatype": "float32", + "m": 24, + "ef_construction": 300, + }, + } + ] + + +def test_convert_index_info_warns_on_unparseable_vector_field(caplog): + """A vector field without params is skipped with a warning, not silently.""" + index_info = { + "index_name": "test_index", + "index_definition": {"key_type": "HASH", "prefixes": ["doc"]}, + "attributes": [ + {"identifier": "emb", "attribute": "emb", "type": "VECTOR", "flags": []} + ], + } + + with caplog.at_level("WARNING"): + result = convert_index_info_to_schema(index_info) + + assert result["fields"] == [] + assert "'emb'" in caplog.text and "'test_index'" in caplog.text + + +def test_convert_index_info_resp2_unified_flags_skip_unknown_tokens(): + """An unknown bare token in ``flags`` does not shift the key/value pairs.""" + index_info = { + "index_name": "test_index", + "index_definition": {"key_type": "HASH", "prefixes": ["doc"]}, + "attributes": [ + { + "identifier": "emb", + "attribute": "emb", + "type": "VECTOR", + "flags": [ + "algorithm", + "FLAT", + "data_type", + "FLOAT32", + "dim", + 4, + "NEWMOD", + "distance_metric", + "IP", + ], + } + ], + } + + result = convert_index_info_to_schema(index_info) + + assert result["fields"][0]["attrs"]["distance_metric"] == "ip" + assert result["fields"][0]["attrs"]["dims"] == 4 + + +def test_convert_index_info_skips_vector_field_with_unparseable_dims(caplog): + """A non-numeric ``dim`` skips the field with a warning instead of raising.""" + index_info = { + "index_name": "test_index", + "index_definition": {"key_type": "HASH", "prefixes": ["doc"]}, + "attributes": [ + { + "identifier": "emb", + "attribute": "emb", + "type": "VECTOR", + "flags": ["algorithm", "FLAT", "dim", "FUTUREMOD"], + } + ], + } + + with caplog.at_level("WARNING"): + result = convert_index_info_to_schema(index_info) + + assert result["fields"] == [] + assert "'emb'" in caplog.text