Skip to content

Commit 2462844

Browse files
committed
fix(core): repair FTS half of hybrid search for natural-language queries
Hybrid search was silently running vector-only on natural-language queries — the FTS branch contributed zero candidates. Two causes in the SQLite (and parallel Postgres) FTS query preparation: 1. Sentence punctuation forced phrase matching. A question like "When did Melanie paint a sunrise?" reached FTS5 as the exact phrase '"When did Melanie paint a sunrise?"*', which matches no document. The FTS5 tokenizer ignores this punctuation in the index, so stripping it from word edges loses nothing — but leaving it disabled the entire FTS contribution. _prepare_single_term now strips ?!.,;: from word edges of multi-word queries (interior characters — hyphens, slashes in permalinks/paths — untouched). 2. No relaxation when strict all-terms-AND matched nothing. Questions rarely have every word in one document, so even after (1) the strict AND returned zero rows. The hybrid path now retries once with an OR-joined, stopword-filtered, content-term query when the strict query is empty. bm25/ts_rank still rank multi-term matches first, and fusion with the vector branch keeps relaxed lexical candidates from dominating precision. The relaxation is gated behind a new allow_relaxed=False parameter on SearchRepositoryBase.search; only _search_hybrid opts in. Strict FTS behavior (search_type=text, title, permalink, link resolution) is unchanged — the service layer keeps its own conservative fallback. No config flag, default-safe. Discovered via the benchmark harness: two different fusion algorithms produced byte-identical rankings across 1,986 queries (impossible with two live sources), and instrumentation confirmed fts=0 on 40/40 sampled LoCoMo queries. Benchmark impact (corrected LoCoMo, 1,986 queries, same index, retrieval metrics — every category improves, no regression): recall@5 0.745 -> 0.823 (+7.9) MRR 0.618 -> 0.718 (+10.0) headline r5 0.734 -> 0.801, MRR 0.621 -> 0.706 Largest gains on open_domain (+0.10 r5) and adversarial (+0.12 r5); smallest on temporal (+0.003 r5 / +0.02 MRR). Tests: punctuation no longer phrase-quotes; relaxation builds the expected OR query and respects boolean/quoted/short-query intent; the hybrid opt-in surfaces a partial-overlap document while the default strict path still returns empty. Parallel coverage for Postgres. Full SQLite unit suite green (2968 passed); ty + ruff clean. Signed-off-by: Drew Cain <groksrc@gmail.com>
1 parent d46c688 commit 2462844

9 files changed

Lines changed: 235 additions & 2 deletions

src/basic_memory/repository/postgres_search_repository.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from basic_memory.repository.search_repository_base import (
1919
SearchRepositoryBase,
2020
VectorChunkState,
21+
relaxed_query_words,
2122
)
2223
from basic_memory.repository.metadata_filters import parse_metadata_filters
2324
from basic_memory.repository.semantic_errors import SemanticDependenciesMissingError
@@ -176,6 +177,14 @@ def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
176177
# For non-Boolean queries, prepare single term
177178
return self._prepare_single_term(term, is_prefix)
178179

180+
@staticmethod
181+
def _relaxed_tsquery_text(search_text: Optional[str]) -> Optional[str]:
182+
"""OR-relaxed tsquery expression for a failed strict query, or None."""
183+
words = relaxed_query_words(search_text)
184+
if not words:
185+
return None
186+
return " | ".join(f"{word}:*" for word in words)
187+
179188
def _prepare_boolean_query(self, query: str) -> str:
180189
"""Convert Boolean query to tsquery format.
181190
@@ -234,6 +243,14 @@ def _prepare_single_term(self, term: str, is_prefix: bool = True) -> str:
234243
for char in special_chars:
235244
cleaned_term = cleaned_term.replace(char, " ")
236245

246+
# Sentence punctuation carries no lexical signal in tsquery either;
247+
# strip it from word edges so question-form queries produce clean
248+
# lexemes (parity with the SQLite FTS5 preparation).
249+
if " " in cleaned_term:
250+
cleaned_term = " ".join(
251+
word.strip("?!.,;") for word in cleaned_term.split() if word.strip("?!.,;")
252+
)
253+
237254
# Handle multi-word queries
238255
if " " in cleaned_term:
239256
words = [w for w in cleaned_term.split() if w.strip()]
@@ -908,6 +925,7 @@ async def search(
908925
min_similarity: Optional[float] = None,
909926
limit: int = 10,
910927
offset: int = 0,
928+
allow_relaxed: bool = False,
911929
) -> List[SearchIndexRow]:
912930
"""Search across all indexed content using PostgreSQL tsvector."""
913931
# --- Dispatch vector / hybrid modes (shared logic) ---
@@ -982,6 +1000,20 @@ async def search(
9821000
async with db.scoped_session(self.session_maker) as session:
9831001
result = await session.execute(text(sql), params)
9841002
rows = result.fetchall()
1003+
# Trigger: multi-word natural-language query matched nothing
1004+
# under the default all-terms-AND tsquery semantics.
1005+
# Why: questions rarely have every word in one document;
1006+
# without relaxation the FTS half of hybrid search contributes
1007+
# zero candidates (parity with the SQLite path).
1008+
# Outcome: one retry with OR-joined prefix lexemes; ts_rank
1009+
# still ranks multi-term matches first.
1010+
relaxed = (
1011+
self._relaxed_tsquery_text(search_text) if allow_relaxed and not rows else None
1012+
)
1013+
if relaxed and params.get("text"):
1014+
params["text"] = relaxed
1015+
result = await session.execute(text(sql), params)
1016+
rows = result.fetchall()
9851017
except Exception as e:
9861018
if self._is_tsquery_syntax_error(e):
9871019
logger.warning(f"tsquery syntax error for search term: {search_text}, error: {e}")

src/basic_memory/repository/search_repository_base.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,36 @@
4040
OVERSIZED_ENTITY_VECTOR_SHARD_SIZE = 256
4141
_SQLITE_MAX_PREPARE_WINDOW = 8
4242

43+
# Interrogative/function words contribute lexical noise when a strict
44+
# full-text query is relaxed: "when OR did OR a" matches loud wrong documents
45+
# that displace genuine results from the ranking window.
46+
RELAXATION_STOPWORDS = frozenset(
47+
"a an and are as at be but by did do does for from had has have how i in is it of on "
48+
"or that the their they this to was we were what when where which who whom whose why "
49+
"will with you your".split()
50+
)
51+
52+
53+
def relaxed_query_words(search_text: Optional[str]) -> Optional[list[str]]:
54+
"""Content-bearing words for OR-relaxing a strict full-text query.
55+
56+
Returns None when relaxation must not apply: empty input, quoted phrases,
57+
or explicit boolean queries (user intent is not second-guessed).
58+
"""
59+
if not search_text:
60+
return None
61+
stripped = search_text.strip()
62+
if '"' in stripped or any(op in f" {stripped} " for op in (" AND ", " OR ", " NOT ")):
63+
return None
64+
words = [word.strip("?!.,;:") for word in stripped.split()]
65+
words = [
66+
word
67+
for word in words
68+
if word and word.isalnum() and word.lower() not in RELAXATION_STOPWORDS
69+
]
70+
return words or None
71+
72+
4373
# Entity, observation, and relation rows in search_index carry ids from independent
4474
# auto-increment sequences, so a bare id is ambiguous across row types. Every map in
4575
# the vector/hybrid retrieval path must key rows by (type, id) to avoid collisions.
@@ -229,6 +259,7 @@ async def search(
229259
min_similarity: Optional[float] = None,
230260
limit: int = 10,
231261
offset: int = 0,
262+
allow_relaxed: bool = False,
232263
) -> List[SearchIndexRow]:
233264
"""Search across all indexed content.
234265
@@ -2174,6 +2205,9 @@ async def _search_hybrid(
21742205
query_start = time.perf_counter()
21752206
candidate_limit = max(self._semantic_vector_k, (limit + offset) * 10)
21762207
fts_start = time.perf_counter()
2208+
# allow_relaxed: question-form queries rarely AND-match, and a dead FTS
2209+
# branch silently degrades hybrid to vector-only ranking. Fusion plus
2210+
# bm25 keep relaxed lexical candidates from dominating precision.
21772211
fts_results = await self.search(
21782212
search_text=search_text,
21792213
permalink=permalink,
@@ -2187,6 +2221,7 @@ async def _search_hybrid(
21872221
retrieval_mode=SearchRetrievalMode.FTS,
21882222
limit=candidate_limit,
21892223
offset=0,
2224+
allow_relaxed=True,
21902225
)
21912226
fts_ms = (time.perf_counter() - fts_start) * 1000
21922227
vector_start = time.perf_counter()

src/basic_memory/repository/sqlite_search_repository.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@
2323
from basic_memory.repository.embedding_provider import EmbeddingProvider
2424
from basic_memory.repository.embedding_provider_factory import create_embedding_provider
2525
from basic_memory.repository.search_index_row import SearchIndexRow
26-
from basic_memory.repository.search_repository_base import SearchRepositoryBase
26+
from basic_memory.repository.search_repository_base import (
27+
SearchRepositoryBase,
28+
relaxed_query_words,
29+
)
2730
from basic_memory.repository.metadata_filters import parse_metadata_filters, build_sqlite_json_path
2831
from basic_memory.repository.semantic_errors import SemanticDependenciesMissingError
2932
from basic_memory.schemas.search import SearchItemType, SearchRetrievalMode
@@ -255,6 +258,19 @@ def _prepare_single_term(self, term: str, is_prefix: bool = True) -> str:
255258
if "*" in term and all(c.isalnum() or c in "*_-" for c in term):
256259
return term
257260

261+
# Natural-language queries arrive with sentence punctuation that FTS5
262+
# treats as syntax ("When did Melanie paint a sunrise?"). The tokenizer
263+
# ignores this punctuation in the INDEX, so stripping it from word
264+
# edges loses nothing — but leaving it forces the whole question into
265+
# an exact-phrase match that returns zero rows, silently disabling the
266+
# FTS half of hybrid search. Interior characters (hyphens, slashes —
267+
# permalinks and paths) are untouched.
268+
if " " in term:
269+
words = [word.strip("?!.,;:") for word in term.split()]
270+
term = " ".join(word for word in words if word)
271+
if not term:
272+
return ""
273+
258274
# Characters that can cause FTS5 syntax errors when used as operators
259275
# We're more conservative here - only quote when we detect problematic patterns
260276
problematic_chars = [
@@ -351,6 +367,14 @@ def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
351367
# For non-Boolean queries, use the single term preparation logic
352368
return self._prepare_single_term(term, is_prefix)
353369

370+
@staticmethod
371+
def _relaxed_fts_text(search_text: Optional[str]) -> Optional[str]:
372+
"""OR-relaxed FTS5 expression for a failed strict query, or None."""
373+
words = relaxed_query_words(search_text)
374+
if not words:
375+
return None
376+
return " OR ".join(f"{word}*" for word in words)
377+
354378
# ------------------------------------------------------------------
355379
# sqlite-vec extension loading (SQLite-specific)
356380
# ------------------------------------------------------------------
@@ -953,8 +977,15 @@ async def search(
953977
min_similarity: Optional[float] = None,
954978
limit: int = 10,
955979
offset: int = 0,
980+
allow_relaxed: bool = False,
956981
) -> List[SearchIndexRow]:
957-
"""Search across all indexed content using SQLite FTS5."""
982+
"""Search across all indexed content using SQLite FTS5.
983+
984+
``allow_relaxed=True`` retries a zero-result strict multi-word query
985+
with OR-joined content terms. Only the hybrid path opts in: its FTS
986+
branch otherwise contributes nothing for question-form queries.
987+
Service-level FTS searches keep their own conservative fallback.
988+
"""
958989
# --- Dispatch vector / hybrid modes (shared logic) ---
959990
dispatched = await self._dispatch_retrieval_mode(
960991
search_text=search_text,
@@ -1021,6 +1052,21 @@ async def search(
10211052
async with db.scoped_session(self.session_maker) as session:
10221053
result = await session.execute(text(sql), params)
10231054
rows = result.fetchall()
1055+
# Trigger: multi-word natural-language query matched nothing
1056+
# under the default all-terms-AND semantics.
1057+
# Why: questions ("when did X do Y") rarely have every word in
1058+
# one document; without relaxation the FTS half of hybrid
1059+
# search contributes zero candidates and ranking degrades to
1060+
# vector-only.
1061+
# Outcome: one retry with OR-joined prefix terms; bm25 still
1062+
# ranks multi-term matches first.
1063+
relaxed = (
1064+
self._relaxed_fts_text(search_text) if allow_relaxed and not rows else None
1065+
)
1066+
if relaxed and params.get("text"):
1067+
params["text"] = relaxed
1068+
result = await session.execute(text(sql), params)
1069+
rows = result.fetchall()
10241070
except Exception as e:
10251071
# Handle FTS5 syntax errors and provide user-friendly feedback
10261072
if self._is_fts5_syntax_error(e): # pragma: no cover

tests/repository/test_hybrid_fusion.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ async def search(
7777
min_similarity: Optional[float] = None,
7878
limit: int = 10,
7979
offset: int = 0,
80+
allow_relaxed: bool = False,
8081
) -> list[SearchIndexRow]:
8182
return [] # pragma: no cover
8283

tests/repository/test_postgres_search_repository.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,3 +1001,57 @@ async def test_postgres_search_categories_exact_match(session_maker, test_projec
10011001
# Multiple categories union.
10021002
multi = await repo.search(categories=["requirement", "decision"])
10031003
assert {r.id for r in multi} == {70101, 70102}
1004+
1005+
1006+
@pytest.mark.asyncio
1007+
async def test_postgres_question_punctuation_and_relaxation(session_maker, test_project):
1008+
"""Question-form queries must produce clean lexemes and a usable relaxation.
1009+
1010+
Parity with SQLite: sentence punctuation previously reached tsquery terms,
1011+
and a strict all-AND miss had no relaxed retry, silently disabling the FTS
1012+
half of hybrid search for natural-language questions.
1013+
"""
1014+
repo = PostgresSearchRepository(session_maker, project_id=test_project.id)
1015+
1016+
# Edge punctuation stripped before lexeme formatting.
1017+
prepared = repo._prepare_search_term("When did Melanie paint a sunrise?")
1018+
assert "?" not in prepared
1019+
assert "sunrise:*" in prepared
1020+
1021+
# Relaxation drops stopwords and OR-joins content terms.
1022+
relaxed = repo._relaxed_tsquery_text("When did Melanie paint a sunrise?")
1023+
assert relaxed == "Melanie:* | paint:* | sunrise:*"
1024+
1025+
# User intent is not second-guessed.
1026+
assert repo._relaxed_tsquery_text("alpha AND beta") is None
1027+
assert repo._relaxed_tsquery_text('"exact phrase"') is None
1028+
assert repo._relaxed_tsquery_text(None) is None
1029+
1030+
1031+
@pytest.mark.asyncio
1032+
async def test_postgres_multiword_query_relaxes_on_strict_miss(session_maker, test_project):
1033+
repo = PostgresSearchRepository(session_maker, project_id=test_project.id)
1034+
now = datetime.now(timezone.utc)
1035+
await repo.index_item(
1036+
SearchIndexRow(
1037+
project_id=test_project.id,
1038+
id=77,
1039+
title="Trip plans",
1040+
content_stems="melanie painted a sunrise over the lake last year",
1041+
content_snippet="Melanie painted a sunrise over the lake last year.",
1042+
permalink="docs/trip-plans",
1043+
file_path="docs/trip-plans.md",
1044+
type="entity",
1045+
metadata={"note_type": "note"},
1046+
created_at=now,
1047+
updated_at=now,
1048+
)
1049+
)
1050+
1051+
# Default path stays strict: zero results, exactly as before.
1052+
strict = await repo.search(search_text="When did Melanie paint a sunrise?")
1053+
assert strict == []
1054+
1055+
# The hybrid FTS branch opts in; relaxation surfaces the doc.
1056+
results = await repo.search(search_text="When did Melanie paint a sunrise?", allow_relaxed=True)
1057+
assert any(r.id == 77 for r in results)

tests/repository/test_search_repository.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,3 +1124,65 @@ async def test_search_categories_exact_match(search_repository, search_entity):
11241124
# Multiple categories union: both observations come back.
11251125
multi = await search_repository.search(categories=["requirement", "decision"])
11261126
assert {r.id for r in multi} == {70001, 70002}
1127+
1128+
1129+
@pytest.mark.asyncio
1130+
async def test_question_punctuation_does_not_phrase_quote(search_repository):
1131+
"""Sentence punctuation must not force exact-phrase matching (#hybrid-fts).
1132+
1133+
'When did Melanie paint a sunrise?' previously became the FTS5 phrase
1134+
'"When did Melanie paint a sunrise?"*' — zero rows for any corpus — which
1135+
silently disabled the FTS half of hybrid search for question queries.
1136+
"""
1137+
prepared = search_repository._prepare_single_term("When did Melanie paint a sunrise?")
1138+
assert '"' not in prepared
1139+
assert "sunrise*" in prepared
1140+
1141+
1142+
@pytest.mark.asyncio
1143+
async def test_relaxed_fts_text_builds_or_query(search_repository):
1144+
relaxed = search_repository._relaxed_fts_text("When did Melanie paint a sunrise?")
1145+
# Stopwords dropped: relaxation keys on content-bearing terms only.
1146+
assert relaxed == "Melanie* OR paint* OR sunrise*"
1147+
1148+
1149+
@pytest.mark.asyncio
1150+
async def test_relaxed_fts_text_respects_user_intent(search_repository):
1151+
# Explicit boolean and quoted queries are not second-guessed.
1152+
assert search_repository._relaxed_fts_text("alpha AND beta") is None
1153+
assert search_repository._relaxed_fts_text('"exact phrase"') is None
1154+
assert search_repository._relaxed_fts_text("single") == "single*"
1155+
assert search_repository._relaxed_fts_text(None) is None
1156+
1157+
1158+
@pytest.mark.asyncio
1159+
async def test_multiword_query_relaxes_to_or_when_strict_misses(search_repository, search_entity):
1160+
"""A question sharing only SOME words with a doc still surfaces it."""
1161+
from basic_memory.repository.search_index_row import SearchIndexRow
1162+
from basic_memory.schemas.search import SearchItemType
1163+
1164+
row = SearchIndexRow(
1165+
project_id=search_repository.project_id,
1166+
id=search_entity.id,
1167+
type=SearchItemType.ENTITY.value,
1168+
title="Trip plans",
1169+
content_snippet="Melanie painted a sunrise over the lake last year.",
1170+
content_stems="melanie painted a sunrise over the lake last year",
1171+
permalink=search_entity.permalink,
1172+
file_path=search_entity.file_path,
1173+
entity_id=search_entity.id,
1174+
metadata={"note_type": search_entity.note_type},
1175+
created_at=search_entity.created_at,
1176+
updated_at=search_entity.updated_at,
1177+
)
1178+
await search_repository.index_item(row)
1179+
1180+
# Default path stays strict: zero results, exactly as before.
1181+
strict = await search_repository.search(search_text="When did Melanie paint a sunrise?")
1182+
assert strict == []
1183+
1184+
# The hybrid FTS branch opts in; relaxation surfaces the doc.
1185+
results = await search_repository.search(
1186+
search_text="When did Melanie paint a sunrise?", allow_relaxed=True
1187+
)
1188+
assert any(r.entity_id == search_entity.id for r in results)

tests/repository/test_semantic_search_base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ async def search(
8989
min_similarity: float | None = None,
9090
limit: int = 10,
9191
offset: int = 0,
92+
allow_relaxed: bool = False,
9293
) -> list[SearchIndexRow]:
9394
return []
9495

tests/repository/test_vector_pagination.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ async def search(
6262
min_similarity: float | None = None,
6363
limit: int = 10,
6464
offset: int = 0,
65+
allow_relaxed: bool = False,
6566
) -> list[SearchIndexRow]:
6667
return [] # pragma: no cover
6768

tests/repository/test_vector_threshold.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ async def search(
6666
min_similarity: Optional[float] = None,
6767
limit: int = 10,
6868
offset: int = 0,
69+
allow_relaxed: bool = False,
6970
) -> list[SearchIndexRow]:
7071
return [] # pragma: no cover
7172

0 commit comments

Comments
 (0)