|
23 | 23 | from basic_memory.repository.embedding_provider import EmbeddingProvider |
24 | 24 | from basic_memory.repository.embedding_provider_factory import create_embedding_provider |
25 | 25 | 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 | +) |
27 | 30 | from basic_memory.repository.metadata_filters import parse_metadata_filters, build_sqlite_json_path |
28 | 31 | from basic_memory.repository.semantic_errors import SemanticDependenciesMissingError |
29 | 32 | from basic_memory.schemas.search import SearchItemType, SearchRetrievalMode |
@@ -255,6 +258,19 @@ def _prepare_single_term(self, term: str, is_prefix: bool = True) -> str: |
255 | 258 | if "*" in term and all(c.isalnum() or c in "*_-" for c in term): |
256 | 259 | return term |
257 | 260 |
|
| 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 | + |
258 | 274 | # Characters that can cause FTS5 syntax errors when used as operators |
259 | 275 | # We're more conservative here - only quote when we detect problematic patterns |
260 | 276 | problematic_chars = [ |
@@ -351,6 +367,14 @@ def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str: |
351 | 367 | # For non-Boolean queries, use the single term preparation logic |
352 | 368 | return self._prepare_single_term(term, is_prefix) |
353 | 369 |
|
| 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 | + |
354 | 378 | # ------------------------------------------------------------------ |
355 | 379 | # sqlite-vec extension loading (SQLite-specific) |
356 | 380 | # ------------------------------------------------------------------ |
@@ -953,8 +977,15 @@ async def search( |
953 | 977 | min_similarity: Optional[float] = None, |
954 | 978 | limit: int = 10, |
955 | 979 | offset: int = 0, |
| 980 | + allow_relaxed: bool = False, |
956 | 981 | ) -> 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 | + """ |
958 | 989 | # --- Dispatch vector / hybrid modes (shared logic) --- |
959 | 990 | dispatched = await self._dispatch_retrieval_mode( |
960 | 991 | search_text=search_text, |
@@ -1021,6 +1052,21 @@ async def search( |
1021 | 1052 | async with db.scoped_session(self.session_maker) as session: |
1022 | 1053 | result = await session.execute(text(sql), params) |
1023 | 1054 | 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() |
1024 | 1070 | except Exception as e: |
1025 | 1071 | # Handle FTS5 syntax errors and provide user-friendly feedback |
1026 | 1072 | if self._is_fts5_syntax_error(e): # pragma: no cover |
|
0 commit comments