Skip to content

Commit 2dfbf45

Browse files
authored
fix: (schema) ensure field modifiers follow canonical order for RediSearch parser (#434)
Fix field modifier ordering to satisfy RediSearch parser requirements where INDEXEMPTY and INDEXMISSING must appear BEFORE SORTABLE in field definitions. This resolves index creation failures when using index_missing=True with sortable=True. Changes: - Add _normalize_field_modifiers() helper function with set-based optimization - Refactor TextField, TagField, NumericField, GeoField to use helper - Implement canonical order: [INDEXEMPTY] [INDEXMISSING] [SORTABLE [UNF]] [NOINDEX] - Add unit tests for helper and field classes - Add integration tests against live Redis Fixes: issue [#431](#431 (comment)) Related: Field 'INDEXMISSING' does not have a type error BREAKING CHANGE: None - backward compatible, only changes internal ordering
1 parent 99b9d3a commit 2dfbf45

File tree

3 files changed

+904
-24
lines changed

3 files changed

+904
-24
lines changed

redisvl/schema/fields.py

Lines changed: 73 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"""
3636

3737
from enum import Enum
38-
from typing import Any, Dict, Literal, Optional, Tuple, Type, Union
38+
from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Union
3939

4040
from pydantic import BaseModel, Field, field_validator, model_validator
4141
from redis.commands.search.field import Field as RedisField
@@ -97,6 +97,49 @@ class CompressionType(str, Enum):
9797
LeanVec8x8 = "LeanVec8x8"
9898

9999

100+
### Helper Functions ###
101+
102+
103+
def _normalize_field_modifiers(
104+
field: RedisField, canonical_order: List[str], want_unf: bool = False
105+
) -> None:
106+
"""Normalize field modifier ordering for RediSearch parser.
107+
108+
RediSearch has a parser limitation where INDEXEMPTY and
109+
INDEXMISSING must appear BEFORE SORTABLE in field definitions. This function
110+
reorders field.args_suffix to match the canonical order.
111+
112+
Args:
113+
field: Redis field object whose args_suffix will be normalized
114+
canonical_order: List of modifiers in desired canonical order
115+
want_unf: Whether UNF should be added after SORTABLE (default: False)
116+
117+
Time Complexity: O(n + m), where n = len(field.args_suffix), m = len(canonical_order).
118+
- O(n) to create the set from field.args_suffix
119+
- O(m) to iterate over canonical_order and perform set lookups (O(1) average case per lookup)
120+
Space Complexity: O(n)
121+
122+
Example:
123+
>>> field = RedisTextField("title")
124+
>>> field.args_suffix = ["SORTABLE", "INDEXMISSING"]
125+
>>> _normalize_field_modifiers(field, ["INDEXEMPTY", "INDEXMISSING", "SORTABLE"])
126+
>>> field.args_suffix
127+
['INDEXMISSING', 'SORTABLE']
128+
"""
129+
suffix_set = set(field.args_suffix)
130+
131+
# Build new suffix with only known modifiers in canonical order
132+
new_suffix = []
133+
for modifier in canonical_order:
134+
if modifier in suffix_set:
135+
new_suffix.append(modifier)
136+
# Special case: UNF only appears with SORTABLE
137+
if modifier == "SORTABLE" and want_unf and "UNF" not in suffix_set:
138+
new_suffix.append("UNF")
139+
140+
field.args_suffix = new_suffix
141+
142+
100143
### Field Attributes ###
101144

102145

@@ -290,7 +333,7 @@ def validate_svs_params(self):
290333
):
291334
logger.warning(
292335
f"LeanVec compression selected without 'reduce'. "
293-
f"Consider setting reduce={self.dims//2} for better performance"
336+
f"Consider setting reduce={self.dims // 2} for better performance"
294337
)
295338

296339
if self.graph_max_degree and self.graph_max_degree < 32:
@@ -371,16 +414,11 @@ def as_redis_field(self) -> RedisField:
371414

372415
field = RedisTextField(name, **kwargs)
373416

374-
# Add UNF support (only when sortable)
375-
# UNF must come before NOINDEX in the args_suffix
376-
if self.attrs.unf and self.attrs.sortable: # type: ignore
377-
if "NOINDEX" in field.args_suffix:
378-
# Insert UNF before NOINDEX
379-
noindex_idx = field.args_suffix.index("NOINDEX")
380-
field.args_suffix.insert(noindex_idx, "UNF")
381-
else:
382-
# No NOINDEX, append normally
383-
field.args_suffix.append("UNF")
417+
# Normalize suffix ordering to satisfy RediSearch parser expectations.
418+
# Canonical order: [INDEXEMPTY] [INDEXMISSING] [SORTABLE [UNF]] [NOINDEX]
419+
canonical_order = ["INDEXEMPTY", "INDEXMISSING", "SORTABLE", "UNF", "NOINDEX"]
420+
want_unf = self.attrs.unf and self.attrs.sortable # type: ignore
421+
_normalize_field_modifiers(field, canonical_order, want_unf)
384422

385423
return field
386424

@@ -416,7 +454,14 @@ def as_redis_field(self) -> RedisField:
416454
if self.attrs.no_index: # type: ignore
417455
kwargs["no_index"] = True
418456

419-
return RedisTagField(name, **kwargs)
457+
field = RedisTagField(name, **kwargs)
458+
459+
# Normalize suffix ordering to satisfy RediSearch parser expectations.
460+
# Canonical order: [INDEXEMPTY] [INDEXMISSING] [SORTABLE] [NOINDEX]
461+
canonical_order = ["INDEXEMPTY", "INDEXMISSING", "SORTABLE", "NOINDEX"]
462+
_normalize_field_modifiers(field, canonical_order)
463+
464+
return field
420465

421466

422467
class NumericField(BaseField):
@@ -446,16 +491,12 @@ def as_redis_field(self) -> RedisField:
446491

447492
field = RedisNumericField(name, **kwargs)
448493

449-
# Add UNF support (only when sortable)
450-
# UNF must come before NOINDEX in the args_suffix
451-
if self.attrs.unf and self.attrs.sortable: # type: ignore
452-
if "NOINDEX" in field.args_suffix:
453-
# Insert UNF before NOINDEX
454-
noindex_idx = field.args_suffix.index("NOINDEX")
455-
field.args_suffix.insert(noindex_idx, "UNF")
456-
else:
457-
# No NOINDEX, append normally
458-
field.args_suffix.append("UNF")
494+
# Normalize suffix ordering to satisfy RediSearch parser expectations.
495+
# Canonical order: [INDEXMISSING] [SORTABLE [UNF]] [NOINDEX]
496+
# Note: INDEXEMPTY is not supported for NUMERIC fields
497+
canonical_order = ["INDEXMISSING", "SORTABLE", "UNF", "NOINDEX"]
498+
want_unf = self.attrs.unf and self.attrs.sortable # type: ignore
499+
_normalize_field_modifiers(field, canonical_order, want_unf)
459500

460501
return field
461502

@@ -485,7 +526,15 @@ def as_redis_field(self) -> RedisField:
485526
if self.attrs.no_index: # type: ignore
486527
kwargs["no_index"] = True
487528

488-
return RedisGeoField(name, **kwargs)
529+
field = RedisGeoField(name, **kwargs)
530+
531+
# Normalize suffix ordering to satisfy RediSearch parser expectations.
532+
# Canonical order: [INDEXMISSING] [SORTABLE] [NOINDEX]
533+
# Note: INDEXEMPTY is not supported for GEO fields
534+
canonical_order = ["INDEXMISSING", "SORTABLE", "NOINDEX"]
535+
_normalize_field_modifiers(field, canonical_order)
536+
537+
return field
489538

490539

491540
class FlatVectorField(BaseField):

0 commit comments

Comments
 (0)