diff --git a/src/basic_memory/repository/search_repository_base.py b/src/basic_memory/repository/search_repository_base.py index 52e2d0a8..06fd4304 100644 --- a/src/basic_memory/repository/search_repository_base.py +++ b/src/basic_memory/repository/search_repository_base.py @@ -53,14 +53,28 @@ RELAXATION_STOPWORDS = frozenset( def relaxed_query_words(search_text: Optional[str]) -> Optional[list[str]]: """Content-bearing words for OR-relaxing a strict full-text query. - Returns None when relaxation must not apply: empty input, quoted phrases, - or explicit boolean queries (user intent is not second-guessed). + Returns None when relaxation must not apply. These eligibility rules match + SearchService._is_relaxed_fts_fallback_eligible so the hybrid FTS branch + relaxes exactly the same query shapes as the service-level FTS path: + + - empty / quoted / explicit-boolean queries (user intent is not + second-guessed); + - fewer than three alphanumeric tokens (short queries like "New Feature" + over-broaden under OR — and in hybrid the relaxed FTS-only rows normalize + to 1.0 and can outrank the vector result the user wanted); + - any pure-digit token ("root note 1", "SPEC 16") — identifier-like queries + over-broaden and create false positives under OR. """ if not search_text: return None stripped = search_text.strip() if '"' in stripped or any(op in f" {stripped} " for op in (" AND ", " OR ", " NOT ")): return None + # Eligibility checks run on raw alphanumeric tokens (parity with the + # service), before stopword filtering. + tokens = re.findall(r"[A-Za-z0-9]+", stripped.lower()) + if len(tokens) < 3 or any(token.isdigit() for token in tokens): + return None words = [word.strip("?!.,;:") for word in stripped.split()] words = [ word diff --git a/tests/repository/test_search_repository.py b/tests/repository/test_search_repository.py index 0068b3f1..3a95f1da 100644 --- a/tests/repository/test_search_repository.py +++ b/tests/repository/test_search_repository.py @@ -1156,16 +1156,19 @@ async def test_relaxed_query_drops_stopwords(search_repository): @pytest.mark.asyncio async def test_relaxed_query_respects_user_intent(search_repository): - # Explicit boolean and quoted queries are not second-guessed (both backends). - if is_postgres_backend(search_repository): - relaxer = search_repository._relaxed_tsquery_text - single = "single:*" - else: - relaxer = search_repository._relaxed_fts_text - single = "single*" + # Eligibility matches the service-level relaxation (both backends): quoted, + # boolean, short (<3 tokens), and numeric-identifier queries are not relaxed. + relaxer = ( + search_repository._relaxed_tsquery_text + if is_postgres_backend(search_repository) + else search_repository._relaxed_fts_text + ) assert relaxer("alpha AND beta") is None assert relaxer('"exact phrase"') is None - assert relaxer("single") == single + assert relaxer("single") is None # < 3 tokens + assert relaxer("New Feature") is None # < 3 tokens (link title) + assert relaxer("root note 1") is None # numeric identifier token + assert relaxer("SPEC 16 design") is None # numeric token assert relaxer(None) is None