diff --git a/src/basic_memory/config.py b/src/basic_memory/config.py index 497e3bd3..5f1dae8a 100644 --- a/src/basic_memory/config.py +++ b/src/basic_memory/config.py @@ -203,6 +203,12 @@ class BasicMemoryConfig(BaseSettings): ge=0.0, le=1.0, ) + default_search_type: Literal["text", "vector", "hybrid"] | None = Field( + default=None, + description="Default search type for search_notes when not specified per-query. " + "Valid values: text, vector, hybrid. " + "When unset, defaults to 'hybrid' if semantic search is enabled, otherwise 'text'.", + ) # Database connection pool configuration (Postgres only) db_pool_size: int = Field( diff --git a/src/basic_memory/mcp/tools/search.py b/src/basic_memory/mcp/tools/search.py index 01d38723..0de0215d 100644 --- a/src/basic_memory/mcp/tools/search.py +++ b/src/basic_memory/mcp/tools/search.py @@ -25,20 +25,20 @@ ) -def _semantic_search_enabled_for_text_search() -> bool: - """Resolve semantic-search enablement in both MCP and CLI invocation paths.""" +def _default_search_type() -> str: + """Pick default search mode from config, falling back to auto-detection. + + Priority: config default_search_type > auto-detect (hybrid if semantic enabled, else text). + """ try: - return get_container().config.semantic_search_enabled + config = get_container().config except RuntimeError: - # Trigger: MCP container is not initialized (e.g., `bm tool search-notes` direct call). - # Why: CLI path still needs the same semantic-default behavior as MCP server path. - # Outcome: load config directly and keep text-mode retrieval behavior consistent. - return ConfigManager().config.semantic_search_enabled + config = ConfigManager().config + if config.default_search_type: + return config.default_search_type -def _default_search_type() -> str: - """Pick default search mode from semantic-search config.""" - return "hybrid" if _semantic_search_enabled_for_text_search() else "text" + return "hybrid" if config.semantic_search_enabled else "text" def _format_search_error_response( diff --git a/tests/mcp/test_tool_search.py b/tests/mcp/test_tool_search.py index c5385a7c..2582b167 100644 --- a/tests/mcp/test_tool_search.py +++ b/tests/mcp/test_tool_search.py @@ -788,6 +788,7 @@ async def search(self, payload, page, page_size): @dataclass class StubConfig: semantic_search_enabled: bool = True + default_search_type: str | None = None @dataclass class StubContainer: @@ -849,6 +850,7 @@ async def search(self, payload, page, page_size): @dataclass class StubConfig: semantic_search_enabled: bool = False + default_search_type: str | None = None @dataclass class StubContainer: @@ -909,6 +911,7 @@ async def search(self, payload, page, page_size): @dataclass class StubConfig: semantic_search_enabled: bool = True + default_search_type: str | None = None @dataclass class StubContainer: @@ -976,7 +979,11 @@ def raise_runtime_error(): lambda: type( "StubConfigManager", (), - {"config": type("Cfg", (), {"semantic_search_enabled": True})()}, + { + "config": type( + "Cfg", (), {"semantic_search_enabled": True, "default_search_type": None} + )() + }, )(), ) @@ -1037,7 +1044,11 @@ def raise_runtime_error(): lambda: type( "StubConfigManager", (), - {"config": type("Cfg", (), {"semantic_search_enabled": False})()}, + { + "config": type( + "Cfg", (), {"semantic_search_enabled": False, "default_search_type": None} + )() + }, )(), ) @@ -1567,3 +1578,54 @@ async def search(self, payload, page, page_size): # "note_type" aliased to "type", "priority" passes through unchanged assert captured_payload["metadata_filters"] == {"type": "spec", "priority": "high"} + + +def test_default_search_type_uses_config_value(): + """_default_search_type should return config.default_search_type when set.""" + import sys + from unittest.mock import MagicMock, patch + + search_module = sys.modules["basic_memory.mcp.tools.search"] + + mock_config = MagicMock() + mock_config.default_search_type = "vector" + mock_config.semantic_search_enabled = True + mock_container = MagicMock() + mock_container.config = mock_config + + with patch.object(search_module, "get_container", return_value=mock_container): + assert search_module._default_search_type() == "vector" + + +def test_default_search_type_falls_back_to_hybrid_when_semantic_enabled(): + """When default_search_type is None and semantic is enabled, default to hybrid.""" + import sys + from unittest.mock import MagicMock, patch + + search_module = sys.modules["basic_memory.mcp.tools.search"] + + mock_config = MagicMock() + mock_config.default_search_type = None + mock_config.semantic_search_enabled = True + mock_container = MagicMock() + mock_container.config = mock_config + + with patch.object(search_module, "get_container", return_value=mock_container): + assert search_module._default_search_type() == "hybrid" + + +def test_default_search_type_falls_back_to_text_when_semantic_disabled(): + """When default_search_type is None and semantic is disabled, default to text.""" + import sys + from unittest.mock import MagicMock, patch + + search_module = sys.modules["basic_memory.mcp.tools.search"] + + mock_config = MagicMock() + mock_config.default_search_type = None + mock_config.semantic_search_enabled = False + mock_container = MagicMock() + mock_container.config = mock_config + + with patch.object(search_module, "get_container", return_value=mock_container): + assert search_module._default_search_type() == "text" diff --git a/tests/test_config.py b/tests/test_config.py index 8924f589..4ef94ec4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -903,6 +903,22 @@ def test_semantic_min_similarity_bounds_validation(self): with pytest.raises(Exception): BasicMemoryConfig(semantic_min_similarity=1.1) + def test_default_search_type_defaults_to_none(self): + """default_search_type should be None by default (auto-detect).""" + config = BasicMemoryConfig() + assert config.default_search_type is None + + def test_default_search_type_accepts_valid_values(self): + """default_search_type accepts text, vector, hybrid.""" + for search_type in ("text", "vector", "hybrid"): + config = BasicMemoryConfig(default_search_type=search_type) + assert config.default_search_type == search_type + + def test_default_search_type_rejects_invalid_values(self): + """default_search_type rejects unknown values.""" + with pytest.raises(Exception): + BasicMemoryConfig(default_search_type="invalid") + class TestFormattingConfig: """Test file formatting configuration options."""