Skip to content
Merged
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
16 changes: 13 additions & 3 deletions docs/litellm-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ All options can be set in config or as environment variables.
| `semantic_embedding_forward_dimensions` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_FORWARD_DIMENSIONS` | Auto | Sends `dimensions` to LiteLLM only when supported. Auto is enabled for `text-embedding-3` model strings. |
| `semantic_embedding_document_input_type` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_INPUT_TYPE` | Auto | LiteLLM `input_type` for indexed notes/passages. |
| `semantic_embedding_query_input_type` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_INPUT_TYPE` | Auto | LiteLLM `input_type` for search queries. |
| `semantic_embedding_document_prefix` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_PREFIX` | Unset | Literal text prefix prepended to indexed document chunks before embedding. |
| `semantic_embedding_query_prefix` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_PREFIX` | Unset | Literal text prefix prepended to search queries before embedding. |
| `semantic_embedding_batch_size` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_BATCH_SIZE` | `2` | Number of text chunks per provider request. |
| `semantic_embedding_request_concurrency` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_REQUEST_CONCURRENCY` | `4` | Maximum concurrent LiteLLM embedding requests. |
| `semantic_embedding_sync_batch_size` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_SYNC_BATCH_SIZE` | `2` | Number of prepared vector jobs flushed through the sync pipeline together. |
Expand Down Expand Up @@ -124,9 +126,17 @@ export BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_INPUT_TYPE=passage
export BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_INPUT_TYPE=query
```

Changing provider, model, dimensions, dimension-forwarding, or document/query
roles changes Basic Memory's stored vector identity. Rebuild embeddings after
any of those changes:
`input_type` is an API parameter. For models that require role text in the
actual input string, configure literal prefixes instead or in addition:

```bash
export BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_PREFIX="title: none | text: "
export BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_PREFIX="task: search result | query: "
```

Changing provider, model, dimensions, dimension-forwarding, document/query roles,
or prefixes changes Basic Memory's stored vector identity. Rebuild embeddings
after any of those changes:

```bash
bm reindex --embeddings
Expand Down
15 changes: 15 additions & 0 deletions docs/semantic-search.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ All settings are fields on `BasicMemoryConfig` and can be set via environment va
| `semantic_embedding_batch_size` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_BATCH_SIZE` | `2` | Number of texts to embed per batch. |
| `semantic_embedding_document_input_type` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_INPUT_TYPE` | Auto for known LiteLLM models | Optional LiteLLM `input_type` for indexed document/passages. |
| `semantic_embedding_query_input_type` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_INPUT_TYPE` | Auto for known LiteLLM models | Optional LiteLLM `input_type` for search queries. |
| `semantic_embedding_document_prefix` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_PREFIX` | Unset | Optional literal text prefix prepended to indexed document chunks before embedding. |
| `semantic_embedding_query_prefix` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_PREFIX` | Unset | Optional literal text prefix prepended to search queries before embedding. |
| `semantic_vector_k` | `BASIC_MEMORY_SEMANTIC_VECTOR_K` | `100` | Candidate count for vector nearest-neighbour retrieval. Higher values improve recall at the cost of latency. |

## Embedding Providers
Expand Down Expand Up @@ -190,6 +192,18 @@ export BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_INPUT_TYPE=passage
export BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_INPUT_TYPE=query
```

Some asymmetric models require literal role text in the input string rather
than, or in addition to, an API `input_type` parameter:

```bash
export BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_PREFIX="title: none | text: "
export BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_PREFIX="task: search result | query: "
```

The document prefix is prepended to indexed chunks during sync/reindex. The
query prefix is prepended to search text for vector and hybrid retrieval.
Prefixes work with `fastembed`, `openai`, and `litellm` providers.

#### Live LiteLLM Validation

Provider APIs differ in subtle ways: some accept `dimensions`, some require separate
Expand Down Expand Up @@ -334,6 +348,7 @@ bm reindex -p my-project
- **Model change**: After changing `semantic_embedding_model`
- **Dimension change**: After changing `semantic_embedding_dimensions`
- **LiteLLM role change**: After changing `semantic_embedding_document_input_type` or `semantic_embedding_query_input_type`
- **Literal prefix change**: After changing `semantic_embedding_document_prefix` or `semantic_embedding_query_prefix`

The reindex command shows progress with embedded/skipped/error counts:

Expand Down
14 changes: 14 additions & 0 deletions src/basic_memory/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,20 @@ def __init__(self, **data: Any) -> None: ...
"Use with asymmetric embedding models such as Cohere or NVIDIA retrieval models."
),
)
semantic_embedding_document_prefix: str | None = Field(
default=None,
description=(
"Optional literal text prefix prepended to indexed document chunks before "
"embedding. Use with prefix-sensitive asymmetric embedding models."
),
)
semantic_embedding_query_prefix: str | None = Field(
default=None,
description=(
"Optional literal text prefix prepended to search queries before embedding. "
"Use with prefix-sensitive asymmetric embedding models."
),
)
semantic_embedding_sync_batch_size: int = Field(
default=2,
description="Batch size for vector sync orchestration flushes.",
Expand Down
4 changes: 3 additions & 1 deletion src/basic_memory/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ async def lifespan(app: FastMCP):
f"Semantic search: provider={config.semantic_embedding_provider}, "
f"model={config.semantic_embedding_model}, "
f"dimensions={config.semantic_embedding_dimensions or 'auto'}, "
f"batch_size={config.semantic_embedding_batch_size}"
f"batch_size={config.semantic_embedding_batch_size}, "
f"document_prefix_set={bool(config.semantic_embedding_document_prefix)}, "
f"query_prefix_set={bool(config.semantic_embedding_query_prefix)}"
)

# Log configured projects with their routing mode
Expand Down
18 changes: 17 additions & 1 deletion src/basic_memory/repository/embedding_provider.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Embedding provider protocol for pluggable semantic backends."""

from typing import Any, Protocol
from typing import Any, Protocol, runtime_checkable


class EmbeddingProvider(Protocol):
Expand All @@ -20,3 +20,19 @@ async def embed_documents(self, texts: list[str]) -> list[list[float]]:
def runtime_log_attrs(self) -> dict[str, Any]:
"""Return provider-specific runtime settings suitable for startup logs."""
...


@runtime_checkable
class EmbeddingIdentityProvider(Protocol):
"""Optional capability for providers with semantics beyond model and dimensions."""

def identity_key(self) -> str:
"""Return a stable identity for persisted-vector invalidation."""
...


def embedding_provider_identity(provider: EmbeddingProvider) -> str:
"""Return a provider's explicit semantic identity or the protocol fallback."""
if isinstance(provider, EmbeddingIdentityProvider):
return provider.identity_key()
return f"{provider.model_name}:{provider.dimensions}"
24 changes: 21 additions & 3 deletions src/basic_memory/repository/embedding_provider_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,17 @@

from basic_memory.config import BasicMemoryConfig, default_fastembed_cache_dir
from basic_memory.repository.embedding_provider import EmbeddingProvider
from basic_memory.repository.prefixing_provider import (
PrefixingEmbeddingProvider,
embedding_prefix_digest,
normalize_embedding_prefix,
)

# Cache key fields are limited to values that change the *identity* of the loaded
# provider instance (provider, model_name, explicit LiteLLM endpoint/key routing,
# dimensions, semantic role/input-type settings, batch/request knobs, and the
# resolved cache dir). Thread/parallel knobs are deliberately excluded - they
# change ONNX *execution* only, not the loaded weights. Including them caused #872: in a
# dimensions, semantic role/input-type/prefix settings, batch/request knobs,
# and the resolved cache dir). Thread/parallel knobs are deliberately excluded -
# they change ONNX *execution* only, not the loaded weights. Including them caused #872: in a
# container/cgroup the CPU-derived thread count can drift between calls, producing
# a fresh cache key and reloading the ~2.3GB model into a CPU arena that never
# returns memory to the OS.
Expand All @@ -28,6 +33,8 @@
int,
str | None,
str | None,
str | None,
str | None,
str,
]

Expand Down Expand Up @@ -124,6 +131,8 @@ def _provider_cache_key(app_config: BasicMemoryConfig) -> ProviderCacheKey:
app_config.semantic_embedding_request_concurrency,
app_config.semantic_embedding_document_input_type,
app_config.semantic_embedding_query_input_type,
embedding_prefix_digest(app_config.semantic_embedding_document_prefix),
embedding_prefix_digest(app_config.semantic_embedding_query_prefix),
_resolve_cache_dir(app_config),
)

Expand Down Expand Up @@ -225,6 +234,15 @@ def create_embedding_provider(app_config: BasicMemoryConfig) -> EmbeddingProvide
else:
raise ValueError(f"Unsupported semantic embedding provider: {provider_name}")

document_prefix = normalize_embedding_prefix(app_config.semantic_embedding_document_prefix)
query_prefix = normalize_embedding_prefix(app_config.semantic_embedding_query_prefix)
if document_prefix is not None or query_prefix is not None:
provider = PrefixingEmbeddingProvider(
provider,
document_prefix=document_prefix,
query_prefix=query_prefix,
)

with _EMBEDDING_PROVIDER_CACHE_LOCK:
if cached_provider := _EMBEDDING_PROVIDER_CACHE.get(cache_key):
return cached_provider
Expand Down
82 changes: 82 additions & 0 deletions src/basic_memory/repository/prefixing_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Embedding provider wrapper for role-specific literal text prefixes."""

from __future__ import annotations

import hashlib
from typing import Any

from basic_memory.repository.embedding_provider import (
EmbeddingProvider,
embedding_provider_identity,
)


def normalize_embedding_prefix(value: str | None) -> str | None:
"""Treat unset and empty prefixes as disabled while preserving meaningful spaces."""
if value == "":
return None
return value


def embedding_prefix_digest(value: str | None) -> str:
"""Return a stable non-secret prefix identity, reserving ``-`` for unset."""
normalized = normalize_embedding_prefix(value)
if normalized is None:
return "-"
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()


class PrefixingEmbeddingProvider(EmbeddingProvider):
"""Apply document/query text prefixes before delegating to an embedding provider."""

def __init__(
self,
provider: EmbeddingProvider,
*,
document_prefix: str | None = None,
query_prefix: str | None = None,
) -> None:
self.provider = provider
self.document_prefix = normalize_embedding_prefix(document_prefix)
self.query_prefix = normalize_embedding_prefix(query_prefix)

@property
def model_name(self) -> str:
return self.provider.model_name

@property
def dimensions(self) -> int:
return self.provider.dimensions

async def embed_query(self, text: str) -> list[float]:
if self.query_prefix is not None:
text = f"{self.query_prefix}{text}"
return await self.provider.embed_query(text)

async def embed_documents(self, texts: list[str]) -> list[list[float]]:
if self.document_prefix is not None:
texts = [f"{self.document_prefix}{text}" for text in texts]
return await self.provider.embed_documents(texts)

def runtime_log_attrs(self) -> dict[str, Any]:
attrs = self.provider.runtime_log_attrs()
attrs.update(
{
"document_prefix_set": self.document_prefix is not None,
"query_prefix_set": self.query_prefix is not None,
}
)
if self.document_prefix is not None:
attrs["document_prefix_length"] = len(self.document_prefix)
if self.query_prefix is not None:
attrs["query_prefix_length"] = len(self.query_prefix)
return attrs

def identity_key(self) -> str:
"""Return embedding semantics without exposing literal prefix content."""
provider_identity = embedding_provider_identity(self.provider)
return (
f"{type(self.provider).__name__}:{provider_identity}:"
f"document_prefix_sha256={embedding_prefix_digest(self.document_prefix)}:"
f"query_prefix_sha256={embedding_prefix_digest(self.query_prefix)}"
)
22 changes: 10 additions & 12 deletions src/basic_memory/repository/search_repository_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker

from basic_memory import db
from basic_memory.repository.embedding_provider import EmbeddingProvider
from basic_memory.repository.embedding_provider import (
EmbeddingProvider,
embedding_provider_identity,
)
from basic_memory.repository.search_index_row import SearchIndexRow
from basic_memory.repository.semantic_errors import (
SemanticDependenciesMissingError,
Expand Down Expand Up @@ -692,17 +695,12 @@ def _embedding_model_key(self) -> str:
"""Build a stable model identity for vector invalidation checks."""
assert self._embedding_provider is not None
provider = self._embedding_provider

provider_identity = f"{provider.model_name}:{provider.dimensions}"
from basic_memory.repository.litellm_provider import LiteLLMEmbeddingProvider

if isinstance(provider, LiteLLMEmbeddingProvider):
# Trigger: LiteLLM can change request semantics without changing model/dimensions.
# Why: asymmetric providers use role-specific document/query params, and
# dimension forwarding changes provider-side output-size behavior.
# Outcome: reindex treats those semantic config changes as stale vectors.
provider_identity = provider.identity_key()

# Trigger: providers can change request/input semantics without changing
# model/dimensions.
# Why: asymmetric providers may use role-specific API params or literal
# text-prefix transforms that change stored vector meaning.
# Outcome: reindex treats those semantic config changes as stale vectors.
provider_identity = embedding_provider_identity(provider)
return f"{type(provider).__name__}:{provider_identity}"

def _plan_entity_vector_shard(
Expand Down
2 changes: 2 additions & 0 deletions src/basic_memory/schemas/project_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ class EmbeddingStatus(BaseModel):
embedding_provider: Optional[str] = None
embedding_model: Optional[str] = None
embedding_dimensions: Optional[int] = None
embedding_document_prefix_set: bool = False
embedding_query_prefix_set: bool = False

# Counts
total_indexed_entities: int = 0
Expand Down
8 changes: 8 additions & 0 deletions src/basic_memory/services/project_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,8 @@ async def get_embedding_status(self, project_id: int) -> EmbeddingStatus:
provider = config.semantic_embedding_provider
model = config.semantic_embedding_model
dimensions = config.semantic_embedding_dimensions
document_prefix_set = bool(config.semantic_embedding_document_prefix)
query_prefix_set = bool(config.semantic_embedding_query_prefix)

is_postgres = config.database_backend == DatabaseBackend.POSTGRES

Expand Down Expand Up @@ -1074,6 +1076,8 @@ async def get_embedding_status(self, project_id: int) -> EmbeddingStatus:
embedding_provider=provider,
embedding_model=model,
embedding_dimensions=dimensions,
embedding_document_prefix_set=document_prefix_set,
embedding_query_prefix_set=query_prefix_set,
total_indexed_entities=total_indexed_entities,
vector_tables_exist=False,
reindex_recommended=True,
Expand Down Expand Up @@ -1190,6 +1194,8 @@ async def _vec_scalar(vec_sql) -> int:
embedding_provider=provider,
embedding_model=model,
embedding_dimensions=dimensions,
embedding_document_prefix_set=document_prefix_set,
embedding_query_prefix_set=query_prefix_set,
total_indexed_entities=total_indexed_entities,
vector_tables_exist=False,
reindex_recommended=True,
Expand Down Expand Up @@ -1224,6 +1230,8 @@ async def _vec_scalar(vec_sql) -> int:
embedding_provider=provider,
embedding_model=model,
embedding_dimensions=dimensions,
embedding_document_prefix_set=document_prefix_set,
embedding_query_prefix_set=query_prefix_set,
total_indexed_entities=total_indexed_entities,
total_entities_with_chunks=total_entities_with_chunks,
total_chunks=total_chunks,
Expand Down
Loading
Loading