Skip to content

Commit 2e2211b

Browse files
committed
complete MLT options
1 parent 5611553 commit 2e2211b

5 files changed

Lines changed: 227 additions & 7 deletions

File tree

examples/more_like_this.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@
1414
- Content discovery and exploration
1515
"""
1616

17-
import json
18-
1917
from _common import MockItem, setup_mock_items
2018

2119
from paradedb.functions import Score
@@ -99,12 +97,12 @@ def demo_similar_by_text() -> None:
9997
user_description = "comfortable wireless audio for running"
10098
print(f"\nUser wants: '{user_description}'")
10199

102-
# MoreLikeThis with text requires JSON format: {"field": "text"}
103-
text_json = json.dumps({"description": user_description})
104-
100+
# MoreLikeThis with text requires fields parameter
105101
print("\nMatching products:")
106102
similar = (
107-
MockItem.objects.filter(MoreLikeThis(text=text_json))
103+
MockItem.objects.filter(
104+
MoreLikeThis(text=user_description, fields=["description"])
105+
)
108106
.annotate(score=Score())
109107
.order_by("-score")[:5]
110108
)

src/paradedb/search.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ def __init__(
126126
min_doc_freq: int | None = None,
127127
max_term_freq: int | None = None,
128128
max_doc_freq: int | None = None,
129+
min_word_length: int | None = None,
130+
max_word_length: int | None = None,
131+
stopwords: Iterable[str] | None = None,
129132
) -> None:
130133
super().__init__()
131134
self.product_id = product_id
@@ -138,6 +141,9 @@ def __init__(
138141
self.min_doc_freq = min_doc_freq
139142
self.max_term_freq = max_term_freq
140143
self.max_doc_freq = max_doc_freq
144+
self.min_word_length = min_word_length
145+
self.max_word_length = max_word_length
146+
self.stopwords = list(stopwords) if stopwords is not None else None
141147
self._validate()
142148

143149
def _validate(self) -> None:
@@ -246,6 +252,9 @@ def _get_options(self) -> dict[str, object | None]:
246252
"min_doc_frequency": self.min_doc_freq,
247253
"max_term_frequency": self.max_term_freq,
248254
"max_doc_frequency": self.max_doc_freq,
255+
"min_word_length": self.min_word_length,
256+
"max_word_length": self.max_word_length,
257+
"stopwords": self.stopwords,
249258
}
250259

251260
@staticmethod
@@ -259,7 +268,12 @@ def _render_options(options: dict[str, object | None]) -> str:
259268
for key, value in options.items():
260269
if value is None:
261270
continue
262-
rendered.append(f"{key} => {value}")
271+
# Handle stopwords array
272+
if key == "stopwords" and isinstance(value, list):
273+
quoted = "'" + "','".join(w.replace("'", "''") for w in value) + "'"
274+
rendered.append(f"{key} => ARRAY[{quoted}]")
275+
else:
276+
rendered.append(f"{key} => {value}")
263277
if not rendered:
264278
return ""
265279
return ", " + ", ".join(rendered)

tests/integration/test_paradedb_queries.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,127 @@ def test_more_like_this_by_document() -> None:
134134
assert ids == {12}
135135

136136

137+
def test_more_like_this_with_stopwords() -> None:
138+
"""MLT with stopwords excludes terms from matching - verified by comparing results."""
139+
# Get baseline results without stopwords
140+
baseline_ids = _ids(
141+
MockItem.objects.filter(
142+
MoreLikeThis(
143+
product_id=3, # "Sleek running shoes"
144+
fields=["description"],
145+
)
146+
)
147+
)
148+
149+
# Get results with "shoes" as stopword
150+
with_stopword_ids = _ids(
151+
MockItem.objects.filter(
152+
MoreLikeThis(
153+
product_id=3,
154+
fields=["description"],
155+
stopwords=["shoes"],
156+
)
157+
)
158+
)
159+
160+
# Source item always included in both
161+
assert 3 in baseline_ids
162+
assert 3 in with_stopword_ids
163+
164+
# With "shoes" as stopword, results should be different
165+
# (fewer matches since "shoes" term is excluded from matching)
166+
assert (
167+
with_stopword_ids != baseline_ids
168+
), "Stopwords should change the results - excluding 'shoes' should remove shoe-related matches"
169+
170+
# Typically, stopwords should reduce the number of matches
171+
# (unless all matches are from other terms like "running")
172+
assert len(with_stopword_ids) <= len(
173+
baseline_ids
174+
), "Stopwords should not increase match count"
175+
176+
177+
def test_more_like_this_with_word_length() -> None:
178+
"""MLT with min/max word length filters terms - verified by comparing results."""
179+
# Get baseline without word length filter
180+
baseline_ids = _ids(
181+
MockItem.objects.filter(
182+
MoreLikeThis(
183+
product_id=3, # "Sleek running shoes"
184+
fields=["description"],
185+
)
186+
)
187+
)
188+
189+
# Get results with min_word_length=6 (filters out "shoes" which is 5 chars)
190+
with_min_length_ids = _ids(
191+
MockItem.objects.filter(
192+
MoreLikeThis(
193+
product_id=3,
194+
fields=["description"],
195+
min_word_length=6,
196+
)
197+
)
198+
)
199+
200+
# Source item always included
201+
assert 3 in baseline_ids
202+
assert 3 in with_min_length_ids
203+
204+
# Results should differ when short words are filtered
205+
assert (
206+
with_min_length_ids != baseline_ids
207+
), "min_word_length=6 should filter out 'shoes' (5 chars) and change results"
208+
209+
210+
def test_more_like_this_stopwords_reversible() -> None:
211+
"""Verify stopwords effect is consistent and reversible."""
212+
# First query with stopwords
213+
ids_with = _ids(
214+
MockItem.objects.filter(
215+
MoreLikeThis(
216+
product_id=3,
217+
fields=["description"],
218+
stopwords=["shoes"], # The main matching term
219+
)
220+
)
221+
)
222+
223+
# Second query without stopwords (baseline)
224+
ids_without = _ids(
225+
MockItem.objects.filter(
226+
MoreLikeThis(
227+
product_id=3,
228+
fields=["description"],
229+
)
230+
)
231+
)
232+
233+
# Third query with same stopwords again - should be consistent
234+
ids_with_again = _ids(
235+
MockItem.objects.filter(
236+
MoreLikeThis(
237+
product_id=3,
238+
fields=["description"],
239+
stopwords=["shoes"],
240+
)
241+
)
242+
)
243+
244+
# Stopwords should produce consistent results (deterministic)
245+
assert ids_with == ids_with_again, "Same stopwords should produce same results"
246+
247+
# With "shoes" as stopword, we should have fewer or equal matches
248+
# (excluding the main matching term reduces similarity matches)
249+
assert len(ids_with) <= len(
250+
ids_without
251+
), "Stopwords should not increase match count"
252+
253+
# Source item always included regardless of stopwords
254+
assert 3 in ids_with
255+
assert 3 in ids_without
256+
257+
137258
def test_metadata_color_literal_search() -> None:
138259
"""Search over JSON color subfield should return the silver items."""
139260
with connection.cursor() as cursor:

tests/test_edge_cases.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,9 +266,35 @@ def test_mlt_with_all_options(self) -> None:
266266
min_doc_freq=1,
267267
max_term_freq=100,
268268
max_doc_freq=1000,
269+
min_word_length=3,
270+
max_word_length=15,
271+
stopwords=["the", "a"],
269272
)
270273
assert mlt.min_term_freq == 2
271274
assert mlt.max_query_terms == 10
275+
assert mlt.min_word_length == 3
276+
assert mlt.max_word_length == 15
277+
assert mlt.stopwords == ["the", "a"]
278+
279+
def test_mlt_stopwords_empty_list(self) -> None:
280+
"""MLT with empty stopwords list works."""
281+
mlt = MoreLikeThis(product_id=1, stopwords=[])
282+
assert mlt.stopwords == []
283+
284+
def test_mlt_stopwords_tuple(self) -> None:
285+
"""MLT with stopwords as tuple converts to list."""
286+
mlt = MoreLikeThis(product_id=1, stopwords=("the", "a", "an"))
287+
assert mlt.stopwords == ["the", "a", "an"]
288+
289+
def test_mlt_word_length_validation(self) -> None:
290+
"""MLT word length parameters accept integers."""
291+
mlt = MoreLikeThis(
292+
product_id=1,
293+
min_word_length=2,
294+
max_word_length=20,
295+
)
296+
assert isinstance(mlt.min_word_length, int)
297+
assert isinstance(mlt.max_word_length, int)
272298

273299

274300
class TestScoreEdgeCases:

tests/test_sql_generation.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,67 @@ def test_mlt_by_document(self) -> None:
415415
== 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."id" @@@ pdb.more_like_this(\'{"description":"running shoes","category":"Footwear"}\')'
416416
)
417417

418+
def test_mlt_with_word_length(self) -> None:
419+
"""MLT with min/max word length parameters."""
420+
queryset = Product.objects.filter(
421+
MoreLikeThis(product_id=5, min_word_length=3, max_word_length=15)
422+
)
423+
sql = str(queryset.query)
424+
assert "min_word_length => 3" in sql
425+
assert "max_word_length => 15" in sql
426+
assert "pdb.more_like_this(5," in sql
427+
428+
def test_mlt_with_stopwords(self) -> None:
429+
"""MLT with stopwords array parameter."""
430+
queryset = Product.objects.filter(
431+
MoreLikeThis(product_id=5, stopwords=["the", "a", "an"])
432+
)
433+
sql = str(queryset.query)
434+
assert "stopwords => ARRAY['the','a','an']" in sql
435+
436+
def test_mlt_with_all_options(self) -> None:
437+
"""MLT with all available options including new ones."""
438+
queryset = Product.objects.filter(
439+
MoreLikeThis(
440+
product_id=5,
441+
fields=["description"],
442+
min_term_freq=2,
443+
max_query_terms=10,
444+
min_doc_freq=1,
445+
max_term_freq=100,
446+
max_doc_freq=1000,
447+
min_word_length=3,
448+
max_word_length=20,
449+
stopwords=["the", "and", "or"],
450+
)
451+
)
452+
sql = str(queryset.query)
453+
assert "min_term_frequency => 2" in sql
454+
assert "max_query_terms => 10" in sql
455+
assert "min_doc_frequency => 1" in sql
456+
assert "max_term_frequency => 100" in sql
457+
assert "max_doc_frequency => 1000" in sql
458+
assert "min_word_length => 3" in sql
459+
assert "max_word_length => 20" in sql
460+
assert "stopwords => ARRAY['the','and','or']" in sql
461+
assert "ARRAY['description']" in sql
462+
463+
def test_mlt_text_with_new_options(self) -> None:
464+
"""MLT with text input and new word length/stopwords options."""
465+
queryset = Product.objects.filter(
466+
MoreLikeThis(
467+
text="comfortable running shoes",
468+
fields=["description"],
469+
min_word_length=4,
470+
max_word_length=12,
471+
stopwords=["comfortable"],
472+
)
473+
)
474+
sql = str(queryset.query)
475+
assert "min_word_length => 4" in sql
476+
assert "max_word_length => 12" in sql
477+
assert "stopwords => ARRAY['comfortable']" in sql
478+
418479

419480
class TestDjangoIntegration:
420481
"""Test Django ORM integration."""

0 commit comments

Comments
 (0)