From 8837d66af249b55cb40a1d1bc0414e1c143730df Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 12 Feb 2026 18:30:26 -0600 Subject: [PATCH 1/3] fix: make semantic search dependencies optional extras MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move fastembed, sqlite-vec, and openai from required to optional dependencies (`pip install 'basic-memory[semantic]'`). This fixes installation on Intel Macs where onnxruntime >= 1.24 has no x86_64 macOS wheels. - ⚡ Base `pip install basic-memory` now works on all platforms - 🔧 Defer imports in embedding_provider_factory.py - 📝 Update error messages to reference `basic-memory[semantic]` - 📖 Add Installation & Platform Compatibility docs with Intel Mac workarounds Co-Authored-By: Claude Opus 4.6 Signed-off-by: phernandez --- docs/semantic-search.md | 58 +++++++++++++++++-- pyproject.toml | 4 ++ src/basic_memory/cli/commands/db.py | 16 ++--- src/basic_memory/mcp/tools/search.py | 4 +- .../repository/embedding_provider_factory.py | 8 ++- .../repository/fastembed_provider.py | 2 +- .../repository/openai_provider.py | 2 +- .../repository/postgres_search_repository.py | 4 +- .../repository/search_repository_base.py | 16 ++--- .../repository/sqlite_search_repository.py | 2 +- tests/mcp/test_tool_search.py | 4 +- tests/repository/test_fastembed_provider.py | 2 +- tests/repository/test_openai_provider.py | 2 +- .../test_postgres_search_repository.py | 4 +- tests/services/test_search_service.py | 48 ++++++++------- uv.lock | 17 ++++-- 16 files changed, 120 insertions(+), 73 deletions(-) diff --git a/docs/semantic-search.md b/docs/semantic-search.md index 0ec887663..be77776e0 100644 --- a/docs/semantic-search.md +++ b/docs/semantic-search.md @@ -12,21 +12,70 @@ Basic Memory's default search uses full-text search (FTS) — keyword matching w Semantic search is **opt-in** — existing behavior is completely unchanged unless you enable it. It works on both SQLite (local) and Postgres (cloud) backends. +## Installation + +Semantic search dependencies (fastembed, sqlite-vec, openai) are **optional extras** — they are not installed with the base `basic-memory` package. Install them with: + +```bash +pip install 'basic-memory[semantic]' +``` + +This keeps the base install lightweight and avoids platform-specific issues with ONNX Runtime wheels. + +### Platform Compatibility + +| Platform | FastEmbed (local) | OpenAI (API) | +|---|---|---| +| macOS ARM64 (Apple Silicon) | Yes | Yes | +| macOS x86_64 (Intel Mac) | No — see workaround below | Yes | +| Linux x86_64 | Yes | Yes | +| Linux ARM64 | Yes | Yes | +| Windows x86_64 | Yes | Yes | + +#### Intel Mac Workaround + +The default FastEmbed provider uses ONNX Runtime, which dropped Intel Mac (x86_64) wheels starting in v1.24. Intel Mac users have two options: + +**Option 1: Use OpenAI embeddings (recommended)** + +Install only the OpenAI dependency manually — no ONNX Runtime or FastEmbed needed: + +```bash +pip install openai sqlite-vec +export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true +export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=openai +export OPENAI_API_KEY=sk-... +``` + +**Option 2: Pin an older ONNX Runtime** + +FastEmbed's ONNX Runtime dependency is unpinned, so you can constrain it to an older version that still ships Intel Mac wheels by passing both requirements in the same install command: + +```bash +pip install 'basic-memory[semantic]' 'onnxruntime<1.24' +``` + ## Quick Start -1. Enable semantic search: +1. Install semantic extras: + +```bash +pip install 'basic-memory[semantic]' +``` + +2. Enable semantic search: ```bash export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true ``` -2. Build vector embeddings for your existing content: +3. Build vector embeddings for your existing content: ```bash bm reindex --embeddings ``` -3. Search using semantic modes: +4. Search using semantic modes: ```python # Pure vector similarity @@ -63,7 +112,8 @@ FastEmbed runs entirely locally using ONNX models — no API key, no network cal - **Tradeoff**: Smaller model, fast inference, good quality for most use cases ```bash -# FastEmbed is the default — just enable semantic search +# Install semantic extras and enable +pip install 'basic-memory[semantic]' export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true ``` diff --git a/pyproject.toml b/pyproject.toml index 0b1cba624..1beb7d5a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,10 @@ dependencies = [ "sniffio>=1.3.1", "anyio>=4.10.0", "httpx>=0.28.0", +] + +[project.optional-dependencies] +semantic = [ "fastembed>=0.7.4", "sqlite-vec>=0.1.6", "openai>=1.100.2", diff --git a/src/basic_memory/cli/commands/db.py b/src/basic_memory/cli/commands/db.py index f18248109..3e2a806f6 100644 --- a/src/basic_memory/cli/commands/db.py +++ b/src/basic_memory/cli/commands/db.py @@ -111,9 +111,7 @@ def reindex( embeddings: bool = typer.Option( False, "--embeddings", "-e", help="Rebuild vector embeddings (requires semantic search)" ), - search: bool = typer.Option( - False, "--search", "-s", help="Rebuild full-text search index" - ), + search: bool = typer.Option(False, "--search", "-s", help="Rebuild full-text search index"), project: str = typer.Option( None, "--project", "-p", help="Reindex a specific project (default: all)" ), @@ -193,12 +191,8 @@ async def _reindex(app_config, search: bool, embeddings: bool, project: str | No project_path = Path(proj.path) entity_parser = EntityParser(project_path) markdown_processor = MarkdownProcessor(entity_parser, app_config=app_config) - file_service = FileService( - project_path, markdown_processor, app_config=app_config - ) - search_service = SearchService( - search_repository, entity_repository, file_service - ) + file_service = FileService(project_path, markdown_processor, app_config=app_config) + search_service = SearchService(search_repository, entity_repository, file_service) with Progress( SpinnerColumn(), @@ -212,9 +206,7 @@ async def _reindex(app_config, search: bool, embeddings: bool, project: str | No def on_progress(entity_id, index, total): progress.update(task, total=total, completed=index) - stats = await search_service.reindex_vectors( - progress_callback=on_progress - ) + stats = await search_service.reindex_vectors(progress_callback=on_progress) progress.update(task, completed=stats["total_entities"]) console.print( diff --git a/src/basic_memory/mcp/tools/search.py b/src/basic_memory/mcp/tools/search.py index 005dac4f1..dfde0db17 100644 --- a/src/basic_memory/mcp/tools/search.py +++ b/src/basic_memory/mcp/tools/search.py @@ -38,14 +38,14 @@ def _format_search_error_response( `search_notes("{project}", "{query}", search_type="text")` """).strip() - if "pip install basic-memory" in error_message.lower(): + if "pip install" in error_message.lower() and "semantic" in error_message.lower(): return dedent(f""" # Search Failed - Semantic Dependencies Missing Semantic retrieval is enabled but required packages are not installed. ## Fix - 1. Reinstall basic-memory: `pip install basic-memory` + 1. Install semantic extras: `pip install 'basic-memory[semantic]'` 2. Restart Basic Memory 3. Retry your query: `search_notes("{project}", "{query}", search_type="{search_type}")` diff --git a/src/basic_memory/repository/embedding_provider_factory.py b/src/basic_memory/repository/embedding_provider_factory.py index 1869c8032..90e000be9 100644 --- a/src/basic_memory/repository/embedding_provider_factory.py +++ b/src/basic_memory/repository/embedding_provider_factory.py @@ -2,8 +2,6 @@ from basic_memory.config import BasicMemoryConfig from basic_memory.repository.embedding_provider import EmbeddingProvider -from basic_memory.repository.fastembed_provider import FastEmbedEmbeddingProvider -from basic_memory.repository.openai_provider import OpenAIEmbeddingProvider def create_embedding_provider(app_config: BasicMemoryConfig) -> EmbeddingProvider: @@ -18,6 +16,9 @@ def create_embedding_provider(app_config: BasicMemoryConfig) -> EmbeddingProvide extra_kwargs["dimensions"] = app_config.semantic_embedding_dimensions if provider_name == "fastembed": + # Deferred import: fastembed (and its onnxruntime dep) may not be installed + from basic_memory.repository.fastembed_provider import FastEmbedEmbeddingProvider + return FastEmbedEmbeddingProvider( model_name=app_config.semantic_embedding_model, batch_size=app_config.semantic_embedding_batch_size, @@ -25,6 +26,9 @@ def create_embedding_provider(app_config: BasicMemoryConfig) -> EmbeddingProvide ) if provider_name == "openai": + # Deferred import: openai may not be installed + from basic_memory.repository.openai_provider import OpenAIEmbeddingProvider + model_name = app_config.semantic_embedding_model or "text-embedding-3-small" if model_name == "bge-small-en-v1.5": model_name = "text-embedding-3-small" diff --git a/src/basic_memory/repository/fastembed_provider.py b/src/basic_memory/repository/fastembed_provider.py index 21ce12f55..b59673b54 100644 --- a/src/basic_memory/repository/fastembed_provider.py +++ b/src/basic_memory/repository/fastembed_provider.py @@ -48,7 +48,7 @@ def _create_model() -> "TextEmbedding": ) as exc: # pragma: no cover - exercised via tests with monkeypatch raise SemanticDependenciesMissingError( "fastembed package is missing. " - "Reinstall basic-memory: pip install basic-memory" + "Install semantic extras: pip install 'basic-memory[semantic]'" ) from exc resolved_model_name = self._MODEL_ALIASES.get(self.model_name, self.model_name) return TextEmbedding(model_name=resolved_model_name) diff --git a/src/basic_memory/repository/openai_provider.py b/src/basic_memory/repository/openai_provider.py index 1ea239ac8..65a6021bc 100644 --- a/src/basic_memory/repository/openai_provider.py +++ b/src/basic_memory/repository/openai_provider.py @@ -45,7 +45,7 @@ async def _get_client(self) -> Any: except ImportError as exc: # pragma: no cover - covered via monkeypatch tests raise SemanticDependenciesMissingError( "OpenAI dependency is missing. " - "Reinstall basic-memory: pip install basic-memory" + "Install semantic extras: pip install 'basic-memory[semantic]'" ) from exc api_key = self._api_key or os.getenv("OPENAI_API_KEY") diff --git a/src/basic_memory/repository/postgres_search_repository.py b/src/basic_memory/repository/postgres_search_repository.py index b76ef653b..a24ab306b 100644 --- a/src/basic_memory/repository/postgres_search_repository.py +++ b/src/basic_memory/repository/postgres_search_repository.py @@ -311,9 +311,7 @@ async def _ensure_vector_tables(self) -> None: f"provider expects {self._vector_dimensions}. " "Dropping and recreating search_vector_embeddings." ) - await session.execute( - text("DROP TABLE IF EXISTS search_vector_embeddings") - ) + await session.execute(text("DROP TABLE IF EXISTS search_vector_embeddings")) await session.execute( text( diff --git a/src/basic_memory/repository/search_repository_base.py b/src/basic_memory/repository/search_repository_base.py index 589be85ca..ff52ed470 100644 --- a/src/basic_memory/repository/search_repository_base.py +++ b/src/basic_memory/repository/search_repository_base.py @@ -904,13 +904,9 @@ async def _search_vector_only( ) # Use (id, type) tuples to avoid collisions between different # search_index row types that share the same auto-increment id. - allowed_keys = { - (row.id, row.type) for row in filtered_rows if row.id is not None - } + allowed_keys = {(row.id, row.type) for row in filtered_rows if row.id is not None} search_index_rows = { - k: v - for k, v in search_index_rows.items() - if (v.id, v.type) in allowed_keys + k: v for k, v in search_index_rows.items() if (v.id, v.type) in allowed_keys } ranked_rows: list[SearchIndexRow] = [] @@ -1077,17 +1073,13 @@ async def _search_hybrid( for rank, row in enumerate(fts_results, start=1): if row.id is None: continue - fused_scores[row.id] = fused_scores.get(row.id, 0.0) + ( - 1.0 / (RRF_K + rank) - ) + fused_scores[row.id] = fused_scores.get(row.id, 0.0) + (1.0 / (RRF_K + rank)) rows_by_id[row.id] = row for rank, row in enumerate(vector_results, start=1): if row.id is None: continue - fused_scores[row.id] = fused_scores.get(row.id, 0.0) + ( - 1.0 / (RRF_K + rank) - ) + fused_scores[row.id] = fused_scores.get(row.id, 0.0) + (1.0 / (RRF_K + rank)) rows_by_id[row.id] = row ranked = sorted(fused_scores.items(), key=lambda item: item[1], reverse=True) diff --git a/src/basic_memory/repository/sqlite_search_repository.py b/src/basic_memory/repository/sqlite_search_repository.py index 92c9d6f85..b3f95bc5d 100644 --- a/src/basic_memory/repository/sqlite_search_repository.py +++ b/src/basic_memory/repository/sqlite_search_repository.py @@ -340,7 +340,7 @@ async def _ensure_sqlite_vec_loaded(self, session) -> None: except ImportError as exc: raise SemanticDependenciesMissingError( "sqlite-vec package is missing. " - "Reinstall basic-memory: pip install basic-memory" + "Install semantic extras: pip install 'basic-memory[semantic]'" ) from exc async with self._sqlite_vec_lock: diff --git a/tests/mcp/test_tool_search.py b/tests/mcp/test_tool_search.py index dfb8d7eca..2cdf18de2 100644 --- a/tests/mcp/test_tool_search.py +++ b/tests/mcp/test_tool_search.py @@ -291,13 +291,13 @@ def test_format_search_error_semantic_dependencies_missing(self): """Test formatting for missing semantic dependencies.""" result = _format_search_error_response( "test-project", - "fastembed package is missing. Reinstall basic-memory: pip install basic-memory", + "fastembed package is missing. Install semantic extras: pip install 'basic-memory[semantic]'", "semantic query", "hybrid", ) assert "# Search Failed - Semantic Dependencies Missing" in result - assert "pip install basic-memory" in result + assert "pip install 'basic-memory[semantic]'" in result def test_format_search_error_generic(self): """Test formatting for generic errors.""" diff --git a/tests/repository/test_fastembed_provider.py b/tests/repository/test_fastembed_provider.py index 169c2660f..a10c5aa71 100644 --- a/tests/repository/test_fastembed_provider.py +++ b/tests/repository/test_fastembed_provider.py @@ -84,4 +84,4 @@ def _raising_import(name, globals=None, locals=None, fromlist=(), level=0): with pytest.raises(SemanticDependenciesMissingError) as error: await provider.embed_query("test") - assert "pip install basic-memory" in str(error.value) + assert "pip install 'basic-memory[semantic]'" in str(error.value) diff --git a/tests/repository/test_openai_provider.py b/tests/repository/test_openai_provider.py index cc3f05ff7..8fb8dafb3 100644 --- a/tests/repository/test_openai_provider.py +++ b/tests/repository/test_openai_provider.py @@ -92,7 +92,7 @@ def _raising_import(name, globals=None, locals=None, fromlist=(), level=0): with pytest.raises(SemanticDependenciesMissingError) as error: await provider.embed_query("test") - assert "pip install basic-memory" in str(error.value) + assert "pip install 'basic-memory[semantic]'" in str(error.value) @pytest.mark.asyncio diff --git a/tests/repository/test_postgres_search_repository.py b/tests/repository/test_postgres_search_repository.py index c196b193f..cb0e9868d 100644 --- a/tests/repository/test_postgres_search_repository.py +++ b/tests/repository/test_postgres_search_repository.py @@ -432,9 +432,7 @@ async def embed_documents(self, texts: list[str]) -> list[list[float]]: @pytest.mark.asyncio -async def test_postgres_dimension_mismatch_triggers_table_recreation( - session_maker, test_project -): +async def test_postgres_dimension_mismatch_triggers_table_recreation(session_maker, test_project): """Changing embedding dimensions should drop and recreate the embeddings table.""" await _skip_if_pgvector_unavailable(session_maker) diff --git a/tests/services/test_search_service.py b/tests/services/test_search_service.py index b904f7c60..36faf91e7 100644 --- a/tests/services/test_search_service.py +++ b/tests/services/test_search_service.py @@ -980,17 +980,19 @@ async def test_reindex_vectors(search_service, session_maker, test_project): # Create some entities for i in range(3): - entity = await entity_repo.create({ - "title": f"Vector Test Entity {i}", - "entity_type": "note", - "entity_metadata": {}, - "content_type": "text/markdown", - "file_path": f"test/vector-test-{i}.md", - "permalink": f"test/vector-test-{i}", - "project_id": test_project.id, - "created_at": datetime.now(), - "updated_at": datetime.now(), - }) + entity = await entity_repo.create( + { + "title": f"Vector Test Entity {i}", + "entity_type": "note", + "entity_metadata": {}, + "content_type": "text/markdown", + "file_path": f"test/vector-test-{i}.md", + "permalink": f"test/vector-test-{i}", + "project_id": test_project.id, + "created_at": datetime.now(), + "updated_at": datetime.now(), + } + ) await search_service.index_entity(entity, content=f"Content for entity {i}") # Track progress calls @@ -1020,17 +1022,19 @@ async def test_reindex_vectors_no_callback(search_service, session_maker, test_p from datetime import datetime entity_repo = EntityRepository(session_maker, project_id=test_project.id) - entity = await entity_repo.create({ - "title": "No Callback Entity", - "entity_type": "note", - "entity_metadata": {}, - "content_type": "text/markdown", - "file_path": "test/no-callback.md", - "permalink": "test/no-callback", - "project_id": test_project.id, - "created_at": datetime.now(), - "updated_at": datetime.now(), - }) + entity = await entity_repo.create( + { + "title": "No Callback Entity", + "entity_type": "note", + "entity_metadata": {}, + "content_type": "text/markdown", + "file_path": "test/no-callback.md", + "permalink": "test/no-callback", + "project_id": test_project.id, + "created_at": datetime.now(), + "updated_at": datetime.now(), + } + ) await search_service.index_entity(entity, content="Test content") stats = await search_service.reindex_vectors() diff --git a/uv.lock b/uv.lock index 44e8d1467..db1eef26b 100644 --- a/uv.lock +++ b/uv.lock @@ -151,7 +151,6 @@ dependencies = [ { name = "asyncpg" }, { name = "dateparser" }, { name = "fastapi", extra = ["standard"] }, - { name = "fastembed" }, { name = "fastmcp" }, { name = "greenlet" }, { name = "httpx" }, @@ -162,7 +161,6 @@ dependencies = [ { name = "mdformat-frontmatter" }, { name = "mdformat-gfm" }, { name = "nest-asyncio" }, - { name = "openai" }, { name = "pillow" }, { name = "psycopg" }, { name = "pybars3" }, @@ -178,12 +176,18 @@ dependencies = [ { name = "rich" }, { name = "sniffio" }, { name = "sqlalchemy" }, - { name = "sqlite-vec" }, { name = "typer" }, { name = "unidecode" }, { name = "watchfiles" }, ] +[package.optional-dependencies] +semantic = [ + { name = "fastembed" }, + { name = "openai" }, + { name = "sqlite-vec" }, +] + [package.dev-dependencies] dev = [ { name = "freezegun" }, @@ -210,7 +214,7 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.30.0" }, { name = "dateparser", specifier = ">=1.2.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.8" }, - { name = "fastembed", specifier = ">=0.7.4" }, + { name = "fastembed", marker = "extra == 'semantic'", specifier = ">=0.7.4" }, { name = "fastmcp", specifier = "==2.12.3" }, { name = "greenlet", specifier = ">=3.1.1" }, { name = "httpx", specifier = ">=0.28.0" }, @@ -221,7 +225,7 @@ requires-dist = [ { name = "mdformat-frontmatter", specifier = ">=2.0.8" }, { name = "mdformat-gfm", specifier = ">=0.3.7" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, - { name = "openai", specifier = ">=1.100.2" }, + { name = "openai", marker = "extra == 'semantic'", specifier = ">=1.100.2" }, { name = "pillow", specifier = ">=11.1.0" }, { name = "psycopg", specifier = "==3.3.1" }, { name = "pybars3", specifier = ">=0.9.7" }, @@ -237,11 +241,12 @@ requires-dist = [ { name = "rich", specifier = ">=13.9.4" }, { name = "sniffio", specifier = ">=1.3.1" }, { name = "sqlalchemy", specifier = ">=2.0.0" }, - { name = "sqlite-vec", specifier = ">=0.1.6" }, + { name = "sqlite-vec", marker = "extra == 'semantic'", specifier = ">=0.1.6" }, { name = "typer", specifier = ">=0.9.0" }, { name = "unidecode", specifier = ">=1.3.8" }, { name = "watchfiles", specifier = ">=1.0.4" }, ] +provides-extras = ["semantic"] [package.metadata.requires-dev] dev = [ From 5aff765c2202336a2b5c28c9c7380a97bbeb4bb1 Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 12 Feb 2026 18:56:42 -0600 Subject: [PATCH 2/3] ci: install semantic extras in GitHub Actions test workflows The semantic search dependencies (fastembed, sqlite-vec, openai) were moved to optional extras in the previous commit but CI was still installing only `.[dev]`, causing test failures for semantic search tests. Co-Authored-By: Claude Opus 4.6 Signed-off-by: phernandez --- .github/workflows/test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ef9c2ceab..bddd52b14 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,7 +55,7 @@ jobs: - name: Install dependencies run: | - uv pip install -e .[dev] + uv pip install -e ".[dev,semantic]" - name: Run type checks run: | @@ -105,7 +105,7 @@ jobs: - name: Install dependencies run: | - uv pip install -e .[dev] + uv pip install -e ".[dev,semantic]" - name: Run tests (Postgres via testcontainers) run: | @@ -141,7 +141,7 @@ jobs: - name: Install dependencies run: | - uv pip install -e .[dev] + uv pip install -e ".[dev,semantic]" - name: Run combined coverage (SQLite + Postgres) run: | From 5af621000688d8c65b8d0e9ee30d96471a1541a0 Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 12 Feb 2026 20:45:22 -0600 Subject: [PATCH 3/3] fix: update error message in search_repository_base to reference semantic extras MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review feedback — the SemanticDependenciesMissingError in _assert_semantic_available() now mentions `pip install 'basic-memory[semantic]'` for consistency with all other error messages. Co-Authored-By: Claude Opus 4.6 Signed-off-by: phernandez --- src/basic_memory/repository/search_repository_base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/basic_memory/repository/search_repository_base.py b/src/basic_memory/repository/search_repository_base.py index ff52ed470..25d6b39b5 100644 --- a/src/basic_memory/repository/search_repository_base.py +++ b/src/basic_memory/repository/search_repository_base.py @@ -353,7 +353,8 @@ def _assert_semantic_available(self) -> None: if self._embedding_provider is None: raise SemanticDependenciesMissingError( "No embedding provider configured. " - "Ensure semantic_search_enabled is true in your config." + "Install semantic extras: pip install 'basic-memory[semantic]' " + "and set semantic_search_enabled=true." ) def _compose_row_source_text(self, row) -> str: