Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 60 additions & 4 deletions redisvl/redis/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down Expand Up @@ -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
Comment on lines +407 to +415

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loop is where dim stops coming from a trusted top-level key and starts coming from flags, which makes line 375 reachable — the one numeric conversion here that isn't wrapped, unlike m (418), ef_construction (424) and the SVS params through 476:

flags [... "dim","FUTUREMOD" ...]  -> ValueError: invalid literal for int() with base 10: 'FUTUREMOD'
flags [... "M","NOTANUMBER" ...]   -> OK, field returned with m omitted

Worth wrapping so a gap in dim takes the return None -> warn-and-skip path instead of escaping from_existing. The allowlist above doesn't cover this — dim is a known key, so it still consumes whatever follows.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrapped in d4d7a2e. dim/dims now go through the same try/except (ValueError, TypeError) as the other numeric params. An unparseable value leaves dims unset, so the field takes the existing return None → warn-and-skip path. A new unit test covers ["algorithm","FLAT","dim","FUTUREMOD"]: the field is skipped with the warning, and nothing escapes convert_index_info_to_schema.

else:
# Check if we have any attributes beyond the type declaration
if len(attrs) <= 6:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Comment on lines +655 to +662

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly what the issue asked for, but it won't reach the user it's for — and that isn't your fault.

get_logger attaches a NullHandler to the leaf logger (redisvl/utils/log.py:12-13), which makes Python count a handler as found and suppresses logging.lastResort, the stderr fallback for WARNING+ with no configuration:

plain script, no logging config  ->  nothing printed
same + logging.basicConfig()     ->  WARNING:redisvl.redis.connection:Skipping vector field 'emb' ...

So the skip is still silent for the issue's own reproduction, and the caplog test passes only because caplog installs a handler. Pre-existing and library-wide (23 get_logger sites, 89 warning/error calls) — I'm filing it separately, just flagging that the second half of #712 doesn't land yet.

Optional: the message could name the consequence — the schema is incomplete, and rebuilding from it with create(overwrite=True) drops the field. Keeping %r means the test still passes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tracking the NullHandler issue separately. I also took the optional suggestion in d4d7a2e: the warning now says the recovered schema is incomplete and that recreating the index from it (e.g. create(overwrite=True)) drops the field. It still uses %r for the names, so the test is unchanged.

continue
field["attrs"] = attrs
else:
Expand Down
8 changes: 8 additions & 0 deletions tests/integration/test_async_search_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 8 additions & 0 deletions tests/integration/test_search_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
120 changes: 120 additions & 0 deletions tests/unit/test_convert_index_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good tests, and the first vector coverage this file has had. But they pin today's redis-py wire shape as a literal, so if it changes again they'll pass while the library breaks.

There's already an integration test for exactly this configuration that can't fire: test_client_from_existing_and_batch_search (tests/integration/test_search_index.py:624) asserts reopened.schema == index.schema on an index with a vector field, parametrised over {}, {"protocol": 3} and {"legacy_responses": False}. On redis-py 8 all three negotiate RESP3 (DEFAULT_RESP_VERSION = 3, redis/utils.py:283), so new-response-format duplicates protocol-3 and the RESP2-unified shape is never reached. Same in the async twin at tests/integration/test_async_search_index.py:546.

Could you add one param to each?

        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",
            ),
        ),

On main each file then gives 1 failed, 3 passed; on this branch, 8 passed. No new test function, and it runs on every redis-py 8.x matrix leg.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in d4d7a2e. I added the resp2-new-response-format param ({"protocol": 2, "legacy_responses": False}, skipped on redis-py < 8) to test_client_from_existing_and_batch_search in both test_search_index.py and test_async_search_index.py. Locally on redis-py 8.1.0 / Redis 8.4, with main's connection.py the new param fails in both files (2 failed, 6 passed), and on this branch all 8 pass.

"""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",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This expected dict has no index_missing, which freezes a real loss into the suite: a vector field's INDEXMISSING is discarded on every protocol, so a from_existing -> create(overwrite=True) round trip rebuilds it without the modifier and ismissing(@emb) then errors.

It can't be fixed here — field_data puts a Python bool into the VECTOR attribute dict, so create() with a vector index_missing=True fails today with Invalid input of type: 'bool', and the server rejects the token inside the attribute block anyway (Bad arguments for algorithm FLAT). Fixing the read side alone would turn a quiet loss into a hard failure on rebuild.

So leave the behaviour — but a one-line comment noting the discard is deliberate would stop the next reader assuming it was missed. Separate issue coming.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a comment above the expected dict in d4d7a2e. It says INDEXMISSING is deliberately not recovered as index_missing, because a vector field can't be recreated with it yet, so restoring it would turn a quiet loss into a failure on create().

],
}
],
}

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
Loading