From c5213f90b2c0f521ed92c095322ac56c482ba226 Mon Sep 17 00:00:00 2001 From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:48:48 +0000 Subject: [PATCH 1/2] fix(schema): emit INDEXMISSING after the vector attribute block A vector field with index_missing=True put INDEXMISSING into the algorithm attribute dict, so create() failed with "Invalid input of type: 'bool'", and the server rejects the token inside the attribute block anyway ("Bad arguments for algorithm"). Pass index_missing to the redis-py VectorField instead, which appends INDEXMISSING after the block as FT.CREATE expects. --- redisvl/schema/fields.py | 26 ++++++++--- ...est_field_modifier_ordering_integration.py | 40 +++++++++++++++++ tests/unit/test_fields.py | 45 +++++++++---------- 3 files changed, 83 insertions(+), 28 deletions(-) diff --git a/redisvl/schema/fields.py b/redisvl/schema/fields.py index 0a414ac97..1bf0b3837 100644 --- a/redisvl/schema/fields.py +++ b/redisvl/schema/fields.py @@ -229,8 +229,6 @@ def field_data(self) -> dict[str, Any]: } if self.initial_cap is not None: # Only include it if it's set field_data["INITIAL_CAP"] = self.initial_cap - if self.index_missing: # Only include it if it's set - field_data["INDEXMISSING"] = True return field_data @@ -557,7 +555,13 @@ def as_redis_field(self) -> RedisField: field_data = self.attrs.field_data if self.attrs.block_size is not None: field_data["BLOCK_SIZE"] = self.attrs.block_size - return RedisVectorField(name, self.attrs.algorithm, field_data, as_name=as_name) + return RedisVectorField( + name, + self.attrs.algorithm, + field_data, + as_name=as_name, + index_missing=self.attrs.index_missing, + ) class HNSWVectorField(BaseField): @@ -578,7 +582,13 @@ def as_redis_field(self) -> RedisField: "EPSILON": self.attrs.epsilon, } ) - return RedisVectorField(name, self.attrs.algorithm, field_data, as_name=as_name) + return RedisVectorField( + name, + self.attrs.algorithm, + field_data, + as_name=as_name, + index_missing=self.attrs.index_missing, + ) class SVSVectorField(BaseField): @@ -605,7 +615,13 @@ def as_redis_field(self) -> RedisField: field_data["REDUCE"] = self.attrs.reduce if self.attrs.training_threshold is not None: field_data["TRAINING_THRESHOLD"] = self.attrs.training_threshold - return RedisVectorField(name, self.attrs.algorithm, field_data, as_name=as_name) + return RedisVectorField( + name, + self.attrs.algorithm, + field_data, + as_name=as_name, + index_missing=self.attrs.index_missing, + ) FIELD_TYPE_MAP = { diff --git a/tests/integration/test_field_modifier_ordering_integration.py b/tests/integration/test_field_modifier_ordering_integration.py index 401b756ce..79882401a 100644 --- a/tests/integration/test_field_modifier_ordering_integration.py +++ b/tests/integration/test_field_modifier_ordering_integration.py @@ -256,6 +256,46 @@ def test_indexmissing_enables_ismissing_query(self, client, redis_url, worker_id ) index.delete(drop=True) + @pytest.mark.parametrize("algorithm", ["flat", "hnsw"]) + def test_vector_field_index_missing(self, client, redis_url, worker_id, algorithm): + """Test that a vector field with INDEXMISSING creates and supports ismissing().""" + skip_if_search_version_below_for_indexmissing(client) + name = f"test_vec_ismissing_{algorithm}_{worker_id}" + prefix = f"vec_ismiss_{algorithm}_{worker_id}:" + schema_dict = { + "index": {"name": name, "prefix": prefix, "storage_type": "hash"}, + "fields": [ + { + "name": "embedding", + "type": "vector", + "attrs": { + "algorithm": algorithm, + "dims": 3, + "index_missing": True, + }, + } + ], + } + + schema = IndexSchema.from_dict(schema_dict) + index = SearchIndex(schema=schema, redis_url=redis_url) + + index.create(overwrite=True) + + client.hset(f"{prefix}1", "embedding", b"\x00" * 12) + client.hset(f"{prefix}2", "other_field", "no embedding") + + result = client.execute_command( + "FT.SEARCH", name, "ismissing(@embedding)", "DIALECT", "2" + ) + + assert result[0] == 1 + assert f"{prefix}2" in str(result) + + # Cleanup + client.delete(f"{prefix}1", f"{prefix}2") + index.delete(drop=True) + class TestIndexEmptyIntegration: """Integration tests for INDEXEMPTY functionality.""" diff --git a/tests/unit/test_fields.py b/tests/unit/test_fields.py index eeeda6046..1c21a9fc1 100644 --- a/tests/unit/test_fields.py +++ b/tests/unit/test_fields.py @@ -383,33 +383,32 @@ def test_vector_fields_redis_creation_with_index_missing(): assert redis_field.name == "hnsw_embedding" -def test_vector_field_data_includes_index_missing(): - """Test that vector field field_data includes INDEXMISSING when enabled.""" - - # Test with index_missing=True - flat_field_with_missing = FlatVectorField( +@pytest.mark.parametrize( + "field_class,algorithm", + [ + (FlatVectorField, "flat"), + (HNSWVectorField, "hnsw"), + (SVSVectorField, "svs-vamana"), + ], +) +def test_vector_field_index_missing_follows_attribute_block(field_class, algorithm): + """INDEXMISSING is a field modifier, so it must follow the vector attribute + block rather than be counted and sent inside it.""" + field = field_class( name="embedding", - attrs={"algorithm": "flat", "dims": 128, "index_missing": True}, + attrs={"algorithm": algorithm, "dims": 128, "index_missing": True}, ) - field_data = flat_field_with_missing.attrs.field_data - assert "INDEXMISSING" in field_data - assert field_data["INDEXMISSING"] == True + assert "INDEXMISSING" not in field.attrs.field_data - # Test with index_missing=False (default) - flat_field_without_missing = FlatVectorField( - name="embedding", attrs={"algorithm": "flat", "dims": 128} - ) - field_data = flat_field_without_missing.attrs.field_data - assert "INDEXMISSING" not in field_data + args = field.as_redis_field().redis_args() + attr_count = args[3] + assert "INDEXMISSING" not in args[: 4 + attr_count] + assert args[4 + attr_count :] == ["INDEXMISSING"] - # Test HNSW field with index_missing=True - hnsw_field_with_missing = HNSWVectorField( - name="embedding", - attrs={"algorithm": "hnsw", "dims": 256, "index_missing": True}, - ) - field_data = hnsw_field_with_missing.attrs.field_data - assert "INDEXMISSING" in field_data - assert field_data["INDEXMISSING"] == True + # Without index_missing, nothing follows the attribute block + field = field_class(name="embedding", attrs={"algorithm": algorithm, "dims": 128}) + args = field.as_redis_field().redis_args() + assert len(args) == 4 + args[3] def test_field_factory_with_new_attributes(): From 064fd78f912f39b9112d6233b399c75cd5b4a53c Mon Sep 17 00:00:00 2001 From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:46:52 +0000 Subject: [PATCH 2/2] fix(schema): recover INDEXMISSING on vector fields in from_existing A vector field can now be created with index_missing, so read the INDEXMISSING modifier back from FT.INFO too. Otherwise a from_existing and create(overwrite=True) round trip drops it. --- redisvl/redis/connection.py | 8 +++ tests/integration/test_async_search_index.py | 1 + ...est_field_modifier_ordering_integration.py | 64 ++++++------------- tests/integration/test_search_index.py | 1 + tests/unit/test_convert_index_info.py | 4 +- tests/unit/test_fields.py | 33 ---------- 6 files changed, 30 insertions(+), 81 deletions(-) diff --git a/redisvl/redis/connection.py b/redisvl/redis/connection.py index fba7f1949..407c63885 100644 --- a/redisvl/redis/connection.py +++ b/redisvl/redis/connection.py @@ -405,6 +405,7 @@ def parse_vector_attrs(attrs): # params in "flags" as key/value pairs, possibly followed by # valueless modifiers: ["algorithm", "FLAT", "dim", 4, "INDEXMISSING"] flags = attrs.get("flags") or [] + index_missing = "INDEXMISSING" in flags i = 0 while i < len(flags): key = str(flags[i]).lower() @@ -414,6 +415,10 @@ def parse_vector_attrs(attrs): vector_attrs.setdefault(key, flags[i + 1]) i += 2 else: + # INDEXMISSING is a valueless modifier after the key/value pairs + index_missing = "INDEXMISSING" in attrs[6:] + attrs = attrs[:6] + [a for a in attrs[6:] if a != "INDEXMISSING"] + # Check if we have any attributes beyond the type declaration if len(attrs) <= 6: # Redis 6.2.6-v9 or similar: no vector params in FT.INFO @@ -561,6 +566,9 @@ def parse_vector_attrs(attrs): except (ValueError, TypeError): pass + if index_missing: + normalized["index_missing"] = True + # Validate that we have required dims if "dims" not in normalized: # Could not parse dims - this field is not properly supported diff --git a/tests/integration/test_async_search_index.py b/tests/integration/test_async_search_index.py index 3504832ac..261416550 100644 --- a/tests/integration/test_async_search_index.py +++ b/tests/integration/test_async_search_index.py @@ -582,6 +582,7 @@ async def test_client_from_existing_and_batch_search( "distance_metric": "cosine", "algorithm": "flat", "datatype": "float32", + "index_missing": True, }, }, ], diff --git a/tests/integration/test_field_modifier_ordering_integration.py b/tests/integration/test_field_modifier_ordering_integration.py index 79882401a..77beee723 100644 --- a/tests/integration/test_field_modifier_ordering_integration.py +++ b/tests/integration/test_field_modifier_ordering_integration.py @@ -207,7 +207,21 @@ def test_index_creation_with_multiple_modifiers(self, client, redis_url, worker_ # Cleanup index.delete(drop=True) - def test_indexmissing_enables_ismissing_query(self, client, redis_url, worker_id): + @pytest.mark.parametrize( + "field_type,attrs,value", + [ + pytest.param("text", {}, "has value", id="text"), + pytest.param( + "vector", + {"algorithm": "hnsw", "dims": 3}, + b"\x00" * 12, + id="vector-hnsw", + ), + ], + ) + def test_indexmissing_enables_ismissing_query( + self, client, redis_url, worker_id, field_type, attrs, value + ): """Test that INDEXMISSING enables ismissing() query function.""" skip_if_search_version_below_for_indexmissing(client) schema_dict = { @@ -219,8 +233,8 @@ def test_indexmissing_enables_ismissing_query(self, client, redis_url, worker_id "fields": [ { "name": "optional_field", - "type": "text", - "attrs": {"index_missing": True}, + "type": field_type, + "attrs": {**attrs, "index_missing": True}, } ], } @@ -231,9 +245,9 @@ def test_indexmissing_enables_ismissing_query(self, client, redis_url, worker_id index.create(overwrite=True) # Create documents with and without the field - client.hset(f"ismiss_{worker_id}:1", "optional_field", "has value") + client.hset(f"ismiss_{worker_id}:1", "optional_field", value) client.hset(f"ismiss_{worker_id}:2", "other_field", "no optional_field") - client.hset(f"ismiss_{worker_id}:3", "optional_field", "also has value") + client.hset(f"ismiss_{worker_id}:3", "optional_field", value) # Query for missing fields result = client.execute_command( @@ -256,46 +270,6 @@ def test_indexmissing_enables_ismissing_query(self, client, redis_url, worker_id ) index.delete(drop=True) - @pytest.mark.parametrize("algorithm", ["flat", "hnsw"]) - def test_vector_field_index_missing(self, client, redis_url, worker_id, algorithm): - """Test that a vector field with INDEXMISSING creates and supports ismissing().""" - skip_if_search_version_below_for_indexmissing(client) - name = f"test_vec_ismissing_{algorithm}_{worker_id}" - prefix = f"vec_ismiss_{algorithm}_{worker_id}:" - schema_dict = { - "index": {"name": name, "prefix": prefix, "storage_type": "hash"}, - "fields": [ - { - "name": "embedding", - "type": "vector", - "attrs": { - "algorithm": algorithm, - "dims": 3, - "index_missing": True, - }, - } - ], - } - - schema = IndexSchema.from_dict(schema_dict) - index = SearchIndex(schema=schema, redis_url=redis_url) - - index.create(overwrite=True) - - client.hset(f"{prefix}1", "embedding", b"\x00" * 12) - client.hset(f"{prefix}2", "other_field", "no embedding") - - result = client.execute_command( - "FT.SEARCH", name, "ismissing(@embedding)", "DIALECT", "2" - ) - - assert result[0] == 1 - assert f"{prefix}2" in str(result) - - # Cleanup - client.delete(f"{prefix}1", f"{prefix}2") - index.delete(drop=True) - class TestIndexEmptyIntegration: """Integration tests for INDEXEMPTY functionality.""" diff --git a/tests/integration/test_search_index.py b/tests/integration/test_search_index.py index 38f039187..4ebd69a86 100644 --- a/tests/integration/test_search_index.py +++ b/tests/integration/test_search_index.py @@ -645,6 +645,7 @@ def test_client_from_existing_and_batch_search( "distance_metric": "cosine", "algorithm": "flat", "datatype": "float32", + "index_missing": True, }, }, ], diff --git a/tests/unit/test_convert_index_info.py b/tests/unit/test_convert_index_info.py index b08b7b44e..b00e9287c 100644 --- a/tests/unit/test_convert_index_info.py +++ b/tests/unit/test_convert_index_info.py @@ -274,9 +274,6 @@ def test_convert_index_info_resp2_unified_vector_flags(): 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", @@ -288,6 +285,7 @@ def test_convert_index_info_resp2_unified_vector_flags(): "datatype": "float32", "m": 24, "ef_construction": 300, + "index_missing": True, }, } ] diff --git a/tests/unit/test_fields.py b/tests/unit/test_fields.py index 1c21a9fc1..96aaec235 100644 --- a/tests/unit/test_fields.py +++ b/tests/unit/test_fields.py @@ -356,33 +356,6 @@ def test_redis_field_creation_with_index_attributes( # We can't directly inspect them, but we can verify the field creation doesn't fail -def test_vector_fields_redis_creation_with_index_missing(): - """Test that vector fields properly handle index_missing in Redis field creation.""" - - # Test FlatVectorField with index_missing - flat_field = FlatVectorField( - name="flat_embedding", - attrs={ - "algorithm": "flat", - "dims": 128, - "index_missing": True, - "block_size": 100, - }, - ) - redis_field = flat_field.as_redis_field() - assert isinstance(redis_field, RedisVectorField) - assert redis_field.name == "flat_embedding" - - # Test HNSWVectorField with index_missing - hnsw_field = HNSWVectorField( - name="hnsw_embedding", - attrs={"algorithm": "hnsw", "dims": 256, "index_missing": True, "m": 24}, - ) - redis_field = hnsw_field.as_redis_field() - assert isinstance(redis_field, RedisVectorField) - assert redis_field.name == "hnsw_embedding" - - @pytest.mark.parametrize( "field_class,algorithm", [ @@ -398,18 +371,12 @@ def test_vector_field_index_missing_follows_attribute_block(field_class, algorit name="embedding", attrs={"algorithm": algorithm, "dims": 128, "index_missing": True}, ) - assert "INDEXMISSING" not in field.attrs.field_data args = field.as_redis_field().redis_args() attr_count = args[3] assert "INDEXMISSING" not in args[: 4 + attr_count] assert args[4 + attr_count :] == ["INDEXMISSING"] - # Without index_missing, nothing follows the attribute block - field = field_class(name="embedding", attrs={"algorithm": algorithm, "dims": 128}) - args = field.as_redis_field().redis_args() - assert len(args) == 4 + args[3] - def test_field_factory_with_new_attributes(): """Test FieldFactory.create_field with the new index attributes."""