Repository navigation
LCORE-4709: Implemented sentence transformers provider #3034
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
asimurka
wants to merge
1
commit into
lightspeed-core:main
Choose a base branch
from
asimurka:sentence-transformers-provider
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+659
−0
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,7 @@ | ||
| """Sentence-transformers inference provider package.""" | ||
|
|
||
| from providers.inference.sentence_transformers.provider import ( | ||
| SentenceTransformersInferenceProvider, | ||
| ) | ||
|
|
||
| __all__ = ["SentenceTransformersInferenceProvider"] |
72 changes: 72 additions & 0 deletions
72
src/providers/inference/sentence_transformers/model_cache.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| """In-process cache for sentence-transformers embedding and rerank models.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import Literal, Optional, overload | ||
|
|
||
| from pydantic_ai.embeddings import EmbeddingModel | ||
| from sentence_transformers import CrossEncoder | ||
|
|
||
|
|
||
| class SentenceTransformersModelCache: | ||
| """Process-local cache of loaded embedding and rerank model instances.""" | ||
|
|
||
| def __init__(self) -> None: | ||
| """Initialize empty embedding and rerank caches.""" | ||
| self._embeddings: dict[str, EmbeddingModel] = {} | ||
| self._rerankers: dict[str, CrossEncoder] = {} | ||
|
|
||
| @overload | ||
| def get( | ||
| self, | ||
| provider_model_id: str, | ||
| model_type: Literal["embedding"], | ||
| ) -> Optional[EmbeddingModel]: ... | ||
|
|
||
| @overload | ||
| def get( | ||
| self, | ||
| provider_model_id: str, | ||
| model_type: Literal["rerank"], | ||
| ) -> Optional[CrossEncoder]: ... | ||
|
|
||
| def get( | ||
| self, | ||
| provider_model_id: str, | ||
| model_type: Literal["embedding", "rerank"], | ||
| ) -> Optional[EmbeddingModel | CrossEncoder]: | ||
| """Return a cached model when present. | ||
|
|
||
| Args: | ||
| provider_model_id: Cache key; the Hugging Face model id. | ||
| model_type: Capability whose cache to read (embedding or rerank). | ||
|
|
||
| Returns: | ||
| The cached model, or None if it has not been stored. | ||
|
|
||
| """ | ||
|
asimurka marked this conversation as resolved.
|
||
| match model_type: | ||
|
asimurka marked this conversation as resolved.
|
||
| case "embedding": | ||
| return self._embeddings.get(provider_model_id) | ||
| case "rerank": | ||
| return self._rerankers.get(provider_model_id) | ||
|
|
||
| def put( | ||
| self, | ||
| provider_model_id: str, | ||
| model: EmbeddingModel | CrossEncoder, | ||
| ) -> None: | ||
| """Store a model in the cache. | ||
|
|
||
| The target store is chosen from the instance type: CrossEncoder goes | ||
| to the rerank cache, anything else to the embedding cache. | ||
|
|
||
| Args: | ||
| provider_model_id: Cache key; the Hugging Face model id. | ||
| model: Model instance to cache. | ||
|
|
||
| """ | ||
| if isinstance(model, CrossEncoder): | ||
| self._rerankers[provider_model_id] = model | ||
| else: | ||
| self._embeddings[provider_model_id] = model | ||
185 changes: 185 additions & 0 deletions
185
src/providers/inference/sentence_transformers/provider.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,185 @@ | ||
| """Local sentence-transformers inference provider (embedding and rerank).""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import TYPE_CHECKING, Any, Optional | ||
|
|
||
| from pydantic_ai.embeddings.sentence_transformers import ( | ||
| SentenceTransformerEmbeddingModel, | ||
| ) | ||
| from pydantic_ai.models import Model | ||
| from pydantic_ai.providers import Provider | ||
| from sentence_transformers import CrossEncoder, SentenceTransformer | ||
|
|
||
| from log import get_logger | ||
| from models.config import SentenceTransformersInferenceProviderEntry | ||
| from providers.inference.base import InferenceProvider | ||
| from providers.inference.catalog import CatalogModel, DiscoveredModel | ||
| from providers.inference.errors import ModelTypeNotSupportedError | ||
| from providers.inference.sentence_transformers.model_cache import ( | ||
| SentenceTransformersModelCache, | ||
| ) | ||
|
|
||
| if TYPE_CHECKING: | ||
|
asimurka marked this conversation as resolved.
|
||
| from pydantic_ai.embeddings import EmbeddingModel | ||
|
|
||
| from providers.inference.types import ModelType | ||
|
|
||
| logger = get_logger(__name__) | ||
|
|
||
|
|
||
| class SentenceTransformersInferenceProvider( | ||
| InferenceProvider[SentenceTransformersInferenceProviderEntry, Provider[Any]] | ||
| ): | ||
| """Inference provider for local sentence-transformers embedding and rerank. | ||
|
|
||
| This provider has no HTTP client and no remote discovery. The configured | ||
| allowed_models list is the catalog. Chat models are never exposed. | ||
| """ | ||
|
|
||
| def __init__(self, entry: SentenceTransformersInferenceProviderEntry) -> None: | ||
| """Initialize with an empty in-process model cache. | ||
|
|
||
| Args: | ||
| entry: Provider configuration entry with a non-empty allow-list. | ||
|
|
||
| """ | ||
| super().__init__(entry) | ||
| self._cache = SentenceTransformersModelCache() | ||
|
|
||
| def _build_provider(self) -> Optional[Provider[Any]]: | ||
| """Return no Pydantic AI provider; models run in-process. | ||
|
|
||
| Returns: | ||
| Always None. | ||
|
|
||
| """ | ||
| return None | ||
|
|
||
| async def _discover_models(self) -> list[DiscoveredModel]: | ||
| """Remote discovery is unsupported for sentence-transformers. | ||
|
|
||
| Returns: | ||
| An empty list. Catalog construction uses allowed_models only. | ||
|
|
||
| """ | ||
| return [] | ||
|
|
||
| async def _load_catalog(self) -> list[CatalogModel]: | ||
| """Build the catalog exclusively from configured allowed models. | ||
|
|
||
| Equivalent to the base-class skip_model_discovery path. Entries typed | ||
| as llm are omitted so this provider never exposes chat models. | ||
|
|
||
| Returns: | ||
| Catalog rows for embedding and rerank models only. | ||
|
|
||
| """ | ||
| catalog: list[CatalogModel] = [] | ||
| for allowed in self._entry.allowed_models: | ||
| model_type = allowed.model_type or self._infer_model_type( | ||
| allowed.provider_model_id | ||
| ) | ||
| if model_type == "llm": | ||
| logger.warning( | ||
| "Omitting configured model %r with provider model ID %r " | ||
| "from sentence-transformers provider %r; llm is unsupported", | ||
| allowed.name, | ||
| allowed.provider_model_id, | ||
| self.provider_id, | ||
| ) | ||
| continue | ||
| catalog.append( | ||
| CatalogModel( | ||
| name=allowed.name, | ||
| provider_model_id=allowed.provider_model_id, | ||
| model_type=model_type, | ||
| metadata=dict(allowed.metadata), | ||
| ) | ||
| ) | ||
| return catalog | ||
|
|
||
| def _create_llm(self, model: CatalogModel) -> Model: | ||
| """Reject LLM construction; this provider never serves chat models. | ||
|
|
||
| Args: | ||
| model: Catalog entry that requested LLM support. | ||
|
|
||
| Raises: | ||
| ModelTypeNotSupportedError: Always, because llm is unsupported. | ||
|
|
||
| """ | ||
| _ = model | ||
| raise ModelTypeNotSupportedError("llm", self.provider_id) | ||
|
|
||
| def _create_embedding(self, model: CatalogModel) -> EmbeddingModel: | ||
| """Create or reuse a cached Pydantic AI sentence-transformers embedding model. | ||
|
|
||
| Args: | ||
| model: Catalog entry describing the embedding model to instantiate. | ||
|
|
||
| Returns: | ||
|
asimurka marked this conversation as resolved.
|
||
| A cached Pydantic AI embedding model for the provider model id. | ||
|
|
||
| """ | ||
| provider_model_id = model.provider_model_id | ||
| cached = self._cache.get(provider_model_id, "embedding") | ||
| if cached is not None: | ||
| return cached | ||
|
|
||
| config = self._entry.config | ||
| embedding = SentenceTransformerEmbeddingModel( | ||
| SentenceTransformer( | ||
| provider_model_id, | ||
| device=config.device, | ||
|
asimurka marked this conversation as resolved.
|
||
| cache_folder=config.cache_dir, | ||
| ) | ||
| ) | ||
| self._cache.put(provider_model_id, embedding) | ||
| return embedding | ||
|
|
||
| def _create_rerank(self, model: CatalogModel) -> CrossEncoder: | ||
| """Create or reuse a cached sentence-transformers cross-encoder. | ||
|
|
||
| Args: | ||
| model: Catalog entry describing the reranker to instantiate. | ||
|
|
||
| Returns: | ||
| A cached CrossEncoder for the provider model id. | ||
|
|
||
| """ | ||
| provider_model_id = model.provider_model_id | ||
| cached = self._cache.get(provider_model_id, "rerank") | ||
| if cached is not None: | ||
| return cached | ||
|
|
||
| config = self._entry.config | ||
| reranker = CrossEncoder( | ||
| provider_model_id, | ||
| device=config.device, | ||
| cache_folder=config.cache_dir, | ||
| ) | ||
| self._cache.put(provider_model_id, reranker) | ||
| return reranker | ||
|
|
||
| def _infer_model_type( | ||
| self, | ||
| provider_model_id: str, | ||
| **kwargs: Any, | ||
| ) -> ModelType: | ||
| """Infer embedding vs rerank when allowed_models model_type is omitted. | ||
|
|
||
| Args: | ||
| provider_model_id: Hugging Face model id or local path. | ||
| **kwargs: Unused; accepted for base-class compatibility. | ||
|
|
||
| Returns: | ||
| rerank when the id looks like a cross-encoder or reranker, | ||
| otherwise embedding. Never llm. | ||
|
|
||
| """ | ||
| _ = kwargs | ||
| normalized = provider_model_id.lower().replace("-", "").replace("_", "") | ||
| if "crossencoder" in normalized or "rerank" in normalized: | ||
| return "rerank" | ||
| return "embedding" | ||
13 changes: 13 additions & 0 deletions
13
tests/unit/providers/inference/sentence_transformers/README.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| # List of source files stored in `tests/unit/providers/inference/sentence_transformers` directory | ||
|
|
||
| ## [__init__.py](__init__.py) | ||
|
|
||
| Unit tests for the sentence-transformers inference provider. | ||
|
|
||
| ## [test_model_cache.py](test_model_cache.py) | ||
|
|
||
| Unit tests for the in-process sentence-transformers model cache. | ||
|
|
||
| ## [test_provider.py](test_provider.py) | ||
|
|
||
| Unit tests for the sentence-transformers inference provider. |
1 change: 1 addition & 0 deletions
1
tests/unit/providers/inference/sentence_transformers/__init__.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """Unit tests for the sentence-transformers inference provider.""" |
39 changes: 39 additions & 0 deletions
39
tests/unit/providers/inference/sentence_transformers/test_model_cache.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| """Unit tests for the in-process sentence-transformers model cache.""" | ||
|
|
||
| from typing import Any | ||
|
|
||
| from sentence_transformers import CrossEncoder | ||
|
|
||
| from providers.inference.sentence_transformers.model_cache import ( | ||
| SentenceTransformersModelCache, | ||
| ) | ||
|
|
||
|
|
||
| def test_get_and_put_embedding_caches_by_provider_model_id(mocker: Any) -> None: | ||
| """Stores embeddings by instance type and provider model id.""" | ||
| cache = SentenceTransformersModelCache() | ||
| embed_a = mocker.Mock(name="embed-a") | ||
| embed_b = mocker.Mock(name="embed-b") | ||
|
|
||
| assert cache.get("model-a", "embedding") is None | ||
| cache.put("model-a", embed_a) | ||
| cache.put("model-b", embed_b) | ||
|
|
||
| assert cache.get("model-a", "embedding") is embed_a | ||
| assert cache.get("model-b", "embedding") is embed_b | ||
| assert cache.get("model-a", "rerank") is None | ||
|
|
||
|
|
||
| def test_get_and_put_rerank_caches_by_provider_model_id(mocker: Any) -> None: | ||
| """Stores cross-encoders by instance type and provider model id.""" | ||
| cache = SentenceTransformersModelCache() | ||
| rerank_a = mocker.Mock(name="rerank-a", spec=CrossEncoder) | ||
| rerank_b = mocker.Mock(name="rerank-b", spec=CrossEncoder) | ||
|
|
||
| assert cache.get("model-a", "rerank") is None | ||
| cache.put("model-a", rerank_a) | ||
| cache.put("model-b", rerank_b) | ||
|
|
||
| assert cache.get("model-a", "rerank") is rerank_a | ||
| assert cache.get("model-b", "rerank") is rerank_b | ||
| assert cache.get("model-a", "embedding") is None |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.