From fb7c755e93fd08bdb22dc767332b08375857af98 Mon Sep 17 00:00:00 2001 From: "chenzitian.czt" Date: Thu, 25 Jun 2026 17:31:56 +0800 Subject: [PATCH 1/3] test(storage): add FTS fallback tests for empty query_embedding Cover the search_memories() paths when NoopEmbedding returns an empty vector: FTS results returned when query text is present, empty result when both embedding and query are missing, unrelated-query filtering, and user_id scoping. --- tests/unit/test_adapter_fts_fallback.py | 101 ++++++++++++++++++++++++ tests/unit/test_noop_embedding.py | 19 ----- 2 files changed, 101 insertions(+), 19 deletions(-) create mode 100644 tests/unit/test_adapter_fts_fallback.py 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 # --------------------------------------------------------------------------- From 7f9b6272cd88ca1746397c9c10a5944a065540d4 Mon Sep 17 00:00:00 2001 From: "chenzitian.czt" Date: Sat, 27 Jun 2026 16:17:30 +0800 Subject: [PATCH 2/3] test(integration): update noop embedding search test for FTS fallback The integration test asserted search() returns [] when EMBEDDING_PROVIDER=none, but PR #1091 changes that contract to fall back to FTS5 keyword search when query_embedding is empty and query text is present. Update the test to expect the FTS hit and reflect the new behavior in the module docstring. --- tests/integration/test_noop_embedding_mode.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/integration/test_noop_embedding_mode.py b/tests/integration/test_noop_embedding_mode.py index 7ea2043ca..8de7c8ce2 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): From d7b51de9a2ad8b08b43682d676b9ce1a184ea4d9 Mon Sep 17 00:00:00 2001 From: "chenzitian.czt" Date: Thu, 2 Jul 2026 15:39:30 +0800 Subject: [PATCH 3/3] fix(search): fall back to FTS when embedding is disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When EMBEDDING_PROVIDER=none (NoopEmbedding), the core layer short-circuited search() to return [] before the adapter's FTS fallback could run, so queries on stored memories silently returned no hits. Remove the early-return in Memory.search() and AsyncMemory.search() so query_embedding stays None and the request flows through to the storage adapter. The adapter already falls back to text-only search when the target store supports it. On the OceanBase side, _supports_text_search_without_vector() no longer gates on the hybrid_search flag, and OceanBaseVectorStore.search() now routes to _fulltext_search() when vectors is None and a query is present, so deployments with hybrid_search=False also get FTS fallback. The integration test that固化ed the old "returns empty" contract now asserts the FTS hit for both sync and async paths. --- src/powermem/core/async_memory.py | 26 +++++++++---------- src/powermem/core/memory.py | 26 +++++++++---------- src/powermem/storage/adapter.py | 2 +- src/powermem/storage/oceanbase/oceanbase.py | 12 +++++++++ tests/integration/test_noop_embedding_mode.py | 8 +++--- 5 files changed, 44 insertions(+), 30 deletions(-) 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 8de7c8ce2..01faf456d 100644 --- a/tests/integration/test_noop_embedding_mode.py +++ b/tests/integration/test_noop_embedding_mode.py @@ -102,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", "")