fix(python): accept automatic fuzziness in MatchQuery (#4467)

`MatchQuery(..., fuzziness=None)` raised a Pydantic validation error
before a search could run because the field accepted only integers.
Allow `Optional[int]`, retain the exact-match default of `0`, and make
the documented parameter type explicit. The existing Rust binding
already accepts and serializes the optional value.

Regression coverage checks construction and JSON serialization for
`None`, `0`, `1`, and `2`, plus native indexed searches with the
default, explicit exact matching, automatic mode, and one- and two-edit
controls. The two `None` regressions reproduced the original constructor
error before the fix.

Validation against the source-built extension in the locked uv
environment:

- `uv run --extra tests --extra dev maturin develop --extras tests,dev`
- `uv run --no-sync --extra tests --extra dev pytest
python/tests/test_fts.py -k 'match_query_fuzziness or fts_query_to_json'
-q` — 10 passed.
- `uv run --no-sync --extra tests --extra dev pytest
python/tests/test_fts.py -q --disable-warnings` — 61 passed.
- Locked-environment `ruff check .`, `ruff format .`, and `git diff
--check` passed.

The reported engine limitation remains observable with pinned Lance
`14.0.0-beta.1`: its dataset planner compatibility gate rewrites `None`
to zero, so a five-character AUTO query still omits one-edit matches.
Automatic expansion for longer terms requires upstream Lance activation.
The native acceptance regression uses a two-character term, whose
documented automatic distance is zero, while the JSON regression
verifies that Python forwards `None` as `null`. This keeps the repair
within the issue's requested Python constructor scope.

Fixes #4466

<!-- lance-gatekeeper-fix:v1 agent=f5f78166a4f0c7433e7890792b729c3d
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
This commit is contained in:
lancedb-gatefixer[bot]
2026-10-08 18:39:56 -07:00
committed by GitHub
parent 4487b1108c
commit b460989753
2 changed files with 31 additions and 2 deletions
+2 -2
View File
@@ -479,7 +479,7 @@ class MatchQuery(FullTextQuery):
boost : float, default 1.0
The boost factor for the query.
The score of each matching document is multiplied by this value.
fuzziness : int, optional
fuzziness : int or None, default 0
The maximum edit distance for each term in the match query.
Defaults to 0 (exact match).
If None, fuzziness is applied automatically by the rules:
@@ -506,7 +506,7 @@ class MatchQuery(FullTextQuery):
query: str
column: str
boost: float = pydantic.Field(1.0, kw_only=True)
fuzziness: int = pydantic.Field(0, kw_only=True)
fuzziness: Optional[int] = pydantic.Field(0, kw_only=True)
max_expansions: int = pydantic.Field(50, kw_only=True)
operator: FullTextOperator = pydantic.Field(FullTextOperator.OR, kw_only=True)
prefix_length: int = pydantic.Field(0, kw_only=True)
+29
View File
@@ -13,6 +13,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import json
import os
import random
import shutil
@@ -1137,6 +1138,34 @@ def test_fts_lindera_tokenizer(
assert [row["text"] for row in results] == ["成田国際空港"]
@pytest.mark.parametrize("fuzziness", [None, 0, 1, 2])
def test_match_query_fuzziness_to_json(fuzziness):
query = MatchQuery("puppy", "text", fuzziness=fuzziness)
assert query.fuzziness == fuzziness
assert json.loads(query.to_json())["match"]["fuzziness"] == fuzziness
@pytest.mark.parametrize(
"options, expected",
[
pytest.param({}, ["ab"], id="default-exact"),
pytest.param({"fuzziness": 0}, ["ab"], id="explicit-exact"),
pytest.param({"fuzziness": None}, ["ab"], id="automatic"),
pytest.param({"fuzziness": 1}, ["ab", "ac"], id="one-edit"),
pytest.param({"fuzziness": 2}, ["ab", "ac", "zz"], id="two-edits"),
],
)
def test_match_query_fuzziness_search(tmp_db, options, expected):
# Automatic fuzziness is zero for two-character terms.
table = tmp_db.create_table("fuzzy", pa.table({"text": ["ab", "ac", "zz"]}))
table.create_index("text", config=FTS(stem=False, remove_stop_words=False))
results = table.search(MatchQuery("ab", "text", **options)).to_list()
assert sorted(row["text"] for row in results) == sorted(expected)
def test_fts_query_to_json():
"""Test that FTS query to_json() produces valid JSON strings with exact format."""