perf(search): skip SequenceMatcher when the ratio cannot reach the threshold

The LoRAs page search ran difflib.SequenceMatcher over every token of every
cached entry. Matching entries exit at the substring check, so the cost was
paid entirely by the entries that do not match - exactly the state the list is
in while a word is still being typed. At 100k entries one non-matching query
took 1.7-3.2s, and the handler runs that work on the event loop.

Gate the matcher with two exact necessary conditions instead of a heuristic:
2*min(len_a,len_b)/(len_a+len_b) is an upper bound on the ratio, and so is
twice the character-multiset intersection (a matching block can only use
characters present in both strings). Pairs that fail either bound provably
cannot reach the threshold, so every result is unchanged.

A non-matching query now takes 0.28-0.36s at 100k entries (6-9x faster) and
54-72ms at 20k (was 0.32-0.62s). Add a Hypothesis equivalence test against the
previous implementation, a regression test proving obvious misses never reach
the matcher, and a library-scale benchmark.

Fixes #1179
This commit is contained in:
Will Miao
2026-11-07 15:09:22 +08:00
parent ddcecdd786
commit f50cc8cc62
3 changed files with 205 additions and 0 deletions
+27
View File
@@ -1,3 +1,4 @@
from collections import Counter
from difflib import SequenceMatcher
import functools
import logging
@@ -409,6 +410,12 @@ def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
"""
Check if text matches pattern using fuzzy matching.
Returns True if similarity ratio is above threshold.
Two cheap necessary conditions gate ``SequenceMatcher``: its ratio
``2*M/(len_a+len_b)`` is bounded both by the length ratio and by the number
of characters the two strings have in common. Skipping pairs that cannot
reach ``threshold`` keeps per-keystroke searches responsive on large
libraries without changing a single result (see #1179).
"""
if not pattern or not text:
return False
@@ -428,8 +435,28 @@ def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
# If not found as substring, try fuzzy matching
# Check if any part of the text matches this word
word_len = len(word)
word_char_counts = list(Counter(word).items())
found_match = False
for text_part in text.split():
part_len = len(text_part)
total_len = word_len + part_len
min_len = part_len if part_len < word_len else word_len
# No pairing can beat 2*min_len/total_len, so a text part whose
# length is far off the query word is discarded outright.
if 2 * min_len < threshold * total_len:
continue
# Matching blocks can only use characters present in both strings,
# so the character-multiset intersection bounds the matched chars.
common = 0
for char, count in word_char_counts:
part_count = text_part.count(char)
common += count if count < part_count else part_count
if 2 * common < threshold * total_len:
continue
ratio = SequenceMatcher(None, text_part, word).ratio()
if ratio >= threshold:
found_match = True
@@ -150,6 +150,51 @@ class TestFuzzyMatchPerformance:
benchmark(match)
class TestFuzzyMatchLibraryScale:
"""Benchmark one search query over a large library (issue #1179).
Before the prefilters, a non-matching query at this size spent well over
100ms in ``SequenceMatcher`` because every token of every non-matching
entry reached the matcher.
"""
LIBRARY_SIZE = 20_000
@staticmethod
def _build_library(size: int) -> list[str]:
vocabulary = (
"anime style character realistic portrait detail lineart chinese dark "
"magic girl pixel art photo cinematic illustration concept fantasy armor "
"cyberpunk watercolor sketch vintage retro neon glow soft light shadow "
"chibi cute mecha samurai landscape nature city night flower dragon knight"
).split()
folders = ("", "anime/", "realistic/", "styles/", "characters/")
rng = random.Random(1179)
library = []
for _ in range(size):
words = rng.sample(vocabulary, k=rng.randint(2, 4))
library.append(f"{rng.choice(folders)}{'_'.join(words)}.safetensors")
return library
def test_non_matching_query(self, benchmark):
"""Worst case: nothing matches, so every entry is fully scanned."""
library = self._build_library(self.LIBRARY_SIZE)
def search():
return sum(1 for name in library if fuzzy_match(name, "zzzqq"))
assert benchmark(search) == 0
def test_typo_query(self, benchmark):
"""A typo shares characters with many tokens, so it does the most work."""
library = self._build_library(self.LIBRARY_SIZE)
def search():
return sum(1 for name in library if fuzzy_match(name, "anmei"))
benchmark(search)
class TestRecipeFingerprintPerformance:
"""Performance benchmarks for recipe fingerprint calculation."""
+133
View File
@@ -0,0 +1,133 @@
"""Equivalence tests for the ``fuzzy_match`` prefilters added for #1179.
``fuzzy_match`` now skips ``SequenceMatcher`` when its ratio provably cannot
reach ``threshold``. These tests pin the two properties that make that legal:
the optimised implementation agrees with the pre-optimisation one on every
input, and obvious misses never reach the matcher at all.
"""
from __future__ import annotations
from difflib import SequenceMatcher
import pytest
from hypothesis import given, settings, strategies as st
import py.utils.utils as utils_module
from py.utils.utils import fuzzy_match
def reference_fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
"""The implementation from before #1179, kept as the oracle."""
if not pattern or not text:
return False
text = text.lower()
pattern = pattern.lower()
for word in pattern.split():
if word in text:
continue
found_match = False
for text_part in text.split():
if SequenceMatcher(None, text_part, word).ratio() >= threshold:
found_match = True
break
if not found_match:
return False
return True
ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789 -."
TEXT = st.text(alphabet=ALPHABET, max_size=60)
PATTERN = st.text(alphabet=ALPHABET, max_size=24)
THRESHOLDS = st.sampled_from([0.0, 0.5, 0.75, 0.8, 0.85, 0.9, 1.0])
@st.composite
def similar_pairs(draw):
"""Two strings sharing a stem, to exercise the threshold boundary."""
stem = draw(st.text(alphabet="abcdefghijklmnopqrstuvwxyz_", min_size=1, max_size=10))
tail_a = draw(st.text(alphabet="abcdefghijklmnopqrstuvwxyz", max_size=3))
tail_b = draw(st.text(alphabet="abcdefghijklmnopqrstuvwxyz", max_size=3))
return stem + tail_a, stem + tail_b
@settings(max_examples=300, deadline=None)
@given(TEXT, PATTERN, THRESHOLDS)
def test_matches_reference_on_arbitrary_input(text, pattern, threshold):
assert fuzzy_match(text, pattern, threshold) == reference_fuzzy_match(
text, pattern, threshold
)
@settings(max_examples=300, deadline=None)
@given(similar_pairs(), THRESHOLDS)
def test_matches_reference_near_threshold(pair, threshold):
text, pattern = pair
assert fuzzy_match(text, pattern, threshold) == reference_fuzzy_match(
text, pattern, threshold
)
# Both argument orders: the prefilter is not allowed to be directional.
assert fuzzy_match(pattern, text, threshold) == reference_fuzzy_match(
pattern, text, threshold
)
class _CountingSequenceMatcher(SequenceMatcher):
"""SequenceMatcher that records how often ``ratio()`` is evaluated."""
calls = 0
def ratio(self) -> float:
type(self).calls += 1
return super().ratio()
@pytest.fixture
def counting_matcher(monkeypatch):
_CountingSequenceMatcher.calls = 0
monkeypatch.setattr(utils_module, "SequenceMatcher", _CountingSequenceMatcher)
return _CountingSequenceMatcher
def test_obvious_miss_never_reaches_sequence_matcher(counting_matcher):
assert fuzzy_match("photorealistic_anime_v3_fp16", "zzzqq") is False
assert counting_matcher.calls == 0
def test_length_mismatch_never_reaches_sequence_matcher(counting_matcher):
# 2 * 3 / (3 + 18) is far below the 0.85 threshold.
assert fuzzy_match("high_detail_anime_style", "abc") is False
assert counting_matcher.calls == 0
def test_near_match_still_consults_sequence_matcher(counting_matcher):
# "animee" is not a substring of "anime", so only the matcher can accept it.
assert fuzzy_match("anime", "animee") is True
assert counting_matcher.calls >= 1
@pytest.mark.parametrize(
("text", "pattern", "expected"),
[
# A leading insertion changes the first character: a prefix/first-char
# prefilter would wrongly reject this, the applied bounds do not.
("anime", "nanime", True),
("anime", "animee", True),
# A transposition scores 0.8, below the default threshold.
("anime", "anmei", False),
("anime", "animee_", False),
("Anime", "anime", True),
("anime style", "anime styl", True),
("anime", "anime style", False),
("anime_style_v2", "zzzqq", False),
("", "anime", False),
("anime", "", False),
],
)
def test_threshold_boundaries_are_preserved(text, pattern, expected):
assert fuzzy_match(text, pattern) is expected