diff --git a/py/services/recipe_fts_index.py b/py/services/recipe_fts_index.py index 84c84e92..a97e2fa9 100644 --- a/py/services/recipe_fts_index.py +++ b/py/services/recipe_fts_index.py @@ -509,21 +509,20 @@ class RecipeFTSIndex: if not fields: return term_expr - # Build field-restricted query with OR between fields + # Build field-restricted query where ALL words must match within at least one field field_clauses = [] for field in fields: if field in self.FIELD_MAP: cols = self.FIELD_MAP[field] for col in cols: - # FTS5 column filter syntax: column:term - # Need to handle multiple terms properly - for term in prefix_terms: - field_clauses.append(f'{col}:{term}') + # Create clause where ALL terms must match in this column (implicit AND) + col_terms = [f'{col}:{term}' for term in prefix_terms] + field_clauses.append('(' + ' '.join(col_terms) + ')') if not field_clauses: return term_expr - # Combine field clauses with OR + # Any field matching all terms is acceptable (OR between field clauses) return ' OR '.join(field_clauses) def _escape_fts_query(self, text: str) -> str: diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index 289acfa2..f025312d 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -632,7 +632,12 @@ class RecipeScanner: fields = None try: - return self._fts_index.search(search, fields) + result = self._fts_index.search(search, fields) + # Return None if empty to trigger fuzzy fallback + # Empty FTS results may indicate query syntax issues or need for fuzzy matching + if not result: + return None + return result except Exception as exc: logger.debug("FTS search failed, falling back to fuzzy search: %s", exc) return None diff --git a/tests/services/test_recipe_fts_index.py b/tests/services/test_recipe_fts_index.py index dbeb39a1..923fbf5f 100644 --- a/tests/services/test_recipe_fts_index.py +++ b/tests/services/test_recipe_fts_index.py @@ -274,6 +274,81 @@ class TestRecipeFTSIndexFieldRestriction: results = fts_index.search('sunset', fields={'title', 'tags'}) assert 'recipe-1' in results + def test_search_multiple_words_field_restricted(self, fts_index): + """Test that multi-word searches require ALL words to match within at least one field. + + This is a regression test for the bug where field-restricted multi-word searches + incorrectly used OR between all word+field combinations, returning recipes that + only matched some of the search words. + """ + # Create recipes that test multi-word matching: + # - recipe-1: both "cute" and "cat" in title + # - recipe-2: only "cute" in title + # - recipe-3: both words split across title and tags (should NOT match when searching title only) + # - recipe-4: both "cute" and "cat" in tags + # - recipe-5: only "cat" in title + test_recipes = [ + { + 'id': 'recipe-1', + 'title': 'cute cat photo', + 'tags': ['animal'], + 'loras': [], + 'gen_params': {}, + }, + { + 'id': 'recipe-2', + 'title': 'cute dog picture', + 'tags': ['pet'], + 'loras': [], + 'gen_params': {}, + }, + { + 'id': 'recipe-3', + 'title': 'cute', + 'tags': ['cat', 'animal'], # "cute" in title, "cat" in tags + 'loras': [], + 'gen_params': {}, + }, + { + 'id': 'recipe-4', + 'title': 'kitten image', + 'tags': ['cute', 'cat'], # both words in tags + 'loras': [], + 'gen_params': {}, + }, + { + 'id': 'recipe-5', + 'title': 'cat only', + 'tags': [], + 'loras': [], + 'gen_params': {}, + }, + ] + fts_index.build_index(test_recipes) + + # Search "cute cat" in title only - should only match recipe-1 (both words in title) + results = fts_index.search('cute cat', fields={'title'}) + assert results == {'recipe-1'}, f"Expected only recipe-1, got {results}" + + # Search "cute cat" in tags only - should only match recipe-4 (both words in tags) + results = fts_index.search('cute cat', fields={'tags'}) + assert results == {'recipe-4'}, f"Expected only recipe-4, got {results}" + + # Search "cute cat" in both title and tags - should match recipe-1 and recipe-4 + # (each has both words in one of the specified fields) + results = fts_index.search('cute cat', fields={'title', 'tags'}) + assert results == {'recipe-1', 'recipe-4'}, f"Expected recipe-1 and recipe-4, got {results}" + + # Search without field restriction - should match recipes where words appear in any indexed field + results = fts_index.search('cute cat') + # recipe-1, recipe-2 (cute), recipe-3 (cute in title, cat in tags), recipe-4, recipe-5 (cat) + # Actually, without field restriction, FTS searches all fields as one bag of content + # So any recipe with both "cute" and "cat" anywhere should match + assert 'recipe-1' in results # both in title + assert 'recipe-4' in results # both in tags + # recipe-3: "cute" in title, "cat" in tags - both words present + assert 'recipe-3' in results + class TestRecipeFTSIndexIncrementalOperations: """Tests for incremental add/remove/update operations."""