mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-05 07:40:31 +00:00
* Feature: match fuzzy terms in place inside the parsed query Fuzzy matching was a separate clause OR'd in above the query: a flat bag of the query's words, re-parsed through tantivy's own parser, blended beside the exact clause. Nothing around a term reached it, so a fielded term fuzzed across every default field, a filter did not constrain it, and an exclusion had to be hoisted back over the whole blend to stop the clause re-admitting what the query had just excluded. Widen each leaf where it sits instead, through emit()'s rewrite_leaf hook, so fielding, negation, AND, REQUIRE and positive filters constrain the fuzzy match exactly as they constrain the exact one. Each of a leaf's words becomes a Fuzzy leaf on the leaf's own field, boosted to 0.1, beside the leaf and any CJK alternative it already had. * Hello?
468 lines
16 KiB
Python
468 lines
16 KiB
Python
"""Fuzzy matching applied inside the parsed query rather than beside it.
|
|
|
|
With a threshold set, every leaf gains a near-match alternative where it
|
|
sits, so fielding, negation, AND and positive filters all constrain the
|
|
fuzzy match exactly as they constrain the exact one. The old clause was a
|
|
flat bag of words OR'd in at the top level, which none of them reached.
|
|
|
|
Threshold is 0.0 here so these tests see the matching behavior, not the
|
|
score filter. The filter has its own file, test_fuzzy_scoring.py.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
|
|
from documents.models import Document
|
|
from documents.models import StoragePath
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
from pytest_django.fixtures import SettingsWrapper
|
|
|
|
|
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _fuzzy_on(settings: SettingsWrapper) -> None:
|
|
settings.ADVANCED_FUZZY_SEARCH_THRESHOLD = 0.0
|
|
|
|
|
|
class TestStructureIsHonoured:
|
|
@pytest.fixture
|
|
def near_matches_both(
|
|
self,
|
|
index_document: Callable[..., Document],
|
|
) -> Document:
|
|
"""One document near-matching both query words, one only the first."""
|
|
both = index_document(title="A", content="invoices report")
|
|
index_document(title="B", content="invoices only")
|
|
return both
|
|
|
|
def test_a_fielded_term_fuzzes_only_that_field(
|
|
self,
|
|
index_document: Callable[..., Document],
|
|
matched_ids: Callable[[str], set[int]],
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- One document with a near-miss of the word in its title, one
|
|
with a near-miss in its content only
|
|
WHEN:
|
|
- "title:invoce" is searched
|
|
THEN:
|
|
- Only the title document matches. The old clause searched a
|
|
fielded word across every default field
|
|
"""
|
|
titled = index_document(title="Invoces", content="nothing")
|
|
index_document(title="Nothing", content="invoces here")
|
|
|
|
assert matched_ids("title:invoce") == {titled.pk}
|
|
|
|
def test_every_word_needs_a_near_match(
|
|
self,
|
|
near_matches_both: Document,
|
|
matched_ids: Callable[[str], set[int]],
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document near-matching both words, and one near-matching
|
|
only the first
|
|
WHEN:
|
|
- "invoce reprot" is searched (both words misspelled)
|
|
THEN:
|
|
- Only the document near-matching both survives. The old
|
|
clause OR'd the words, so anything near one of them matched
|
|
"""
|
|
assert matched_ids("invoce reprot") == {near_matches_both.pk}
|
|
|
|
def test_a_negated_word_is_not_fuzzed(
|
|
self,
|
|
index_document: Callable[..., Document],
|
|
matched_ids: Callable[[str], set[int]],
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document containing "taxi" and one containing "tax"
|
|
WHEN:
|
|
- "invoice NOT tax" is searched
|
|
THEN:
|
|
- The "taxi" document survives and the "tax" one does not. A
|
|
negated leaf keeps its exact side only: fuzzing it with
|
|
prefix matching would exclude every word starting near it
|
|
"""
|
|
taxi = index_document(title="A", content="invoice taxi fare")
|
|
index_document(title="B", content="invoice tax return")
|
|
|
|
assert matched_ids("invoice NOT tax") == {taxi.pk}
|
|
|
|
def test_a_negation_inside_a_branch_still_binds(
|
|
self,
|
|
index_document: Callable[..., Document],
|
|
matched_ids: Callable[[str], set[int]],
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- An "invoice" document containing "secret", one without, and
|
|
a "bill" document
|
|
WHEN:
|
|
- "(invoce AND NOT secret) OR bill" is searched
|
|
THEN:
|
|
- The secret document stays out. The old clause restated only
|
|
top-level conjunctive exclusions, so an Or branch's NOT was
|
|
never applied to the fuzzy side
|
|
"""
|
|
index_document(title="A", content="invoices secret")
|
|
clean = index_document(title="B", content="invoices only")
|
|
bill = index_document(title="C", content="bill")
|
|
|
|
assert matched_ids("(invoce AND NOT secret) OR bill") == {
|
|
clean.pk,
|
|
bill.pk,
|
|
}
|
|
|
|
def test_a_structured_filter_constrains_the_fuzzy_match(
|
|
self,
|
|
index_document: Callable[..., Document],
|
|
matched_ids: Callable[[str], set[int]],
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Two near-miss documents created in different years
|
|
WHEN:
|
|
- "created:2024 invoce" is searched
|
|
THEN:
|
|
- Only the 2024 one matches. The old blend restated negations
|
|
above the fuzzy clause but never positive constraints, so a
|
|
filter did not reach the fuzzy side at all.
|
|
|
|
created is the right field to test with: it is a DATE field,
|
|
so it has no fuzzy side of its own and cannot be widened.
|
|
type: would not test this, because it is an alias for
|
|
document_type, which is one of the five default search
|
|
fields and so gets widened like any other leaf
|
|
"""
|
|
matching = index_document(
|
|
title="A",
|
|
content="invoices",
|
|
created=datetime.date(2024, 6, 1),
|
|
)
|
|
index_document(
|
|
title="B",
|
|
content="invoices",
|
|
created=datetime.date(2023, 6, 1),
|
|
)
|
|
|
|
assert matched_ids("created:2024 invoce") == {matching.pk}
|
|
|
|
def test_a_filter_on_a_non_default_field_constrains_the_fuzzy_match(
|
|
self,
|
|
index_document: Callable[..., Document],
|
|
matched_ids: Callable[[str], set[int]],
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Two near-miss documents with different storage paths
|
|
WHEN:
|
|
- "path:archive invoce" is searched
|
|
THEN:
|
|
- Only the matching one comes back. storage_path is a TEXT
|
|
field that is NOT one of the default search fields, so it
|
|
gets no fuzzy side; the release note promises this case and
|
|
the date test above does not cover it
|
|
"""
|
|
archive = StoragePath.objects.create(name="archive", path="archive/{title}")
|
|
other = StoragePath.objects.create(name="misc", path="misc/{title}")
|
|
matching = index_document(
|
|
title="A",
|
|
content="invoices",
|
|
storage_path=archive,
|
|
)
|
|
index_document(
|
|
title="B",
|
|
content="invoices",
|
|
storage_path=other,
|
|
)
|
|
|
|
assert matched_ids("path:archive invoce") == {matching.pk}
|
|
|
|
def test_a_require_filters_on_the_widened_side_too(
|
|
self,
|
|
near_matches_both: Document,
|
|
matched_ids: Callable[[str], set[int]],
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document near-matching both words and one near-matching
|
|
only the scored side
|
|
WHEN:
|
|
- "invoce REQUIRE reprot" is searched
|
|
THEN:
|
|
- Only the document near-matching both comes back. The
|
|
filter-only side is unscored but still filters, and it is
|
|
widened like any other leaf, so a near miss satisfies it
|
|
"""
|
|
assert matched_ids("invoce REQUIRE reprot") == {near_matches_both.pk}
|
|
|
|
|
|
class TestRecallIsKept:
|
|
def test_a_typo_still_finds_its_document(
|
|
self,
|
|
index_document: Callable[..., Document],
|
|
matched_ids: Callable[[str], set[int]],
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A correctly spelled document
|
|
WHEN:
|
|
- A one-edit misspelling is searched
|
|
THEN:
|
|
- It matches. This is what the whole feature is for
|
|
"""
|
|
doc = index_document(title="A", content="invoice total")
|
|
|
|
assert matched_ids("invoce") == {doc.pk}
|
|
|
|
def test_a_hyphenated_term_matches_on_one_word(
|
|
self,
|
|
index_document: Callable[..., Document],
|
|
matched_ids: Callable[[str], set[int]],
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document holding a near-miss of one half of a hyphenated
|
|
term
|
|
WHEN:
|
|
- "COVID-19" is searched
|
|
THEN:
|
|
- It matches. The words inside a term are OR'd on the fuzzy
|
|
side, which is the per-word recall the old clause had
|
|
"""
|
|
doc = index_document(title="A", content="covidx cases")
|
|
|
|
assert matched_ids("COVID-19") == {doc.pk}
|
|
|
|
def test_a_word_the_index_cannot_hold_does_not_narrow_the_query(
|
|
self,
|
|
index_document: Callable[..., Document],
|
|
matched_ids: Callable[[str], set[int]],
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- An "invoice" document
|
|
WHEN:
|
|
- "invoice __" is searched, where __ is characters the
|
|
tokenizer discards entirely
|
|
THEN:
|
|
- It still matches. If such a word got a fuzzy alternative,
|
|
the leaf would collapse to a required clause that can never
|
|
match and the query would return less with fuzzy on than off
|
|
"""
|
|
doc = index_document(title="A", content="invoice total")
|
|
|
|
assert matched_ids("invoice __") == {doc.pk}
|
|
|
|
def test_a_one_character_word_no_longer_matches_everything(
|
|
self,
|
|
index_document: Callable[..., Document],
|
|
matched_ids: Callable[[str], set[int]],
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Two unrelated documents
|
|
WHEN:
|
|
- "x invoice" is searched, where x is a one-character word no
|
|
document holds
|
|
THEN:
|
|
- Nothing matches. One-character words get no fuzzy side, and
|
|
the exact side requires a term no document has. Today this
|
|
query matches the whole corpus, because a one-character
|
|
prefix fuzzy term matches every term in the field
|
|
"""
|
|
index_document(title="A", content="invoice total")
|
|
index_document(title="B", content="unrelated")
|
|
|
|
assert matched_ids("x invoice") == set()
|
|
|
|
|
|
class TestCjkAndFuzzyTogether:
|
|
def test_a_cjk_term_keeps_its_bigram_side_with_fuzzy_on(
|
|
self,
|
|
index_document: Callable[..., Document],
|
|
matched_ids: Callable[[str], set[int]],
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document with a CJK term inside an unspaced run
|
|
WHEN:
|
|
- That term is searched with fuzzy on
|
|
THEN:
|
|
- It matches through the bigram side, which sits in the same
|
|
Or as the fuzzy side and is unaffected by it
|
|
"""
|
|
doc = index_document(
|
|
title="A",
|
|
content="東京都の公共文書について",
|
|
)
|
|
|
|
assert matched_ids("東京") == {doc.pk}
|
|
|
|
def test_a_negated_cjk_term_keeps_its_bigram_side(
|
|
self,
|
|
index_document: Callable[..., Document],
|
|
matched_ids: Callable[[str], set[int]],
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- An "invoice" document whose only 東京 is inside a run, and
|
|
one with no 東京 at all
|
|
WHEN:
|
|
- "invoice NOT 東京" is searched with fuzzy on
|
|
THEN:
|
|
- The 東京 document is excluded. A negated leaf loses its
|
|
fuzzy side but keeps its CJK one, so the exclusion still
|
|
reaches inside the run
|
|
"""
|
|
index_document(title="A", content="invoice 東京都の報告書")
|
|
clean = index_document(title="B", content="invoice only")
|
|
|
|
assert matched_ids("invoice NOT 東京") == {clean.pk}
|
|
|
|
|
|
class TestTheHookIsSkipped:
|
|
def test_a_plain_query_passes_no_hook(
|
|
self,
|
|
index_document: Callable[..., Document],
|
|
matched_ids: Callable[[str], set[int]],
|
|
settings: SettingsWrapper,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Fuzzy off and a query with no CJK in it
|
|
WHEN:
|
|
- It is parsed
|
|
THEN:
|
|
- emit() is called once, with rewrite_leaf=None. Nothing is
|
|
widened, so the query is exactly what it was before any of
|
|
this work
|
|
"""
|
|
settings.ADVANCED_FUZZY_SEARCH_THRESHOLD = None
|
|
from documents.search import _query
|
|
|
|
calls: list[object] = []
|
|
real = _query.tantivy_emit
|
|
|
|
def spy(*args: object, **kwargs: object) -> object:
|
|
calls.append(kwargs.get("rewrite_leaf"))
|
|
return real(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(_query, "tantivy_emit", spy)
|
|
index_document(title="A", content="invoice")
|
|
|
|
matched_ids("invoice")
|
|
|
|
assert calls == [None]
|
|
|
|
|
|
class TestTheEmitRegistryIsInvisible:
|
|
def test_a_non_cjk_query_gives_the_same_result_under_either_registry(
|
|
self,
|
|
index_document: Callable[..., Document],
|
|
matched_ids: Callable[[str], set[int]],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A plain latin document and a non-CJK query with fuzzy on,
|
|
which now selects the registry carrying the internal bigram
|
|
fields even though nothing in the query is CJK
|
|
WHEN:
|
|
- The same query runs with that selection forced back to the
|
|
public registry
|
|
THEN:
|
|
- The same documents come back. The bigram fields are absent
|
|
from PUBLIC_FIELDS and the two registries agree on every
|
|
field a query can name, so adding them changes nothing for
|
|
a query that never reaches them
|
|
"""
|
|
from documents.search import _query
|
|
from documents.search._registry import get_field_registry
|
|
|
|
index_document(title="A", content="invoice total")
|
|
index_document(title="B", content="unrelated")
|
|
|
|
with_bigram_fields = matched_ids("invoce")
|
|
|
|
monkeypatch.setattr(
|
|
_query,
|
|
"_get_emit_field_registry",
|
|
lambda language: get_field_registry(language),
|
|
)
|
|
|
|
assert matched_ids("invoce") == with_bigram_fields
|
|
|
|
|
|
class TestQuotingDoesNotWiden:
|
|
@pytest.fixture
|
|
def pks(self, index_document: Callable[..., Document]) -> dict[str, int]:
|
|
"""One document per word, and one holding both."""
|
|
return {
|
|
"tax": index_document(title="A", content="tax invoice").pk,
|
|
"report": index_document(title="B", content="report invoice").pk,
|
|
"both": index_document(title="C", content="tax report").pk,
|
|
}
|
|
|
|
@pytest.mark.parametrize(
|
|
"query",
|
|
[
|
|
pytest.param("tax report", id="unquoted"),
|
|
pytest.param('"tax report"', id="quoted"),
|
|
],
|
|
)
|
|
def test_a_quoted_phrase_needs_every_word_like_the_bare_words(
|
|
self,
|
|
matched_ids: Callable[[str], set[int]],
|
|
pks: dict[str, int],
|
|
query: str,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document per word, and one holding both
|
|
WHEN:
|
|
- The words are searched unquoted and as a quoted phrase
|
|
THEN:
|
|
- Only the document holding both matches, either way. With
|
|
the phrase's words Or'd on the fuzzy side, the quoted form
|
|
matched every document, including near-misses of one word
|
|
"""
|
|
assert matched_ids(query) == {pks["both"]}
|
|
|
|
|
|
class TestCjkIsNotFuzzed:
|
|
def test_a_cjk_term_does_not_match_a_run_sharing_its_start(
|
|
self,
|
|
index_document: Callable[..., Document],
|
|
matched_ids: Callable[[str], set[int]],
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document holding 東京都 and one holding only 京都, each
|
|
inside a longer unspaced run
|
|
WHEN:
|
|
- "東京" is searched with fuzzy on
|
|
THEN:
|
|
- Only the 東京都 document matches. A prefix Fuzzy over the
|
|
whole run would match the 京都 document too, undoing what
|
|
the bigram fields' multitoken=AND guarantees with fuzzy off
|
|
"""
|
|
tokyo = index_document(title="A", content="東京都の報告書")
|
|
index_document(title="B", content="京都の観光案内について")
|
|
|
|
assert matched_ids("東京") == {tokyo.pk}
|