Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions src/powermem/core/async_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 13 additions & 13 deletions src/powermem/core/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/powermem/storage/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
12 changes: 12 additions & 0 deletions src/powermem/storage/oceanbase/oceanbase.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
21 changes: 14 additions & 7 deletions tests/integration/test_noop_embedding_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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", "")
101 changes: 101 additions & 0 deletions tests/unit/test_adapter_fts_fallback.py
Original file line number Diff line number Diff line change
@@ -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"
19 changes: 0 additions & 19 deletions tests/unit/test_noop_embedding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
Loading