Skip to content

Commit 7f7198d

Browse files
committed
feat(core): add semantic embedding text prefixes
Signed-off-by: Dmitry Golubev <lastguru@gmail.com>
1 parent 0e59bbf commit 7f7198d

13 files changed

Lines changed: 377 additions & 17 deletions

docs/litellm-provider.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ All options can be set in config or as environment variables.
4848
| `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. |
4949
| `semantic_embedding_document_input_type` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_INPUT_TYPE` | Auto | LiteLLM `input_type` for indexed notes/passages. |
5050
| `semantic_embedding_query_input_type` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_INPUT_TYPE` | Auto | LiteLLM `input_type` for search queries. |
51+
| `semantic_embedding_document_prefix` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_PREFIX` | Unset | Literal text prefix prepended to indexed document chunks before embedding. |
52+
| `semantic_embedding_query_prefix` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_PREFIX` | Unset | Literal text prefix prepended to search queries before embedding. |
5153
| `semantic_embedding_batch_size` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_BATCH_SIZE` | `2` | Number of text chunks per provider request. |
5254
| `semantic_embedding_request_concurrency` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_REQUEST_CONCURRENCY` | `4` | Maximum concurrent LiteLLM embedding requests. |
5355
| `semantic_embedding_sync_batch_size` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_SYNC_BATCH_SIZE` | `2` | Number of prepared vector jobs flushed through the sync pipeline together. |
@@ -101,9 +103,17 @@ export BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_INPUT_TYPE=passage
101103
export BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_INPUT_TYPE=query
102104
```
103105

104-
Changing provider, model, dimensions, dimension-forwarding, or document/query
105-
roles changes the meaning of stored vectors. Rebuild embeddings after any of
106-
those changes:
106+
`input_type` is an API parameter. For models that require role text in the
107+
actual input string, configure literal prefixes instead or in addition:
108+
109+
```bash
110+
export BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_PREFIX="title: none | text: "
111+
export BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_PREFIX="task: search result | query: "
112+
```
113+
114+
Changing provider, model, dimensions, dimension-forwarding, document/query roles,
115+
or prefixes changes Basic Memory's stored vector identity. Rebuild embeddings
116+
after any of those changes:
107117

108118
```bash
109119
bm reindex --embeddings

docs/semantic-search.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ All settings are fields on `BasicMemoryConfig` and can be set via environment va
106106
| `semantic_embedding_batch_size` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_BATCH_SIZE` | `2` | Number of texts to embed per batch. |
107107
| `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. |
108108
| `semantic_embedding_query_input_type` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_INPUT_TYPE` | Auto for known LiteLLM models | Optional LiteLLM `input_type` for search queries. |
109+
| `semantic_embedding_document_prefix` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_PREFIX` | Unset | Optional literal text prefix prepended to indexed document chunks before embedding. |
110+
| `semantic_embedding_query_prefix` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_PREFIX` | Unset | Optional literal text prefix prepended to search queries before embedding. |
109111
| `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. |
110112

111113
## Embedding Providers
@@ -174,6 +176,18 @@ export BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_INPUT_TYPE=passage
174176
export BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_INPUT_TYPE=query
175177
```
176178

179+
Some asymmetric models require literal role text in the input string rather
180+
than, or in addition to, an API `input_type` parameter:
181+
182+
```bash
183+
export BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_PREFIX="title: none | text: "
184+
export BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_PREFIX="task: search result | query: "
185+
```
186+
187+
The document prefix is prepended to indexed chunks during sync/reindex. The
188+
query prefix is prepended to search text for vector and hybrid retrieval.
189+
Prefixes work with `fastembed`, `openai`, and `litellm` providers.
190+
177191
#### Live LiteLLM Validation
178192

179193
Provider APIs differ in subtle ways: some accept `dimensions`, some require separate
@@ -317,6 +331,7 @@ bm reindex -p my-project
317331
- **Model change**: After changing `semantic_embedding_model`
318332
- **Dimension change**: After changing `semantic_embedding_dimensions`
319333
- **LiteLLM role change**: After changing `semantic_embedding_document_input_type` or `semantic_embedding_query_input_type`
334+
- **Literal prefix change**: After changing `semantic_embedding_document_prefix` or `semantic_embedding_query_prefix`
320335

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

src/basic_memory/config.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,20 @@ def __init__(self, **data: Any) -> None: ...
293293
"Use with asymmetric embedding models such as Cohere or NVIDIA retrieval models."
294294
),
295295
)
296+
semantic_embedding_document_prefix: str | None = Field(
297+
default=None,
298+
description=(
299+
"Optional literal text prefix prepended to indexed document chunks before "
300+
"embedding. Use with prefix-sensitive asymmetric embedding models."
301+
),
302+
)
303+
semantic_embedding_query_prefix: str | None = Field(
304+
default=None,
305+
description=(
306+
"Optional literal text prefix prepended to search queries before embedding. "
307+
"Use with prefix-sensitive asymmetric embedding models."
308+
),
309+
)
296310
semantic_embedding_sync_batch_size: int = Field(
297311
default=2,
298312
description="Batch size for vector sync orchestration flushes.",

src/basic_memory/mcp/server.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ async def lifespan(app: FastMCP):
8181
f"Semantic search: provider={config.semantic_embedding_provider}, "
8282
f"model={config.semantic_embedding_model}, "
8383
f"dimensions={config.semantic_embedding_dimensions or 'auto'}, "
84-
f"batch_size={config.semantic_embedding_batch_size}"
84+
f"batch_size={config.semantic_embedding_batch_size}, "
85+
f"document_prefix_set={bool(config.semantic_embedding_document_prefix)}, "
86+
f"query_prefix_set={bool(config.semantic_embedding_query_prefix)}"
8587
)
8688

8789
# Log configured projects with their routing mode

src/basic_memory/repository/embedding_provider_factory.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@
77

88
from basic_memory.config import BasicMemoryConfig, default_fastembed_cache_dir
99
from basic_memory.repository.embedding_provider import EmbeddingProvider
10+
from basic_memory.repository.prefixing_provider import (
11+
PrefixingEmbeddingProvider,
12+
normalize_embedding_prefix,
13+
)
1014

1115
# Cache key fields are limited to values that change the *identity* of the loaded
12-
# model (provider, model_name, dimensions, LiteLLM role/input-type/forward-dimension
13-
# settings, batch/request knobs that affect the LiteLLM identity, and the resolved
14-
# cache dir). Thread/parallel knobs are deliberately excluded they change ONNX
15-
# *execution* only, not the loaded weights. Including them caused #872: in a
16+
# provider instance (provider, model_name, dimensions, semantic role/input-type/prefix
17+
# settings, batch/request knobs, and the resolved cache dir). Thread/parallel knobs
18+
# are deliberately excluded - they change ONNX *execution* only, not the loaded
19+
# weights. Including them caused #872: in a
1620
# container/cgroup the CPU-derived thread count can drift between calls, producing
1721
# a fresh cache key and reloading the ~2.3GB model into a CPU arena that never
1822
# returns memory to the OS.
@@ -25,6 +29,8 @@
2529
int,
2630
str | None,
2731
str | None,
32+
str | None,
33+
str | None,
2834
str,
2935
]
3036

@@ -105,6 +111,8 @@ def _provider_cache_key(app_config: BasicMemoryConfig) -> ProviderCacheKey:
105111
app_config.semantic_embedding_request_concurrency,
106112
app_config.semantic_embedding_document_input_type,
107113
app_config.semantic_embedding_query_input_type,
114+
normalize_embedding_prefix(app_config.semantic_embedding_document_prefix),
115+
normalize_embedding_prefix(app_config.semantic_embedding_query_prefix),
108116
_resolve_cache_dir(app_config),
109117
)
110118

@@ -204,6 +212,15 @@ def create_embedding_provider(app_config: BasicMemoryConfig) -> EmbeddingProvide
204212
else:
205213
raise ValueError(f"Unsupported semantic embedding provider: {provider_name}")
206214

215+
document_prefix = normalize_embedding_prefix(app_config.semantic_embedding_document_prefix)
216+
query_prefix = normalize_embedding_prefix(app_config.semantic_embedding_query_prefix)
217+
if document_prefix is not None or query_prefix is not None:
218+
provider = PrefixingEmbeddingProvider(
219+
provider,
220+
document_prefix=document_prefix,
221+
query_prefix=query_prefix,
222+
)
223+
207224
with _EMBEDDING_PROVIDER_CACHE_LOCK:
208225
if cached_provider := _EMBEDDING_PROVIDER_CACHE.get(cache_key):
209226
return cached_provider
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Embedding provider wrapper for role-specific literal text prefixes."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from typing import Any
7+
8+
from basic_memory.repository.embedding_provider import EmbeddingProvider
9+
10+
11+
def normalize_embedding_prefix(value: str | None) -> str | None:
12+
"""Treat unset and empty prefixes as disabled while preserving meaningful spaces."""
13+
if value == "":
14+
return None
15+
return value
16+
17+
18+
class PrefixingEmbeddingProvider(EmbeddingProvider):
19+
"""Apply document/query text prefixes before delegating to an embedding provider."""
20+
21+
def __init__(
22+
self,
23+
provider: EmbeddingProvider,
24+
*,
25+
document_prefix: str | None = None,
26+
query_prefix: str | None = None,
27+
) -> None:
28+
self.provider = provider
29+
self.document_prefix = normalize_embedding_prefix(document_prefix)
30+
self.query_prefix = normalize_embedding_prefix(query_prefix)
31+
32+
@property
33+
def model_name(self) -> str:
34+
return self.provider.model_name
35+
36+
@property
37+
def dimensions(self) -> int:
38+
return self.provider.dimensions
39+
40+
async def embed_query(self, text: str) -> list[float]:
41+
if self.query_prefix is not None:
42+
text = f"{self.query_prefix}{text}"
43+
return await self.provider.embed_query(text)
44+
45+
async def embed_documents(self, texts: list[str]) -> list[list[float]]:
46+
if self.document_prefix is not None:
47+
texts = [f"{self.document_prefix}{text}" for text in texts]
48+
return await self.provider.embed_documents(texts)
49+
50+
def runtime_log_attrs(self) -> dict[str, Any]:
51+
attrs = self.provider.runtime_log_attrs()
52+
attrs.update(
53+
{
54+
"document_prefix_set": self.document_prefix is not None,
55+
"query_prefix_set": self.query_prefix is not None,
56+
}
57+
)
58+
if self.document_prefix is not None:
59+
attrs["document_prefix_length"] = len(self.document_prefix)
60+
if self.query_prefix is not None:
61+
attrs["query_prefix_length"] = len(self.query_prefix)
62+
return attrs
63+
64+
def identity_key(self) -> str:
65+
"""Return embedding semantics including literal text-prefix transforms."""
66+
provider_identity_key = getattr(self.provider, "identity_key", None)
67+
if callable(provider_identity_key):
68+
provider_identity = provider_identity_key()
69+
else:
70+
provider_identity = f"{self.provider.model_name}:{self.provider.dimensions}"
71+
72+
document_prefix = self.document_prefix or "-"
73+
query_prefix = self.query_prefix or "-"
74+
return (
75+
f"{type(self.provider).__name__}:{provider_identity}:"
76+
f"document_prefix={json.dumps(document_prefix, ensure_ascii=True)}:"
77+
f"query_prefix={json.dumps(query_prefix, ensure_ascii=True)}"
78+
)

src/basic_memory/repository/search_repository_base.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -641,15 +641,16 @@ def _embedding_model_key(self) -> str:
641641
assert self._embedding_provider is not None
642642
provider = self._embedding_provider
643643

644-
provider_identity = f"{provider.model_name}:{provider.dimensions}"
645-
from basic_memory.repository.litellm_provider import LiteLLMEmbeddingProvider
646-
647-
if isinstance(provider, LiteLLMEmbeddingProvider):
648-
# Trigger: LiteLLM can change request semantics without changing model/dimensions.
649-
# Why: asymmetric providers use role-specific document/query params, and
650-
# dimension forwarding changes provider-side output-size behavior.
644+
provider_identity_key = getattr(provider, "identity_key", None)
645+
if callable(provider_identity_key):
646+
# Trigger: providers can change request/input semantics without changing
647+
# model/dimensions.
648+
# Why: asymmetric providers may use role-specific API params or literal
649+
# text-prefix transforms that change stored vector meaning.
651650
# Outcome: reindex treats those semantic config changes as stale vectors.
652-
provider_identity = provider.identity_key()
651+
provider_identity = provider_identity_key()
652+
else:
653+
provider_identity = f"{provider.model_name}:{provider.dimensions}"
653654

654655
return f"{type(provider).__name__}:{provider_identity}"
655656

src/basic_memory/schemas/project_info.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ class EmbeddingStatus(BaseModel):
8787
embedding_provider: Optional[str] = None
8888
embedding_model: Optional[str] = None
8989
embedding_dimensions: Optional[int] = None
90+
embedding_document_prefix_set: bool = False
91+
embedding_query_prefix_set: bool = False
9092

9193
# Counts
9294
total_indexed_entities: int = 0

src/basic_memory/services/project_service.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -989,6 +989,8 @@ async def get_embedding_status(self, project_id: int) -> EmbeddingStatus:
989989
provider = config.semantic_embedding_provider
990990
model = config.semantic_embedding_model
991991
dimensions = config.semantic_embedding_dimensions
992+
document_prefix_set = bool(config.semantic_embedding_document_prefix)
993+
query_prefix_set = bool(config.semantic_embedding_query_prefix)
992994

993995
is_postgres = config.database_backend == DatabaseBackend.POSTGRES
994996

@@ -1025,6 +1027,8 @@ async def get_embedding_status(self, project_id: int) -> EmbeddingStatus:
10251027
embedding_provider=provider,
10261028
embedding_model=model,
10271029
embedding_dimensions=dimensions,
1030+
embedding_document_prefix_set=document_prefix_set,
1031+
embedding_query_prefix_set=query_prefix_set,
10281032
total_indexed_entities=total_indexed_entities,
10291033
vector_tables_exist=False,
10301034
reindex_recommended=True,
@@ -1132,6 +1136,8 @@ async def _vec_scalar(vec_sql) -> int:
11321136
embedding_provider=provider,
11331137
embedding_model=model,
11341138
embedding_dimensions=dimensions,
1139+
embedding_document_prefix_set=document_prefix_set,
1140+
embedding_query_prefix_set=query_prefix_set,
11351141
total_indexed_entities=total_indexed_entities,
11361142
vector_tables_exist=False,
11371143
reindex_recommended=True,
@@ -1164,6 +1170,8 @@ async def _vec_scalar(vec_sql) -> int:
11641170
embedding_provider=provider,
11651171
embedding_model=model,
11661172
embedding_dimensions=dimensions,
1173+
embedding_document_prefix_set=document_prefix_set,
1174+
embedding_query_prefix_set=query_prefix_set,
11671175
total_indexed_entities=total_indexed_entities,
11681176
total_entities_with_chunks=total_entities_with_chunks,
11691177
total_chunks=total_chunks,

tests/repository/test_openai_provider.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
)
1616
from basic_memory.repository.fastembed_provider import FastEmbedEmbeddingProvider
1717
from basic_memory.repository.openai_provider import OpenAIEmbeddingProvider
18+
from basic_memory.repository.prefixing_provider import PrefixingEmbeddingProvider
1819
from basic_memory.repository.semantic_errors import SemanticDependenciesMissingError
1920

2021

@@ -547,6 +548,69 @@ def test_embedding_provider_factory_creates_new_provider_for_different_cache_key
547548
assert provider_a is not provider_b
548549

549550

551+
def test_embedding_provider_factory_wraps_provider_when_prefixes_are_configured():
552+
"""Factory should apply literal prefixes independently of provider backend."""
553+
config = BasicMemoryConfig(
554+
env="test",
555+
projects={"test-project": "/tmp/basic-memory-test"},
556+
default_project="test-project",
557+
semantic_search_enabled=True,
558+
semantic_embedding_provider="fastembed",
559+
semantic_embedding_document_prefix="title: none | text: ",
560+
semantic_embedding_query_prefix="task: search result | query: ",
561+
)
562+
563+
provider = create_embedding_provider(config)
564+
565+
assert isinstance(provider, PrefixingEmbeddingProvider)
566+
assert isinstance(provider.provider, FastEmbedEmbeddingProvider)
567+
assert provider.document_prefix == "title: none | text: "
568+
assert provider.query_prefix == "task: search result | query: "
569+
570+
571+
def test_embedding_provider_factory_reuses_provider_for_same_prefixes():
572+
"""Prefix fields participate in the process-local provider cache key."""
573+
config = BasicMemoryConfig(
574+
env="test",
575+
projects={"test-project": "/tmp/basic-memory-test"},
576+
default_project="test-project",
577+
semantic_search_enabled=True,
578+
semantic_embedding_provider="fastembed",
579+
semantic_embedding_document_prefix="doc: ",
580+
semantic_embedding_query_prefix="query: ",
581+
)
582+
583+
provider_a = create_embedding_provider(config)
584+
provider_b = create_embedding_provider(config)
585+
586+
assert provider_a is provider_b
587+
588+
589+
def test_embedding_provider_factory_separates_cache_for_different_prefixes():
590+
"""Changing literal prefixes should not reuse a stale cached provider."""
591+
shared_config = {
592+
"env": "test",
593+
"projects": {"test-project": "/tmp/basic-memory-test"},
594+
"default_project": "test-project",
595+
"semantic_search_enabled": True,
596+
"semantic_embedding_provider": "fastembed",
597+
"semantic_embedding_query_prefix": "query: ",
598+
}
599+
first_config = BasicMemoryConfig(
600+
**shared_config,
601+
semantic_embedding_document_prefix="doc: ",
602+
)
603+
second_config = BasicMemoryConfig(
604+
**shared_config,
605+
semantic_embedding_document_prefix="document: ",
606+
)
607+
608+
first_provider = create_embedding_provider(first_config)
609+
second_provider = create_embedding_provider(second_config)
610+
611+
assert first_provider is not second_provider
612+
613+
550614
def test_embedding_provider_factory_reuses_provider_when_only_thread_knobs_differ():
551615
"""Thread/parallel knobs tune ONNX execution, not model identity (#872).
552616

0 commit comments

Comments
 (0)