diff --git a/redisvl/extensions/cache/llm/semantic.py b/redisvl/extensions/cache/llm/semantic.py index 096e08d6..7b71e617 100644 --- a/redisvl/extensions/cache/llm/semantic.py +++ b/redisvl/extensions/cache/llm/semantic.py @@ -398,8 +398,10 @@ def check( if return_fields and not isinstance(return_fields, list): raise TypeError("Return fields must be a list of values.") - # Use overrides or defaults - distance_threshold = distance_threshold or self._distance_threshold + # Use overrides or defaults. Note: 0 is a valid distance threshold + # (exact match only), so only fall back on None. + if distance_threshold is None: + distance_threshold = self._distance_threshold # Vectorize prompt if not provided if vector is None and prompt is not None: @@ -491,8 +493,10 @@ async def acheck( if return_fields and not isinstance(return_fields, list): raise TypeError("Return fields must be a list of values.") - # Use overrides or defaults - distance_threshold = distance_threshold or self._distance_threshold + # Use overrides or defaults. Note: 0 is a valid distance threshold + # (exact match only), so only fall back on None. + if distance_threshold is None: + distance_threshold = self._distance_threshold # Vectorize prompt if not provided if vector is None and prompt is not None: diff --git a/redisvl/extensions/message_history/semantic_history.py b/redisvl/extensions/message_history/semantic_history.py index 319cc0e3..8664da43 100644 --- a/redisvl/extensions/message_history/semantic_history.py +++ b/redisvl/extensions/message_history/semantic_history.py @@ -231,8 +231,10 @@ def get_relevant( # Validate and normalize role parameter roles_to_filter = self._validate_roles(role) - # override distance threshold - distance_threshold = distance_threshold or self._distance_threshold + # override distance threshold. Note: 0 is a valid distance threshold + # (exact match only), so only fall back on None. + if distance_threshold is None: + distance_threshold = self._distance_threshold return_fields = [ SESSION_FIELD_NAME, diff --git a/tests/integration/test_llmcache.py b/tests/integration/test_llmcache.py index 9673581b..1b8c4706 100644 --- a/tests/integration/test_llmcache.py +++ b/tests/integration/test_llmcache.py @@ -577,6 +577,33 @@ def test_check_no_match(cache, vectorizer): assert len(check_result) == 0 +def test_check_with_zero_distance_threshold(cache): + """A per-call distance_threshold of 0 must be honored, not treated as unset. + + Regression test: `distance_threshold or self._distance_threshold` treated the + valid threshold 0 as falsy and silently fell back to the cache default, so + callers requesting exact-match-only lookups received fuzzy matches. + """ + cache.store("what is the capital of france?", "paris") + + # Semantically similar but not identical -> must NOT hit at threshold 0 + assert cache.check("what's the capital of france?", distance_threshold=0) == [] + + # Sanity check: the same query does hit at the cache's default threshold + assert cache.check("what's the capital of france?") != [] + + +@pytest.mark.asyncio +async def test_acheck_with_zero_distance_threshold(cache): + """Async variant: a per-call distance_threshold of 0 must be honored.""" + await cache.astore("what is the capital of france?", "paris") + + assert ( + await cache.acheck("what's the capital of france?", distance_threshold=0) + ) == [] + assert (await cache.acheck("what's the capital of france?")) != [] + + def test_check_invalid_input(cache): with pytest.raises(ValueError): cache.check() diff --git a/tests/integration/test_message_history.py b/tests/integration/test_message_history.py index 4442dbca..842a1fd0 100644 --- a/tests/integration/test_message_history.py +++ b/tests/integration/test_message_history.py @@ -606,6 +606,30 @@ def test_semantic_add_and_get_relevant(semantic_history): bad_context = semantic_history.get_relevant("test prompt", top_k="3") +@requires_hf +def test_semantic_get_relevant_with_zero_distance_threshold(semantic_history): + """A per-call distance_threshold of 0 must be honored, not treated as unset. + + Regression test: `distance_threshold or self._distance_threshold` treated the + valid threshold 0 as falsy and silently fell back to the configured default, + so callers requesting exact-match-only context received fuzzy matches. + """ + semantic_history.store( + prompt="list of common fruits", + response="apples, oranges, bananas, strawberries", + ) + semantic_history.set_distance_threshold(0.5) + + # Semantically similar but not identical -> must NOT match at threshold 0 + assert ( + semantic_history.get_relevant("list of typical fruits", distance_threshold=0) + == [] + ) + + # Sanity check: the same query does match at the configured default + assert semantic_history.get_relevant("list of typical fruits") != [] + + @requires_hf def test_semantic_get_raw(semantic_history): semantic_history.store("first prompt", "first response")