diff --git a/redisvl/schema/fields.py b/redisvl/schema/fields.py index 0a414ac9..1bf0b383 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 401b756c..79882401 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 eeeda604..1c21a9fc 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():