fix(recipes): eliminate O(n) fuzzy search fallback over 42k+ recipes

Drop the SequenceMatcher-based fuzzy_match fallback that froze the server
when FTS returned empty results. FTS now returns empty set for zero results
(no fallback), and when the index is not yet ready, search returns empty
rather than scanning all items in Python.
This commit is contained in:
Will Miao
2026-07-25 17:26:53 +08:00
parent 1e4c315481
commit f34c02756d
2 changed files with 25 additions and 49 deletions

View File

@@ -21,7 +21,7 @@ from .checkpoint_scanner import CheckpointScanner
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
from .recipes.errors import RecipeNotFoundError from .recipes.errors import RecipeNotFoundError
from ..utils.civitai_utils import extract_civitai_image_id from ..utils.civitai_utils import extract_civitai_image_id
from ..utils.utils import calculate_recipe_fingerprint, fuzzy_match from ..utils.utils import calculate_recipe_fingerprint
from natsort import natsorted from natsort import natsorted
import sys import sys
import re import re
@@ -1020,13 +1020,16 @@ class RecipeScanner:
try: try:
result = self._fts_index.search(search, fields) result = self._fts_index.search(search, fields)
# Return None if empty to trigger fuzzy fallback # Return empty set for empty FTS results — do NOT fall back to
# Empty FTS results may indicate query syntax issues or need for fuzzy matching # Python fuzzy matching, which freezes the server with 10k+ recipes.
# FTS5 prefix matching with unicode61 tokenizer correctly handles
# compound tokens (e.g. "illustrious" matches "path/illustrious/model").
# If FTS returns nothing, there are genuinely no matching recipes.
if not result: if not result:
return None return set()
return result return result
except Exception as exc: except Exception as exc:
logger.debug("FTS search failed, falling back to fuzzy search: %s", exc) logger.debug("FTS search failed, falling back to title-only search: %s", exc)
return None return None
def _update_fts_index_for_recipe( def _update_fts_index_for_recipe(
@@ -2079,49 +2082,14 @@ class RecipeScanner:
if str(item.get("id", "")) in fts_matching_ids if str(item.get("id", "")) in fts_matching_ids
] ]
else: else:
# Fallback to fuzzy_match (slower but always available) # FTS index not yet built — return empty rather than
# Build the search predicate based on search options # scanning 42k+ items in Python. The FTS background build
def matches_search(item): # finishes in seconds; by the time a user navigates here
# Search in title if enabled # and types a search, it is already available.
if search_options.get("title", True): logger.debug(
if fuzzy_match(str(item.get("title", "")), search): "FTS index not ready — search '%s' returning empty", search
return True )
filtered_data = []
# Search in tags if enabled
if search_options.get("tags", True) and "tags" in item:
for tag in item["tags"]:
if fuzzy_match(tag, search):
return True
# Search in lora file names if enabled
if search_options.get("lora_name", True) and "loras" in item:
for lora in item["loras"]:
if fuzzy_match(str(lora.get("file_name", "")), search):
return True
# Search in lora model names if enabled
if search_options.get("lora_model", True) and "loras" in item:
for lora in item["loras"]:
if fuzzy_match(str(lora.get("modelName", "")), search):
return True
# Search in prompt and negative_prompt if enabled
if search_options.get("prompt", True) and "gen_params" in item:
gen_params = item["gen_params"]
if fuzzy_match(str(gen_params.get("prompt", "")), search):
return True
if fuzzy_match(
str(gen_params.get("negative_prompt", "")), search
):
return True
# No match found
return False
# Filter the data using the search predicate
filtered_data = [
item for item in filtered_data if matches_search(item)
]
# Apply additional filters # Apply additional filters
if filters: if filters:

View File

@@ -77,7 +77,15 @@ def recipe_scanner(tmp_path: Path, monkeypatch):
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)]) monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
stub = StubLoraScanner() stub = StubLoraScanner()
scanner = RecipeScanner(lora_scanner=stub) scanner = RecipeScanner(lora_scanner=stub)
asyncio.run(scanner.refresh_cache(force=True))
async def _init():
await scanner.refresh_cache(force=True)
# Wait for FTS index build to finish — asyncio.run()
# cancels background tasks on return, so we must await it here.
if scanner._fts_index_task:
await scanner._fts_index_task
asyncio.run(_init())
yield scanner, stub yield scanner, stub
RecipeScanner._instance = None RecipeScanner._instance = None
settings_manager_module.reset_settings_manager() settings_manager_module.reset_settings_manager()