diff --git a/src/basic_memory/repository/postgres_search_repository.py b/src/basic_memory/repository/postgres_search_repository.py index 1e2c60b6..92d201ff 100644 --- a/src/basic_memory/repository/postgres_search_repository.py +++ b/src/basic_memory/repository/postgres_search_repository.py @@ -243,17 +243,14 @@ class PostgresSearchRepository(SearchRepositoryBase): for char in special_chars: cleaned_term = cleaned_term.replace(char, " ") - # Sentence punctuation carries no lexical signal in tsquery either; - # strip it from word edges so question-form queries produce clean - # lexemes (parity with the SQLite FTS5 preparation). - if " " in cleaned_term: - cleaned_term = " ".join( - word.strip("?!.,;") for word in cleaned_term.split() if word.strip("?!.,;") - ) - # Handle multi-word queries if " " in cleaned_term: - words = [w for w in cleaned_term.split() if w.strip()] + # Strip sentence punctuation from word edges so question-form + # queries produce clean lexemes (parity with SQLite FTS5 prep). + # The tsquery tokenizer ignores this punctuation anyway; leaving it + # in only risks tsquery syntax errors. Interior characters are kept. + words = [w.strip("?!.,;") for w in cleaned_term.split()] + words = [w for w in words if w] if not words: # All characters were special chars, search won't match anything # Return a safe search term that won't cause syntax errors @@ -266,8 +263,11 @@ class PostgresSearchRepository(SearchRepositoryBase): # Join with AND operator return " & ".join(prepared_words) - # Single word - cleaned_term = cleaned_term.strip() + # Single word: strip edge punctuation; guard the now-empty case so a + # bare ":*"/"" never reaches tsquery. + cleaned_term = cleaned_term.strip().strip("?!.,;") + if not cleaned_term: + return "NOSPECIALCHARS:*" if is_prefix: return f"{cleaned_term}:*" else: diff --git a/tests/repository/test_postgres_search_repository.py b/tests/repository/test_postgres_search_repository.py index 953f9542..f3c497e6 100644 --- a/tests/repository/test_postgres_search_repository.py +++ b/tests/repository/test_postgres_search_repository.py @@ -1048,10 +1048,12 @@ async def test_postgres_multiword_query_relaxes_on_strict_miss(session_maker, te ) ) - # Default path stays strict: zero results, exactly as before. - strict = await repo.search(search_text="When did Melanie paint a sunrise?") + # A content word absent from the doc ("hiking") makes the strict + # all-terms-AND query miss even after Postgres drops stopwords — without + # it, to_tsquery('english', ...) already strips "when/did/a" and matches. + strict = await repo.search(search_text="Did Melanie go hiking at sunrise?") assert strict == [] - # The hybrid FTS branch opts in; relaxation surfaces the doc. - results = await repo.search(search_text="When did Melanie paint a sunrise?", allow_relaxed=True) + # The hybrid FTS branch opts in; OR-relaxation surfaces the partial match. + results = await repo.search(search_text="Did Melanie go hiking at sunrise?", allow_relaxed=True) assert any(r.id == 77 for r in results) diff --git a/tests/repository/test_search_repository.py b/tests/repository/test_search_repository.py index 088077f2..0068b3f1 100644 --- a/tests/repository/test_search_repository.py +++ b/tests/repository/test_search_repository.py @@ -1136,23 +1136,37 @@ async def test_question_punctuation_does_not_phrase_quote(search_repository): """ prepared = search_repository._prepare_single_term("When did Melanie paint a sunrise?") assert '"' not in prepared - assert "sunrise*" in prepared + # Prefix syntax differs by backend: FTS5 uses '*', tsquery uses ':*'. + if is_postgres_backend(search_repository): + assert "sunrise:*" in prepared + else: + assert "sunrise*" in prepared @pytest.mark.asyncio -async def test_relaxed_fts_text_builds_or_query(search_repository): - relaxed = search_repository._relaxed_fts_text("When did Melanie paint a sunrise?") - # Stopwords dropped: relaxation keys on content-bearing terms only. - assert relaxed == "Melanie* OR paint* OR sunrise*" +async def test_relaxed_query_drops_stopwords(search_repository): + """Relaxation keys on content-bearing terms in each backend's syntax.""" + if is_postgres_backend(search_repository): + relaxed = search_repository._relaxed_tsquery_text("When did Melanie paint a sunrise?") + assert relaxed == "Melanie:* | paint:* | sunrise:*" + else: + relaxed = search_repository._relaxed_fts_text("When did Melanie paint a sunrise?") + assert relaxed == "Melanie* OR paint* OR sunrise*" @pytest.mark.asyncio -async def test_relaxed_fts_text_respects_user_intent(search_repository): - # Explicit boolean and quoted queries are not second-guessed. - assert search_repository._relaxed_fts_text("alpha AND beta") is None - assert search_repository._relaxed_fts_text('"exact phrase"') is None - assert search_repository._relaxed_fts_text("single") == "single*" - assert search_repository._relaxed_fts_text(None) is None +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*" + assert relaxer("alpha AND beta") is None + assert relaxer('"exact phrase"') is None + assert relaxer("single") == single + assert relaxer(None) is None @pytest.mark.asyncio @@ -1177,12 +1191,13 @@ async def test_multiword_query_relaxes_to_or_when_strict_misses(search_repositor ) await search_repository.index_item(row) - # Default path stays strict: zero results, exactly as before. - strict = await search_repository.search(search_text="When did Melanie paint a sunrise?") + # "hiking" is absent from the doc, so strict all-terms-AND misses on both + # backends (Postgres's stopword stripping can't rescue it either). + strict = await search_repository.search(search_text="Did Melanie go hiking at sunrise?") assert strict == [] - # The hybrid FTS branch opts in; relaxation surfaces the doc. + # The hybrid FTS branch opts in; OR-relaxation surfaces the partial match. results = await search_repository.search( - search_text="When did Melanie paint a sunrise?", allow_relaxed=True + search_text="Did Melanie go hiking at sunrise?", allow_relaxed=True ) assert any(r.entity_id == search_entity.id for r in results)