diff --git a/src/powermem/core/async_memory.py b/src/powermem/core/async_memory.py index 1538df289..e5847e7ee 100644 --- a/src/powermem/core/async_memory.py +++ b/src/powermem/core/async_memory.py @@ -1095,20 +1095,20 @@ async def search( query_embedding = None if retrieval_mode != "fts": if self._is_embedding_disabled(): - return { - "results": [], - "relations": [] - } - try: - query_embedding = await asyncio.to_thread( - embedding_service.embed, query, memory_action="search" - ) - except Exception as exc: - logger.warning( - "Search embedding failed; falling back to text search " - "when available: %s", - exc, + logger.info( + "Embedding disabled; falling back to FTS for query" ) + else: + try: + query_embedding = await asyncio.to_thread( + embedding_service.embed, query, memory_action="search" + ) + except Exception as exc: + logger.warning( + "Search embedding failed; falling back to text search " + "when available: %s", + exc, + ) # Search in storage asynchronously - pass query text to enable hybrid search diff --git a/src/powermem/core/memory.py b/src/powermem/core/memory.py index b35397843..1537a10fb 100644 --- a/src/powermem/core/memory.py +++ b/src/powermem/core/memory.py @@ -1779,20 +1779,20 @@ def search( query_embedding = None if retrieval_mode != "fts": if self._is_embedding_disabled(): - return { - "results": [], - "relations": [] - } - try: - query_embedding = embedding_service.embed( - query, memory_action="search" - ) - except Exception as exc: - logger.warning( - "Search embedding failed; falling back to text search " - "when available: %s", - exc, + logger.info( + "Embedding disabled; falling back to FTS for query" ) + else: + try: + query_embedding = embedding_service.embed( + query, memory_action="search" + ) + except Exception as exc: + logger.warning( + "Search embedding failed; falling back to text search " + "when available: %s", + exc, + ) # Search in storage - pass query text to enable hybrid search diff --git a/src/powermem/storage/adapter.py b/src/powermem/storage/adapter.py index d27b32f8b..768164e12 100644 --- a/src/powermem/storage/adapter.py +++ b/src/powermem/storage/adapter.py @@ -81,7 +81,7 @@ def _supports_text_search_without_vector(self, target_store: VectorStoreBase) -> if store_module.endswith("sqlite.sqlite_vector_store"): return True if ".oceanbase." in store_module: - return bool(getattr(target_store, "hybrid_search", False)) + return True return False def _metadata_filter_key_for_store( diff --git a/src/powermem/storage/oceanbase/oceanbase.py b/src/powermem/storage/oceanbase/oceanbase.py index ae635ae71..e8e7ea7ea 100644 --- a/src/powermem/storage/oceanbase/oceanbase.py +++ b/src/powermem/storage/oceanbase/oceanbase.py @@ -921,6 +921,18 @@ def search(self, ] return results + if vectors is None and query: + logger.info( + "OceanBase search: no query embedding; falling back to FTS" + ) + results = self._fulltext_search(query, search_limit, filters) + if threshold is not None: + results = [ + result for result in results + if result.payload.get("_quality_score", result.score) >= threshold + ] + return results + if mode == "vector" or not self.hybrid_search or not query: return self._vector_search(query, vectors, search_limit, filters) diff --git a/tests/integration/test_noop_embedding_mode.py b/tests/integration/test_noop_embedding_mode.py index 7ea2043ca..01faf456d 100644 --- a/tests/integration/test_noop_embedding_mode.py +++ b/tests/integration/test_noop_embedding_mode.py @@ -7,7 +7,9 @@ - add() stores memories using a mock fallback vector - get() retrieves stored memories by ID - update() and delete() function normally -- search() returns empty results (vector search requires embeddings) +- search() falls back to FTS5 keyword search (vector search requires + embeddings; when query_embedding is empty but query text is present, + the adapter forwards the query to the SQLite FTS layer) """ from __future__ import annotations @@ -59,14 +61,17 @@ def test_noop_embedding_preserves_basic_memory_crud(tmp_path): assert memory.get(memory_id, user_id="user_noop_embed") is None -def test_noop_embedding_search_returns_empty(tmp_path): - """Vector search must return empty results when embedding is disabled.""" +def test_noop_embedding_search_returns_fts_hits(tmp_path): + """When embedding is disabled, search falls back to FTS5 keyword search + and returns matching memories instead of an empty list.""" memory = Memory(config=_sqlite_noop_embedding_config(tmp_path)) memory.add("User loves hiking", user_id="user_search_noop") results = memory.search("hiking", user_id="user_search_noop") - assert results["results"] == [] + assert len(results["results"]) == 1 + hit = results["results"][0] + assert "hiking" in hit.get("memory", "") or "hiking" in hit.get("content", "") def test_noop_embedding_add_multiple_memories(tmp_path): @@ -97,11 +102,13 @@ async def test_noop_embedding_async_crud(tmp_path): @pytest.mark.asyncio -async def test_noop_embedding_async_search_returns_empty(tmp_path): - """Async vector search must return empty results when embedding is disabled.""" +async def test_noop_embedding_async_search_returns_fts_hits(tmp_path): + """Async search falls back to FTS5 keyword search when embedding is disabled.""" memory = AsyncMemory(config=_sqlite_noop_embedding_config(tmp_path)) await memory.add("Async user loves hiking", user_id="async_search_noop") results = await memory.search("hiking", user_id="async_search_noop") - assert results["results"] == [] + assert len(results["results"]) == 1 + hit = results["results"][0] + assert "hiking" in hit.get("memory", "") or "hiking" in hit.get("content", "") diff --git a/tests/unit/test_adapter_fts_fallback.py b/tests/unit/test_adapter_fts_fallback.py new file mode 100644 index 000000000..40076c10a --- /dev/null +++ b/tests/unit/test_adapter_fts_fallback.py @@ -0,0 +1,101 @@ +"""Tests for StorageAdapter FTS fallback when embedding is unavailable. + +When EMBEDDING_PROVIDER=none (NoopEmbedding), search_memories() receives an +empty query_embedding. The adapter must fall through to the storage layer's +FTS5 full-text search using the raw query text instead of returning []. +""" + +import pytest + +from powermem.storage.adapter import StorageAdapter +from powermem.storage.sqlite.sqlite_vector_store import SQLiteVectorStore + + +def _add(adapter, content, user_id="u1"): + adapter.add_memory({"content": content, "user_id": user_id}) + + +def test_search_with_empty_embedding_and_query_returns_fts_results(): + store = SQLiteVectorStore(database_path=":memory:") + adapter = StorageAdapter(store) + + _add(adapter, "Claude Code is a terminal assistant for software engineering") + _add(adapter, "Python is a popular programming language") + + results = adapter.search_memories( + query_embedding=[], + user_id="u1", + query="terminal assistant software", + limit=5, + ) + + assert len(results) == 1 + assert "Claude Code" in results[0]["memory"] + + +def test_search_with_none_embedding_and_query_returns_fts_results(): + store = SQLiteVectorStore(database_path=":memory:") + adapter = StorageAdapter(store) + + _add(adapter, "Claude Code is a terminal assistant for software engineering") + _add(adapter, "Python is a popular programming language") + + results = adapter.search_memories( + query_embedding=None, + user_id="u1", + query="Python programming", + limit=5, + ) + + assert len(results) == 1 + assert "Python" in results[0]["memory"] + + +def test_search_with_empty_embedding_and_no_query_returns_empty(): + store = SQLiteVectorStore(database_path=":memory:") + adapter = StorageAdapter(store) + + _add(adapter, "some memory content") + + results = adapter.search_memories( + query_embedding=[], + user_id="u1", + query=None, + limit=5, + ) + + assert results == [] + + +def test_search_fts_does_not_match_unrelated_query(): + store = SQLiteVectorStore(database_path=":memory:") + adapter = StorageAdapter(store) + + _add(adapter, "Claude Code is a terminal assistant for software engineering") + + results = adapter.search_memories( + query_embedding=[], + user_id="u1", + query="cooking recipe pasta", + limit=5, + ) + + assert results == [] + + +def test_search_fts_respects_user_id_filter(): + store = SQLiteVectorStore(database_path=":memory:") + adapter = StorageAdapter(store) + + _add(adapter, "shared keyword memory", user_id="u1") + _add(adapter, "shared keyword memory", user_id="u2") + + results = adapter.search_memories( + query_embedding=[], + user_id="u1", + query="shared keyword", + limit=5, + ) + + assert len(results) == 1 + assert results[0]["user_id"] == "u1" diff --git a/tests/unit/test_noop_embedding.py b/tests/unit/test_noop_embedding.py index 8edf0c3f1..0d69a32c0 100644 --- a/tests/unit/test_noop_embedding.py +++ b/tests/unit/test_noop_embedding.py @@ -203,25 +203,6 @@ def test_adapter_falls_back_to_mock_vector_when_precomputed_embedding_is_empty() assert len(stored_vector) > 0, "adapter must not store a zero-length vector" -def test_adapter_search_returns_empty_for_empty_query_embedding(): - """search_memories with an empty query_embedding (returned by NoopEmbedding) - must return [] instead of attempting a vector search.""" - from powermem.storage.adapter import StorageAdapter - - mock_store = MagicMock() - mock_store.collection_name = "test" - - adapter = StorageAdapter(mock_store, embedding_service=None) - result = adapter.search_memories( - query_embedding=[], # empty from NoopEmbedding - user_id="u1", - query="anything", - ) - - assert result == [] - mock_store.search.assert_not_called() - - # --------------------------------------------------------------------------- # MemoryConfig with NoopEmbeddingConfig requires no API key # ---------------------------------------------------------------------------