Skip to content

Commit 2f9ed50

Browse files
committed
fix: use whitespace intersection for filtered text queries
1 parent 6e0a270 commit 2f9ed50

6 files changed

Lines changed: 130 additions & 15 deletions

File tree

redisvl/query/query.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1569,5 +1569,5 @@ def _build_query_string(self) -> str:
15691569
text = "(" + " | ".join(field_queries) + ")"
15701570

15711571
if filter_expression and filter_expression != "*":
1572-
text += f" AND {filter_expression}"
1572+
text += f" {filter_expression}"
15731573
return text

redisvl/utils/full_text_query_helper.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def build_query_string(
6262
query = f"(~@{text_field_name}:({self._tokenize_and_escape_query(text)})"
6363

6464
if filter_expression and filter_expression != "*":
65-
query += f" AND {filter_expression}"
65+
query += f" {filter_expression}"
6666

6767
return query + ")"
6868

tests/integration/test_stopwords_integration.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
import pytest
44

55
from redisvl.index import SearchIndex
6-
from redisvl.query import FilterQuery
6+
from redisvl.query import AggregateHybridQuery, FilterQuery, TextQuery
7+
from redisvl.query.filter import Tag
8+
from redisvl.redis.utils import array_to_buffer
79
from redisvl.schema import IndexSchema
10+
from tests.conftest import skip_if_redis_version_below
811

912

1013
@pytest.fixture
@@ -91,6 +94,56 @@ def default_stopwords_index(client, default_stopwords_schema):
9194
index.delete(drop=True)
9295

9396

97+
@pytest.fixture
98+
def filtered_queries_stopwords_disabled_index(redis_url, redis_test_name):
99+
"""Index with STOPWORDS 0 for filtered text and hybrid query regressions."""
100+
index_name = redis_test_name("filtered_queries_stopwords_disabled")
101+
index = SearchIndex.from_dict(
102+
{
103+
"index": {
104+
"name": index_name,
105+
"prefix": f"{index_name}:",
106+
"storage_type": "hash",
107+
"stopwords": [],
108+
},
109+
"fields": [
110+
{"name": "text", "type": "text"},
111+
{"name": "team", "type": "tag"},
112+
{
113+
"name": "embedding",
114+
"type": "vector",
115+
"attrs": {
116+
"dims": 2,
117+
"distance_metric": "cosine",
118+
"algorithm": "flat",
119+
"datatype": "float32",
120+
},
121+
},
122+
],
123+
},
124+
redis_url=redis_url,
125+
)
126+
index.create(overwrite=True, drop=True)
127+
index.load(
128+
[
129+
{
130+
"text": "reference handbook",
131+
"team": "docs",
132+
"embedding": array_to_buffer([1.0, 0.0], "float32"),
133+
},
134+
{
135+
"text": "reference handbook",
136+
"team": "support",
137+
"embedding": array_to_buffer([1.0, 0.0], "float32"),
138+
},
139+
]
140+
)
141+
142+
yield index
143+
144+
index.delete(drop=True)
145+
146+
94147
def test_create_index_with_stopwords_disabled(client, stopwords_disabled_index):
95148
"""Test creating an index with STOPWORDS 0."""
96149
# Verify index was created
@@ -190,3 +243,46 @@ def test_stopwords_disabled_allows_searching_common_words(
190243
# With STOPWORDS 0, "of" should be indexed and searchable
191244
assert len(results.docs) > 0
192245
assert any("of" in doc.title.lower() for doc in results.docs)
246+
247+
248+
def test_filtered_text_query_with_stopwords_disabled(
249+
filtered_queries_stopwords_disabled_index,
250+
):
251+
"""Filtered text queries should not add AND as a full-text search term."""
252+
query = TextQuery(
253+
text="handbook",
254+
text_field_name="text",
255+
filter_expression=Tag("team") == "docs",
256+
return_fields=["text", "team"],
257+
stopwords=None,
258+
)
259+
260+
results = filtered_queries_stopwords_disabled_index.query(query)
261+
262+
assert len(results) == 1
263+
assert results[0]["text"] == "reference handbook"
264+
assert results[0]["team"] == "docs"
265+
266+
267+
def test_filtered_aggregate_hybrid_query_with_stopwords_disabled(
268+
filtered_queries_stopwords_disabled_index,
269+
):
270+
"""Filtered aggregate hybrid queries should work with STOPWORDS 0."""
271+
skip_if_redis_version_below(
272+
filtered_queries_stopwords_disabled_index.client, "7.2.0"
273+
)
274+
query = AggregateHybridQuery(
275+
text="handbook",
276+
text_field_name="text",
277+
vector=[1.0, 0.0],
278+
vector_field_name="embedding",
279+
filter_expression=Tag("team") == "docs",
280+
return_fields=["text", "team"],
281+
stopwords=None,
282+
)
283+
284+
results = filtered_queries_stopwords_disabled_index.query(query)
285+
286+
assert len(results) == 1
287+
assert results[0]["text"] == "reference handbook"
288+
assert results[0]["team"] == "docs"

tests/unit/test_aggregation_types.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,11 @@ def test_hybrid_query_with_string_filter():
160160
# Check that the generated query string includes both text search and filter
161161
query_string = str(hybrid_query)
162162
assert f"@{text_field_name}:(search | document | 12345)" in query_string
163-
assert f"AND {string_filter}" in query_string
163+
assert (
164+
f"@{text_field_name}:(search | document | 12345) {string_filter}"
165+
in query_string
166+
)
167+
assert " AND " not in query_string
164168

165169
# Test with FilterExpression - should also work (existing functionality)
166170
filter_expression = Tag("category") == "tech"
@@ -181,7 +185,11 @@ def test_hybrid_query_with_string_filter():
181185
f"@{text_field_name}:(search | document | 12345)"
182186
in query_string_with_filter_expr
183187
)
184-
assert "AND @category:{tech}" in query_string_with_filter_expr
188+
assert (
189+
f"@{text_field_name}:(search | document | 12345) @category:{{tech}}"
190+
in query_string_with_filter_expr
191+
)
192+
assert " AND " not in query_string_with_filter_expr
185193

186194
# Test with no filter - should only have text search
187195
hybrid_query_no_filter = AggregateHybridQuery(
@@ -195,7 +203,7 @@ def test_hybrid_query_with_string_filter():
195203
assert f"@{text_field_name}:(search | document | 12345)" in query_string_no_filter
196204
assert "AND" not in query_string_no_filter
197205

198-
# Test with wildcard filter - should only have text search (no AND clause)
206+
# Test with wildcard filter - should only have text search (no filter clause)
199207
hybrid_query_wildcard = AggregateHybridQuery(
200208
text=text,
201209
text_field_name=text_field_name,

tests/unit/test_hybrid_types.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ def test_hybrid_query_with_all_parameters():
129129
# Verify that the expected query pieces have been defined
130130
assert get_query_pieces(hybrid_query) == [
131131
"SEARCH",
132-
"(~@description:(the | toon=>{$weight:2.0} | squad=>{$weight:1.5} | play | basketball | against | a | gang | of | aliens) AND @genre:{comedy})",
132+
"(~@description:(the | toon=>{$weight:2.0} | squad=>{$weight:1.5} | play | basketball | against | a | gang | of | aliens) @genre:{comedy})",
133133
"SCORER",
134134
"TFIDF",
135135
"YIELD_SCORE_AS",
@@ -385,7 +385,7 @@ def test_hybrid_query_with_string_filter():
385385

386386
assert get_query_pieces(hybrid_query) == [
387387
"SEARCH",
388-
"(~@description:(toon | squad | play | basketball | gang | aliens) AND @category:{tech|science|engineering})",
388+
"(~@description:(toon | squad | play | basketball | gang | aliens) @category:{tech|science|engineering})",
389389
"SCORER",
390390
"BM25STD",
391391
"VSIM",
@@ -418,7 +418,7 @@ def test_hybrid_query_with_tag_filter():
418418

419419
assert get_query_pieces(hybrid_query) == [
420420
"SEARCH",
421-
"(~@description:(toon | squad | play | basketball | gang | aliens) AND @genre:{comedy})",
421+
"(~@description:(toon | squad | play | basketball | gang | aliens) @genre:{comedy})",
422422
"SCORER",
423423
"BM25STD",
424424
"VSIM",
@@ -452,7 +452,8 @@ def test_hybrid_query_with_numeric_filter():
452452
# Verify filter is included in serialized query
453453
args = get_query_pieces(hybrid_query)
454454
expected = "@age:[(30 +inf]"
455-
assert args[1].endswith(f"AND {expected})") # Check text filter
455+
assert args[1].endswith(f" {expected})") # Check text filter
456+
assert " AND " not in args[1]
456457
assert args[8] == expected # Check vector filter
457458

458459

@@ -472,7 +473,8 @@ def test_hybrid_query_with_text_filter():
472473
# Verify filter is included in serialized query
473474
args = get_query_pieces(hybrid_query)
474475
expected = '@job:("engineer")'
475-
assert args[1].endswith(f"AND {expected})") # Check text filter
476+
assert args[1].endswith(f" {expected})") # Check text filter
477+
assert " AND " not in args[1]
476478
assert args[8] == expected # Check vector filter
477479

478480

@@ -492,7 +494,8 @@ def test_hybrid_query_with_combined_filters():
492494
# Verify both filters are included in serialized query
493495
args = get_query_pieces(hybrid_query)
494496
expected = "(@genre:{comedy} @rating:[(7.0 +inf])"
495-
assert args[1].endswith(f"AND {expected})") # Check text filter
497+
assert args[1].endswith(f" {expected})") # Check text filter
498+
assert " AND " not in args[1]
496499
assert args[8] == expected # Check vector filter
497500

498501

tests/unit/test_query_types.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,11 @@ def test_text_query_with_string_filter():
300300
# Check that the generated query string includes both text search and filter
301301
query_string = str(text_query)
302302
assert f"@{text_field_name}:(search | document | 12345)" in query_string
303-
assert f"AND {string_filter}" in query_string
303+
assert (
304+
f"@{text_field_name}:(search | document | 12345) {string_filter}"
305+
in query_string
306+
)
307+
assert " AND " not in query_string
304308

305309
# Test with FilterExpression - should also work (existing functionality)
306310
filter_expression = Tag("category") == "tech"
@@ -319,7 +323,11 @@ def test_text_query_with_string_filter():
319323
f"@{text_field_name}:(search | document | 12345)"
320324
in query_string_with_filter_expr
321325
)
322-
assert "AND @category:{tech}" in query_string_with_filter_expr
326+
assert (
327+
f"@{text_field_name}:(search | document | 12345) @category:{{tech}}"
328+
in query_string_with_filter_expr
329+
)
330+
assert " AND " not in query_string_with_filter_expr
323331

324332
# Test with no filter - should only have text search
325333
text_query_no_filter = TextQuery(
@@ -331,7 +339,7 @@ def test_text_query_with_string_filter():
331339
assert f"@{text_field_name}:(search | document | 12345)" in query_string_no_filter
332340
assert "AND" not in query_string_no_filter
333341

334-
# Test with wildcard filter - should only have text search (no AND clause)
342+
# Test with wildcard filter - should only have text search (no filter clause)
335343
text_query_wildcard = TextQuery(
336344
text=text,
337345
text_field_name=text_field_name,

0 commit comments

Comments
 (0)