Compare commits

..
Author SHA1 Message Date
generall d4c4d86e10 fix parallel worker init 2025-05-10 13:30:54 +02:00
generall a63cb00a91 add token embeddings 2025-05-10 13:30:54 +02:00
George b785640bd5 fix: fix list of supported bm25 languages (#506) 2025-04-15 14:16:09 +03:00
Hossam Hagag 5568a62c2f ci: Unlock numpy in ci (#504) 2025-04-11 13:00:54 +03:00
George Panchuk b34209dcfb bump version to 0.6.1 2025-04-10 16:23:54 +03:00
George c91d42dda7 Update setting jina v3 tasks (#503)
* new: improve task setter in jina v3

* refactor

* new: add hf_token secret

* fix: cross platform env propagation
2025-04-10 16:19:41 +03:00
George aa0c475a1f fix: fix splade name (#499) 2025-03-17 11:07:14 +03:00
Dmitrii OgnandGeorge Panchuk 4c239b11d5 Custom rerankers support (#496)
* Custom rerankers support

* Test for reranker_custom_model

* test fix

* Model description type fix

* Test fix

* fix: fix naming

* fix: remove redundant arg from tests

* new: update readme

---------

Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
2025-03-16 20:02:58 +03:00
Hossam HagagandGeorge Panchuk 6acfb001fb Speedup ci (#489)
* chore: Trigger CI test

* chore: Trigger CI test

* chore: Trigger CI test

* chore: Trigger CI test

* chore: Trigger CI test

* chore: Trigger CI test

* chore: Trigger CI test

* chore: Trigger CI test

* chore: Trigger CI test

* Trigger CI

* Trigger CI

* Trigger CI

* Trigger CI test

* Trigger CI test

* Trigger CI test

* Trigger CI test

* Trigger CI test

* Trigger CI test

* Trigger CI test

* Trigger CI test

* Trigger CI test

* Trigger CI test

* Trigger CI test

* Trigger CI test

* Trigger CI test

* Trigger CI test

* Trigger CI test

* Trigger CI test

* Trigger CI test

* Trigger CI test

* Trigger CI test

* Trigger CI test

* Trigger CI test

* Trigger CI test

* Trigger CI test

* Trigger CI test

* Trigger CI test

* Trigger CI test

* new: Added on workflow dispatch

* tests: Updated tests

* fix: Fix CI

* fix: Fix CI

* fix: Fix CI

* improve: Prevent stop iteration error caused by next

* fix: Fix variable might be referenced before assignment

* refactor: Revised the way of getting models to test

* fix: Fix test in image model

* refactor: Call one model

* fix: Fix ci

* fix: Fix splade model name

* tests: Updated tests

* chore: Remove cache

* tests: Update multi task tests

* tests: Update multi task tests

* tests: Updated tests

* refactor: refactor utils func, add comments, conditions refactor

---------

Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
2025-03-06 04:39:58 +02:00
George 1729aab1ec new: preserve embeddings in a type set by their model (#492)
* new: preserve embeddings in a type set by their model

* fix: remove type coercion

* fix: remove redundant type

* fix: fix random data type in tests
2025-03-03 17:46:35 +01:00
George 42fca3b467 Deprecate archive struct (#490)
* bump version to 0.6.0

* new: deprecate gcp archive structure
2025-03-03 17:12:47 +01:00
George Panchuk 2082108baf bump version to 0.6.0 2025-02-26 13:55:26 +01:00
George 6cda2ce7f0 fix: fix batch embedding precision and shape (#488) 2025-02-26 13:51:20 +01:00
George 5bd5c0a0f0 fix: fix colpali preprocessing, add examples to readme (#487) 2025-02-26 12:51:13 +01:00
George 58ee7cc95c fix: fix thenlper, update warnings (#486) 2025-02-21 17:40:14 +01:00
George 27eeb39473 new: add custom models (#479)
* fix: fix onnx text embedding list supported models, do not add already registered models, add tests

* fix: autouse fixture in custom model tests

* Refactor custom models (#482)

* refactor: refactor custom models

* fix: fix types

* remove commented out code
2025-02-20 14:20:12 +01:00
George 4e527b1c63 new: allow mmh3<6.0.0 (#484) 2025-02-20 14:18:44 +01:00
Hossam Hagag 8d04b81782 chore: Remove redundant specific model path (#480) 2025-02-18 03:13:14 +02:00
Dmitrii OgnandGeorge b389798d8e Migration of models to dataclasses (#474)
* Migration of models to dataclasses

* Model description file

* Test fix

* kw_only support

* Multitask embeddings test fix

* list_supported_models type fix

* Dim fix for sparsemodels

* Dim fix for sparsemodels

* Dim fix for sparsemodels (x2)

* Model management type fix

* Interface docstring fixes

* Mypy fixes

* Typing fix again

* Typing fix again

* Special cast to SparseModelDescription

* Special cast to SparseModelDescription

* Special cast to SparseModelDescription

* typing fix for colpali

* typing fix for colpali

* typing fix for colpali

* typing fix for colpali

* Let's try generic typing for ModelManagment

* wip: dataclass idea, small fixes (#475)

* wip: dataclass idea, small fixes

* fix: fix exception message in base model description

* remove custom model descriptions

* make license, description and size in gb mandatory in model description

* fix: introduce _list_supported_models which returns model description objects

* test: add test for list supported models

* fix: fix list supported models usage in tests

---------

Co-authored-by: George <george.panchuk@qdrant.tech>
2025-02-16 00:00:26 +01:00
George 6f43572373 fix: fix problem appeared in poetry 2.1.0 (#478) 2025-02-15 22:27:41 +01:00
George de4ecb48c7 new: add late interaction multimodal to package init (#471) 2025-02-12 18:10:07 +01:00
George b9d605138f new: add py.typed (#472) 2025-02-12 18:09:54 +01:00
Hossam Hagag a931f143ef Fix ci (#468)
* fix: Fix ci

* fix: Fix ci

* fix: Fix ci

* fix: Fix ci by downgrading mkdocstrings
2025-02-06 20:54:21 +01:00
Hossam HagagandGeorge Panchuk 105ff19035 new: Add mypy type checker (#470)
* new: Add mypy type checker

* fix: fix mypy command

* fix: fix indentation for mypy

* fix: do not install redundant optional groups cuz python3.13 does not support onnx

---------

Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
2025-02-06 20:46:43 +01:00
Georgeandhh-space-invader 4599b933ca wip: type hints for colpali (#469)
* wip: type hints for colpali

* new: Add colpali type hints

* refactor: Remove redundant type ignore

* fix: address remaining mypy issues

---------

Co-authored-by: hh-space-invader <h.hagag.ali@gmail.com>
2025-02-06 20:28:10 +01:00
Hossam HagagandGeorge Panchuk 0fa1596c3d new: Add missing type hints (#464)
* new: Add missing type hints

* refactor: Removed type ignore

* fix: fix mypy complaints

* fix: remove redundant type coercion, fix skip list type

* new: more precise type for sparse embedding inference, a small revert for parallel processor

---------

Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
2025-02-06 16:46:23 +01:00
Georgeandd.rudenko 2fe33c5a62 colpali v1.3 by AndrewOgn (#427)
* wip: design draft

* Operators fix

* Fix model inputs

* Import from fastembed.late_interaction_multimodal

* Fixed method misspelling

* Tests, which do not run in CI
Docstring improvements

* Fix tests

* Bump colpali to version v1.3

* Remove colpali v1.2

* Remove colpali v1.2 from tests

* partial fix of change requests:
descriptions
docs
black

* query_max_length

* black colpali

* Added comment for EMPTY_TEXT_PLACEHOLDER

* Review fixes

* Removed redundant VISUAL_PROMPT_PREFIX

* type fix + model info

* new: add specific model path to colpali

* fix: revert accidental renaming

* fix: remove max_length from encode_batch

* refactoring: remove redundant QUERY_MAX_LENGTH variable

* refactoring: remove redundant document marker token id

* fix: fix type hints, fix tests, handle single image path embed, rename model, update description

* license: add gemma to NOTICE

* fix: do not run colpali test in ci

* fix: fix colpali test

---------

Co-authored-by: d.rudenko <dmitrii.rudenko@qdrant.com>
2025-02-06 16:23:06 +01:00
Hossam Hagag 969ea29923 new: Added type stub (#458)
* new: Added type stub

* chore: Updated stubs

* chore: device_id type hint

* chore: add -> none to init without args

* new: Added workflow type check

* chore: Revert added type checkers
2025-02-06 11:24:45 +01:00
Hossam HagagandGeorge Panchuk a5b266e018 Image type hints (#457)
* chore: Added type hints

* new: Add type hints for parallel processor

* new: Add image type hints

* fix: NdArray -> NumpyArray

* fix: remove redundant property

* refactoring: remove redundant new lines

* refactoring: remove redundant new line

* fix: fix image input types

* fix: remove redundant import

* fix: remove mp subscriptions due to mac os issues

* chore: Update type hints

* chore: Added type gints for functional

* refactor

---------

Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
2025-02-06 11:24:19 +01:00
Hossam HagagandGeorge Panchuk 877d963bd1 Rerank type hints (#459)
* chore: Update type hints

* remove redundant array creation, update type hints

---------

Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
2025-02-04 17:40:50 +01:00
Hossam HagagandGeorge Panchuk 37a66d9e16 new: Add sparse type hints (#460)
* new: Add sparse type hints

* fix: ndarray -> numpyarray

---------

Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
2025-02-04 17:17:25 +01:00
Hossam HagagandGeorge Panchuk b08febbf93 Late interaction type hints (#461)
* chore: Add type hints

* new: Add late_interaction type hints

* fix: ndarray -> numpy array

---------

Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
2025-02-04 17:14:11 +01:00
Hossam Hagag 6dbdd6d171 chore: Added type hints (#454)
* chore: Added type hints

* fix: Fix generic type

* fix: Fix generic type

* new: Add type hints for parallel processor

* fix: Revert queue sub type as its not supported

* fix: Revert queue sub type as its not supported

* fix: Fixed type hints

* chore: Updated type hints

* fix: Update task id to be public

* chore: Updated type hints

* chore: Updated type hints

* fix: minor reverts in parallel processor and onnx text model
2025-02-04 17:06:05 +01:00
63 changed files with 2940 additions and 1345 deletions
+1 -1
View File
@@ -21,5 +21,5 @@ jobs:
path: .cache
restore-keys: |
mkdocs-material-
- run: pip install mkdocs-material mkdocstrings pillow cairosvg mknotebooks
- run: pip install mkdocs-material mkdocstrings==0.27.0 pillow cairosvg mknotebooks
- run: mkdocs gh-deploy --force
+6 -3
View File
@@ -1,9 +1,10 @@
name: Tests
on:
push:
branches: [ master, main, gpu ]
pull_request:
branches: [ master, main, gpu ]
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
@@ -41,5 +42,7 @@ jobs:
poetry install --no-interaction --no-ansi --without dev,docs
- name: Run pytest
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
poetry run pytest
poetry run pytest
+38
View File
@@ -0,0 +1,38 @@
name: type-checkers
on: [push]
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: true
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
os: [ubuntu-latest]
name: Python ${{ matrix.python-version }} test
steps:
- uses: actions/checkout@v1
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip poetry
poetry install --no-interaction --no-ansi --without dev,docs,test
- name: mypy
run: |
poetry run mypy fastembed \
--disallow-incomplete-defs \
--disallow-untyped-defs \
--disable-error-code=import-untyped
- name: pyright
run: |
poetry run pyright tests/type_stub.py
+6
View File
@@ -12,5 +12,11 @@ This distribution includes the following Jina AI models, each with its respectiv
These models are developed by Jina (https://jina.ai/) and are subject to Jina AI's licensing terms.
This distribution includes the following Google models, each with its respective license:
- vidore/colpali-v1.3
- License: gemma
Gemma is provided under and subject to the Gemma Terms of Use found at ai.google.dev/gemma/terms
Additional Notes:
This project also includes third-party libraries with their respective licenses. Please refer to the documentation of each library for details regarding its usage and licensing terms.
+55
View File
@@ -63,6 +63,23 @@ embeddings = list(model.embed(documents))
```
Dense text embedding can also be extended with models which are not in the list of supported models.
```python
from fastembed import TextEmbedding
from fastembed.common.model_description import PoolingType, ModelSource
TextEmbedding.add_custom_model(
model="intfloat/multilingual-e5-small",
pooling=PoolingType.MEAN,
normalization=True,
sources=ModelSource(hf="intfloat/multilingual-e5-small"), # can be used with an `url` to load files from a private storage
dim=384,
model_file="onnx/model.onnx", # can be used to load an already supported model with another optimization or quantization, e.g. onnx/model_O4.onnx
)
model = TextEmbedding(model_name="intfloat/multilingual-e5-small")
embeddings = list(model.embed(documents))
```
### 🔱 Sparse text embeddings
@@ -137,6 +154,27 @@ embeddings = list(model.embed(images))
# ]
```
### Late interaction multimodal models (ColPali)
```python
from fastembed import LateInteractionMultimodalEmbedding
doc_images = [
"./path/to/qdrant_pdf_doc_1_screenshot.jpg",
"./path/to/colpali_pdf_doc_2_screenshot.jpg",
]
query = "What is Qdrant?"
model = LateInteractionMultimodalEmbedding(model_name="Qdrant/colpali-v1.3-fp16")
doc_images_embeddings = list(model.embed_image(doc_images))
# shape (2, 1030, 128)
# [array([[-0.03353882, -0.02090454, ..., -0.15576172, -0.07678223]], dtype=float32)]
query_embedding = model.embed_text(query)
# shape (1, 20, 128)
# [array([[-0.00218201, 0.14758301, ..., -0.02207947, 0.16833496]], dtype=float32)]
```
### 🔄 Rerankers
```python
from fastembed.rerank.cross_encoder import TextCrossEncoder
@@ -152,6 +190,23 @@ scores = list(encoder.rerank(query, documents))
# [-11.48061752319336, 5.472434997558594]
```
Text cross encoders can also be extended with models which are not in the list of supported models.
```python
from fastembed.rerank.cross_encoder import TextCrossEncoder
from fastembed.common.model_description import ModelSource
TextCrossEncoder.add_custom_model(
model="Xenova/ms-marco-MiniLM-L-4-v2",
model_file="onnx/model.onnx",
sources=ModelSource(hf="Xenova/ms-marco-MiniLM-L-4-v2"),
)
model = TextCrossEncoder(model_name="Xenova/ms-marco-MiniLM-L-4-v2")
scores = list(model.rerank_pairs(
[("What is AI?", "Artificial intelligence is ..."), ("What is ML?", "Machine learning is ..."),]
))
```
## ⚡️ FastEmbed on a GPU
FastEmbed supports running on GPU devices.
+2
View File
@@ -2,6 +2,7 @@ import importlib.metadata
from fastembed.image import ImageEmbedding
from fastembed.late_interaction import LateInteractionTextEmbedding
from fastembed.late_interaction_multimodal import LateInteractionMultimodalEmbedding
from fastembed.sparse import SparseEmbedding, SparseTextEmbedding
from fastembed.text import TextEmbedding
@@ -17,4 +18,5 @@ __all__ = [
"SparseEmbedding",
"ImageEmbedding",
"LateInteractionTextEmbedding",
"LateInteractionMultimodalEmbedding",
]
+2 -2
View File
@@ -1,3 +1,3 @@
from fastembed.common.types import ImageInput, OnnxProvider, PathInput, PilInput
from fastembed.common.types import ImageInput, OnnxProvider, PathInput
__all__ = ["OnnxProvider", "ImageInput", "PathInput", "PilInput"]
__all__ = ["OnnxProvider", "ImageInput", "PathInput"]
+52
View File
@@ -0,0 +1,52 @@
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional, Any
@dataclass(frozen=True)
class ModelSource:
hf: Optional[str] = None
url: Optional[str] = None
_deprecated_tar_struct: bool = False
@property
def deprecated_tar_struct(self) -> bool:
return self._deprecated_tar_struct
def __post_init__(self) -> None:
if self.hf is None and self.url is None:
raise ValueError(
f"At least one source should be set, current sources: hf={self.hf}, url={self.url}"
)
@dataclass(frozen=True)
class BaseModelDescription:
model: str
sources: ModelSource
model_file: str
description: str
license: str
size_in_GB: float
additional_files: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class DenseModelDescription(BaseModelDescription):
dim: Optional[int] = None
tasks: Optional[dict[str, Any]] = field(default_factory=dict)
def __post_init__(self) -> None:
assert self.dim is not None, "dim is required for dense model description"
@dataclass(frozen=True)
class SparseModelDescription(BaseModelDescription):
requires_idf: Optional[bool] = None
vocab_size: Optional[int] = None
class PoolingType(str, Enum):
CLS = "CLS"
MEAN = "MEAN"
DISABLED = "DISABLED"
+62 -25
View File
@@ -4,7 +4,7 @@ import json
import shutil
import tarfile
from pathlib import Path
from typing import Any, Optional
from typing import Any, Optional, Union, TypeVar, Generic
import requests
from huggingface_hub import snapshot_download, model_info, list_repo_tree
@@ -16,9 +16,12 @@ from huggingface_hub.utils import (
)
from loguru import logger
from tqdm import tqdm
from fastembed.common.model_description import BaseModelDescription
T = TypeVar("T", bound=BaseModelDescription)
class ModelManagement:
class ModelManagement(Generic[T]):
METADATA_FILE = "files_metadata.json"
@classmethod
@@ -26,12 +29,41 @@ class ModelManagement:
"""Lists the supported models.
Returns:
list[dict[str, Any]]: A list of dictionaries containing the model information.
list[T]: A list of dictionaries containing the model information.
"""
raise NotImplementedError()
@classmethod
def _get_model_description(cls, model_name: str) -> dict[str, Any]:
def add_custom_model(
cls,
*args: Any,
**kwargs: Any,
) -> None:
"""Add a custom model to the existing embedding classes based on the passed model descriptions
Model description dict should contain the fields same as in one of the model descriptions presented
in fastembed.common.model_description
E.g. for BaseModelDescription:
model: str
sources: ModelSource
model_file: str
description: str
license: str
size_in_GB: float
additional_files: list[str]
Returns:
None
"""
raise NotImplementedError()
@classmethod
def _list_supported_models(cls) -> list[T]:
raise NotImplementedError()
@classmethod
def _get_model_description(cls, model_name: str) -> T:
"""
Gets the model description from the model_name.
@@ -42,10 +74,10 @@ class ModelManagement:
ValueError: If the model_name is not supported.
Returns:
dict[str, Any]: The model description.
T: The model description.
"""
for model in cls.list_supported_models():
if model_name.lower() == model["model"].lower():
for model in cls._list_supported_models():
if model_name.lower() == model.model.lower():
return model
raise ValueError(f"Model {model_name} is not supported in {cls.__name__}.")
@@ -114,7 +146,6 @@ class ModelManagement:
extra_patterns (list[str]): extra patterns to allow in the snapshot download, typically
includes the required model files.
local_files_only (bool, optional): Whether to only use local files. Defaults to False.
specific_model_path (Optional[str], optional): The path to the model dir already pooled from external source
Returns:
Path: The path to the model directory.
"""
@@ -148,8 +179,8 @@ class ModelManagement:
def _collect_file_metadata(
model_dir: Path, repo_files: list[RepoFile]
) -> dict[str, dict[str, int]]:
meta = {}
) -> dict[str, dict[str, Union[int, str]]]:
meta: dict[str, dict[str, Union[int, str]]] = {}
file_info_map = {f.path: f for f in repo_files}
for file_path in model_dir.rglob("*"):
if file_path.is_file() and file_path.name != cls.METADATA_FILE:
@@ -161,7 +192,9 @@ class ModelManagement:
}
return meta
def _save_file_metadata(model_dir: Path, meta: dict[str, dict[str, int]]) -> None:
def _save_file_metadata(
model_dir: Path, meta: dict[str, dict[str, Union[int, str]]]
) -> None:
try:
if not model_dir.exists():
model_dir.mkdir(parents=True, exist_ok=True)
@@ -293,9 +326,14 @@ class ModelManagement:
@classmethod
def retrieve_model_gcs(
cls, model_name: str, source_url: str, cache_dir: str, local_files_only: bool = False
cls,
model_name: str,
source_url: str,
cache_dir: str,
deprecated_tar_struct: bool = False,
local_files_only: bool = False,
) -> Path:
fast_model_name = f"fast-{model_name.split('/')[-1]}"
fast_model_name = f"{'fast-' if deprecated_tar_struct else ''}{model_name.split('/')[-1]}"
cache_tmp_dir = Path(cache_dir) / "tmp"
model_tmp_dir = cache_tmp_dir / fast_model_name
model_dir = Path(cache_dir) / fast_model_name
@@ -337,14 +375,12 @@ class ModelManagement:
return model_dir
@classmethod
def download_model(
cls, model: dict[str, Any], cache_dir: Path, retries: int = 3, **kwargs: Any
) -> Path:
def download_model(cls, model: T, cache_dir: str, retries: int = 3, **kwargs: Any) -> Path:
"""
Downloads a model from HuggingFace Hub or Google Cloud Storage.
Args:
model (dict[str, Any]): The model description.
model (T): The model description.
Example:
```
{
@@ -369,22 +405,22 @@ class ModelManagement:
if specific_model_path:
return Path(specific_model_path)
retries = 1 if local_files_only else retries
hf_source = model.get("sources", {}).get("hf")
url_source = model.get("sources", {}).get("url")
hf_source = model.sources.hf
url_source = model.sources.url
sleep = 3.0
while retries > 0:
retries -= 1
if hf_source:
extra_patterns = [model["model_file"]]
extra_patterns.extend(model.get("additional_files", []))
extra_patterns = [model.model_file]
extra_patterns.extend(model.additional_files)
try:
return Path(
cls.download_files_from_huggingface(
hf_source,
cache_dir=str(cache_dir),
cache_dir=cache_dir,
extra_patterns=extra_patterns,
**kwargs,
)
@@ -400,9 +436,10 @@ class ModelManagement:
if url_source or local_files_only:
try:
return cls.retrieve_model_gcs(
model["model"],
url_source,
model.model,
str(url_source),
str(cache_dir),
deprecated_tar_struct=model.sources.deprecated_tar_struct,
local_files_only=local_files_only,
)
except Exception:
@@ -418,4 +455,4 @@ class ModelManagement:
time.sleep(sleep)
sleep *= 3
raise ValueError(f"Could not load model {model['model']} from any source.")
raise ValueError(f"Could not load model {model.model} from any source.")
+17 -13
View File
@@ -6,7 +6,10 @@ from typing import Any, Generic, Iterable, Optional, Sequence, Type, TypeVar
import numpy as np
import onnxruntime as ort
from fastembed.common.types import OnnxProvider
from numpy.typing import NDArray
from tokenizers import Tokenizer
from fastembed.common.types import OnnxProvider, NumpyArray
from fastembed.parallel_processor import Worker
# Holds type of the embedding result
@@ -15,26 +18,26 @@ T = TypeVar("T")
@dataclass
class OnnxOutputContext:
model_output: np.ndarray
attention_mask: Optional[np.ndarray] = None
input_ids: Optional[np.ndarray] = None
model_output: NumpyArray
attention_mask: Optional[NDArray[np.int64]] = None
input_ids: Optional[NDArray[np.int64]] = None
class OnnxModel(Generic[T]):
@classmethod
def _get_worker_class(cls) -> Type["EmbeddingWorker"]:
def _get_worker_class(cls) -> Type["EmbeddingWorker[T]"]:
raise NotImplementedError("Subclasses must implement this method")
def _post_process_onnx_output(self, output: OnnxOutputContext) -> Iterable[T]:
raise NotImplementedError("Subclasses must implement this method")
def __init__(self) -> None:
self.model = None
self.tokenizer = None
self.model: Optional[ort.InferenceSession] = None
self.tokenizer: Optional[Tokenizer] = None
def _preprocess_onnx_input(
self, onnx_input: dict[str, np.ndarray], **kwargs: Any
) -> dict[str, np.ndarray]:
self, onnx_input: dict[str, NumpyArray], **kwargs: Any
) -> dict[str, NumpyArray]:
"""
Preprocess the onnx input.
"""
@@ -70,7 +73,7 @@ class OnnxModel(Generic[T]):
onnx_providers = ["CPUExecutionProvider"]
available_providers = ort.get_available_providers()
requested_provider_names = []
requested_provider_names: list[str] = []
for provider in onnx_providers:
# check providers available
provider_name = provider if isinstance(provider, str) else provider[0]
@@ -91,6 +94,7 @@ class OnnxModel(Generic[T]):
str(model_path), providers=onnx_providers, sess_options=so
)
if "CUDAExecutionProvider" in requested_provider_names:
assert self.model is not None
current_providers = self.model.get_providers()
if "CUDAExecutionProvider" not in current_providers:
warnings.warn(
@@ -107,13 +111,13 @@ class OnnxModel(Generic[T]):
raise NotImplementedError("Subclasses must implement this method")
class EmbeddingWorker(Worker):
class EmbeddingWorker(Worker, Generic[T]):
def init_embedding(
self,
model_name: str,
cache_dir: str,
**kwargs: Any,
) -> OnnxModel:
) -> OnnxModel[T]:
raise NotImplementedError()
def __init__(
@@ -125,7 +129,7 @@ class EmbeddingWorker(Worker):
self.model = self.init_embedding(model_name, cache_dir, **kwargs)
@classmethod
def start(cls, model_name: str, cache_dir: str, **kwargs: Any) -> "EmbeddingWorker":
def start(cls, model_name: str, cache_dir: str, **kwargs: Any) -> "EmbeddingWorker[T]":
return cls(model_name=model_name, cache_dir=cache_dir, **kwargs)
def process(self, items: Iterable[tuple[int, Any]]) -> Iterable[tuple[int, Any]]:
+4 -3
View File
@@ -1,4 +1,5 @@
import json
from typing import Any
from pathlib import Path
from tokenizers import AddedToken, Tokenizer
@@ -6,7 +7,7 @@ from tokenizers import AddedToken, Tokenizer
from fastembed.image.transform.operators import Compose
def load_special_tokens(model_dir: Path) -> dict:
def load_special_tokens(model_dir: Path) -> dict[str, Any]:
tokens_map_path = model_dir / "special_tokens_map.json"
if not tokens_map_path.exists():
raise ValueError(f"Could not find special_tokens_map.json in {model_dir}")
@@ -17,7 +18,7 @@ def load_special_tokens(model_dir: Path) -> dict:
return tokens_map
def load_tokenizer(model_dir: Path) -> tuple[Tokenizer, dict]:
def load_tokenizer(model_dir: Path) -> tuple[Tokenizer, dict[str, int]]:
config_path = model_dir / "config.json"
if not config_path.exists():
raise ValueError(f"Could not find config.json in {model_dir}")
@@ -59,7 +60,7 @@ def load_tokenizer(model_dir: Path) -> tuple[Tokenizer, dict]:
elif isinstance(token, dict):
tokenizer.add_special_tokens([AddedToken(**token)])
special_token_to_id = {}
special_token_to_id: dict[str, int] = {}
for token in tokens_map.values():
if isinstance(token, str):
+11 -3
View File
@@ -1,7 +1,9 @@
from pathlib import Path
import sys
from PIL import Image
from typing import Any, Iterable, Union
from typing import Any, Union
import numpy as np
from numpy.typing import NDArray
if sys.version_info >= (3, 10):
from typing import TypeAlias
@@ -10,7 +12,13 @@ else:
PathInput: TypeAlias = Union[str, Path]
PilInput: TypeAlias = Union[Image.Image, Iterable[Image.Image]]
ImageInput: TypeAlias = Union[PathInput, Iterable[PathInput], PilInput]
ImageInput: TypeAlias = Union[PathInput, Image.Image]
OnnxProvider: TypeAlias = Union[str, tuple[str, dict[Any, Any]]]
NumpyArray = Union[
NDArray[np.float32],
NDArray[np.float16],
NDArray[np.int8],
NDArray[np.int64],
NDArray[np.int32],
]
+13 -1
View File
@@ -8,11 +8,14 @@ from itertools import islice
from typing import Iterable, Optional, TypeVar
import numpy as np
from numpy.typing import NDArray
from fastembed.common.types import NumpyArray
T = TypeVar("T")
def normalize(input_array: np.ndarray, p: int = 2, dim: int = 1, eps: float = 1e-12) -> np.ndarray:
def normalize(input_array: NumpyArray, p: int = 2, dim: int = 1, eps: float = 1e-12) -> NumpyArray:
# Calculate the Lp norm along the specified dimension
norm = np.linalg.norm(input_array, ord=p, axis=dim, keepdims=True)
norm = np.maximum(norm, eps) # Avoid division by zero
@@ -20,6 +23,15 @@ def normalize(input_array: np.ndarray, p: int = 2, dim: int = 1, eps: float = 1e
return normalized_array
def mean_pooling(input_array: NumpyArray, attention_mask: NDArray[np.int64]) -> NumpyArray:
input_mask_expanded = np.expand_dims(attention_mask, axis=-1).astype(np.int64)
input_mask_expanded = np.tile(input_mask_expanded, (1, 1, input_array.shape[-1]))
sum_embeddings = np.sum(input_array * input_mask_expanded, axis=1)
sum_mask = np.sum(input_mask_expanded, axis=1)
pooled_embeddings = sum_embeddings / np.maximum(sum_mask, 1e-9)
return pooled_embeddings
def iter_batch(iterable: Iterable[T], size: int) -> Iterable[list[T]]:
"""
>>> list(iter_batch([1,2,3,4,5], 3))
+15 -11
View File
@@ -1,10 +1,11 @@
from typing import Any, Iterable, Optional, Sequence, Type
import numpy as np
from typing import Any, Iterable, Optional, Sequence, Type, Union
from dataclasses import asdict
from fastembed.common.types import NumpyArray
from fastembed.common import ImageInput, OnnxProvider
from fastembed.image.image_embedding_base import ImageEmbeddingBase
from fastembed.image.onnx_embedding import OnnxImageEmbedding
from fastembed.common.model_description import DenseModelDescription
class ImageEmbedding(ImageEmbeddingBase):
@@ -35,9 +36,13 @@ class ImageEmbedding(ImageEmbeddingBase):
]
```
"""
result = []
return [asdict(model) for model in cls._list_supported_models()]
@classmethod
def _list_supported_models(cls) -> list[DenseModelDescription]:
result: list[DenseModelDescription] = []
for embedding in cls.EMBEDDINGS_REGISTRY:
result.extend(embedding.list_supported_models())
result.extend(embedding._list_supported_models())
return result
def __init__(
@@ -53,8 +58,8 @@ class ImageEmbedding(ImageEmbeddingBase):
):
super().__init__(model_name, cache_dir, threads, **kwargs)
for EMBEDDING_MODEL_TYPE in self.EMBEDDINGS_REGISTRY:
supported_models = EMBEDDING_MODEL_TYPE.list_supported_models()
if any(model_name.lower() == model["model"].lower() for model in supported_models):
supported_models = EMBEDDING_MODEL_TYPE._list_supported_models()
if any(model_name.lower() == model.model.lower() for model in supported_models):
self.model = EMBEDDING_MODEL_TYPE(
model_name,
cache_dir,
@@ -74,14 +79,13 @@ class ImageEmbedding(ImageEmbeddingBase):
def embed(
self,
images: ImageInput,
images: Union[ImageInput, Iterable[ImageInput]],
batch_size: int = 16,
parallel: Optional[int] = None,
**kwargs: Any,
) -> Iterable[np.ndarray]:
) -> Iterable[NumpyArray]:
"""
Encode a list of documents into list of embeddings.
We use mean pooling with attention so that the model can handle variable-length inputs.
Encode a list of images into list of embeddings.
Args:
images: Iterator of image paths or single image path to embed
+7 -7
View File
@@ -1,12 +1,12 @@
from typing import Iterable, Optional, Any
import numpy as np
from typing import Iterable, Optional, Any, Union
from fastembed.common.model_description import DenseModelDescription
from fastembed.common.types import NumpyArray
from fastembed.common.model_management import ModelManagement
from fastembed.common.types import ImageInput
class ImageEmbeddingBase(ModelManagement):
class ImageEmbeddingBase(ModelManagement[DenseModelDescription]):
def __init__(
self,
model_name: str,
@@ -21,11 +21,11 @@ class ImageEmbeddingBase(ModelManagement):
def embed(
self,
images: ImageInput,
images: Union[ImageInput, Iterable[ImageInput]],
batch_size: int = 16,
parallel: Optional[int] = None,
**kwargs: Any,
) -> Iterable[np.ndarray]:
) -> Iterable[NumpyArray]:
"""
Embeds a list of images into a list of embeddings.
@@ -39,6 +39,6 @@ class ImageEmbeddingBase(ModelManagement):
**kwargs: Additional keyword argument to pass to the embed method.
Yields:
Iterable[np.ndarray]: The embeddings.
Iterable[NdArray]: The embeddings.
"""
raise NotImplementedError()
+65 -73
View File
@@ -1,72 +1,65 @@
from typing import Any, Iterable, Optional, Sequence, Type
from typing import Any, Iterable, Optional, Sequence, Type, Union
import numpy as np
from fastembed.common.types import NumpyArray
from fastembed.common import ImageInput, OnnxProvider
from fastembed.common.onnx_model import OnnxOutputContext
from fastembed.common.utils import define_cache_dir, normalize
from fastembed.image.image_embedding_base import ImageEmbeddingBase
from fastembed.image.onnx_image_model import ImageEmbeddingWorker, OnnxImageModel
supported_onnx_models = [
{
"model": "Qdrant/clip-ViT-B-32-vision",
"dim": 512,
"description": "Image embeddings, Multimodal (text&image), 2021 year",
"license": "mit",
"size_in_GB": 0.34,
"sources": {
"hf": "Qdrant/clip-ViT-B-32-vision",
},
"model_file": "model.onnx",
},
{
"model": "Qdrant/resnet50-onnx",
"dim": 2048,
"description": "Image embeddings, Unimodal (image), 2016 year",
"license": "apache-2.0",
"size_in_GB": 0.1,
"sources": {
"hf": "Qdrant/resnet50-onnx",
},
"model_file": "model.onnx",
},
{
"model": "Qdrant/Unicom-ViT-B-16",
"dim": 768,
"description": "Image embeddings (more detailed than Unicom-ViT-B-32), Multimodal (text&image), 2023 year",
"license": "apache-2.0",
"size_in_GB": 0.82,
"sources": {
"hf": "Qdrant/Unicom-ViT-B-16",
},
"model_file": "model.onnx",
},
{
"model": "Qdrant/Unicom-ViT-B-32",
"dim": 512,
"description": "Image embeddings, Multimodal (text&image), 2023 year",
"license": "apache-2.0",
"size_in_GB": 0.48,
"sources": {
"hf": "Qdrant/Unicom-ViT-B-32",
},
"model_file": "model.onnx",
},
{
"model": "jinaai/jina-clip-v1",
"dim": 768,
"description": "Image embeddings, Multimodal (text&image), 2024 year",
"license": "apache-2.0",
"size_in_GB": 0.34,
"sources": {
"hf": "jinaai/jina-clip-v1",
},
"model_file": "onnx/vision_model.onnx",
},
from fastembed.common.model_description import DenseModelDescription, ModelSource
supported_onnx_models: list[DenseModelDescription] = [
DenseModelDescription(
model="Qdrant/clip-ViT-B-32-vision",
dim=512,
description="Image embeddings, Multimodal (text&image), 2021 year",
license="mit",
size_in_GB=0.34,
sources=ModelSource(hf="Qdrant/clip-ViT-B-32-vision"),
model_file="model.onnx",
),
DenseModelDescription(
model="Qdrant/resnet50-onnx",
dim=2048,
description="Image embeddings, Unimodal (image), 2016 year",
license="apache-2.0",
size_in_GB=0.1,
sources=ModelSource(hf="Qdrant/resnet50-onnx"),
model_file="model.onnx",
),
DenseModelDescription(
model="Qdrant/Unicom-ViT-B-16",
dim=768,
description="Image embeddings (more detailed than Unicom-ViT-B-32), Multimodal (text&image), 2023 year",
license="apache-2.0",
size_in_GB=0.82,
sources=ModelSource(hf="Qdrant/Unicom-ViT-B-16"),
model_file="model.onnx",
),
DenseModelDescription(
model="Qdrant/Unicom-ViT-B-32",
dim=512,
description="Image embeddings, Multimodal (text&image), 2023 year",
license="apache-2.0",
size_in_GB=0.48,
sources=ModelSource(hf="Qdrant/Unicom-ViT-B-32"),
model_file="model.onnx",
),
DenseModelDescription(
model="jinaai/jina-clip-v1",
dim=768,
description="Image embeddings, Multimodal (text&image), 2024 year",
license="apache-2.0",
size_in_GB=0.34,
sources=ModelSource(hf="jinaai/jina-clip-v1"),
model_file="onnx/vision_model.onnx",
),
]
class OnnxImageEmbedding(ImageEmbeddingBase, OnnxImageModel[np.ndarray]):
class OnnxImageEmbedding(ImageEmbeddingBase, OnnxImageModel[NumpyArray]):
def __init__(
self,
model_name: str,
@@ -111,15 +104,14 @@ class OnnxImageEmbedding(ImageEmbeddingBase, OnnxImageModel[np.ndarray]):
self.cuda = cuda
# This device_id will be used if we need to load model in current process
self.device_id: Optional[int] = None
if device_id is not None:
self.device_id = device_id
elif self.device_ids is not None:
self.device_id = self.device_ids[0]
else:
self.device_id = None
self.model_description = self._get_model_description(model_name)
self.cache_dir = define_cache_dir(cache_dir)
self.cache_dir = str(define_cache_dir(cache_dir))
self._model_dir = self.download_model(
self.model_description,
self.cache_dir,
@@ -136,7 +128,7 @@ class OnnxImageEmbedding(ImageEmbeddingBase, OnnxImageModel[np.ndarray]):
"""
self._load_onnx_model(
model_dir=self._model_dir,
model_file=self.model_description["model_file"],
model_file=self.model_description.model_file,
threads=self.threads,
providers=self.providers,
cuda=self.cuda,
@@ -144,22 +136,22 @@ class OnnxImageEmbedding(ImageEmbeddingBase, OnnxImageModel[np.ndarray]):
)
@classmethod
def list_supported_models(cls) -> list[dict[str, Any]]:
def _list_supported_models(cls) -> list[DenseModelDescription]:
"""
Lists the supported models.
Returns:
list[dict[str, Any]]: A list of dictionaries containing the model information.
list[DenseModelDescription]: A list of DenseModelDescription objects containing the model information.
"""
return supported_onnx_models
def embed(
self,
images: ImageInput,
images: Union[ImageInput, Iterable[ImageInput]],
batch_size: int = 16,
parallel: Optional[int] = None,
**kwargs: Any,
) -> Iterable[np.ndarray]:
) -> Iterable[NumpyArray]:
"""
Encode a list of images into list of embeddings.
We use mean pooling with attention so that the model can handle variable-length inputs.
@@ -189,23 +181,23 @@ class OnnxImageEmbedding(ImageEmbeddingBase, OnnxImageModel[np.ndarray]):
)
@classmethod
def _get_worker_class(cls) -> Type["ImageEmbeddingWorker"]:
def _get_worker_class(cls) -> Type["ImageEmbeddingWorker[NumpyArray]"]:
return OnnxImageEmbeddingWorker
def _preprocess_onnx_input(
self, onnx_input: dict[str, np.ndarray], **kwargs: Any
) -> dict[str, np.ndarray]:
self, onnx_input: dict[str, NumpyArray], **kwargs: Any
) -> dict[str, NumpyArray]:
"""
Preprocess the onnx input.
"""
return onnx_input
def _post_process_onnx_output(self, output: OnnxOutputContext) -> Iterable[np.ndarray]:
return normalize(output.model_output).astype(np.float32)
def _post_process_onnx_output(self, output: OnnxOutputContext) -> Iterable[NumpyArray]:
return normalize(output.model_output)
class OnnxImageEmbeddingWorker(ImageEmbeddingWorker):
class OnnxImageEmbeddingWorker(ImageEmbeddingWorker[NumpyArray]):
def init_embedding(self, model_name: str, cache_dir: str, **kwargs: Any) -> OnnxImageEmbedding:
return OnnxImageEmbedding(
model_name=model_name,
+17 -13
View File
@@ -2,11 +2,13 @@ import contextlib
import os
from multiprocessing import get_all_start_methods
from pathlib import Path
from typing import Any, Iterable, Optional, Sequence, Type
from typing import Any, Iterable, Optional, Sequence, Type, Union
import numpy as np
from PIL import Image
from fastembed.image.transform.operators import Compose
from fastembed.common.types import NumpyArray
from fastembed.common import ImageInput, OnnxProvider
from fastembed.common.onnx_model import EmbeddingWorker, OnnxModel, OnnxOutputContext, T
from fastembed.common.preprocessor_utils import load_preprocessor
@@ -18,19 +20,19 @@ from fastembed.parallel_processor import ParallelWorkerPool
class OnnxImageModel(OnnxModel[T]):
@classmethod
def _get_worker_class(cls) -> Type["ImageEmbeddingWorker"]:
def _get_worker_class(cls) -> Type["ImageEmbeddingWorker[T]"]:
raise NotImplementedError("Subclasses must implement this method")
def _post_process_onnx_output(self, output: OnnxOutputContext) -> Iterable[T]:
raise NotImplementedError("Subclasses must implement this method")
def __init__(self):
def __init__(self) -> None:
super().__init__()
self.processor = None
self.processor: Optional[Compose] = None
def _preprocess_onnx_input(
self, onnx_input: dict[str, np.ndarray], **kwargs: Any
) -> dict[str, np.ndarray]:
self, onnx_input: dict[str, NumpyArray], **kwargs: Any
) -> dict[str, NumpyArray]:
"""
Preprocess the onnx input.
"""
@@ -58,8 +60,9 @@ class OnnxImageModel(OnnxModel[T]):
def load_onnx_model(self) -> None:
raise NotImplementedError("Subclasses must implement this method")
def _build_onnx_input(self, encoded: np.ndarray) -> dict[str, np.ndarray]:
return {node.name: encoded for node in self.model.get_inputs()}
def _build_onnx_input(self, encoded: NumpyArray) -> dict[str, NumpyArray]:
input_name = self.model.get_inputs()[0].name # type: ignore[union-attr]
return {input_name: encoded}
def onnx_embed(self, images: list[ImageInput], **kwargs: Any) -> OnnxOutputContext:
with contextlib.ExitStack():
@@ -67,10 +70,11 @@ class OnnxImageModel(OnnxModel[T]):
Image.open(image) if not isinstance(image, Image.Image) else image
for image in images
]
encoded = self.processor(image_files)
assert self.processor is not None, "Processor is not initialized"
encoded = np.array(self.processor(image_files))
onnx_input = self._build_onnx_input(encoded)
onnx_input = self._preprocess_onnx_input(onnx_input)
model_output = self.model.run(None, onnx_input)
model_output = self.model.run(None, onnx_input) # type: ignore[union-attr]
embeddings = model_output[0].reshape(len(images), -1)
return OnnxOutputContext(model_output=embeddings)
@@ -78,7 +82,7 @@ class OnnxImageModel(OnnxModel[T]):
self,
model_name: str,
cache_dir: str,
images: ImageInput,
images: Union[ImageInput, Iterable[ImageInput]],
batch_size: int = 256,
parallel: Optional[int] = None,
providers: Optional[Sequence[OnnxProvider]] = None,
@@ -121,10 +125,10 @@ class OnnxImageModel(OnnxModel[T]):
start_method=start_method,
)
for batch in pool.ordered_map(iter_batch(images, batch_size), **params):
yield from self._post_process_onnx_output(batch)
yield from self._post_process_onnx_output(batch) # type: ignore
class ImageEmbeddingWorker(EmbeddingWorker):
class ImageEmbeddingWorker(EmbeddingWorker[T]):
def process(self, items: Iterable[tuple[int, Any]]) -> Iterable[tuple[int, Any]]:
for idx, batch in items:
embeddings = self.model.onnx_embed(batch)
+29 -30
View File
@@ -1,8 +1,10 @@
from typing import Sized, Union
from typing import Union
import numpy as np
from PIL import Image
from fastembed.common.types import NumpyArray
def convert_to_rgb(image: Image.Image) -> Image.Image:
if image.mode == "RGB":
@@ -13,9 +15,9 @@ def convert_to_rgb(image: Image.Image) -> Image.Image:
def center_crop(
image: Union[Image.Image, np.ndarray],
image: Union[Image.Image, NumpyArray],
size: tuple[int, int],
) -> np.ndarray:
) -> NumpyArray:
if isinstance(image, np.ndarray):
_, orig_height, orig_width = image.shape
else:
@@ -40,7 +42,7 @@ def center_crop(
new_height = max(crop_height, orig_height)
new_width = max(crop_width, orig_width)
new_shape = image.shape[:-2] + (new_height, new_width)
new_image = np.zeros_like(image, shape=new_shape)
new_image = np.zeros_like(image, shape=new_shape, dtype=np.float32)
top_pad = (new_height - orig_height) // 2
bottom_pad = top_pad + orig_height
@@ -61,37 +63,34 @@ def center_crop(
def normalize(
image: np.ndarray,
mean: Union[float, np.ndarray],
std: Union[float, np.ndarray],
) -> np.ndarray:
if not isinstance(image, np.ndarray):
raise ValueError("image must be a numpy array")
image: NumpyArray,
mean: Union[float, list[float]],
std: Union[float, list[float]],
) -> NumpyArray:
num_channels = image.shape[1] if len(image.shape) == 4 else image.shape[0]
if not np.issubdtype(image.dtype, np.floating):
image = image.astype(np.float32)
if isinstance(mean, Sized):
if len(mean) != num_channels:
raise ValueError(
f"mean must have {num_channels} elements if it is an iterable, got {len(mean)}"
)
else:
mean = [mean] * num_channels
mean = np.array(mean, dtype=image.dtype)
mean = mean if isinstance(mean, list) else [mean] * num_channels
if isinstance(std, Sized):
if len(std) != num_channels:
raise ValueError(
f"std must have {num_channels} elements if it is an iterable, got {len(std)}"
)
else:
std = [std] * num_channels
std = np.array(std, dtype=image.dtype)
if len(mean) != num_channels:
raise ValueError(
f"mean must have the same number of channels as the image, image has {num_channels} channels, got "
f"{len(mean)}"
)
image = ((image.T - mean) / std).T
mean_arr = np.array(mean, dtype=np.float32)
std = std if isinstance(std, list) else [std] * num_channels
if len(std) != num_channels:
raise ValueError(
f"std must have the same number of channels as the image, image has {num_channels} channels, got {len(std)}"
)
std_arr = np.array(std, dtype=np.float32)
image = ((image.T - mean_arr) / std_arr).T
return image
@@ -114,11 +113,11 @@ def resize(
return image.resize(new_size, resample)
def rescale(image: np.ndarray, scale: float, dtype: type = np.float32) -> np.ndarray:
def rescale(image: NumpyArray, scale: float, dtype: type = np.float32) -> NumpyArray:
return (image * scale).astype(dtype)
def pil2ndarray(image: Union[Image.Image, np.ndarray]) -> np.ndarray:
def pil2ndarray(image: Union[Image.Image, NumpyArray]) -> NumpyArray:
if isinstance(image, Image.Image):
return np.asarray(image).transpose((2, 0, 1))
return image
+19 -18
View File
@@ -1,8 +1,8 @@
from typing import Any, Union, Optional
import numpy as np
from PIL import Image
from fastembed.common.types import NumpyArray
from fastembed.image.transform.functional import (
center_crop,
convert_to_rgb,
@@ -15,7 +15,7 @@ from fastembed.image.transform.functional import (
class Transform:
def __call__(self, images: list) -> Union[list[Image.Image], list[np.ndarray]]:
def __call__(self, images: list[Any]) -> Union[list[Image.Image], list[NumpyArray]]:
raise NotImplementedError("Subclasses must implement this method")
@@ -28,7 +28,7 @@ class CenterCrop(Transform):
def __init__(self, size: tuple[int, int]):
self.size = size
def __call__(self, images: list[Image.Image]) -> list[np.ndarray]:
def __call__(self, images: list[Image.Image]) -> list[NumpyArray]:
return [center_crop(image=image, size=self.size) for image in images]
@@ -37,7 +37,7 @@ class Normalize(Transform):
self.mean = mean
self.std = std
def __call__(self, images: list[np.ndarray]) -> list[np.ndarray]:
def __call__(self, images: list[NumpyArray]) -> list[NumpyArray]:
return [normalize(image, mean=self.mean, std=self.std) for image in images]
@@ -58,12 +58,12 @@ class Rescale(Transform):
def __init__(self, scale: float = 1 / 255):
self.scale = scale
def __call__(self, images: list[np.ndarray]) -> list[np.ndarray]:
def __call__(self, images: list[NumpyArray]) -> list[NumpyArray]:
return [rescale(image, scale=self.scale) for image in images]
class PILtoNDarray(Transform):
def __call__(self, images: list[Union[Image.Image, np.ndarray]]) -> list[np.ndarray]:
def __call__(self, images: list[Union[Image.Image, NumpyArray]]) -> list[NumpyArray]:
return [pil2ndarray(image) for image in images]
@@ -71,7 +71,7 @@ class PadtoSquare(Transform):
def __init__(
self,
size: int,
fill_color: Optional[Union[str, int, tuple[int, ...]]] = None,
fill_color: Union[str, int, tuple[int, ...]],
):
self.size = size
self.fill_color = fill_color
@@ -87,8 +87,8 @@ class Compose:
self.transforms = transforms
def __call__(
self, images: Union[list[Image.Image], list[np.ndarray]]
) -> Union[list[np.ndarray], list[Image.Image]]:
self, images: Union[list[Image.Image], list[NumpyArray]]
) -> Union[list[NumpyArray], list[Image.Image]]:
for transform in self.transforms:
images = transform(images)
return images
@@ -122,7 +122,7 @@ class Compose:
Returns:
Compose: Image processor.
"""
transforms = []
transforms: list[Transform] = []
cls._get_convert_to_rgb(transforms, config)
cls._get_resize(transforms, config)
cls._get_pad2square(transforms, config)
@@ -139,7 +139,7 @@ class Compose:
@classmethod
def _get_resize(cls, transforms: list[Transform], config: dict[str, Any]) -> None:
mode = config.get("image_processor_type", "CLIPImageProcessor")
if mode == "CLIPImageProcessor":
if mode in ("CLIPImageProcessor", "SiglipImageProcessor"):
if config.get("do_resize", False):
size = config["size"]
if "shortest_edge" in size:
@@ -202,15 +202,16 @@ class Compose:
@staticmethod
def _get_center_crop(transforms: list[Transform], config: dict[str, Any]) -> None:
mode = config.get("image_processor_type", "CLIPImageProcessor")
if mode == "CLIPImageProcessor":
if mode in ("CLIPImageProcessor", "SiglipImageProcessor"):
if config.get("do_center_crop", False):
crop_size = config["crop_size"]
if isinstance(crop_size, int):
crop_size = (crop_size, crop_size)
elif isinstance(crop_size, dict):
crop_size = (crop_size["height"], crop_size["width"])
crop_size_raw = config["crop_size"]
crop_size: tuple[int, int]
if isinstance(crop_size_raw, int):
crop_size = (crop_size_raw, crop_size_raw)
elif isinstance(crop_size_raw, dict):
crop_size = (crop_size_raw["height"], crop_size_raw["width"])
else:
raise ValueError(f"Invalid crop size: {crop_size}")
raise ValueError(f"Invalid crop size: {crop_size_raw}")
transforms.append(CenterCrop(size=crop_size))
elif mode == "ConvNextFeatureExtractor":
pass
+50 -48
View File
@@ -4,6 +4,7 @@ from typing import Any, Iterable, Optional, Sequence, Type, Union
import numpy as np
from tokenizers import Encoding
from fastembed.common.types import NumpyArray
from fastembed.common import OnnxProvider
from fastembed.common.onnx_model import OnnxOutputContext
from fastembed.common.utils import define_cache_dir
@@ -11,35 +12,31 @@ from fastembed.late_interaction.late_interaction_embedding_base import (
LateInteractionTextEmbeddingBase,
)
from fastembed.text.onnx_text_model import OnnxTextModel, TextEmbeddingWorker
from fastembed.common.model_description import DenseModelDescription, ModelSource
supported_colbert_models = [
{
"model": "colbert-ir/colbertv2.0",
"dim": 128,
"description": "Late interaction model",
"license": "mit",
"size_in_GB": 0.44,
"sources": {
"hf": "colbert-ir/colbertv2.0",
},
"model_file": "model.onnx",
},
{
"model": "answerdotai/answerai-colbert-small-v1",
"dim": 96,
"description": "Text embeddings, Unimodal (text), Multilingual (~100 languages), 512 input tokens truncation, 2024 year",
"license": "apache-2.0",
"size_in_GB": 0.13,
"sources": {
"hf": "answerdotai/answerai-colbert-small-v1",
},
"model_file": "vespa_colbert.onnx",
},
supported_colbert_models: list[DenseModelDescription] = [
DenseModelDescription(
model="colbert-ir/colbertv2.0",
dim=128,
description="Late interaction model",
license="mit",
size_in_GB=0.44,
sources=ModelSource(hf="colbert-ir/colbertv2.0"),
model_file="model.onnx",
),
DenseModelDescription(
model="answerdotai/answerai-colbert-small-v1",
dim=96,
description="Text embeddings, Unimodal (text), Multilingual (~100 languages), 512 input tokens truncation, 2024 year",
license="apache-2.0",
size_in_GB=0.13,
sources=ModelSource(hf="answerdotai/answerai-colbert-small-v1"),
model_file="vespa_colbert.onnx",
),
]
class Colbert(LateInteractionTextEmbeddingBase, OnnxTextModel[np.ndarray]):
class Colbert(LateInteractionTextEmbeddingBase, OnnxTextModel[NumpyArray]):
QUERY_MARKER_TOKEN_ID = 1
DOCUMENT_MARKER_TOKEN_ID = 2
MIN_QUERY_LENGTH = 31 # it's 32, we add one additional special token in the beginning
@@ -47,9 +44,9 @@ class Colbert(LateInteractionTextEmbeddingBase, OnnxTextModel[np.ndarray]):
def _post_process_onnx_output(
self, output: OnnxOutputContext, is_doc: bool = True
) -> Iterable[np.ndarray]:
) -> Iterable[NumpyArray]:
if not is_doc:
return output.model_output.astype(np.float32)
return output.model_output
if output.input_ids is None or output.attention_mask is None:
raise ValueError(
@@ -57,22 +54,26 @@ class Colbert(LateInteractionTextEmbeddingBase, OnnxTextModel[np.ndarray]):
)
for i, token_sequence in enumerate(output.input_ids):
for j, token_id in enumerate(token_sequence):
for j, token_id in enumerate(token_sequence): # type: ignore
if token_id in self.skip_list or token_id == self.pad_token_id:
output.attention_mask[i, j] = 0
output.model_output *= np.expand_dims(output.attention_mask, 2).astype(np.float32)
output.model_output *= np.expand_dims(output.attention_mask, 2)
norm = np.linalg.norm(output.model_output, ord=2, axis=2, keepdims=True)
norm_clamped = np.maximum(norm, 1e-12)
output.model_output /= norm_clamped
return output.model_output.astype(np.float32)
return output.model_output
def _preprocess_onnx_input(
self, onnx_input: dict[str, np.ndarray], is_doc: bool = True, **kwargs: Any
) -> dict[str, np.ndarray]:
self, onnx_input: dict[str, NumpyArray], is_doc: bool = True, **kwargs: Any
) -> dict[str, NumpyArray]:
marker_token = self.DOCUMENT_MARKER_TOKEN_ID if is_doc else self.QUERY_MARKER_TOKEN_ID
onnx_input["input_ids"] = np.insert(onnx_input["input_ids"], 1, marker_token, axis=1)
onnx_input["attention_mask"] = np.insert(onnx_input["attention_mask"], 1, 1, axis=1)
onnx_input["input_ids"] = np.insert(
onnx_input["input_ids"].astype(np.int64), 1, marker_token, axis=1
)
onnx_input["attention_mask"] = np.insert(
onnx_input["attention_mask"].astype(np.int64), 1, 1, axis=1
)
return onnx_input
def tokenize(self, documents: list[str], is_doc: bool = True, **kwargs: Any) -> list[Encoding]:
@@ -83,6 +84,7 @@ class Colbert(LateInteractionTextEmbeddingBase, OnnxTextModel[np.ndarray]):
)
def _tokenize_query(self, query: str) -> list[Encoding]:
assert self.tokenizer is not None
encoded = self.tokenizer.encode_batch([query])
# colbert authors recommend to pad queries with [MASK] tokens for query augmentation to improve performance
if len(encoded[0].ids) < self.MIN_QUERY_LENGTH:
@@ -102,15 +104,15 @@ class Colbert(LateInteractionTextEmbeddingBase, OnnxTextModel[np.ndarray]):
return encoded
def _tokenize_documents(self, documents: list[str]) -> list[Encoding]:
encoded = self.tokenizer.encode_batch(documents)
encoded = self.tokenizer.encode_batch(documents) # type: ignore[union-attr]
return encoded
@classmethod
def list_supported_models(cls) -> list[dict[str, Any]]:
def _list_supported_models(cls) -> list[DenseModelDescription]:
"""Lists the supported models.
Returns:
list[dict[str, Any]]: A list of dictionaries containing the model information.
list[DenseModelDescription]: A list of DenseModelDescription objects containing the model information.
"""
return supported_colbert_models
@@ -158,15 +160,14 @@ class Colbert(LateInteractionTextEmbeddingBase, OnnxTextModel[np.ndarray]):
self.cuda = cuda
# This device_id will be used if we need to load model in current process
self.device_id: Optional[int] = None
if device_id is not None:
self.device_id = device_id
elif self.device_ids is not None:
self.device_id = self.device_ids[0]
else:
self.device_id = None
self.model_description = self._get_model_description(model_name)
self.cache_dir = define_cache_dir(cache_dir)
self.cache_dir = str(define_cache_dir(cache_dir))
self._model_dir = self.download_model(
self.model_description,
@@ -174,9 +175,9 @@ class Colbert(LateInteractionTextEmbeddingBase, OnnxTextModel[np.ndarray]):
local_files_only=self._local_files_only,
specific_model_path=specific_model_path,
)
self.mask_token_id = None
self.pad_token_id = None
self.skip_list = set()
self.mask_token_id: Optional[int] = None
self.pad_token_id: Optional[int] = None
self.skip_list: set[int] = set()
if not self.lazy_load:
self.load_onnx_model()
@@ -184,12 +185,13 @@ class Colbert(LateInteractionTextEmbeddingBase, OnnxTextModel[np.ndarray]):
def load_onnx_model(self) -> None:
self._load_onnx_model(
model_dir=self._model_dir,
model_file=self.model_description["model_file"],
model_file=self.model_description.model_file,
threads=self.threads,
providers=self.providers,
cuda=self.cuda,
device_id=self.device_id,
)
assert self.tokenizer is not None
self.mask_token_id = self.special_token_to_id[self.MASK_TOKEN]
self.pad_token_id = self.tokenizer.padding["pad_id"]
self.skip_list = {
@@ -206,7 +208,7 @@ class Colbert(LateInteractionTextEmbeddingBase, OnnxTextModel[np.ndarray]):
batch_size: int = 256,
parallel: Optional[int] = None,
**kwargs: Any,
) -> Iterable[np.ndarray]:
) -> Iterable[NumpyArray]:
"""
Encode a list of documents into list of embeddings.
We use mean pooling with attention so that the model can handle variable-length inputs.
@@ -234,7 +236,7 @@ class Colbert(LateInteractionTextEmbeddingBase, OnnxTextModel[np.ndarray]):
**kwargs,
)
def query_embed(self, query: Union[str, Iterable[str]], **kwargs: Any) -> Iterable[np.ndarray]:
def query_embed(self, query: Union[str, Iterable[str]], **kwargs: Any) -> Iterable[NumpyArray]:
if isinstance(query, str):
query = [query]
@@ -247,11 +249,11 @@ class Colbert(LateInteractionTextEmbeddingBase, OnnxTextModel[np.ndarray]):
)
@classmethod
def _get_worker_class(cls) -> Type[TextEmbeddingWorker]:
def _get_worker_class(cls) -> Type[TextEmbeddingWorker[NumpyArray]]:
return ColbertEmbeddingWorker
class ColbertEmbeddingWorker(TextEmbeddingWorker):
class ColbertEmbeddingWorker(TextEmbeddingWorker[NumpyArray]):
def init_embedding(self, model_name: str, cache_dir: str, **kwargs: Any) -> Colbert:
return Colbert(
model_name=model_name,
+20 -24
View File
@@ -1,24 +1,20 @@
from typing import Any, Type
import numpy as np
from fastembed.common.types import NumpyArray
from fastembed.late_interaction.colbert import Colbert, ColbertEmbeddingWorker
from fastembed.common.model_description import DenseModelDescription, ModelSource
from fastembed.late_interaction.colbert import Colbert
from fastembed.text.onnx_text_model import TextEmbeddingWorker
supported_jina_colbert_models = [
{
"model": "jinaai/jina-colbert-v2",
"dim": 128,
"description": "New model that expands capabilities of colbert-v1 with multilingual and context length of 8192, 2024 year",
"license": "cc-by-nc-4.0",
"size_in_GB": 2.24,
"sources": {
"hf": "jinaai/jina-colbert-v2",
},
"model_file": "onnx/model.onnx",
"additional_files": ["onnx/model.onnx_data"],
},
supported_jina_colbert_models: list[DenseModelDescription] = [
DenseModelDescription(
model="jinaai/jina-colbert-v2",
dim=128,
description="New model that expands capabilities of colbert-v1 with multilingual and context length of 8192, 2024 year",
license="cc-by-nc-4.0",
size_in_GB=2.24,
sources=ModelSource(hf="jinaai/jina-colbert-v2"),
model_file="onnx/model.onnx",
additional_files=["onnx/model.onnx_data"],
)
]
@@ -29,21 +25,21 @@ class JinaColbert(Colbert):
MASK_TOKEN = "<mask>"
@classmethod
def _get_worker_class(cls) -> Type[TextEmbeddingWorker]:
def _get_worker_class(cls) -> Type[ColbertEmbeddingWorker]:
return JinaColbertEmbeddingWorker
@classmethod
def list_supported_models(cls) -> list[dict[str, Any]]:
def _list_supported_models(cls) -> list[DenseModelDescription]:
"""Lists the supported models.
Returns:
list[dict[str, Any]]: A list of dictionaries containing the model information.
list[DenseModelDescription]: A list of DenseModelDescription objects containing the model information.
"""
return supported_jina_colbert_models
def _preprocess_onnx_input(
self, onnx_input: dict[str, np.ndarray], is_doc: bool = True, **kwargs: Any
) -> dict[str, np.ndarray]:
self, onnx_input: dict[str, NumpyArray], is_doc: bool = True, **kwargs: Any
) -> dict[str, NumpyArray]:
onnx_input = super()._preprocess_onnx_input(onnx_input, is_doc)
# the attention mask for jina-colbert-v2 is always 1 in queries
@@ -52,7 +48,7 @@ class JinaColbert(Colbert):
return onnx_input
class JinaColbertEmbeddingWorker(TextEmbeddingWorker):
class JinaColbertEmbeddingWorker(ColbertEmbeddingWorker):
def init_embedding(self, model_name: str, cache_dir: str, **kwargs: Any) -> JinaColbert:
return JinaColbert(
model_name=model_name,
@@ -1,11 +1,11 @@
from typing import Iterable, Optional, Union, Any
import numpy as np
from fastembed.common.model_description import DenseModelDescription
from fastembed.common.types import NumpyArray
from fastembed.common.model_management import ModelManagement
class LateInteractionTextEmbeddingBase(ModelManagement):
class LateInteractionTextEmbeddingBase(ModelManagement[DenseModelDescription]):
def __init__(
self,
model_name: str,
@@ -24,10 +24,10 @@ class LateInteractionTextEmbeddingBase(ModelManagement):
batch_size: int = 256,
parallel: Optional[int] = None,
**kwargs: Any,
) -> Iterable[np.ndarray]:
) -> Iterable[NumpyArray]:
raise NotImplementedError()
def passage_embed(self, texts: Iterable[str], **kwargs: Any) -> Iterable[np.ndarray]:
def passage_embed(self, texts: Iterable[str], **kwargs: Any) -> Iterable[NumpyArray]:
"""
Embeds a list of text passages into a list of embeddings.
@@ -36,13 +36,13 @@ class LateInteractionTextEmbeddingBase(ModelManagement):
**kwargs: Additional keyword argument to pass to the embed method.
Yields:
Iterable[np.ndarray]: The embeddings.
Iterable[NdArray]: The embeddings.
"""
# This is model-specific, so that different models can have specialized implementations
yield from self.embed(texts, **kwargs)
def query_embed(self, query: Union[str, Iterable[str]], **kwargs: Any) -> Iterable[np.ndarray]:
def query_embed(self, query: Union[str, Iterable[str]], **kwargs: Any) -> Iterable[NumpyArray]:
"""
Embeds queries
@@ -50,11 +50,11 @@ class LateInteractionTextEmbeddingBase(ModelManagement):
query (Union[str, Iterable[str]]): The query to embed, or an iterable e.g. list of queries.
Returns:
Iterable[np.ndarray]: The embeddings.
Iterable[NdArray]: The embeddings.
"""
# This is model-specific, so that different models can have specialized implementations
if isinstance(query, str):
yield from self.embed([query], **kwargs)
if isinstance(query, Iterable):
else:
yield from self.embed(query, **kwargs)
@@ -1,7 +1,8 @@
from typing import Any, Iterable, Optional, Sequence, Type, Union
from dataclasses import asdict
import numpy as np
from fastembed.common.model_description import DenseModelDescription
from fastembed.common.types import NumpyArray
from fastembed.common import OnnxProvider
from fastembed.late_interaction.colbert import Colbert
from fastembed.late_interaction.jina_colbert import JinaColbert
@@ -38,9 +39,13 @@ class LateInteractionTextEmbedding(LateInteractionTextEmbeddingBase):
]
```
"""
result = []
return [asdict(model) for model in cls._list_supported_models()]
@classmethod
def _list_supported_models(cls) -> list[DenseModelDescription]:
result: list[DenseModelDescription] = []
for embedding in cls.EMBEDDINGS_REGISTRY:
result.extend(embedding.list_supported_models())
result.extend(embedding._list_supported_models())
return result
def __init__(
@@ -56,8 +61,8 @@ class LateInteractionTextEmbedding(LateInteractionTextEmbeddingBase):
):
super().__init__(model_name, cache_dir, threads, **kwargs)
for EMBEDDING_MODEL_TYPE in self.EMBEDDINGS_REGISTRY:
supported_models = EMBEDDING_MODEL_TYPE.list_supported_models()
if any(model_name.lower() == model["model"].lower() for model in supported_models):
supported_models = EMBEDDING_MODEL_TYPE._list_supported_models()
if any(model_name.lower() == model.model.lower() for model in supported_models):
self.model = EMBEDDING_MODEL_TYPE(
model_name,
cache_dir,
@@ -81,7 +86,7 @@ class LateInteractionTextEmbedding(LateInteractionTextEmbeddingBase):
batch_size: int = 256,
parallel: Optional[int] = None,
**kwargs: Any,
) -> Iterable[np.ndarray]:
) -> Iterable[NumpyArray]:
"""
Encode a list of documents into list of embeddings.
We use mean pooling with attention so that the model can handle variable-length inputs.
@@ -99,7 +104,7 @@ class LateInteractionTextEmbedding(LateInteractionTextEmbeddingBase):
"""
yield from self.model.embed(documents, batch_size, parallel, **kwargs)
def query_embed(self, query: Union[str, Iterable[str]], **kwargs: Any) -> Iterable[np.ndarray]:
def query_embed(self, query: Union[str, Iterable[str]], **kwargs: Any) -> Iterable[NumpyArray]:
"""
Embeds queries
@@ -107,7 +112,7 @@ class LateInteractionTextEmbedding(LateInteractionTextEmbeddingBase):
query (Union[str, Iterable[str]]): The query to embed, or an iterable e.g. list of queries.
Returns:
Iterable[np.ndarray]: The embeddings.
Iterable[NdArray]: The embeddings.
"""
# This is model-specific, so that different models can have specialized implementations
@@ -0,0 +1,82 @@
from typing import Union, Iterable, Optional, List, Dict, Any, Type
import numpy as np
from fastembed.common.onnx_model import OnnxOutputContext
from fastembed.late_interaction.late_interaction_embedding_base import LateInteractionTextEmbeddingBase
from fastembed.text.onnx_embedding import OnnxTextEmbedding
from fastembed.text.onnx_text_model import TextEmbeddingWorker
supported_token_embeddings_models = [
{
"model": "jinaai/jina-embeddings-v2-small-en-tokens",
"dim": 512,
"description": "Text embeddings, Unimodal (text), English, 8192 input tokens truncation,"
" Prefixes for queries/documents: not necessary, 2023 year.",
"license": "apache-2.0",
"size_in_GB": 0.12,
"sources": {"hf": "xenova/jina-embeddings-v2-small-en"},
"model_file": "onnx/model.onnx",
},
]
class TokenEmbeddingsModel(OnnxTextEmbedding, LateInteractionTextEmbeddingBase):
@classmethod
def list_supported_models(cls) -> List[Dict[str, Any]]:
"""Lists the supported models.
Returns:
List[Dict[str, Any]]: A list of dictionaries containing the model information.
"""
return supported_token_embeddings_models
@classmethod
def _get_worker_class(cls) -> Type[TextEmbeddingWorker]:
return TokensEmbeddingWorker
def _post_process_onnx_output(self, output: OnnxOutputContext) -> Iterable[np.ndarray]:
# Size: (batch_size, sequence_length, hidden_size)
embeddings = output.model_output
# Size: (batch_size, sequence_length)
masks = output.attention_mask
# For each document we only select those embeddings that are not masked out
for i in range(embeddings.shape[0]):
yield embeddings[i, masks[i] == 1]
def embed(
self,
documents: Union[str, Iterable[str]],
batch_size: int = 256,
parallel: Optional[int] = None,
**kwargs,
) -> Iterable[np.ndarray]:
yield from OnnxTextEmbedding.embed(self, documents, batch_size=batch_size, parallel=parallel, **kwargs)
def tokenize_docs(self, documents: List[str]) -> List[np.ndarray]:
encoded = self.tokenizer.encode_batch(documents)
return [e.ids for e in encoded]
class TokensEmbeddingWorker(TextEmbeddingWorker):
def init_embedding(self, model_name: str, cache_dir: str, **kwargs) -> TokenEmbeddingsModel:
return TokenEmbeddingsModel(
model_name=model_name,
cache_dir=cache_dir,
threads=1,
**kwargs,
)
if __name__ == "__main__":
# Example usage
model = TokenEmbeddingsModel(model_name="jinaai/jina-embeddings-v2-small-en-tokens")
docs = ["Hello, world!", "hello", "hello hello"]
embeddings = model.embed(docs)
for emb in embeddings:
print(emb.shape)
print(model.tokenize_docs(docs))
@@ -0,0 +1,5 @@
from fastembed.late_interaction_multimodal.late_interaction_multimodal_embedding import (
LateInteractionMultimodalEmbedding,
)
__all__ = ["LateInteractionMultimodalEmbedding"]
@@ -0,0 +1,300 @@
from typing import Any, Iterable, Optional, Sequence, Type, Union
import numpy as np
from tokenizers import Encoding
from fastembed.common import OnnxProvider, ImageInput
from fastembed.common.onnx_model import OnnxOutputContext
from fastembed.common.types import NumpyArray
from fastembed.common.utils import define_cache_dir
from fastembed.late_interaction_multimodal.late_interaction_multimodal_embedding_base import (
LateInteractionMultimodalEmbeddingBase,
)
from fastembed.late_interaction_multimodal.onnx_multimodal_model import (
OnnxMultimodalModel,
TextEmbeddingWorker,
ImageEmbeddingWorker,
)
from fastembed.common.model_description import DenseModelDescription, ModelSource
supported_colpali_models: list[DenseModelDescription] = [
DenseModelDescription(
model="Qdrant/colpali-v1.3-fp16",
dim=128,
description="Text embeddings, Multimodal (text&image), English, 50 tokens query length truncation, 2024.",
license="mit",
size_in_GB=6.5,
sources=ModelSource(hf="Qdrant/colpali-v1.3-fp16"),
additional_files=["model.onnx_data"],
model_file="model.onnx",
),
]
class ColPali(LateInteractionMultimodalEmbeddingBase, OnnxMultimodalModel[NumpyArray]):
QUERY_PREFIX = "Query: "
BOS_TOKEN = "<s>"
PAD_TOKEN = "<pad>"
QUERY_MARKER_TOKEN_ID = [2, 5098]
IMAGE_PLACEHOLDER_SIZE = (3, 448, 448)
EMPTY_TEXT_PLACEHOLDER = np.array(
[257152] * 1024 + [2, 50721, 573, 2416, 235265, 108]
) # This is a tokenization of '<image>' * 1024 + '<bos>Describe the image.\n' line which is used as placeholder
# while processing an image
EVEN_ATTENTION_MASK = np.array([1] * 1030)
def __init__(
self,
model_name: str,
cache_dir: Optional[str] = None,
threads: Optional[int] = None,
providers: Optional[Sequence[OnnxProvider]] = None,
cuda: bool = False,
device_ids: Optional[list[int]] = None,
lazy_load: bool = False,
device_id: Optional[int] = None,
specific_model_path: Optional[str] = None,
**kwargs: Any,
):
"""
Args:
model_name (str): The name of the model to use.
cache_dir (str, optional): The path to the cache directory.
Can be set using the `FASTEMBED_CACHE_PATH` env variable.
Defaults to `fastembed_cache` in the system's temp directory.
threads (int, optional): The number of threads single onnxruntime session can use. Defaults to None.
providers (Optional[Sequence[OnnxProvider]], optional): The list of onnxruntime providers to use.
Mutually exclusive with the `cuda` and `device_ids` arguments. Defaults to None.
cuda (bool, optional): Whether to use cuda for inference. Mutually exclusive with `providers`
Defaults to False.
device_ids (Optional[list[int]], optional): The list of device ids to use for data parallel processing in
workers. Should be used with `cuda=True`, mutually exclusive with `providers`. Defaults to None.
lazy_load (bool, optional): Whether to load the model during class initialization or on demand.
Should be set to True when using multiple-gpu and parallel encoding. Defaults to False.
device_id (Optional[int], optional): The device id to use for loading the model in the worker process.
Raises:
ValueError: If the model_name is not in the format <org>/<model> e.g. BAAI/bge-base-en.
"""
super().__init__(model_name, cache_dir, threads, **kwargs)
self.providers = providers
self.lazy_load = lazy_load
# List of device ids, that can be used for data parallel processing in workers
self.device_ids = device_ids
self.cuda = cuda
# This device_id will be used if we need to load model in current process
self.device_id: Optional[int] = None
if device_id is not None:
self.device_id = device_id
elif self.device_ids is not None:
self.device_id = self.device_ids[0]
self.model_description = self._get_model_description(model_name)
self.cache_dir = str(define_cache_dir(cache_dir))
self._model_dir = self.download_model(
self.model_description,
self.cache_dir,
local_files_only=self._local_files_only,
specific_model_path=specific_model_path,
)
self.mask_token_id = None
self.pad_token_id = None
if not self.lazy_load:
self.load_onnx_model()
@classmethod
def _list_supported_models(cls) -> list[DenseModelDescription]:
"""Lists the supported models.
Returns:
list[DenseModelDescription]: A list of DenseModelDescription objects containing the model information.
"""
return supported_colpali_models
def load_onnx_model(self) -> None:
self._load_onnx_model(
model_dir=self._model_dir,
model_file=self.model_description.model_file,
threads=self.threads,
providers=self.providers,
cuda=self.cuda,
device_id=self.device_id,
)
def _post_process_onnx_image_output(
self,
output: OnnxOutputContext,
) -> Iterable[NumpyArray]:
"""
Post-process the ONNX model output to convert it into a usable format.
Args:
output (OnnxOutputContext): The raw output from the ONNX model.
Returns:
Iterable[NumpyArray]: Post-processed output as NumPy arrays.
"""
assert self.model_description.dim is not None, "Model dim is not defined"
return output.model_output.reshape(
output.model_output.shape[0], -1, self.model_description.dim
)
def _post_process_onnx_text_output(
self,
output: OnnxOutputContext,
) -> Iterable[NumpyArray]:
"""
Post-process the ONNX model output to convert it into a usable format.
Args:
output (OnnxOutputContext): The raw output from the ONNX model.
Returns:
Iterable[NumpyArray]: Post-processed output as NumPy arrays.
"""
return output.model_output
def tokenize(self, documents: list[str], **kwargs: Any) -> list[Encoding]:
texts_query: list[str] = []
for query in documents:
query = self.BOS_TOKEN + self.QUERY_PREFIX + query + self.PAD_TOKEN * 10
query += "\n"
texts_query.append(query)
encoded = self.tokenizer.encode_batch(texts_query) # type: ignore[union-attr]
return encoded
def _preprocess_onnx_text_input(
self, onnx_input: dict[str, NumpyArray], **kwargs: Any
) -> dict[str, NumpyArray]:
onnx_input["input_ids"] = np.array(
[
self.QUERY_MARKER_TOKEN_ID + input_ids[2:].tolist()
for input_ids in onnx_input["input_ids"]
]
)
empty_image_placeholder: NumpyArray = np.zeros(
self.IMAGE_PLACEHOLDER_SIZE, dtype=np.float32
)
onnx_input["pixel_values"] = np.array(
[empty_image_placeholder for _ in onnx_input["input_ids"]],
)
return onnx_input
def _preprocess_onnx_image_input(
self, onnx_input: dict[str, np.ndarray], **kwargs: Any
) -> dict[str, NumpyArray]:
"""
Add placeholders for text input when processing image data for ONNX.
Args:
onnx_input (Dict[str, NumpyArray]): Preprocessed image inputs.
**kwargs: Additional arguments.
Returns:
Dict[str, NumpyArray]: ONNX input with text placeholders.
"""
onnx_input["input_ids"] = np.array(
[self.EMPTY_TEXT_PLACEHOLDER for _ in onnx_input["pixel_values"]]
)
onnx_input["attention_mask"] = np.array(
[self.EVEN_ATTENTION_MASK for _ in onnx_input["pixel_values"]]
)
return onnx_input
def embed_text(
self,
documents: Union[str, Iterable[str]],
batch_size: int = 256,
parallel: Optional[int] = None,
**kwargs: Any,
) -> Iterable[NumpyArray]:
"""
Encode a list of documents into list of embeddings.
Args:
documents: Iterator of documents or single document to embed
batch_size: Batch size for encoding -- higher values will use more memory, but be faster
parallel:
If > 1, data-parallel encoding will be used, recommended for offline encoding of large datasets.
If 0, use all available cores.
If None, don't use data-parallel processing, use default onnxruntime threading instead.
Returns:
List of embeddings, one per document
"""
yield from self._embed_documents(
model_name=self.model_name,
cache_dir=str(self.cache_dir),
documents=documents,
batch_size=batch_size,
parallel=parallel,
providers=self.providers,
cuda=self.cuda,
device_ids=self.device_ids,
**kwargs,
)
def embed_image(
self,
images: Union[ImageInput, Iterable[ImageInput]],
batch_size: int = 16,
parallel: Optional[int] = None,
**kwargs: Any,
) -> Iterable[NumpyArray]:
"""
Encode a list of images into list of embeddings.
Args:
images: Iterator of image paths or single image path to embed
batch_size: Batch size for encoding -- higher values will use more memory, but be faster
parallel:
If > 1, data-parallel encoding will be used, recommended for offline encoding of large datasets.
If 0, use all available cores.
If None, don't use data-parallel processing, use default onnxruntime threading instead.
Returns:
List of embeddings, one per document
"""
yield from self._embed_images(
model_name=self.model_name,
cache_dir=str(self.cache_dir),
images=images,
batch_size=batch_size,
parallel=parallel,
providers=self.providers,
cuda=self.cuda,
device_ids=self.device_ids,
**kwargs,
)
@classmethod
def _get_text_worker_class(cls) -> Type[TextEmbeddingWorker[NumpyArray]]:
return ColPaliTextEmbeddingWorker
@classmethod
def _get_image_worker_class(cls) -> Type[ImageEmbeddingWorker[NumpyArray]]:
return ColPaliImageEmbeddingWorker
class ColPaliTextEmbeddingWorker(TextEmbeddingWorker[NumpyArray]):
def init_embedding(self, model_name: str, cache_dir: str, **kwargs: Any) -> ColPali:
return ColPali(
model_name=model_name,
cache_dir=cache_dir,
threads=1,
**kwargs,
)
class ColPaliImageEmbeddingWorker(ImageEmbeddingWorker[NumpyArray]):
def init_embedding(self, model_name: str, cache_dir: str, **kwargs: Any) -> ColPali:
return ColPali(
model_name=model_name,
cache_dir=cache_dir,
threads=1,
**kwargs,
)
@@ -0,0 +1,130 @@
from typing import Any, Iterable, Optional, Sequence, Type, Union
from dataclasses import asdict
from fastembed.common import OnnxProvider, ImageInput
from fastembed.common.types import NumpyArray
from fastembed.late_interaction_multimodal.colpali import ColPali
from fastembed.late_interaction_multimodal.late_interaction_multimodal_embedding_base import (
LateInteractionMultimodalEmbeddingBase,
)
from fastembed.common.model_description import DenseModelDescription
class LateInteractionMultimodalEmbedding(LateInteractionMultimodalEmbeddingBase):
EMBEDDINGS_REGISTRY: list[Type[LateInteractionMultimodalEmbeddingBase]] = [ColPali]
@classmethod
def list_supported_models(cls) -> list[dict[str, Any]]:
"""
Lists the supported models.
Returns:
list[dict[str, Any]]: A list of dictionaries containing the model information.
Example:
```
[
{
"model": "Qdrant/colpali-v1.3-fp16",
"dim": 128,
"description": "Text embeddings, Unimodal (text), Aligned to image latent space, ColBERT-compatible, 512 tokens max, 2024.",
"license": "mit",
"size_in_GB": 6.06,
"sources": {
"hf": "Qdrant/colpali-v1.3-fp16",
},
"additional_files": [
"model.onnx_data",
],
"model_file": "model.onnx",
},
]
```
"""
return [asdict(model) for model in cls._list_supported_models()]
@classmethod
def _list_supported_models(cls) -> list[DenseModelDescription]:
result: list[DenseModelDescription] = []
for embedding in cls.EMBEDDINGS_REGISTRY:
result.extend(embedding._list_supported_models())
return result
def __init__(
self,
model_name: str,
cache_dir: Optional[str] = None,
threads: Optional[int] = None,
providers: Optional[Sequence[OnnxProvider]] = None,
cuda: bool = False,
device_ids: Optional[list[int]] = None,
lazy_load: bool = False,
**kwargs: Any,
):
super().__init__(model_name, cache_dir, threads, **kwargs)
for EMBEDDING_MODEL_TYPE in self.EMBEDDINGS_REGISTRY:
supported_models = EMBEDDING_MODEL_TYPE._list_supported_models()
if any(model_name.lower() == model.model.lower() for model in supported_models):
self.model = EMBEDDING_MODEL_TYPE(
model_name,
cache_dir,
threads=threads,
providers=providers,
cuda=cuda,
device_ids=device_ids,
lazy_load=lazy_load,
**kwargs,
)
return
raise ValueError(
f"Model {model_name} is not supported in LateInteractionMultimodalEmbedding."
"Please check the supported models using `LateInteractionMultimodalEmbedding.list_supported_models()`"
)
def embed_text(
self,
documents: Union[str, Iterable[str]],
batch_size: int = 256,
parallel: Optional[int] = None,
**kwargs: Any,
) -> Iterable[NumpyArray]:
"""
Encode a list of documents into list of embeddings.
Args:
documents: Iterator of documents or single document to embed
batch_size: Batch size for encoding -- higher values will use more memory, but be faster
parallel:
If > 1, data-parallel encoding will be used, recommended for offline encoding of large datasets.
If 0, use all available cores.
If None, don't use data-parallel processing, use default onnxruntime threading instead.
Returns:
List of embeddings, one per document
"""
yield from self.model.embed_text(documents, batch_size, parallel, **kwargs)
def embed_image(
self,
images: Union[ImageInput, Iterable[ImageInput]],
batch_size: int = 16,
parallel: Optional[int] = None,
**kwargs: Any,
) -> Iterable[NumpyArray]:
"""
Encode a list of images into list of embeddings.
Args:
images: Iterator of image paths or single image path to embed
batch_size: Batch size for encoding -- higher values will use more memory, but be faster
parallel:
If > 1, data-parallel encoding will be used, recommended for offline encoding of large datasets.
If 0, use all available cores.
If None, don't use data-parallel processing, use default onnxruntime threading instead.
Returns:
List of embeddings, one per image
"""
yield from self.model.embed_image(images, batch_size, parallel, **kwargs)
@@ -0,0 +1,67 @@
from typing import Iterable, Optional, Union, Any
from fastembed.common import ImageInput
from fastembed.common.model_description import DenseModelDescription
from fastembed.common.model_management import ModelManagement
from fastembed.common.types import NumpyArray
class LateInteractionMultimodalEmbeddingBase(ModelManagement[DenseModelDescription]):
def __init__(
self,
model_name: str,
cache_dir: Optional[str] = None,
threads: Optional[int] = None,
**kwargs: Any,
):
self.model_name = model_name
self.cache_dir = cache_dir
self.threads = threads
self._local_files_only = kwargs.pop("local_files_only", False)
def embed_text(
self,
documents: Union[str, Iterable[str]],
batch_size: int = 256,
parallel: Optional[int] = None,
**kwargs: Any,
) -> Iterable[NumpyArray]:
"""
Embeds a list of documents into a list of embeddings.
Args:
documents (Iterable[str]): The list of texts to embed.
batch_size: Batch size for encoding -- higher values will use more memory, but be faster
parallel:
If > 1, data-parallel encoding will be used, recommended for offline encoding of large datasets.
If 0, use all available cores.
If None, don't use data-parallel processing, use default onnxruntime threading instead.
**kwargs: Additional keyword argument to pass to the embed method.
Yields:
Iterable[NumpyArray]: The embeddings.
"""
raise NotImplementedError()
def embed_image(
self,
images: Union[ImageInput, Iterable[ImageInput]],
batch_size: int = 16,
parallel: Optional[int] = None,
**kwargs: Any,
) -> Iterable[NumpyArray]:
"""
Encode a list of images into list of embeddings.
Args:
images: Iterator of image paths or single image path to embed
batch_size: Batch size for encoding -- higher values will use more memory, but be faster
parallel:
If > 1, data-parallel encoding will be used, recommended for offline encoding of large datasets.
If 0, use all available cores.
If None, don't use data-parallel processing, use default onnxruntime threading instead.
Returns:
List of embeddings, one per image
"""
raise NotImplementedError()
@@ -0,0 +1,271 @@
import contextlib
import os
from multiprocessing import get_all_start_methods
from pathlib import Path
from typing import Any, Iterable, Optional, Sequence, Type, Union
import numpy as np
from PIL import Image
from tokenizers import Encoding, Tokenizer
from fastembed.common import OnnxProvider, ImageInput
from fastembed.common.onnx_model import EmbeddingWorker, OnnxModel, OnnxOutputContext, T
from fastembed.common.preprocessor_utils import load_tokenizer, load_preprocessor
from fastembed.common.types import NumpyArray
from fastembed.common.utils import iter_batch
from fastembed.image.transform.operators import Compose
from fastembed.parallel_processor import ParallelWorkerPool
class OnnxMultimodalModel(OnnxModel[T]):
ONNX_OUTPUT_NAMES: Optional[list[str]] = None
def __init__(self) -> None:
super().__init__()
self.tokenizer: Optional[Tokenizer] = None
self.processor: Optional[Compose] = None
self.special_token_to_id: dict[str, int] = {}
def _preprocess_onnx_text_input(
self, onnx_input: dict[str, NumpyArray], **kwargs: Any
) -> dict[str, NumpyArray]:
"""
Preprocess the onnx input.
"""
return onnx_input
def _preprocess_onnx_image_input(
self, onnx_input: dict[str, NumpyArray], **kwargs: Any
) -> dict[str, NumpyArray]:
"""
Preprocess the onnx input.
"""
return onnx_input
@classmethod
def _get_text_worker_class(cls) -> Type["TextEmbeddingWorker[T]"]:
raise NotImplementedError("Subclasses must implement this method")
@classmethod
def _get_image_worker_class(cls) -> Type["ImageEmbeddingWorker[T]"]:
raise NotImplementedError("Subclasses must implement this method")
def _post_process_onnx_image_output(self, output: OnnxOutputContext) -> Iterable[T]:
raise NotImplementedError("Subclasses must implement this method")
def _post_process_onnx_text_output(self, output: OnnxOutputContext) -> Iterable[T]:
raise NotImplementedError("Subclasses must implement this method")
def _load_onnx_model(
self,
model_dir: Path,
model_file: str,
threads: Optional[int],
providers: Optional[Sequence[OnnxProvider]] = None,
cuda: bool = False,
device_id: Optional[int] = None,
) -> None:
super()._load_onnx_model(
model_dir=model_dir,
model_file=model_file,
threads=threads,
providers=providers,
cuda=cuda,
device_id=device_id,
)
self.tokenizer, self.special_token_to_id = load_tokenizer(model_dir=model_dir)
assert self.tokenizer is not None
self.processor = load_preprocessor(model_dir=model_dir)
def load_onnx_model(self) -> None:
raise NotImplementedError("Subclasses must implement this method")
def tokenize(self, documents: list[str], **kwargs: Any) -> list[Encoding]:
return self.tokenizer.encode_batch(documents) # type: ignore[union-attr]
def onnx_embed_text(
self,
documents: list[str],
**kwargs: Any,
) -> OnnxOutputContext:
encoded = self.tokenize(documents, **kwargs)
input_ids = np.array([e.ids for e in encoded])
attention_mask = np.array([e.attention_mask for e in encoded]) # type: ignore[union-attr]
input_names = {node.name for node in self.model.get_inputs()} # type: ignore[union-attr]
onnx_input: dict[str, NumpyArray] = {
"input_ids": np.array(input_ids, dtype=np.int64),
}
if "attention_mask" in input_names:
onnx_input["attention_mask"] = np.array(attention_mask, dtype=np.int64)
if "token_type_ids" in input_names:
onnx_input["token_type_ids"] = np.array(
[np.zeros(len(e), dtype=np.int64) for e in input_ids], dtype=np.int64
)
onnx_input = self._preprocess_onnx_text_input(onnx_input, **kwargs)
model_output = self.model.run(self.ONNX_OUTPUT_NAMES, onnx_input) # type: ignore[union-attr]
return OnnxOutputContext(
model_output=model_output[0],
attention_mask=onnx_input.get("attention_mask", attention_mask),
input_ids=onnx_input.get("input_ids", input_ids),
)
def _embed_documents(
self,
model_name: str,
cache_dir: str,
documents: Union[str, Iterable[str]],
batch_size: int = 256,
parallel: Optional[int] = None,
providers: Optional[Sequence[OnnxProvider]] = None,
cuda: bool = False,
device_ids: Optional[list[int]] = None,
**kwargs: Any,
) -> Iterable[T]:
is_small = False
if isinstance(documents, str):
documents = [documents]
is_small = True
if isinstance(documents, list):
if len(documents) < batch_size:
is_small = True
if parallel is None or is_small:
if not hasattr(self, "model") or self.model is None:
self.load_onnx_model()
for batch in iter_batch(documents, batch_size):
yield from self._post_process_onnx_text_output(self.onnx_embed_text(batch))
else:
if parallel == 0:
parallel = os.cpu_count()
start_method = "forkserver" if "forkserver" in get_all_start_methods() else "spawn"
params = {
"model_name": model_name,
"cache_dir": cache_dir,
"providers": providers,
**kwargs,
}
pool = ParallelWorkerPool(
num_workers=parallel or 1,
worker=self._get_text_worker_class(),
cuda=cuda,
device_ids=device_ids,
start_method=start_method,
)
for batch in pool.ordered_map(iter_batch(documents, batch_size), **params):
yield from self._post_process_onnx_text_output(batch) # type: ignore
def onnx_embed_image(self, images: list[ImageInput], **kwargs: Any) -> OnnxOutputContext:
with contextlib.ExitStack():
image_files = [
Image.open(image) if not isinstance(image, Image.Image) else image
for image in images
]
assert self.processor is not None, "Processor is not initialized"
encoded = np.array(self.processor(image_files))
onnx_input = {"pixel_values": encoded}
onnx_input = self._preprocess_onnx_image_input(onnx_input, **kwargs)
model_output = self.model.run(None, onnx_input) # type: ignore[union-attr]
embeddings = model_output[0].reshape(len(images), -1)
return OnnxOutputContext(model_output=embeddings)
def _embed_images(
self,
model_name: str,
cache_dir: str,
images: Union[Iterable[ImageInput], ImageInput],
batch_size: int = 256,
parallel: Optional[int] = None,
providers: Optional[Sequence[OnnxProvider]] = None,
cuda: bool = False,
device_ids: Optional[list[int]] = None,
**kwargs: Any,
) -> Iterable[T]:
is_small = False
if isinstance(images, (str, Path, Image.Image)):
images = [images]
is_small = True
if isinstance(images, list) and len(images) < batch_size:
is_small = True
if parallel is None or is_small:
if not hasattr(self, "model") or self.model is None:
self.load_onnx_model()
for batch in iter_batch(images, batch_size):
yield from self._post_process_onnx_image_output(self.onnx_embed_image(batch))
else:
if parallel == 0:
parallel = os.cpu_count()
start_method = "forkserver" if "forkserver" in get_all_start_methods() else "spawn"
params = {
"model_name": model_name,
"cache_dir": cache_dir,
"providers": providers,
**kwargs,
}
pool = ParallelWorkerPool(
num_workers=parallel or 1,
worker=self._get_image_worker_class(),
cuda=cuda,
device_ids=device_ids,
start_method=start_method,
)
for batch in pool.ordered_map(iter_batch(images, batch_size), **params):
yield from self._post_process_onnx_image_output(batch) # type: ignore
class TextEmbeddingWorker(EmbeddingWorker[T]):
def __init__(
self,
model_name: str,
cache_dir: str,
**kwargs: Any,
):
self.model: OnnxMultimodalModel
super().__init__(model_name, cache_dir, **kwargs)
def init_embedding(
self,
model_name: str,
cache_dir: str,
**kwargs: Any,
) -> OnnxMultimodalModel:
raise NotImplementedError()
def process(self, items: Iterable[tuple[int, Any]]) -> Iterable[tuple[int, Any]]:
for idx, batch in items:
onnx_output = self.model.onnx_embed_text(batch)
yield idx, onnx_output
class ImageEmbeddingWorker(EmbeddingWorker[T]):
def __init__(
self,
model_name: str,
cache_dir: str,
**kwargs: Any,
):
self.model: OnnxMultimodalModel
super().__init__(model_name, cache_dir, **kwargs)
def init_embedding(
self,
model_name: str,
cache_dir: str,
**kwargs: Any,
) -> OnnxMultimodalModel:
raise NotImplementedError()
def process(self, items: Iterable[tuple[int, Any]]) -> Iterable[tuple[int, Any]]:
for idx, batch in items:
embeddings = self.model.onnx_embed_image(batch)
yield idx, embeddings
+1 -1
View File
@@ -140,7 +140,7 @@ class ParallelWorkerPool:
self.processes.append(process)
def ordered_map(self, stream: Iterable[Any], *args: Any, **kwargs: Any) -> Iterable[Any]:
buffer = defaultdict(Any)
buffer: defaultdict[int, Any] = defaultdict(Any) # type: ignore
next_expected = 0
for idx, item in self.semi_ordered_map(stream, *args, **kwargs):
+1
View File
@@ -0,0 +1 @@
partial
@@ -0,0 +1,46 @@
from typing import Optional, Sequence, Any
from fastembed.common import OnnxProvider
from fastembed.common.model_description import BaseModelDescription
from fastembed.rerank.cross_encoder.onnx_text_cross_encoder import OnnxTextCrossEncoder
class CustomTextCrossEncoder(OnnxTextCrossEncoder):
SUPPORTED_MODELS: list[BaseModelDescription] = []
def __init__(
self,
model_name: str,
cache_dir: Optional[str] = None,
threads: Optional[int] = None,
providers: Optional[Sequence[OnnxProvider]] = None,
cuda: bool = False,
device_ids: Optional[list[int]] = None,
lazy_load: bool = False,
device_id: Optional[int] = None,
specific_model_path: Optional[str] = None,
**kwargs: Any,
):
super().__init__(
model_name=model_name,
cache_dir=cache_dir,
threads=threads,
providers=providers,
cuda=cuda,
device_ids=device_ids,
lazy_load=lazy_load,
device_id=device_id,
specific_model_path=specific_model_path,
**kwargs,
)
@classmethod
def _list_supported_models(cls) -> list[BaseModelDescription]:
return cls.SUPPORTED_MODELS
@classmethod
def add_model(
cls,
model_description: BaseModelDescription,
) -> None:
cls.SUPPORTED_MODELS.append(model_description)
@@ -10,78 +10,67 @@ from fastembed.rerank.cross_encoder.onnx_text_model import (
TextRerankerWorker,
)
from fastembed.rerank.cross_encoder.text_cross_encoder_base import TextCrossEncoderBase
from fastembed.common.model_description import BaseModelDescription, ModelSource
supported_onnx_models = [
{
"model": "Xenova/ms-marco-MiniLM-L-6-v2",
"size_in_GB": 0.08,
"sources": {
"hf": "Xenova/ms-marco-MiniLM-L-6-v2",
},
"model_file": "onnx/model.onnx",
"description": "MiniLM-L-6-v2 model optimized for re-ranking tasks.",
"license": "apache-2.0",
},
{
"model": "Xenova/ms-marco-MiniLM-L-12-v2",
"size_in_GB": 0.12,
"sources": {
"hf": "Xenova/ms-marco-MiniLM-L-12-v2",
},
"model_file": "onnx/model.onnx",
"description": "MiniLM-L-12-v2 model optimized for re-ranking tasks.",
"license": "apache-2.0",
},
{
"model": "BAAI/bge-reranker-base",
"size_in_GB": 1.04,
"sources": {
"hf": "BAAI/bge-reranker-base",
},
"model_file": "onnx/model.onnx",
"description": "BGE reranker base model for cross-encoder re-ranking.",
"license": "mit",
},
{
"model": "jinaai/jina-reranker-v1-tiny-en",
"size_in_GB": 0.13,
"sources": {
"hf": "jinaai/jina-reranker-v1-tiny-en",
},
"model_file": "onnx/model.onnx",
"description": "Designed for blazing-fast re-ranking with 8K context length and fewer parameters than jina-reranker-v1-turbo-en.",
"license": "apache-2.0",
},
{
"model": "jinaai/jina-reranker-v1-turbo-en",
"size_in_GB": 0.15,
"sources": {
"hf": "jinaai/jina-reranker-v1-turbo-en",
},
"model_file": "onnx/model.onnx",
"description": "Designed for blazing-fast re-ranking with 8K context length.",
"license": "apache-2.0",
},
{
"model": "jinaai/jina-reranker-v2-base-multilingual",
"size_in_GB": 1.11,
"sources": {
"hf": "jinaai/jina-reranker-v2-base-multilingual",
},
"model_file": "onnx/model.onnx",
"description": "A multi-lingual reranker model for cross-encoder re-ranking with 1K context length and sliding window",
"license": "cc-by-nc-4.0",
},
supported_onnx_models: list[BaseModelDescription] = [
BaseModelDescription(
model="Xenova/ms-marco-MiniLM-L-6-v2",
description="MiniLM-L-6-v2 model optimized for re-ranking tasks.",
license="apache-2.0",
size_in_GB=0.08,
sources=ModelSource(hf="Xenova/ms-marco-MiniLM-L-6-v2"),
model_file="onnx/model.onnx",
),
BaseModelDescription(
model="Xenova/ms-marco-MiniLM-L-12-v2",
description="MiniLM-L-12-v2 model optimized for re-ranking tasks.",
license="apache-2.0",
size_in_GB=0.12,
sources=ModelSource(hf="Xenova/ms-marco-MiniLM-L-12-v2"),
model_file="onnx/model.onnx",
),
BaseModelDescription(
model="BAAI/bge-reranker-base",
description="BGE reranker base model for cross-encoder re-ranking.",
license="mit",
size_in_GB=1.04,
sources=ModelSource(hf="BAAI/bge-reranker-base"),
model_file="onnx/model.onnx",
),
BaseModelDescription(
model="jinaai/jina-reranker-v1-tiny-en",
description="Designed for blazing-fast re-ranking with 8K context length and fewer parameters than jina-reranker-v1-turbo-en.",
license="apache-2.0",
size_in_GB=0.13,
sources=ModelSource(hf="jinaai/jina-reranker-v1-tiny-en"),
model_file="onnx/model.onnx",
),
BaseModelDescription(
model="jinaai/jina-reranker-v1-turbo-en",
description="Designed for blazing-fast re-ranking with 8K context length.",
license="apache-2.0",
size_in_GB=0.15,
sources=ModelSource(hf="jinaai/jina-reranker-v1-turbo-en"),
model_file="onnx/model.onnx",
),
BaseModelDescription(
model="jinaai/jina-reranker-v2-base-multilingual",
description="A multi-lingual reranker model for cross-encoder re-ranking with 1K context length and sliding window",
license="cc-by-nc-4.0",
size_in_GB=1.11,
sources=ModelSource(hf="jinaai/jina-reranker-v2-base-multilingual"),
model_file="onnx/model.onnx",
),
]
class OnnxTextCrossEncoder(TextCrossEncoderBase, OnnxCrossEncoderModel):
@classmethod
def list_supported_models(cls) -> list[dict[str, Any]]:
def _list_supported_models(cls) -> list[BaseModelDescription]:
"""Lists the supported models.
Returns:
list[dict[str, Any]]: A list of dictionaries containing the model information.
list[BaseModelDescription]: A list of BaseModelDescription objects containing the model information.
"""
return supported_onnx_models
@@ -134,15 +123,14 @@ class OnnxTextCrossEncoder(TextCrossEncoderBase, OnnxCrossEncoderModel):
)
# This device_id will be used if we need to load model in current process
self.device_id: Optional[int] = None
if device_id is not None:
self.device_id = device_id
elif self.device_ids is not None:
self.device_id = self.device_ids[0]
else:
self.device_id = None
self.model_description = self._get_model_description(model_name)
self.cache_dir = define_cache_dir(cache_dir)
self.cache_dir = str(define_cache_dir(cache_dir))
self._model_dir = self.download_model(
self.model_description,
self.cache_dir,
@@ -156,7 +144,7 @@ class OnnxTextCrossEncoder(TextCrossEncoderBase, OnnxCrossEncoderModel):
def load_onnx_model(self) -> None:
self._load_onnx_model(
model_dir=self._model_dir,
model_file=self.model_description["model_file"],
model_file=self.model_description.model_file,
threads=self.threads,
providers=self.providers,
cuda=self.cuda,
@@ -1,10 +1,9 @@
import os
from multiprocessing import get_all_start_methods
from pathlib import Path
from typing import Any, Iterable, Optional, Sequence, Type, Union
from typing import Any, Iterable, Optional, Sequence, Type
import numpy as np
from numpy.typing import NDArray
from tokenizers import Encoding
from fastembed.common.onnx_model import (
@@ -13,6 +12,7 @@ from fastembed.common.onnx_model import (
OnnxOutputContext,
OnnxProvider,
)
from fastembed.common.types import NumpyArray
from fastembed.common.preprocessor_utils import load_tokenizer
from fastembed.common.utils import iter_batch
from fastembed.parallel_processor import ParallelWorkerPool
@@ -43,15 +43,14 @@ class OnnxCrossEncoderModel(OnnxModel[float]):
device_id=device_id,
)
self.tokenizer, _ = load_tokenizer(model_dir=model_dir)
assert self.tokenizer is not None
def tokenize(self, pairs: list[tuple[str, str]], **_: Any) -> list[Encoding]:
return self.tokenizer.encode_batch(pairs)
return self.tokenizer.encode_batch(pairs) # type: ignore[union-attr]
def _build_onnx_input(
self, tokenized_input
) -> dict[str, NDArray[Union[np.float32, np.int64]]]:
input_names = {node.name for node in self.model.get_inputs()}
inputs = {
def _build_onnx_input(self, tokenized_input: list[Encoding]) -> dict[str, NumpyArray]:
input_names: set[str] = {node.name for node in self.model.get_inputs()} # type: ignore[union-attr]
inputs: dict[str, NumpyArray] = {
"input_ids": np.array([enc.ids for enc in tokenized_input], dtype=np.int64),
}
if "token_type_ids" in input_names:
@@ -72,9 +71,9 @@ class OnnxCrossEncoderModel(OnnxModel[float]):
tokenized_input = self.tokenize(pairs, **kwargs)
inputs = self._build_onnx_input(tokenized_input)
onnx_input = self._preprocess_onnx_input(inputs, **kwargs)
outputs = self.model.run(self.ONNX_OUTPUT_NAMES, onnx_input)
outputs = self.model.run(self.ONNX_OUTPUT_NAMES, onnx_input) # type: ignore[union-attr]
relevant_output = outputs[0]
scores = relevant_output[:, 0]
scores: NumpyArray = relevant_output[:, 0]
return OnnxOutputContext(model_output=scores)
def _rerank_documents(
@@ -132,21 +131,38 @@ class OnnxCrossEncoderModel(OnnxModel[float]):
start_method=start_method,
)
for batch in pool.ordered_map(iter_batch(pairs, batch_size), **params):
yield from self._post_process_onnx_output(batch)
yield from self._post_process_onnx_output(batch) # type: ignore
def _post_process_onnx_output(self, output: OnnxOutputContext) -> Iterable[float]:
raise NotImplementedError("Subclasses must implement this method")
def _preprocess_onnx_input(
self, onnx_input: dict[str, np.ndarray], **kwargs: Any
) -> dict[str, np.ndarray]:
self, onnx_input: dict[str, NumpyArray], **kwargs: Any
) -> dict[str, NumpyArray]:
"""
Preprocess the onnx input.
"""
return onnx_input
class TextRerankerWorker(EmbeddingWorker):
class TextRerankerWorker(EmbeddingWorker[float]):
def __init__(
self,
model_name: str,
cache_dir: str,
**kwargs: Any,
):
self.model: OnnxCrossEncoderModel
super().__init__(model_name, cache_dir, **kwargs)
def init_embedding(
self,
model_name: str,
cache_dir: str,
**kwargs: Any,
) -> OnnxCrossEncoderModel:
raise NotImplementedError()
def process(self, items: Iterable[tuple[int, Any]]) -> Iterable[tuple[int, Any]]:
for idx, batch in items:
onnx_output = self.model.onnx_embed_pairs(batch)
@@ -1,13 +1,21 @@
from typing import Any, Iterable, Optional, Sequence, Type
from dataclasses import asdict
from fastembed.common import OnnxProvider
from fastembed.rerank.cross_encoder.onnx_text_cross_encoder import OnnxTextCrossEncoder
from fastembed.rerank.cross_encoder.custom_text_cross_encoder import CustomTextCrossEncoder
from fastembed.rerank.cross_encoder.text_cross_encoder_base import TextCrossEncoderBase
from fastembed.common.model_description import (
ModelSource,
BaseModelDescription,
)
class TextCrossEncoder(TextCrossEncoderBase):
CROSS_ENCODER_REGISTRY: list[Type[TextCrossEncoderBase]] = [
OnnxTextCrossEncoder,
CustomTextCrossEncoder,
]
@classmethod
@@ -15,7 +23,7 @@ class TextCrossEncoder(TextCrossEncoderBase):
"""Lists the supported models.
Returns:
list[dict[str, Any]]: A list of dictionaries containing the model information.
list[BaseModelDescription]: A list of dictionaries containing the model information.
Example:
```
@@ -33,9 +41,13 @@ class TextCrossEncoder(TextCrossEncoderBase):
]
```
"""
result = []
return [asdict(model) for model in cls._list_supported_models()]
@classmethod
def _list_supported_models(cls) -> list[BaseModelDescription]:
result: list[BaseModelDescription] = []
for encoder in cls.CROSS_ENCODER_REGISTRY:
result.extend(encoder.list_supported_models())
result.extend(encoder._list_supported_models())
return result
def __init__(
@@ -52,8 +64,8 @@ class TextCrossEncoder(TextCrossEncoderBase):
super().__init__(model_name, cache_dir, threads, **kwargs)
for CROSS_ENCODER_TYPE in self.CROSS_ENCODER_REGISTRY:
supported_models = CROSS_ENCODER_TYPE.list_supported_models()
if any(model_name.lower() == model["model"].lower() for model in supported_models):
supported_models = CROSS_ENCODER_TYPE._list_supported_models()
if any(model_name.lower() == model.model.lower() for model in supported_models):
self.model = CROSS_ENCODER_TYPE(
model_name=model_name,
cache_dir=cache_dir,
@@ -118,3 +130,34 @@ class TextCrossEncoder(TextCrossEncoderBase):
yield from self.model.rerank_pairs(
pairs, batch_size=batch_size, parallel=parallel, **kwargs
)
@classmethod
def add_custom_model(
cls,
model: str,
sources: ModelSource,
model_file: str = "onnx/model.onnx",
description: str = "",
license: str = "",
size_in_gb: float = 0.0,
additional_files: Optional[list[str]] = None,
) -> None:
registered_models = cls._list_supported_models()
for registered_model in registered_models:
if model == registered_model.model:
raise ValueError(
f"Model {model} is already registered in CrossEncoderModel, if you still want to add this model, "
f"please use another model name"
)
CustomTextCrossEncoder.add_model(
BaseModelDescription(
model=model,
sources=sources,
model_file=model_file,
description=description,
license=license,
size_in_GB=size_in_gb,
additional_files=additional_files or [],
)
)
@@ -1,9 +1,10 @@
from typing import Any, Iterable, Optional
from fastembed.common.model_description import BaseModelDescription
from fastembed.common.model_management import ModelManagement
class TextCrossEncoderBase(ModelManagement):
class TextCrossEncoderBase(ModelManagement[BaseModelDescription]):
def __init__(
self,
model_name: str,
+27 -35
View File
@@ -19,14 +19,11 @@ from fastembed.sparse.sparse_embedding_base import (
SparseTextEmbeddingBase,
)
from fastembed.sparse.utils.tokenizer import SimpleTokenizer
from fastembed.common.model_description import SparseModelDescription, ModelSource
supported_languages = [
"arabic",
"azerbaijani",
"basque",
"bengali",
"catalan",
"chinese",
"danish",
"dutch",
"english",
@@ -34,37 +31,30 @@ supported_languages = [
"french",
"german",
"greek",
"hebrew",
"hinglish",
"hungarian",
"indonesian",
"italian",
"kazakh",
"nepali",
"norwegian",
"portuguese",
"romanian",
"russian",
"slovene",
"spanish",
"swedish",
"tajik",
"tamil",
"turkish",
]
supported_bm25_models = [
{
"model": "Qdrant/bm25",
"description": "BM25 as sparse embeddings meant to be used with Qdrant",
"license": "apache-2.0",
"size_in_GB": 0.01,
"sources": {
"hf": "Qdrant/bm25",
},
"model_file": "mock.file", # bm25 does not require a model, so we just use a mock
"additional_files": [f"{lang}.txt" for lang in supported_languages],
"requires_idf": True,
},
supported_bm25_models: list[SparseModelDescription] = [
SparseModelDescription(
model="Qdrant/bm25",
vocab_size=0,
description="BM25 as sparse embeddings meant to be used with Qdrant",
license="apache-2.0",
size_in_GB=0.01,
sources=ModelSource(hf="Qdrant/bm25"),
additional_files=[f"{lang}.txt" for lang in supported_languages],
requires_idf=True,
model_file="mock.file",
),
]
@@ -123,7 +113,7 @@ class Bm25(SparseTextEmbeddingBase):
self.avg_len = avg_len
model_description = self._get_model_description(model_name)
self.cache_dir = define_cache_dir(cache_dir)
self.cache_dir = str(define_cache_dir(cache_dir))
self._model_dir = self.download_model(
model_description,
@@ -137,7 +127,7 @@ class Bm25(SparseTextEmbeddingBase):
self.disable_stemmer = disable_stemmer
if disable_stemmer:
self.stopwords = set()
self.stopwords: set[str] = set()
self.stemmer = None
else:
self.stopwords = set(self._load_stopwords(self._model_dir, self.language))
@@ -146,11 +136,11 @@ class Bm25(SparseTextEmbeddingBase):
self.tokenizer = SimpleTokenizer
@classmethod
def list_supported_models(cls) -> list[dict[str, Any]]:
def _list_supported_models(cls) -> list[SparseModelDescription]:
"""Lists the supported models.
Returns:
list[dict[str, Any]]: A list of dictionaries containing the model information.
list[SparseModelDescription]: A list of SparseModelDescription objects containing the model information.
"""
return supported_bm25_models
@@ -206,7 +196,7 @@ class Bm25(SparseTextEmbeddingBase):
)
for batch in pool.ordered_map(iter_batch(documents, batch_size), **params):
for record in batch:
yield record
yield record # type: ignore
def embed(
self,
@@ -239,7 +229,7 @@ class Bm25(SparseTextEmbeddingBase):
)
def _stem(self, tokens: list[str]) -> list[str]:
stemmed_tokens = []
stemmed_tokens: list[str] = []
for token in tokens:
lower_token = token.lower()
@@ -262,7 +252,7 @@ class Bm25(SparseTextEmbeddingBase):
self,
documents: list[str],
) -> list[SparseEmbedding]:
embeddings = []
embeddings: list[SparseEmbedding] = []
for document in documents:
document = remove_non_alphanumeric(document)
tokens = self.tokenizer.tokenize(document)
@@ -286,8 +276,8 @@ class Bm25(SparseTextEmbeddingBase):
Returns:
dict[int, float]: The token_id to term frequency mapping.
"""
tf_map = {}
counter = defaultdict(int)
tf_map: dict[int, float] = {}
counter: defaultdict[str, int] = defaultdict(int)
for stemmed_token in tokens:
counter[stemmed_token] += 1
@@ -343,7 +333,9 @@ class Bm25Worker(Worker):
def start(cls, model_name: str, cache_dir: str, **kwargs: Any) -> "Bm25Worker":
return cls(model_name=model_name, cache_dir=cache_dir, **kwargs)
def process(self, items: Iterable[tuple[int, Any]]) -> Iterable[tuple[int, Any]]:
def process(
self, items: Iterable[tuple[int, Any]]
) -> Iterable[tuple[int, list[SparseEmbedding]]]:
for idx, batch in items:
onnx_output = self.model.raw_embed(batch)
yield idx, onnx_output
+37 -38
View File
@@ -15,21 +15,20 @@ from fastembed.sparse.sparse_embedding_base import (
SparseTextEmbeddingBase,
)
from fastembed.text.onnx_text_model import OnnxTextModel, TextEmbeddingWorker
from fastembed.common.model_description import SparseModelDescription, ModelSource
supported_bm42_models = [
{
"model": "Qdrant/bm42-all-minilm-l6-v2-attentions",
"vocab_size": 30522,
"description": "Light sparse embedding model, which assigns an importance score to each token in the text",
"license": "apache-2.0",
"size_in_GB": 0.09,
"sources": {
"hf": "Qdrant/all_miniLM_L6_v2_with_attentions",
},
"model_file": "model.onnx",
"additional_files": ["stopwords.txt"],
"requires_idf": True,
},
supported_bm42_models: list[SparseModelDescription] = [
SparseModelDescription(
model="Qdrant/bm42-all-minilm-l6-v2-attentions",
vocab_size=30522,
description="Light sparse embedding model, which assigns an importance score to each token in the text",
license="apache-2.0",
size_in_GB=0.09,
sources=ModelSource(hf="Qdrant/all_miniLM_L6_v2_with_attentions"),
model_file="model.onnx",
additional_files=["stopwords.txt"],
requires_idf=True,
),
]
MODEL_TO_LANGUAGE = {
@@ -102,15 +101,14 @@ class Bm42(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
self.cuda = cuda
# This device_id will be used if we need to load model in current process
self.device_id: Optional[int] = None
if device_id is not None:
self.device_id = device_id
elif self.device_ids is not None:
self.device_id = self.device_ids[0]
else:
self.device_id = None
self.model_description = self._get_model_description(model_name)
self.cache_dir = define_cache_dir(cache_dir)
self.cache_dir = str(define_cache_dir(cache_dir))
self._model_dir = self.download_model(
self.model_description,
@@ -119,10 +117,10 @@ class Bm42(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
specific_model_path=specific_model_path,
)
self.invert_vocab = {}
self.invert_vocab: dict[int, str] = {}
self.special_tokens = set()
self.special_tokens_ids = set()
self.special_tokens: set[str] = set()
self.special_tokens_ids: set[int] = set()
self.punctuation = set(string.punctuation)
self.stopwords = set(self._load_stopwords(self._model_dir))
self.stemmer = SnowballStemmer(MODEL_TO_LANGUAGE[model_name])
@@ -134,20 +132,21 @@ class Bm42(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
def load_onnx_model(self) -> None:
self._load_onnx_model(
model_dir=self._model_dir,
model_file=self.model_description["model_file"],
model_file=self.model_description.model_file,
threads=self.threads,
providers=self.providers,
cuda=self.cuda,
device_id=self.device_id,
)
for token, idx in self.tokenizer.get_vocab().items():
for token, idx in self.tokenizer.get_vocab().items(): # type: ignore[union-attr]
self.invert_vocab[idx] = token
self.special_tokens = set(self.special_token_to_id.keys())
self.special_tokens_ids = set(self.special_token_to_id.values())
self.stopwords = set(self._load_stopwords(self._model_dir))
def _filter_pair_tokens(self, tokens: list[tuple[str, Any]]) -> list[tuple[str, Any]]:
result = []
result: list[tuple[str, Any]] = []
for token, value in tokens:
if token in self.stopwords or token in self.punctuation:
continue
@@ -155,7 +154,7 @@ class Bm42(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
return result
def _stem_pair_tokens(self, tokens: list[tuple[str, Any]]) -> list[tuple[str, Any]]:
result = []
result: list[tuple[str, Any]] = []
for token, value in tokens:
processed_token = self.stemmer.stem_word(token)
result.append((processed_token, value))
@@ -165,7 +164,7 @@ class Bm42(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
def _aggregate_weights(
cls, tokens: list[tuple[str, list[int]]], weights: list[float]
) -> list[tuple[str, float]]:
result = []
result: list[tuple[str, float]] = []
for token, idxs in tokens:
sum_weight = sum(weights[idx] for idx in idxs)
result.append((token, sum_weight))
@@ -174,11 +173,11 @@ class Bm42(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
def _reconstruct_bpe(
self, bpe_tokens: Iterable[tuple[int, str]]
) -> list[tuple[str, list[int]]]:
result = []
acc = ""
acc_idx = []
result: list[tuple[str, list[int]]] = []
acc: str = ""
acc_idx: list[int] = []
continuing_subword_prefix = self.tokenizer.model.continuing_subword_prefix
continuing_subword_prefix = self.tokenizer.model.continuing_subword_prefix # type: ignore[union-attr]
continuing_subword_prefix_len = len(continuing_subword_prefix)
for idx, token in bpe_tokens:
@@ -206,7 +205,7 @@ class Bm42(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
So that the scoring doesn't depend on absolute values assigned by the model, but on the relative importance.
"""
new_vector = {}
new_vector: dict[int, float] = {}
for token, value in vector.items():
token_id = abs(mmh3.hash(token))
@@ -222,7 +221,7 @@ class Bm42(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
if output.input_ids is None:
raise ValueError("input_ids must be provided for document post-processing")
token_ids_batch = output.input_ids
token_ids_batch = output.input_ids.astype(int)
# attention_value shape: (batch_size, num_heads, num_tokens, num_tokens)
pooled_attention = np.mean(output.model_output[:, :, 0], axis=1) * output.attention_mask
@@ -241,7 +240,7 @@ class Bm42(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
weighted = self._aggregate_weights(stemmed, attention_value)
max_token_weight = {}
max_token_weight: dict[str, float] = {}
for token, weight in weighted:
max_token_weight[token] = max(max_token_weight.get(token, 0), weight)
@@ -251,11 +250,11 @@ class Bm42(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
yield SparseEmbedding.from_dict(rescored)
@classmethod
def list_supported_models(cls) -> list[dict[str, Any]]:
def _list_supported_models(cls) -> list[SparseModelDescription]:
"""Lists the supported models.
Returns:
list[dict[str, Any]]: A list of dictionaries containing the model information.
list[SparseModelDescription]: A list of SparseModelDescription objects containing the model information.
"""
return supported_bm42_models
@@ -304,7 +303,7 @@ class Bm42(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
@classmethod
def _query_rehash(cls, tokens: Iterable[str]) -> dict[int, float]:
result = {}
result: dict[int, float] = {}
for token in tokens:
token_id = abs(mmh3.hash(token))
result[token_id] = 1.0
@@ -325,7 +324,7 @@ class Bm42(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
self.load_onnx_model()
for text in query:
encoded = self.tokenizer.encode(text)
encoded = self.tokenizer.encode(text) # type: ignore[union-attr]
document_tokens_with_ids = enumerate(encoded.tokens)
reconstructed = self._reconstruct_bpe(document_tokens_with_ids)
filtered = self._filter_pair_tokens(reconstructed)
@@ -334,11 +333,11 @@ class Bm42(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
yield SparseEmbedding.from_dict(self._query_rehash(token for token, _ in stemmed))
@classmethod
def _get_worker_class(cls) -> Type[TextEmbeddingWorker]:
def _get_worker_class(cls) -> Type[TextEmbeddingWorker[SparseEmbedding]]:
return Bm42TextEmbeddingWorker
class Bm42TextEmbeddingWorker(TextEmbeddingWorker):
class Bm42TextEmbeddingWorker(TextEmbeddingWorker[SparseEmbedding]):
def init_embedding(self, model_name: str, cache_dir: str, **kwargs: Any) -> Bm42:
return Bm42(
model_name=model_name,
+9 -6
View File
@@ -2,23 +2,26 @@ from dataclasses import dataclass
from typing import Iterable, Optional, Union, Any
import numpy as np
from numpy.typing import NDArray
from fastembed.common.model_description import SparseModelDescription
from fastembed.common.types import NumpyArray
from fastembed.common.model_management import ModelManagement
@dataclass
class SparseEmbedding:
values: np.ndarray
indices: np.ndarray
values: NumpyArray
indices: Union[NDArray[np.int64], NDArray[np.int32]]
def as_object(self) -> dict[str, np.ndarray]:
def as_object(self) -> dict[str, NumpyArray]:
return {
"values": self.values,
"indices": self.indices,
}
def as_dict(self) -> dict[int, float]:
return {i: v for i, v in zip(self.indices, self.values)}
return {int(i): float(v) for i, v in zip(self.indices, self.values)} # type: ignore
@classmethod
def from_dict(cls, data: dict[int, float]) -> "SparseEmbedding":
@@ -28,7 +31,7 @@ class SparseEmbedding:
return cls(values=np.array(values), indices=np.array(indices))
class SparseTextEmbeddingBase(ModelManagement):
class SparseTextEmbeddingBase(ModelManagement[SparseModelDescription]):
def __init__(
self,
model_name: str,
@@ -81,5 +84,5 @@ class SparseTextEmbeddingBase(ModelManagement):
# This is model-specific, so that different models can have specialized implementations
if isinstance(query, str):
yield from self.embed([query], **kwargs)
if isinstance(query, Iterable):
else:
yield from self.embed(query, **kwargs)
+10 -4
View File
@@ -1,4 +1,5 @@
from typing import Any, Iterable, Optional, Sequence, Type, Union
from dataclasses import asdict
from fastembed.common import OnnxProvider
from fastembed.sparse.bm25 import Bm25
@@ -9,6 +10,7 @@ from fastembed.sparse.sparse_embedding_base import (
)
from fastembed.sparse.splade_pp import SpladePP
import warnings
from fastembed.common.model_description import SparseModelDescription
class SparseTextEmbedding(SparseTextEmbeddingBase):
@@ -38,9 +40,13 @@ class SparseTextEmbedding(SparseTextEmbeddingBase):
]
```
"""
result = []
return [asdict(model) for model in cls._list_supported_models()]
@classmethod
def _list_supported_models(cls) -> list[SparseModelDescription]:
result: list[SparseModelDescription] = []
for embedding in cls.EMBEDDINGS_REGISTRY:
result.extend(embedding.list_supported_models())
result.extend(embedding._list_supported_models())
return result
def __init__(
@@ -65,8 +71,8 @@ class SparseTextEmbedding(SparseTextEmbeddingBase):
model_name = "prithivida/Splade_PP_en_v1"
for EMBEDDING_MODEL_TYPE in self.EMBEDDINGS_REGISTRY:
supported_models = EMBEDDING_MODEL_TYPE.list_supported_models()
if any(model_name.lower() == model["model"].lower() for model in supported_models):
supported_models = EMBEDDING_MODEL_TYPE._list_supported_models()
if any(model_name.lower() == model.model.lower() for model in supported_models):
self.model = EMBEDDING_MODEL_TYPE(
model_name,
cache_dir,
+27 -31
View File
@@ -9,30 +9,27 @@ from fastembed.sparse.sparse_embedding_base import (
SparseTextEmbeddingBase,
)
from fastembed.text.onnx_text_model import OnnxTextModel, TextEmbeddingWorker
from fastembed.common.model_description import SparseModelDescription, ModelSource
supported_splade_models = [
{
"model": "prithivida/Splade_PP_en_v1",
"vocab_size": 30522,
"description": "Independent Implementation of SPLADE++ Model for English.",
"license": "apache-2.0",
"size_in_GB": 0.532,
"sources": {
"hf": "Qdrant/SPLADE_PP_en_v1",
},
"model_file": "model.onnx",
},
{
"model": "prithvida/Splade_PP_en_v1",
"vocab_size": 30522,
"description": "Independent Implementation of SPLADE++ Model for English.",
"license": "apache-2.0",
"size_in_GB": 0.532,
"sources": {
"hf": "Qdrant/SPLADE_PP_en_v1",
},
"model_file": "model.onnx",
},
supported_splade_models: list[SparseModelDescription] = [
SparseModelDescription(
model="prithivida/Splade_PP_en_v1",
vocab_size=30522,
description="Independent Implementation of SPLADE++ Model for English.",
license="apache-2.0",
size_in_GB=0.532,
sources=ModelSource(hf="Qdrant/Splade_PP_en_v1"),
model_file="model.onnx",
),
SparseModelDescription(
model="prithvida/Splade_PP_en_v1",
vocab_size=30522,
description="Independent Implementation of SPLADE++ Model for English.",
license="apache-2.0",
size_in_GB=0.532,
sources=ModelSource(hf="Qdrant/Splade_PP_en_v1"),
model_file="model.onnx",
),
]
@@ -55,11 +52,11 @@ class SpladePP(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
yield SparseEmbedding(values=scores, indices=indices)
@classmethod
def list_supported_models(cls) -> list[dict[str, Any]]:
def _list_supported_models(cls) -> list[SparseModelDescription]:
"""Lists the supported models.
Returns:
list[dict[str, Any]]: A list of dictionaries containing the model information.
list[SparseModelDescription]: A list of SparseModelDescription objects containing the model information.
"""
return supported_splade_models
@@ -106,15 +103,14 @@ class SpladePP(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
self.cuda = cuda
# This device_id will be used if we need to load model in current process
self.device_id: Optional[int] = None
if device_id is not None:
self.device_id = device_id
elif self.device_ids is not None:
self.device_id = self.device_ids[0]
else:
self.device_id = None
self.model_description = self._get_model_description(model_name)
self.cache_dir = define_cache_dir(cache_dir)
self.cache_dir = str(define_cache_dir(cache_dir))
self._model_dir = self.download_model(
self.model_description,
@@ -129,7 +125,7 @@ class SpladePP(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
def load_onnx_model(self) -> None:
self._load_onnx_model(
model_dir=self._model_dir,
model_file=self.model_description["model_file"],
model_file=self.model_description.model_file,
threads=self.threads,
providers=self.providers,
cuda=self.cuda,
@@ -171,11 +167,11 @@ class SpladePP(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
)
@classmethod
def _get_worker_class(cls) -> Type[TextEmbeddingWorker]:
def _get_worker_class(cls) -> Type[TextEmbeddingWorker[SparseEmbedding]]:
return SpladePPEmbeddingWorker
class SpladePPEmbeddingWorker(TextEmbeddingWorker):
class SpladePPEmbeddingWorker(TextEmbeddingWorker[SparseEmbedding]):
def init_embedding(self, model_name: str, cache_dir: str, **kwargs: Any) -> SpladePP:
return SpladePP(
model_name=model_name,
+20 -26
View File
@@ -1,47 +1,41 @@
from typing import Any, Iterable, Type
import numpy as np
from fastembed.common.types import NumpyArray
from fastembed.common.onnx_model import OnnxOutputContext
from fastembed.text.onnx_embedding import OnnxTextEmbedding, OnnxTextEmbeddingWorker
from fastembed.text.onnx_text_model import TextEmbeddingWorker
from fastembed.common.model_description import DenseModelDescription, ModelSource
supported_clip_models = [
{
"model": "Qdrant/clip-ViT-B-32-text",
"dim": 512,
"description": "Text embeddings, Multimodal (text&image), English, 77 input tokens truncation, Prefixes for queries/documents: not necessary, 2021 year",
"license": "mit",
"size_in_GB": 0.25,
"sources": {
"hf": "Qdrant/clip-ViT-B-32-text",
},
"model_file": "model.onnx",
},
supported_clip_models: list[DenseModelDescription] = [
DenseModelDescription(
model="Qdrant/clip-ViT-B-32-text",
dim=512,
description=(
"Text embeddings, Multimodal (text&image), English, 77 input tokens truncation, "
"Prefixes for queries/documents: not necessary, 2021 year"
),
license="mit",
size_in_GB=0.25,
sources=ModelSource(hf="Qdrant/clip-ViT-B-32-text"),
model_file="model.onnx",
),
]
class CLIPOnnxEmbedding(OnnxTextEmbedding):
supported_models = supported_clip_models
@classmethod
def _get_worker_class(cls) -> Type[TextEmbeddingWorker]:
def _get_worker_class(cls) -> Type[OnnxTextEmbeddingWorker]:
return CLIPEmbeddingWorker
@classmethod
def list_supported_models(cls) -> list[dict[str, Any]]:
def _list_supported_models(cls) -> list[DenseModelDescription]:
"""Lists the supported models.
Returns:
list[dict[str, Any]]: A list of dictionaries containing the model information.
list[DenseModelDescription]: A list of DenseModelDescription objects containing the model information.
"""
return cls.supported_models
return supported_clip_models
@classmethod
def add_custom_model(cls, model_info: dict[str, Any]):
cls.supported_models.append(model_info)
def _post_process_onnx_output(self, output: OnnxOutputContext) -> Iterable[np.ndarray]:
def _post_process_onnx_output(self, output: OnnxOutputContext) -> Iterable[NumpyArray]:
return output.model_output
+91
View File
@@ -0,0 +1,91 @@
from typing import Optional, Sequence, Any, Iterable
from dataclasses import dataclass
import numpy as np
from numpy.typing import NDArray
from fastembed.common import OnnxProvider
from fastembed.common.model_description import (
PoolingType,
DenseModelDescription,
)
from fastembed.common.onnx_model import OnnxOutputContext
from fastembed.common.types import NumpyArray
from fastembed.common.utils import normalize, mean_pooling
from fastembed.text.onnx_embedding import OnnxTextEmbedding
@dataclass(frozen=True)
class PostprocessingConfig:
pooling: PoolingType
normalization: bool
class CustomTextEmbedding(OnnxTextEmbedding):
SUPPORTED_MODELS: list[DenseModelDescription] = []
POSTPROCESSING_MAPPING: dict[str, PostprocessingConfig] = {}
def __init__(
self,
model_name: str,
cache_dir: Optional[str] = None,
threads: Optional[int] = None,
providers: Optional[Sequence[OnnxProvider]] = None,
cuda: bool = False,
device_ids: Optional[list[int]] = None,
lazy_load: bool = False,
device_id: Optional[int] = None,
specific_model_path: Optional[str] = None,
**kwargs: Any,
):
super().__init__(
model_name=model_name,
cache_dir=cache_dir,
threads=threads,
providers=providers,
cuda=cuda,
device_ids=device_ids,
lazy_load=lazy_load,
device_id=device_id,
specific_model_path=specific_model_path,
**kwargs,
)
self._pooling = self.POSTPROCESSING_MAPPING[model_name].pooling
self._normalization = self.POSTPROCESSING_MAPPING[model_name].normalization
@classmethod
def _list_supported_models(cls) -> list[DenseModelDescription]:
return cls.SUPPORTED_MODELS
def _post_process_onnx_output(self, output: OnnxOutputContext) -> Iterable[NumpyArray]:
return self._normalize(self._pool(output.model_output, output.attention_mask))
def _pool(
self, embeddings: NumpyArray, attention_mask: Optional[NDArray[np.int64]] = None
) -> NumpyArray:
if self._pooling == PoolingType.CLS:
return embeddings[:, 0]
if self._pooling == PoolingType.MEAN:
if attention_mask is None:
raise ValueError("attention_mask must be provided for mean pooling")
return mean_pooling(embeddings, attention_mask)
if self._pooling == PoolingType.DISABLED:
return embeddings
def _normalize(self, embeddings: NumpyArray) -> NumpyArray:
return normalize(embeddings) if self._normalization else embeddings
@classmethod
def add_model(
cls,
model_description: DenseModelDescription,
pooling: PoolingType,
normalization: bool,
) -> None:
cls.SUPPORTED_MODELS.append(model_description)
cls.POSTPROCESSING_MAPPING[model_description.model] = PostprocessingConfig(
pooling=pooling, normalization=normalization
)
+52 -44
View File
@@ -3,30 +3,33 @@ from typing import Any, Type, Iterable, Union, Optional
import numpy as np
from fastembed.common.onnx_model import OnnxOutputContext
from fastembed.common.types import NumpyArray
from fastembed.text.pooled_normalized_embedding import PooledNormalizedEmbedding
from fastembed.text.onnx_embedding import OnnxTextEmbeddingWorker
from fastembed.text.onnx_text_model import TextEmbeddingWorker
from fastembed.common.model_description import DenseModelDescription, ModelSource
supported_multitask_models = [
{
"model": "jinaai/jina-embeddings-v3",
"dim": 1024,
"tasks": {
supported_multitask_models: list[DenseModelDescription] = [
DenseModelDescription(
model="jinaai/jina-embeddings-v3",
dim=1024,
tasks={
"retrieval.query": 0,
"retrieval.passage": 1,
"separation": 2,
"classification": 3,
"text-matching": 4,
},
"description": "Multi-task unimodal (text) embedding model, multi-lingual (~100), 1024 tokens truncation, and 8192 sequence length. Prefixes for queries/documents: not necessary, 2024 year.",
"license": "cc-by-nc-4.0",
"size_in_GB": 2.29,
"sources": {
"hf": "jinaai/jina-embeddings-v3",
},
"model_file": "onnx/model.onnx",
"additional_files": ["onnx/model.onnx_data"],
},
description=(
"Multi-task unimodal (text) embedding model, multi-lingual (~100), "
"1024 tokens truncation, and 8192 sequence length. Prefixes for queries/documents: not necessary, 2024 year."
),
license="cc-by-nc-4.0",
size_in_GB=2.29,
sources=ModelSource(hf="jinaai/jina-embeddings-v3"),
model_file="onnx/model.onnx",
additional_files=["onnx/model.onnx_data"],
),
]
@@ -41,28 +44,30 @@ class Task(int, Enum):
class JinaEmbeddingV3(PooledNormalizedEmbedding):
PASSAGE_TASK = Task.RETRIEVAL_PASSAGE
QUERY_TASK = Task.RETRIEVAL_QUERY
supported_models = supported_multitask_models
def __init__(self, *args: Any, **kwargs: Any):
def __init__(self, *args: Any, task_id: Optional[int] = None, **kwargs: Any):
super().__init__(*args, **kwargs)
self._current_task_id = self.PASSAGE_TASK
self.default_task_id: Union[Task, int] = (
task_id if task_id is not None else self.PASSAGE_TASK
)
@classmethod
def _get_worker_class(cls) -> Type["TextEmbeddingWorker"]:
def _get_worker_class(cls) -> Type[OnnxTextEmbeddingWorker]:
return JinaEmbeddingV3Worker
@classmethod
def list_supported_models(cls) -> list[dict[str, Any]]:
return cls.supported_models
@classmethod
def add_custom_model(cls, model_info: dict[str, Any]):
cls.supported_models.append(model_info)
def _list_supported_models(cls) -> list[DenseModelDescription]:
return supported_multitask_models
def _preprocess_onnx_input(
self, onnx_input: dict[str, np.ndarray], **kwargs
) -> dict[str, np.ndarray]:
onnx_input["task_id"] = np.array(self._current_task_id, dtype=np.int64)
self,
onnx_input: dict[str, NumpyArray],
task_id: Optional[Union[int, Task]] = None,
**kwargs: Any,
) -> dict[str, NumpyArray]:
if task_id is None:
raise ValueError(f"task_id must be provided for JinaEmbeddingV3, got <{task_id}>")
onnx_input["task_id"] = np.array(task_id, dtype=np.int64)
return onnx_input
def embed(
@@ -70,20 +75,19 @@ class JinaEmbeddingV3(PooledNormalizedEmbedding):
documents: Union[str, Iterable[str]],
batch_size: int = 256,
parallel: Optional[int] = None,
task_id: int = PASSAGE_TASK,
**kwargs,
) -> Iterable[np.ndarray]:
self._current_task_id = task_id
kwargs["task_id"] = task_id
yield from super().embed(documents, batch_size, parallel, **kwargs)
task_id: Optional[int] = None,
**kwargs: Any,
) -> Iterable[NumpyArray]:
task_id = (
task_id if task_id is not None else self.default_task_id
) # required for multiprocessing
yield from super().embed(documents, batch_size, parallel, task_id=task_id, **kwargs)
def query_embed(self, query: Union[str, Iterable[str]], **kwargs) -> Iterable[np.ndarray]:
self._current_task_id = self.QUERY_TASK
yield from super().embed(query, **kwargs)
def query_embed(self, query: Union[str, Iterable[str]], **kwargs: Any) -> Iterable[NumpyArray]:
yield from super().embed(query, task_id=self.QUERY_TASK, **kwargs)
def passage_embed(self, texts: Iterable[str], **kwargs) -> Iterable[np.ndarray]:
self._current_task_id = self.PASSAGE_TASK
yield from super().embed(texts, **kwargs)
def passage_embed(self, texts: Iterable[str], **kwargs: Any) -> Iterable[NumpyArray]:
yield from super().embed(texts, task_id=self.PASSAGE_TASK, **kwargs)
class JinaEmbeddingV3Worker(OnnxTextEmbeddingWorker):
@@ -91,13 +95,17 @@ class JinaEmbeddingV3Worker(OnnxTextEmbeddingWorker):
self,
model_name: str,
cache_dir: str,
**kwargs,
**kwargs: Any,
) -> JinaEmbeddingV3:
model = JinaEmbeddingV3(
return JinaEmbeddingV3(
model_name=model_name,
cache_dir=cache_dir,
threads=1,
**kwargs,
)
model._current_task_id = kwargs["task_id"]
return model
def process(self, items: Iterable[tuple[int, Any]]) -> Iterable[tuple[int, OnnxOutputContext]]:
self.model: JinaEmbeddingV3 # mypy complaints `self.model` does not have `default_task_id`
for idx, batch in items:
onnx_output = self.model.onnx_embed(batch, task_id=self.model.default_task_id)
yield idx, onnx_output
+190 -183
View File
@@ -1,193 +1,200 @@
from typing import Any, Iterable, Optional, Sequence, Type, Union
import numpy as np
from fastembed.common import OnnxProvider
from fastembed.common.types import NumpyArray, OnnxProvider
from fastembed.common.onnx_model import OnnxOutputContext
from fastembed.common.utils import define_cache_dir, normalize
from fastembed.text.onnx_text_model import OnnxTextModel, TextEmbeddingWorker
from fastembed.text.text_embedding_base import TextEmbeddingBase
from fastembed.common.model_description import DenseModelDescription, ModelSource
supported_onnx_models = [
{
"model": "BAAI/bge-base-en",
"dim": 768,
"description": "Text embeddings, Unimodal (text), English, 512 input tokens truncation, Prefixes for queries/documents: necessary, 2023 year.",
"license": "mit",
"size_in_GB": 0.42,
"sources": {
"hf": "Qdrant/fast-bge-base-en",
"url": "https://storage.googleapis.com/qdrant-fastembed/fast-bge-base-en.tar.gz",
},
"model_file": "model_optimized.onnx",
},
{
"model": "BAAI/bge-base-en-v1.5",
"dim": 768,
"description": "Text embeddings, Unimodal (text), English, 512 input tokens truncation, Prefixes for queries/documents: not so necessary, 2023 year.",
"license": "mit",
"size_in_GB": 0.21,
"sources": {
"url": "https://storage.googleapis.com/qdrant-fastembed/fast-bge-base-en-v1.5.tar.gz",
"hf": "qdrant/bge-base-en-v1.5-onnx-q",
},
"model_file": "model_optimized.onnx",
},
{
"model": "BAAI/bge-large-en-v1.5",
"dim": 1024,
"description": "Text embeddings, Unimodal (text), English, 512 input tokens truncation, Prefixes for queries/documents: not so necessary, 2023 year.",
"license": "mit",
"size_in_GB": 1.20,
"sources": {
"hf": "qdrant/bge-large-en-v1.5-onnx",
},
"model_file": "model.onnx",
},
{
"model": "BAAI/bge-small-en",
"dim": 384,
"description": "Text embeddings, Unimodal (text), English, 512 input tokens truncation, Prefixes for queries/documents: necessary, 2023 year.",
"license": "mit",
"size_in_GB": 0.13,
"sources": {
"hf": "Qdrant/bge-small-en",
"url": "https://storage.googleapis.com/qdrant-fastembed/BAAI-bge-small-en.tar.gz",
},
"model_file": "model_optimized.onnx",
},
{
"model": "BAAI/bge-small-en-v1.5",
"dim": 384,
"description": "Text embeddings, Unimodal (text), English, 512 input tokens truncation, Prefixes for queries/documents: not so necessary, 2023 year.",
"license": "mit",
"size_in_GB": 0.067,
"sources": {
"hf": "qdrant/bge-small-en-v1.5-onnx-q",
},
"model_file": "model_optimized.onnx",
},
{
"model": "BAAI/bge-small-zh-v1.5",
"dim": 512,
"description": "Text embeddings, Unimodal (text), Chinese, 512 input tokens truncation, Prefixes for queries/documents: not so necessary, 2023 year.",
"license": "mit",
"size_in_GB": 0.09,
"sources": {
"hf": "Qdrant/bge-small-zh-v1.5",
"url": "https://storage.googleapis.com/qdrant-fastembed/fast-bge-small-zh-v1.5.tar.gz",
},
"model_file": "model_optimized.onnx",
},
{
"model": "thenlper/gte-large",
"dim": 1024,
"description": "Text embeddings, Unimodal (text), English, 512 input tokens truncation, Prefixes for queries/documents: not necessary, 2023 year.",
"license": "mit",
"size_in_GB": 1.20,
"sources": {
"hf": "qdrant/gte-large-onnx",
},
"model_file": "model.onnx",
},
{
"model": "mixedbread-ai/mxbai-embed-large-v1",
"dim": 1024,
"description": "Text embeddings, Unimodal (text), English, 512 input tokens truncation, Prefixes for queries/documents: necessary, 2024 year.",
"license": "apache-2.0",
"size_in_GB": 0.64,
"sources": {
"hf": "mixedbread-ai/mxbai-embed-large-v1",
},
"model_file": "onnx/model.onnx",
},
{
"model": "snowflake/snowflake-arctic-embed-xs",
"dim": 384,
"description": "Text embeddings, Unimodal (text), English, 512 input tokens truncation, Prefixes for queries/documents: necessary, 2024 year.",
"license": "apache-2.0",
"size_in_GB": 0.09,
"sources": {
"hf": "snowflake/snowflake-arctic-embed-xs",
},
"model_file": "onnx/model.onnx",
},
{
"model": "snowflake/snowflake-arctic-embed-s",
"dim": 384,
"description": "Text embeddings, Unimodal (text), English, 512 input tokens truncation, Prefixes for queries/documents: necessary, 2024 year.",
"license": "apache-2.0",
"size_in_GB": 0.13,
"sources": {
"hf": "snowflake/snowflake-arctic-embed-s",
},
"model_file": "onnx/model.onnx",
},
{
"model": "snowflake/snowflake-arctic-embed-m",
"dim": 768,
"description": "Text embeddings, Unimodal (text), English, 512 input tokens truncation, Prefixes for queries/documents: necessary, 2024 year.",
"license": "apache-2.0",
"size_in_GB": 0.43,
"sources": {
"hf": "Snowflake/snowflake-arctic-embed-m",
},
"model_file": "onnx/model.onnx",
},
{
"model": "snowflake/snowflake-arctic-embed-m-long",
"dim": 768,
"description": "Text embeddings, Unimodal (text), English, 2048 input tokens truncation, Prefixes for queries/documents: necessary, 2024 year.",
"license": "apache-2.0",
"size_in_GB": 0.54,
"sources": {
"hf": "snowflake/snowflake-arctic-embed-m-long",
},
"model_file": "onnx/model.onnx",
},
{
"model": "snowflake/snowflake-arctic-embed-l",
"dim": 1024,
"description": "Text embeddings, Unimodal (text), English, 512 input tokens truncation, Prefixes for queries/documents: necessary, 2024 year.",
"license": "apache-2.0",
"size_in_GB": 1.02,
"sources": {
"hf": "snowflake/snowflake-arctic-embed-l",
},
"model_file": "onnx/model.onnx",
},
{
"model": "jinaai/jina-clip-v1",
"dim": 768,
"description": "Text embeddings, Multimodal (text&image), English, Prefixes for queries/documents: not necessary, 2024 year",
"license": "apache-2.0",
"size_in_GB": 0.55,
"sources": {
"hf": "jinaai/jina-clip-v1",
},
"model_file": "onnx/text_model.onnx",
},
supported_onnx_models: list[DenseModelDescription] = [
DenseModelDescription(
model="BAAI/bge-base-en",
dim=768,
description=(
"Text embeddings, Unimodal (text), English, 512 input tokens truncation, "
"Prefixes for queries/documents: necessary, 2023 year."
),
license="mit",
size_in_GB=0.42,
sources=ModelSource(
hf="Qdrant/fast-bge-base-en",
url="https://storage.googleapis.com/qdrant-fastembed/fast-bge-base-en.tar.gz",
_deprecated_tar_struct=True,
),
model_file="model_optimized.onnx",
),
DenseModelDescription(
model="BAAI/bge-base-en-v1.5",
dim=768,
description=(
"Text embeddings, Unimodal (text), English, 512 input tokens truncation, "
"Prefixes for queries/documents: not so necessary, 2023 year."
),
license="mit",
size_in_GB=0.21,
sources=ModelSource(
hf="qdrant/bge-base-en-v1.5-onnx-q",
url="https://storage.googleapis.com/qdrant-fastembed/fast-bge-base-en-v1.5.tar.gz",
_deprecated_tar_struct=True,
),
model_file="model_optimized.onnx",
),
DenseModelDescription(
model="BAAI/bge-large-en-v1.5",
dim=1024,
description=(
"Text embeddings, Unimodal (text), English, 512 input tokens truncation, "
"Prefixes for queries/documents: not so necessary, 2023 year."
),
license="mit",
size_in_GB=1.20,
sources=ModelSource(hf="qdrant/bge-large-en-v1.5-onnx"),
model_file="model.onnx",
),
DenseModelDescription(
model="BAAI/bge-small-en",
dim=384,
description=(
"Text embeddings, Unimodal (text), English, 512 input tokens truncation, "
"Prefixes for queries/documents: necessary, 2023 year."
),
license="mit",
size_in_GB=0.13,
sources=ModelSource(
hf="Qdrant/bge-small-en",
url="https://storage.googleapis.com/qdrant-fastembed/BAAI-bge-small-en.tar.gz",
_deprecated_tar_struct=True,
),
model_file="model_optimized.onnx",
),
DenseModelDescription(
model="BAAI/bge-small-en-v1.5",
dim=384,
description=(
"Text embeddings, Unimodal (text), English, 512 input tokens truncation, "
"Prefixes for queries/documents: not so necessary, 2023 year."
),
license="mit",
size_in_GB=0.067,
sources=ModelSource(hf="qdrant/bge-small-en-v1.5-onnx-q"),
model_file="model_optimized.onnx",
),
DenseModelDescription(
model="BAAI/bge-small-zh-v1.5",
dim=512,
description=(
"Text embeddings, Unimodal (text), Chinese, 512 input tokens truncation, "
"Prefixes for queries/documents: not so necessary, 2023 year."
),
license="mit",
size_in_GB=0.09,
sources=ModelSource(
hf="Qdrant/bge-small-zh-v1.5",
url="https://storage.googleapis.com/qdrant-fastembed/fast-bge-small-zh-v1.5.tar.gz",
_deprecated_tar_struct=True,
),
model_file="model_optimized.onnx",
),
DenseModelDescription(
model="mixedbread-ai/mxbai-embed-large-v1",
dim=1024,
description=(
"Text embeddings, Unimodal (text), English, 512 input tokens truncation, "
"Prefixes for queries/documents: necessary, 2024 year."
),
license="apache-2.0",
size_in_GB=0.64,
sources=ModelSource(hf="mixedbread-ai/mxbai-embed-large-v1"),
model_file="onnx/model.onnx",
),
DenseModelDescription(
model="snowflake/snowflake-arctic-embed-xs",
dim=384,
description=(
"Text embeddings, Unimodal (text), English, 512 input tokens truncation, "
"Prefixes for queries/documents: necessary, 2024 year."
),
license="apache-2.0",
size_in_GB=0.09,
sources=ModelSource(hf="snowflake/snowflake-arctic-embed-xs"),
model_file="onnx/model.onnx",
),
DenseModelDescription(
model="snowflake/snowflake-arctic-embed-s",
dim=384,
description=(
"Text embeddings, Unimodal (text), English, 512 input tokens truncation, "
"Prefixes for queries/documents: necessary, 2024 year."
),
license="apache-2.0",
size_in_GB=0.13,
sources=ModelSource(hf="snowflake/snowflake-arctic-embed-s"),
model_file="onnx/model.onnx",
),
DenseModelDescription(
model="snowflake/snowflake-arctic-embed-m",
dim=768,
description=(
"Text embeddings, Unimodal (text), English, 512 input tokens truncation, "
"Prefixes for queries/documents: necessary, 2024 year."
),
license="apache-2.0",
size_in_GB=0.43,
sources=ModelSource(hf="Snowflake/snowflake-arctic-embed-m"),
model_file="onnx/model.onnx",
),
DenseModelDescription(
model="snowflake/snowflake-arctic-embed-m-long",
dim=768,
description=(
"Text embeddings, Unimodal (text), English, 2048 input tokens truncation, "
"Prefixes for queries/documents: necessary, 2024 year."
),
license="apache-2.0",
size_in_GB=0.54,
sources=ModelSource(hf="snowflake/snowflake-arctic-embed-m-long"),
model_file="onnx/model.onnx",
),
DenseModelDescription(
model="snowflake/snowflake-arctic-embed-l",
dim=1024,
description=(
"Text embeddings, Unimodal (text), English, 512 input tokens truncation, "
"Prefixes for queries/documents: necessary, 2024 year."
),
license="apache-2.0",
size_in_GB=1.02,
sources=ModelSource(hf="snowflake/snowflake-arctic-embed-l"),
model_file="onnx/model.onnx",
),
DenseModelDescription(
model="jinaai/jina-clip-v1",
dim=768,
description=(
"Text embeddings, Multimodal (text&image), English, Prefixes for queries/documents: "
"not necessary, 2024 year"
),
license="apache-2.0",
size_in_GB=0.55,
sources=ModelSource(hf="jinaai/jina-clip-v1"),
model_file="onnx/text_model.onnx",
),
]
class OnnxTextEmbedding(TextEmbeddingBase, OnnxTextModel[np.ndarray]):
class OnnxTextEmbedding(TextEmbeddingBase, OnnxTextModel[NumpyArray]):
"""Implementation of the Flag Embedding model."""
supported_models = supported_onnx_models
@classmethod
def list_supported_models(cls) -> list[dict[str, Any]]:
def _list_supported_models(cls) -> list[DenseModelDescription]:
"""
Lists the supported models.
Returns:
list[dict[str, Any]]: A list of dictionaries containing the model information.
list[DenseModelDescription]: A list of DenseModelDescription objects containing the model information.
"""
return cls.supported_models
@classmethod
def add_custom_model(cls, model_info: dict[str, Any]):
cls.supported_models.append(model_info)
return supported_onnx_models
def __init__(
self,
@@ -232,15 +239,14 @@ class OnnxTextEmbedding(TextEmbeddingBase, OnnxTextModel[np.ndarray]):
self.cuda = cuda
# This device_id will be used if we need to load model in current process
self.device_id: Optional[int] = None
if device_id is not None:
self.device_id = device_id
elif self.device_ids is not None:
self.device_id = self.device_ids[0]
else:
self.device_id = None
self.model_description = self._get_model_description(model_name)
self.cache_dir = define_cache_dir(cache_dir)
self.cache_dir = str(define_cache_dir(cache_dir))
self._model_dir = self.download_model(
self.model_description,
self.cache_dir,
@@ -257,7 +263,7 @@ class OnnxTextEmbedding(TextEmbeddingBase, OnnxTextModel[np.ndarray]):
batch_size: int = 256,
parallel: Optional[int] = None,
**kwargs: Any,
) -> Iterable[np.ndarray]:
) -> Iterable[NumpyArray]:
"""
Encode a list of documents into list of embeddings.
We use mean pooling with attention so that the model can handle variable-length inputs.
@@ -286,31 +292,32 @@ class OnnxTextEmbedding(TextEmbeddingBase, OnnxTextModel[np.ndarray]):
)
@classmethod
def _get_worker_class(cls) -> Type["TextEmbeddingWorker"]:
def _get_worker_class(cls) -> Type["TextEmbeddingWorker[NumpyArray]"]:
return OnnxTextEmbeddingWorker
def _preprocess_onnx_input(
self, onnx_input: dict[str, np.ndarray], **kwargs: Any
) -> dict[str, np.ndarray]:
self, onnx_input: dict[str, NumpyArray], **kwargs: Any
) -> dict[str, NumpyArray]:
"""
Preprocess the onnx input.
"""
return onnx_input
def _post_process_onnx_output(self, output: OnnxOutputContext) -> Iterable[np.ndarray]:
def _post_process_onnx_output(self, output: OnnxOutputContext) -> Iterable[NumpyArray]:
embeddings = output.model_output
if embeddings.ndim == 3: # (batch_size, seq_len, embedding_dim)
processed_embeddings = embeddings[:, 0]
elif embeddings.ndim == 2: # (batch_size, embedding_dim)
processed_embeddings = embeddings
else:
raise ValueError(f"Unsupported embedding shape: {embeddings.shape}")
return normalize(processed_embeddings).astype(np.float32)
return normalize(processed_embeddings)
def load_onnx_model(self) -> None:
self._load_onnx_model(
model_dir=self._model_dir,
model_file=self.model_description["model_file"],
model_file=self.model_description.model_file,
threads=self.threads,
providers=self.providers,
cuda=self.cuda,
@@ -318,7 +325,7 @@ class OnnxTextEmbedding(TextEmbeddingBase, OnnxTextModel[np.ndarray]):
)
class OnnxTextEmbeddingWorker(TextEmbeddingWorker):
class OnnxTextEmbeddingWorker(TextEmbeddingWorker[NumpyArray]):
def init_embedding(
self,
model_name: str,
+17 -17
View File
@@ -4,9 +4,10 @@ from pathlib import Path
from typing import Any, Iterable, Optional, Sequence, Type, Union
import numpy as np
from tokenizers import Encoding
from numpy.typing import NDArray
from tokenizers import Encoding, Tokenizer
from fastembed.common import OnnxProvider
from fastembed.common.types import NumpyArray, OnnxProvider
from fastembed.common.onnx_model import EmbeddingWorker, OnnxModel, OnnxOutputContext, T
from fastembed.common.preprocessor_utils import load_tokenizer
from fastembed.common.utils import iter_batch
@@ -17,20 +18,20 @@ class OnnxTextModel(OnnxModel[T]):
ONNX_OUTPUT_NAMES: Optional[list[str]] = None
@classmethod
def _get_worker_class(cls) -> Type["TextEmbeddingWorker"]:
def _get_worker_class(cls) -> Type["TextEmbeddingWorker[T]"]:
raise NotImplementedError("Subclasses must implement this method")
def _post_process_onnx_output(self, output: OnnxOutputContext) -> Iterable[T]:
raise NotImplementedError("Subclasses must implement this method")
def __init__(self):
def __init__(self) -> None:
super().__init__()
self.tokenizer = None
self.special_token_to_id = {}
self.tokenizer: Optional[Tokenizer] = None
self.special_token_to_id: dict[str, int] = {}
def _preprocess_onnx_input(
self, onnx_input: dict[str, np.ndarray], **kwargs: Any
) -> dict[str, np.ndarray]:
self, onnx_input: dict[str, NumpyArray], **kwargs: Any
) -> dict[str, Union[NumpyArray, NDArray[np.int64]]]:
"""
Preprocess the onnx input.
"""
@@ -44,7 +45,6 @@ class OnnxTextModel(OnnxModel[T]):
providers: Optional[Sequence[OnnxProvider]] = None,
cuda: bool = False,
device_id: Optional[int] = None,
specific_model_path: Optional[str] = None,
) -> None:
super()._load_onnx_model(
model_dir=model_dir,
@@ -60,7 +60,7 @@ class OnnxTextModel(OnnxModel[T]):
raise NotImplementedError("Subclasses must implement this method")
def tokenize(self, documents: list[str], **kwargs: Any) -> list[Encoding]:
return self.tokenizer.encode_batch(documents)
return self.tokenizer.encode_batch(documents) # type: ignore[union-attr]
def onnx_embed(
self,
@@ -70,8 +70,8 @@ class OnnxTextModel(OnnxModel[T]):
encoded = self.tokenize(documents, **kwargs)
input_ids = np.array([e.ids for e in encoded])
attention_mask = np.array([e.attention_mask for e in encoded])
input_names = {node.name for node in self.model.get_inputs()}
onnx_input = {
input_names = {node.name for node in self.model.get_inputs()} # type: ignore[union-attr]
onnx_input: dict[str, NumpyArray] = {
"input_ids": np.array(input_ids, dtype=np.int64),
}
if "attention_mask" in input_names:
@@ -82,7 +82,7 @@ class OnnxTextModel(OnnxModel[T]):
)
onnx_input = self._preprocess_onnx_input(onnx_input, **kwargs)
model_output = self.model.run(self.ONNX_OUTPUT_NAMES, onnx_input)
model_output = self.model.run(self.ONNX_OUTPUT_NAMES, onnx_input) # type: ignore[union-attr]
return OnnxOutputContext(
model_output=model_output[0],
attention_mask=onnx_input.get("attention_mask", attention_mask),
@@ -115,7 +115,7 @@ class OnnxTextModel(OnnxModel[T]):
if not hasattr(self, "model") or self.model is None:
self.load_onnx_model()
for batch in iter_batch(documents, batch_size):
yield from self._post_process_onnx_output(self.onnx_embed(batch))
yield from self._post_process_onnx_output(self.onnx_embed(batch, **kwargs))
else:
if parallel == 0:
parallel = os.cpu_count()
@@ -136,11 +136,11 @@ class OnnxTextModel(OnnxModel[T]):
start_method=start_method,
)
for batch in pool.ordered_map(iter_batch(documents, batch_size), **params):
yield from self._post_process_onnx_output(batch)
yield from self._post_process_onnx_output(batch) # type: ignore
class TextEmbeddingWorker(EmbeddingWorker):
def process(self, items: Iterable[tuple[int, Any]]) -> Iterable[tuple[int, Any]]:
class TextEmbeddingWorker(EmbeddingWorker[T]):
def process(self, items: Iterable[tuple[int, Any]]) -> Iterable[tuple[int, OnnxOutputContext]]:
for idx, batch in items:
onnx_output = self.model.onnx_embed(batch)
yield idx, onnx_output
+92 -91
View File
@@ -1,121 +1,122 @@
from typing import Any, Iterable, Type
import numpy as np
from numpy.typing import NDArray
from fastembed.common.types import NumpyArray
from fastembed.common.onnx_model import OnnxOutputContext
from fastembed.common.utils import mean_pooling
from fastembed.text.onnx_embedding import OnnxTextEmbedding, OnnxTextEmbeddingWorker
from fastembed.text.onnx_text_model import TextEmbeddingWorker
from fastembed.common.model_description import DenseModelDescription, ModelSource
supported_pooled_models = [
{
"model": "nomic-ai/nomic-embed-text-v1.5",
"dim": 768,
"description": "Text embeddings, Multimodal (text, image), English, 8192 input tokens truncation, Prefixes for queries/documents: necessary, 2024 year.",
"license": "apache-2.0",
"size_in_GB": 0.52,
"sources": {
"hf": "nomic-ai/nomic-embed-text-v1.5",
},
"model_file": "onnx/model.onnx",
},
{
"model": "nomic-ai/nomic-embed-text-v1.5-Q",
"dim": 768,
"description": "Text embeddings, Multimodal (text, image), English, 8192 input tokens truncation, Prefixes for queries/documents: necessary, 2024 year.",
"license": "apache-2.0",
"size_in_GB": 0.13,
"sources": {
"hf": "nomic-ai/nomic-embed-text-v1.5",
},
"model_file": "onnx/model_quantized.onnx",
},
{
"model": "nomic-ai/nomic-embed-text-v1",
"dim": 768,
"description": "Text embeddings, Multimodal (text, image), English, 8192 input tokens truncation, Prefixes for queries/documents: necessary, 2024 year.",
"license": "apache-2.0",
"size_in_GB": 0.52,
"sources": {
"hf": "nomic-ai/nomic-embed-text-v1",
},
"model_file": "onnx/model.onnx",
},
{
"model": "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
"dim": 384,
"description": "Text embeddings, Unimodal (text), Multilingual (~50 languages), 512 input tokens truncation, Prefixes for queries/documents: not necessary, 2019 year.",
"license": "apache-2.0",
"size_in_GB": 0.22,
"sources": {
"hf": "qdrant/paraphrase-multilingual-MiniLM-L12-v2-onnx-Q",
},
"model_file": "model_optimized.onnx",
},
{
"model": "sentence-transformers/paraphrase-multilingual-mpnet-base-v2",
"dim": 768,
"description": "Text embeddings, Unimodal (text), Multilingual (~50 languages), 384 input tokens truncation, Prefixes for queries/documents: not necessary, 2021 year.",
"license": "apache-2.0",
"size_in_GB": 1.00,
"sources": {
"hf": "xenova/paraphrase-multilingual-mpnet-base-v2",
},
"model_file": "onnx/model.onnx",
},
{
"model": "intfloat/multilingual-e5-large",
"dim": 1024,
"description": "Text embeddings, Unimodal (text), Multilingual (~100 languages), 512 input tokens truncation, Prefixes for queries/documents: necessary, 2024 year.",
"license": "mit",
"size_in_GB": 2.24,
"sources": {
"url": "https://storage.googleapis.com/qdrant-fastembed/fast-multilingual-e5-large.tar.gz",
"hf": "qdrant/multilingual-e5-large-onnx",
},
"model_file": "model.onnx",
"additional_files": ["model.onnx_data"],
},
supported_pooled_models: list[DenseModelDescription] = [
DenseModelDescription(
model="nomic-ai/nomic-embed-text-v1.5",
dim=768,
description=(
"Text embeddings, Multimodal (text, image), English, 8192 input tokens truncation, "
"Prefixes for queries/documents: necessary, 2024 year."
),
license="apache-2.0",
size_in_GB=0.52,
sources=ModelSource(hf="nomic-ai/nomic-embed-text-v1.5"),
model_file="onnx/model.onnx",
),
DenseModelDescription(
model="nomic-ai/nomic-embed-text-v1.5-Q",
dim=768,
description=(
"Text embeddings, Multimodal (text, image), English, 8192 input tokens truncation, "
"Prefixes for queries/documents: necessary, 2024 year."
),
license="apache-2.0",
size_in_GB=0.13,
sources=ModelSource(hf="nomic-ai/nomic-embed-text-v1.5"),
model_file="onnx/model_quantized.onnx",
),
DenseModelDescription(
model="nomic-ai/nomic-embed-text-v1",
dim=768,
description=(
"Text embeddings, Multimodal (text, image), English, 8192 input tokens truncation, "
"Prefixes for queries/documents: necessary, 2024 year."
),
license="apache-2.0",
size_in_GB=0.52,
sources=ModelSource(hf="nomic-ai/nomic-embed-text-v1"),
model_file="onnx/model.onnx",
),
DenseModelDescription(
model="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
dim=384,
description=(
"Text embeddings, Unimodal (text), Multilingual (~50 languages), 512 input tokens truncation, "
"Prefixes for queries/documents: not necessary, 2019 year."
),
license="apache-2.0",
size_in_GB=0.22,
sources=ModelSource(hf="qdrant/paraphrase-multilingual-MiniLM-L12-v2-onnx-Q"),
model_file="model_optimized.onnx",
),
DenseModelDescription(
model="sentence-transformers/paraphrase-multilingual-mpnet-base-v2",
dim=768,
description=(
"Text embeddings, Unimodal (text), Multilingual (~50 languages), 384 input tokens truncation, "
"Prefixes for queries/documents: not necessary, 2021 year."
),
license="apache-2.0",
size_in_GB=1.00,
sources=ModelSource(hf="xenova/paraphrase-multilingual-mpnet-base-v2"),
model_file="onnx/model.onnx",
),
DenseModelDescription(
model="intfloat/multilingual-e5-large",
dim=1024,
description=(
"Text embeddings, Unimodal (text), Multilingual (~100 languages), 512 input tokens truncation, "
"Prefixes for queries/documents: necessary, 2024 year."
),
license="mit",
size_in_GB=2.24,
sources=ModelSource(
hf="qdrant/multilingual-e5-large-onnx",
url="https://storage.googleapis.com/qdrant-fastembed/fast-multilingual-e5-large.tar.gz",
_deprecated_tar_struct=True,
),
model_file="model.onnx",
additional_files=["model.onnx_data"],
),
]
class PooledEmbedding(OnnxTextEmbedding):
supported_models = supported_pooled_models
@classmethod
def _get_worker_class(cls) -> Type[TextEmbeddingWorker]:
def _get_worker_class(cls) -> Type[OnnxTextEmbeddingWorker]:
return PooledEmbeddingWorker
@classmethod
def mean_pooling(cls, model_output: np.ndarray, attention_mask: np.ndarray) -> np.ndarray:
token_embeddings = model_output
input_mask_expanded = np.expand_dims(attention_mask, axis=-1)
input_mask_expanded = np.tile(input_mask_expanded, (1, 1, token_embeddings.shape[-1]))
input_mask_expanded = input_mask_expanded.astype(float)
sum_embeddings = np.sum(token_embeddings * input_mask_expanded, axis=1)
sum_mask = np.sum(input_mask_expanded, axis=1)
pooled_embeddings = sum_embeddings / np.maximum(sum_mask, 1e-9)
return pooled_embeddings
def mean_pooling(
cls, model_output: NumpyArray, attention_mask: NDArray[np.int64]
) -> NumpyArray:
return mean_pooling(model_output, attention_mask)
@classmethod
def list_supported_models(cls) -> list[dict[str, Any]]:
def _list_supported_models(cls) -> list[DenseModelDescription]:
"""Lists the supported models.
Returns:
list[dict[str, Any]]: A list of dictionaries containing the model information.
list[DenseModelDescription]: A list of DenseModelDescription objects containing the model information.
"""
return cls.supported_models
return supported_pooled_models
@classmethod
def add_custom_model(cls, model_info: dict[str, Any]):
cls.supported_models.append(model_info)
def _post_process_onnx_output(self, output: OnnxOutputContext) -> Iterable[np.ndarray]:
def _post_process_onnx_output(self, output: OnnxOutputContext) -> Iterable[NumpyArray]:
if output.attention_mask is None:
raise ValueError("attention_mask must be provided for document post-processing")
embeddings = output.model_output
attn_mask = output.attention_mask
return self.mean_pooling(embeddings, attn_mask).astype(np.float32)
return self.mean_pooling(embeddings, attn_mask)
class PooledEmbeddingWorker(OnnxTextEmbeddingWorker):
+121 -90
View File
@@ -1,119 +1,150 @@
from typing import Any, Iterable, Type
import numpy as np
from fastembed.common.types import NumpyArray
from fastembed.common.onnx_model import OnnxOutputContext
from fastembed.common.utils import normalize
from fastembed.text.onnx_embedding import OnnxTextEmbedding, OnnxTextEmbeddingWorker
from fastembed.text.onnx_text_model import TextEmbeddingWorker
from fastembed.text.pooled_embedding import PooledEmbedding
from fastembed.common.model_description import DenseModelDescription, ModelSource
supported_pooled_normalized_models = [
{
"model": "sentence-transformers/all-MiniLM-L6-v2",
"dim": 384,
"description": "Text embeddings, Unimodal (text), English, 256 input tokens truncation, Prefixes for queries/documents: not necessary, 2021 year.",
"license": "apache-2.0",
"size_in_GB": 0.09,
"sources": {
"url": "https://storage.googleapis.com/qdrant-fastembed/sentence-transformers-all-MiniLM-L6-v2.tar.gz",
"hf": "qdrant/all-MiniLM-L6-v2-onnx",
},
"model_file": "model.onnx",
},
{
"model": "jinaai/jina-embeddings-v2-base-en",
"dim": 768,
"description": "Text embeddings, Unimodal (text), English, 8192 input tokens truncation, Prefixes for queries/documents: not necessary, 2023 year.",
"license": "apache-2.0",
"size_in_GB": 0.52,
"sources": {"hf": "xenova/jina-embeddings-v2-base-en"},
"model_file": "onnx/model.onnx",
},
{
"model": "jinaai/jina-embeddings-v2-small-en",
"dim": 512,
"description": "Text embeddings, Unimodal (text), English, 8192 input tokens truncation, Prefixes for queries/documents: not necessary, 2023 year.",
"license": "apache-2.0",
"size_in_GB": 0.12,
"sources": {"hf": "xenova/jina-embeddings-v2-small-en"},
"model_file": "onnx/model.onnx",
},
{
"model": "jinaai/jina-embeddings-v2-base-de",
"dim": 768,
"description": "Text embeddings, Unimodal (text), Multilingual (German, English), 8192 input tokens truncation, Prefixes for queries/documents: not necessary, 2024 year.",
"license": "apache-2.0",
"size_in_GB": 0.32,
"sources": {"hf": "jinaai/jina-embeddings-v2-base-de"},
"model_file": "onnx/model_fp16.onnx",
},
{
"model": "jinaai/jina-embeddings-v2-base-code",
"dim": 768,
"description": "Text embeddings, Unimodal (text), Multilingual (English, 30 programming languages), 8192 input tokens truncation, Prefixes for queries/documents: not necessary, 2024 year.",
"license": "apache-2.0",
"size_in_GB": 0.64,
"sources": {"hf": "jinaai/jina-embeddings-v2-base-code"},
"model_file": "onnx/model.onnx",
},
{
"model": "jinaai/jina-embeddings-v2-base-zh",
"dim": 768,
"description": "Text embeddings, Unimodal (text), supports mixed Chinese-English input text, 8192 input tokens truncation, Prefixes for queries/documents: not necessary, 2024 year.",
"license": "apache-2.0",
"size_in_GB": 0.64,
"sources": {"hf": "jinaai/jina-embeddings-v2-base-zh"},
"model_file": "onnx/model.onnx",
},
{
"model": "jinaai/jina-embeddings-v2-base-es",
"dim": 768,
"description": "Text embeddings, Unimodal (text), supports mixed Spanish-English input text, 8192 input tokens truncation, Prefixes for queries/documents: not necessary, 2024 year.",
"license": "apache-2.0",
"size_in_GB": 0.64,
"sources": {"hf": "jinaai/jina-embeddings-v2-base-es"},
"model_file": "onnx/model.onnx",
},
{
"model": "thenlper/gte-base",
"dim": 768,
"description": "General text embeddings, Unimodal (text), supports English only input text, 512 input tokens truncation, Prefixes for queries/documents: not necessary, 2024 year.",
"license": "mit",
"size_in_GB": 0.44,
"sources": {"hf": "thenlper/gte-base"},
"model_file": "onnx/model.onnx",
},
supported_pooled_normalized_models: list[DenseModelDescription] = [
DenseModelDescription(
model="sentence-transformers/all-MiniLM-L6-v2",
dim=384,
description=(
"Text embeddings, Unimodal (text), English, 256 input tokens truncation, "
"Prefixes for queries/documents: not necessary, 2021 year."
),
license="apache-2.0",
size_in_GB=0.09,
sources=ModelSource(
url="https://storage.googleapis.com/qdrant-fastembed/sentence-transformers-all-MiniLM-L6-v2.tar.gz",
hf="qdrant/all-MiniLM-L6-v2-onnx",
_deprecated_tar_struct=True,
),
model_file="model.onnx",
),
DenseModelDescription(
model="jinaai/jina-embeddings-v2-base-en",
dim=768,
description=(
"Text embeddings, Unimodal (text), English, 8192 input tokens truncation, "
"Prefixes for queries/documents: not necessary, 2023 year."
),
license="apache-2.0",
size_in_GB=0.52,
sources=ModelSource(hf="xenova/jina-embeddings-v2-base-en"),
model_file="onnx/model.onnx",
),
DenseModelDescription(
model="jinaai/jina-embeddings-v2-small-en",
dim=512,
description=(
"Text embeddings, Unimodal (text), English, 8192 input tokens truncation, "
"Prefixes for queries/documents: not necessary, 2023 year."
),
license="apache-2.0",
size_in_GB=0.12,
sources=ModelSource(hf="xenova/jina-embeddings-v2-small-en"),
model_file="onnx/model.onnx",
),
DenseModelDescription(
model="jinaai/jina-embeddings-v2-base-de",
dim=768,
description=(
"Text embeddings, Unimodal (text), Multilingual (German, English), 8192 input tokens truncation, "
"Prefixes for queries/documents: not necessary, 2024 year."
),
license="apache-2.0",
size_in_GB=0.32,
sources=ModelSource(hf="jinaai/jina-embeddings-v2-base-de"),
model_file="onnx/model_fp16.onnx",
),
DenseModelDescription(
model="jinaai/jina-embeddings-v2-base-code",
dim=768,
description=(
"Text embeddings, Unimodal (text), Multilingual (English, 30 programming languages), "
"8192 input tokens truncation, Prefixes for queries/documents: not necessary, 2024 year."
),
license="apache-2.0",
size_in_GB=0.64,
sources=ModelSource(hf="jinaai/jina-embeddings-v2-base-code"),
model_file="onnx/model.onnx",
),
DenseModelDescription(
model="jinaai/jina-embeddings-v2-base-zh",
dim=768,
description=(
"Text embeddings, Unimodal (text), supports mixed Chinese-English input text, "
"8192 input tokens truncation, Prefixes for queries/documents: not necessary, 2024 year."
),
license="apache-2.0",
size_in_GB=0.64,
sources=ModelSource(hf="jinaai/jina-embeddings-v2-base-zh"),
model_file="onnx/model.onnx",
),
DenseModelDescription(
model="jinaai/jina-embeddings-v2-base-es",
dim=768,
description=(
"Text embeddings, Unimodal (text), supports mixed Spanish-English input text, "
"8192 input tokens truncation, Prefixes for queries/documents: not necessary, 2024 year."
),
license="apache-2.0",
size_in_GB=0.64,
sources=ModelSource(hf="jinaai/jina-embeddings-v2-base-es"),
model_file="onnx/model.onnx",
),
DenseModelDescription(
model="thenlper/gte-base",
dim=768,
description=(
"General text embeddings, Unimodal (text), supports English only input text, "
"512 input tokens truncation, Prefixes for queries/documents: not necessary, 2024 year."
),
license="mit",
size_in_GB=0.44,
sources=ModelSource(hf="thenlper/gte-base"),
model_file="onnx/model.onnx",
),
DenseModelDescription(
model="thenlper/gte-large",
dim=1024,
description=(
"Text embeddings, Unimodal (text), English, 512 input tokens truncation, "
"Prefixes for queries/documents: not necessary, 2023 year."
),
license="mit",
size_in_GB=1.20,
sources=ModelSource(hf="qdrant/gte-large-onnx"),
model_file="model.onnx",
),
]
class PooledNormalizedEmbedding(PooledEmbedding):
supported_models = supported_pooled_normalized_models
@classmethod
def _get_worker_class(cls) -> Type[TextEmbeddingWorker]:
def _get_worker_class(cls) -> Type[OnnxTextEmbeddingWorker]:
return PooledNormalizedEmbeddingWorker
@classmethod
def list_supported_models(cls) -> list[dict[str, Any]]:
def _list_supported_models(cls) -> list[DenseModelDescription]:
"""Lists the supported models.
Returns:
list[dict[str, Any]]: A list of dictionaries containing the model information.
list[DenseModelDescription]: A list of DenseModelDescription objects containing the model information.
"""
return cls.supported_models
return supported_pooled_normalized_models
@classmethod
def add_custom_model(cls, model_info: dict[str, Any]):
cls.supported_models.append(model_info)
def _post_process_onnx_output(self, output: OnnxOutputContext) -> Iterable[np.ndarray]:
def _post_process_onnx_output(self, output: OnnxOutputContext) -> Iterable[NumpyArray]:
if output.attention_mask is None:
raise ValueError("attention_mask must be provided for document post-processing")
embeddings = output.model_output
attn_mask = output.attention_mask
return normalize(self.mean_pooling(embeddings, attn_mask)).astype(np.float32)
return normalize(self.mean_pooling(embeddings, attn_mask))
class PooledNormalizedEmbeddingWorker(OnnxTextEmbeddingWorker):
+58 -69
View File
@@ -1,14 +1,16 @@
import warnings
from typing import Any, Iterable, Optional, Sequence, Type, Union
from dataclasses import asdict
import numpy as np
from fastembed.common import OnnxProvider
from fastembed.common.types import NumpyArray, OnnxProvider
from fastembed.text.clip_embedding import CLIPOnnxEmbedding
from fastembed.text.custom_text_embedding import CustomTextEmbedding
from fastembed.text.pooled_normalized_embedding import PooledNormalizedEmbedding
from fastembed.text.pooled_embedding import PooledEmbedding
from fastembed.text.multitask_embedding import JinaEmbeddingV3
from fastembed.text.onnx_embedding import OnnxTextEmbedding
from fastembed.text.text_embedding_base import TextEmbeddingBase
from fastembed.common.model_description import DenseModelDescription, ModelSource, PoolingType
class TextEmbedding(TextEmbeddingBase):
@@ -18,69 +20,61 @@ class TextEmbedding(TextEmbeddingBase):
PooledNormalizedEmbedding,
PooledEmbedding,
JinaEmbeddingV3,
CustomTextEmbedding,
]
@classmethod
def list_supported_models(cls) -> list[dict[str, Any]]:
"""
Lists the supported models.
"""Lists the supported models.
Returns:
list[dict[str, Any]]: A list of dictionaries containing the model information.
Example:
```
[
{
"model": "intfloat/multilingual-e5-large",
"dim": 1024,
"description": "Multilingual model, e5-large. Recommend using this model for non-English languages",
"license": "mit",
"size_in_GB": 2.24,
"sources": {
"gcp": "https://storage.googleapis.com/qdrant-fastembed/fast-multilingual-e5-large.tar.gz",
"hf": "qdrant/multilingual-e5-large-onnx",
}
}
]
```
"""
result = []
return [asdict(model) for model in cls._list_supported_models()]
@classmethod
def _list_supported_models(cls) -> list[DenseModelDescription]:
result: list[DenseModelDescription] = []
for embedding in cls.EMBEDDINGS_REGISTRY:
result.extend(embedding.list_supported_models())
result.extend(embedding._list_supported_models())
return result
@classmethod
def add_custom_model(
cls, model_info: dict[str, Any], mean_pooling: bool = True, normalization: bool = False
cls,
model: str,
pooling: PoolingType,
normalization: bool,
sources: ModelSource,
dim: int,
model_file: str = "onnx/model.onnx",
description: str = "",
license: str = "",
size_in_gb: float = 0.0,
additional_files: Optional[list[str]] = None,
) -> None:
"""
Register a custom model so that TextEmbedding(...) can find it later.
registered_models = cls._list_supported_models()
for registered_model in registered_models:
if model == registered_model.model:
raise ValueError(
f"Model {model} is already registered in TextEmbedding, if you still want to add this model, "
f"please use another model name"
)
Args:
model_info: Dictionary describing the model, e.g.:
{
"model": "alibaba/blablabla",
"dim": 512,
"description": "...",
"license": "apache-2.0",
"size_in_GB": 1.23,
"sources": { ... } # optional
}
mean_pooling: apply mean_pooling or not.
normalization: apply normalization or not.
Returns:
None
"""
if mean_pooling and not normalization:
PooledEmbedding.add_custom_model(model_info)
elif mean_pooling and normalization:
PooledNormalizedEmbedding.add_custom_model(model_info)
elif "clip" in model_info["model"].lower():
CLIPOnnxEmbedding.add_custom_model(model_info)
else:
OnnxTextEmbedding.add_custom_model(model_info)
CustomTextEmbedding.add_model(
DenseModelDescription(
model=model,
sources=sources,
dim=dim,
model_file=model_file,
description=description,
license=license,
size_in_GB=size_in_gb,
additional_files=additional_files or [],
),
pooling=pooling,
normalization=normalization,
)
def __init__(
self,
@@ -96,32 +90,27 @@ class TextEmbedding(TextEmbeddingBase):
super().__init__(model_name, cache_dir, threads, **kwargs)
if model_name == "nomic-ai/nomic-embed-text-v1.5-Q":
warnings.warn(
"The model 'nomic-ai/nomic-embed-text-v1.5-Q' has been updated on HuggingFace. "
"Please review the latest documentation and release notes to ensure compatibility with your workflow. ",
UserWarning,
stacklevel=2,
)
if model_name == "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2":
warnings.warn(
"The model 'sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2' has been updated to "
"include a mean pooling layer. Please ensure your usage aligns with the new functionality. "
"Support for the previous version without mean pooling will be removed as of version 0.5.2.",
"The model 'nomic-ai/nomic-embed-text-v1.5-Q' has been updated on HuggingFace. Please review "
"the latest documentation on HF and release notes to ensure compatibility with your workflow. ",
UserWarning,
stacklevel=2,
)
if model_name in {
"sentence-transformers/paraphrase-multilingual-mpnet-base-v2",
"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
"thenlper/gte-large",
"intfloat/multilingual-e5-large",
"sentence-transformers/paraphrase-multilingual-mpnet-base-v2",
}:
warnings.warn(
f"{model_name} has been updated as of fastembed 0.5.2, outputs are now average pooled.",
f"The model {model_name} now uses mean pooling instead of CLS embedding. "
f"In order to preserve the previous behaviour, consider either pinning fastembed version to 0.5.1 or "
"using `add_custom_model` functionality.",
UserWarning,
stacklevel=2,
)
for EMBEDDING_MODEL_TYPE in self.EMBEDDINGS_REGISTRY:
supported_models = EMBEDDING_MODEL_TYPE.list_supported_models()
if any(model_name.lower() == model["model"].lower() for model in supported_models):
supported_models = EMBEDDING_MODEL_TYPE._list_supported_models()
if any(model_name.lower() == model.model.lower() for model in supported_models):
self.model = EMBEDDING_MODEL_TYPE(
model_name=model_name,
cache_dir=cache_dir,
@@ -145,7 +134,7 @@ class TextEmbedding(TextEmbeddingBase):
batch_size: int = 256,
parallel: Optional[int] = None,
**kwargs: Any,
) -> Iterable[np.ndarray]:
) -> Iterable[NumpyArray]:
"""
Encode a list of documents into list of embeddings.
We use mean pooling with attention so that the model can handle variable-length inputs.
@@ -163,7 +152,7 @@ class TextEmbedding(TextEmbeddingBase):
"""
yield from self.model.embed(documents, batch_size, parallel, **kwargs)
def query_embed(self, query: Union[str, Iterable[str]], **kwargs: Any) -> Iterable[np.ndarray]:
def query_embed(self, query: Union[str, Iterable[str]], **kwargs: Any) -> Iterable[NumpyArray]:
"""
Embeds queries
@@ -171,12 +160,12 @@ class TextEmbedding(TextEmbeddingBase):
query (Union[str, Iterable[str]]): The query to embed, or an iterable e.g. list of queries.
Returns:
Iterable[np.ndarray]: The embeddings.
Iterable[NumpyArray]: The embeddings.
"""
# This is model-specific, so that different models can have specialized implementations
yield from self.model.query_embed(query, **kwargs)
def passage_embed(self, texts: Iterable[str], **kwargs: Any) -> Iterable[np.ndarray]:
def passage_embed(self, texts: Iterable[str], **kwargs: Any) -> Iterable[NumpyArray]:
"""
Embeds a list of text passages into a list of embeddings.
+9 -10
View File
@@ -1,11 +1,11 @@
from typing import Iterable, Optional, Union, Any
import numpy as np
from fastembed.common.model_description import DenseModelDescription
from fastembed.common.types import NumpyArray
from fastembed.common.model_management import ModelManagement
class TextEmbeddingBase(ModelManagement):
class TextEmbeddingBase(ModelManagement[DenseModelDescription]):
def __init__(
self,
model_name: str,
@@ -24,10 +24,10 @@ class TextEmbeddingBase(ModelManagement):
batch_size: int = 256,
parallel: Optional[int] = None,
**kwargs: Any,
) -> Iterable[np.ndarray]:
) -> Iterable[NumpyArray]:
raise NotImplementedError()
def passage_embed(self, texts: Iterable[str], **kwargs: Any) -> Iterable[np.ndarray]:
def passage_embed(self, texts: Iterable[str], **kwargs: Any) -> Iterable[NumpyArray]:
"""
Embeds a list of text passages into a list of embeddings.
@@ -36,14 +36,13 @@ class TextEmbeddingBase(ModelManagement):
**kwargs: Additional keyword argument to pass to the embed method.
Yields:
Iterable[np.ndarray]: The embeddings.
Iterable[NumpyArray]: The embeddings.
"""
# This is model-specific, so that different models can have specialized implementations
yield from self.embed(texts, **kwargs)
def query_embed(self, query: Union[str, Iterable[str]], **kwargs: Any) -> Iterable[np.ndarray]:
def query_embed(self, query: Union[str, Iterable[str]], **kwargs: Any) -> Iterable[NumpyArray]:
"""
Embeds queries
@@ -51,11 +50,11 @@ class TextEmbeddingBase(ModelManagement):
query (Union[str, Iterable[str]]): The query to embed, or an iterable e.g. list of queries.
Returns:
Iterable[np.ndarray]: The embeddings.
Iterable[NumpyArray]: The embeddings.
"""
# This is model-specific, so that different models can have specialized implementations
if isinstance(query, str):
yield from self.embed([query], **kwargs)
if isinstance(query, Iterable):
else:
yield from self.embed(query, **kwargs)
+5 -5
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "fastembed"
version = "0.5.1"
version = "0.6.1"
description = "Fast, light, accurate library built for retrieval embedding generation"
authors = ["Qdrant Team <info@qdrant.tech>", "NirantK <nirant.bits@gmail.com>"]
license = "Apache License"
@@ -13,15 +13,15 @@ keywords = ["vector", "embedding", "neural", "search", "qdrant", "sentence-trans
[tool.poetry.dependencies]
python = ">=3.9.0"
numpy = [
{ version = ">=1.21,<2.1.0", python = "<3.10" },
{ version = ">=1.21", python = ">=3.10,<3.12" },
{ version = ">=1.26", python = ">=3.12,<3.13" },
{ version = ">=2.1.0", python = ">=3.13" }
{ version = ">=2.1.0", python = ">=3.13" },
{ version = ">=1.21,<2.1.0", python = "<3.10" },
]
onnxruntime = [
{ version = ">1.20.0", python = ">=3.13" },
{ version = ">=1.17.0,<1.20.0", python = "<3.10" },
{ version = ">=1.17.0,!=1.20.0", python = ">=3.10,<3.13" },
{ version = ">1.20.0", python = ">=3.13" }
]
tqdm = "^4.66"
requests = "^2.31"
@@ -29,7 +29,7 @@ tokenizers = ">=0.15,<1.0"
huggingface-hub = ">=0.20,<1.0"
loguru = "^0.7.2"
pillow = ">=10.3.0,<12.0.0"
mmh3 = "^4.1.0"
mmh3 = ">=4.1.0,<6.0.0"
py-rust-stemmers = "^0.1.0"
[tool.poetry.group.test.dependencies]
-115
View File
@@ -1,115 +0,0 @@
import os
import numpy as np
import pytest
from fastembed.text.text_embedding import TextEmbedding
from tests.utils import delete_model_cache
canonical_vectors = [
{
"model": "intfloat/multilingual-e5-small",
"mean_pooling": True,
"normalization": True,
"canonical_vector": [3.1317e-02, 3.0939e-02, -3.5117e-02, -6.7274e-02, 8.5084e-02],
},
{
"model": "intfloat/multilingual-e5-small",
"mean_pooling": True,
"normalization": False,
"canonical_vector": [1.4604e-01, 1.4428e-01, -1.6376e-01, -3.1372e-01, 3.9677e-01],
},
{
"model": "mixedbread-ai/mxbai-embed-xsmall-v1",
"mean_pooling": False,
"normalization": False,
"canonical_vector": [
2.49407589e-02,
1.00189969e-02,
1.07807154e-02,
3.63860987e-02,
-2.27128249e-02,
],
},
]
DIMENSIONS = {
"intfloat/multilingual-e5-small": 384,
"mixedbread-ai/mxbai-embed-xsmall-v1": 384,
}
SOURCES = {
"intfloat/multilingual-e5-small": "intfloat/multilingual-e5-small",
"mixedbread-ai/mxbai-embed-xsmall-v1": "mixedbread-ai/mxbai-embed-xsmall-v1",
}
@pytest.mark.parametrize("scenario", canonical_vectors)
def test_add_custom_model_variations(scenario):
is_ci = bool(os.getenv("CI", False))
base_model_name = scenario["model"]
mean_pooling = scenario["mean_pooling"]
normalization = scenario["normalization"]
cv = np.array(scenario["canonical_vector"], dtype=np.float32)
backup_supported_models = {}
for embedding_cls in TextEmbedding.EMBEDDINGS_REGISTRY:
backup_supported_models[embedding_cls] = embedding_cls.list_supported_models().copy()
suffixes = []
suffixes.append("mean" if mean_pooling else "no-mean")
suffixes.append("norm" if normalization else "no-norm")
suffix_str = "-".join(suffixes)
custom_model_name = f"{base_model_name}-{suffix_str}"
dim = DIMENSIONS[base_model_name]
hf_source = SOURCES[base_model_name]
model_info = {
"model": custom_model_name,
"dim": dim,
"description": f"{base_model_name} with {suffix_str}",
"license": "mit",
"size_in_GB": 0.13,
"sources": {
"hf": hf_source,
},
"model_file": "onnx/model.onnx",
"additional_files": [],
}
if is_ci and model_info["size_in_GB"] > 1.0:
pytest.skip(
f"Skipping {custom_model_name} on CI due to size_in_GB={model_info['size_in_GB']}"
)
try:
TextEmbedding.add_custom_model(
model_info=model_info, mean_pooling=mean_pooling, normalization=normalization
)
model = TextEmbedding(model_name=custom_model_name)
docs = ["hello world", "flag embedding"]
embeddings = list(model.embed(docs))
embeddings = np.stack(embeddings, axis=0)
assert embeddings.shape == (
2,
dim,
), f"Expected shape (2, {dim}) for {custom_model_name}, but got {embeddings.shape}"
num_compare_dims = cv.shape[0]
assert np.allclose(
embeddings[0, :num_compare_dims], cv, atol=1e-3
), f"Embedding mismatch for {custom_model_name} (first {num_compare_dims} dims)."
assert not np.allclose(embeddings[0, :], 0.0), "Embedding should not be all zeros."
if is_ci:
delete_model_cache(model.model._model_dir)
finally:
for embedding_cls, old_list in backup_supported_models.items():
embedding_cls.supported_models = old_list
+30
View File
@@ -0,0 +1,30 @@
from fastembed import (
TextEmbedding,
SparseTextEmbedding,
ImageEmbedding,
LateInteractionMultimodalEmbedding,
LateInteractionTextEmbedding,
)
def test_text_list_supported_models():
for model_type in [
TextEmbedding,
SparseTextEmbedding,
ImageEmbedding,
LateInteractionMultimodalEmbedding,
LateInteractionTextEmbedding,
]:
supported_models = model_type.list_supported_models()
assert isinstance(supported_models, list)
description = supported_models[0]
assert isinstance(description, dict)
assert "model" in description and description["model"]
if model_type != SparseTextEmbedding:
assert "dim" in description and description["dim"]
assert "license" in description and description["license"]
assert "size_in_GB" in description and description["size_in_GB"]
assert "model_file" in description and description["model_file"]
assert "sources" in description and description["sources"]
assert "hf" in description["sources"] or "url" in description["sources"]
+229
View File
@@ -0,0 +1,229 @@
import itertools
import os
import numpy as np
import pytest
from fastembed.common.model_description import (
PoolingType,
ModelSource,
DenseModelDescription,
BaseModelDescription,
)
from fastembed.common.onnx_model import OnnxOutputContext
from fastembed.common.utils import normalize, mean_pooling
from fastembed.text.custom_text_embedding import CustomTextEmbedding, PostprocessingConfig
from fastembed.rerank.cross_encoder.custom_text_cross_encoder import CustomTextCrossEncoder
from fastembed.rerank.cross_encoder import TextCrossEncoder
from fastembed.text.text_embedding import TextEmbedding
from tests.utils import delete_model_cache
@pytest.fixture(autouse=True)
def restore_custom_models_fixture():
CustomTextEmbedding.SUPPORTED_MODELS = []
CustomTextCrossEncoder.SUPPORTED_MODELS = []
yield
CustomTextEmbedding.SUPPORTED_MODELS = []
CustomTextCrossEncoder.SUPPORTED_MODELS = []
def test_text_custom_model():
is_ci = os.getenv("CI")
custom_model_name = "intfloat/multilingual-e5-small"
canonical_vector = np.array(
[3.1317e-02, 3.0939e-02, -3.5117e-02, -6.7274e-02, 8.5084e-02], dtype=np.float32
)
pooling = PoolingType.MEAN
normalization = True
dim = 384
size_in_gb = 0.47
source = ModelSource(hf=custom_model_name)
TextEmbedding.add_custom_model(
custom_model_name,
pooling=pooling,
normalization=normalization,
sources=source,
dim=dim,
size_in_gb=size_in_gb,
)
assert CustomTextEmbedding.SUPPORTED_MODELS[0] == DenseModelDescription(
model=custom_model_name,
sources=source,
model_file="onnx/model.onnx",
description="",
license="",
size_in_GB=size_in_gb,
additional_files=[],
dim=dim,
tasks={},
)
assert CustomTextEmbedding.POSTPROCESSING_MAPPING[custom_model_name] == PostprocessingConfig(
pooling=pooling, normalization=normalization
)
model = TextEmbedding(custom_model_name)
docs = ["hello world", "flag embedding"]
embeddings = list(model.embed(docs))
embeddings = np.stack(embeddings, axis=0)
assert embeddings.shape == (2, dim)
assert np.allclose(embeddings[0, : canonical_vector.shape[0]], canonical_vector, atol=1e-3)
if is_ci:
delete_model_cache(model.model._model_dir)
def test_cross_encoder_custom_model():
is_ci = os.getenv("CI")
custom_model_name = "Xenova/ms-marco-MiniLM-L-4-v2"
size_in_gb = 0.08
source = ModelSource(hf=custom_model_name)
canonical_vector = np.array([-5.7170815, -11.112114], dtype=np.float32)
TextCrossEncoder.add_custom_model(
custom_model_name,
model_file="onnx/model.onnx",
sources=source,
size_in_gb=size_in_gb,
)
assert CustomTextCrossEncoder.SUPPORTED_MODELS[0] == BaseModelDescription(
model=custom_model_name,
sources=source,
model_file="onnx/model.onnx",
description="",
license="",
size_in_GB=size_in_gb,
)
model = TextCrossEncoder(custom_model_name)
pairs = [
("What is AI?", "Artificial intelligence is ..."),
("What is ML?", "Machine learning is ..."),
]
scores = list(model.rerank_pairs(pairs))
embeddings = np.stack(scores, axis=0)
assert embeddings.shape == (2,)
assert np.allclose(embeddings, canonical_vector, atol=1e-3)
if is_ci:
delete_model_cache(model.model._model_dir)
def test_mock_add_custom_models():
dim = 5
size_in_gb = 0.1
source = ModelSource(hf="artificial")
num_tokens = 10
dummy_pooled_embedding = np.random.random((1, dim)).astype(np.float32)
dummy_token_embedding = np.random.random((1, num_tokens, dim)).astype(np.float32)
dummy_attention_mask = np.ones((1, num_tokens)).astype(np.int64)
dummy_token_output = OnnxOutputContext(
model_output=dummy_token_embedding, attention_mask=dummy_attention_mask
)
dummy_pooled_output = OnnxOutputContext(model_output=dummy_pooled_embedding)
input_data = {
f"{PoolingType.MEAN.lower()}-normalized": dummy_token_output,
f"{PoolingType.MEAN.lower()}": dummy_token_output,
f"{PoolingType.CLS.lower()}-normalized": dummy_token_output,
f"{PoolingType.CLS.lower()}": dummy_token_output,
f"{PoolingType.DISABLED.lower()}-normalized": dummy_pooled_output,
f"{PoolingType.DISABLED.lower()}": dummy_pooled_output,
}
expected_output = {
f"{PoolingType.MEAN.lower()}-normalized": normalize(
mean_pooling(dummy_token_embedding, dummy_attention_mask)
),
f"{PoolingType.MEAN.lower()}": mean_pooling(dummy_token_embedding, dummy_attention_mask),
f"{PoolingType.CLS.lower()}-normalized": normalize(dummy_token_embedding[:, 0]),
f"{PoolingType.CLS.lower()}": dummy_token_embedding[:, 0],
f"{PoolingType.DISABLED.lower()}-normalized": normalize(dummy_pooled_embedding),
f"{PoolingType.DISABLED.lower()}": dummy_pooled_embedding,
}
for pooling, normalization in itertools.product(
(PoolingType.MEAN, PoolingType.CLS, PoolingType.DISABLED), (True, False)
):
model_name = f"{pooling.name.lower()}{'-normalized' if normalization else ''}"
TextEmbedding.add_custom_model(
model_name,
pooling=pooling,
normalization=normalization,
sources=source,
dim=dim,
size_in_gb=size_in_gb,
)
custom_text_embedding = CustomTextEmbedding(
model_name,
lazy_load=True,
specific_model_path="./", # disable model downloading and loading
)
post_processed_output = next(
iter(custom_text_embedding._post_process_onnx_output(input_data[model_name]))
)
assert np.allclose(post_processed_output, expected_output[model_name], atol=1e-3)
def test_do_not_add_existing_model():
existing_base_model = "sentence-transformers/all-MiniLM-L6-v2"
custom_model_name = "intfloat/multilingual-e5-small"
with pytest.raises(ValueError, match=f"Model {existing_base_model} is already registered"):
TextEmbedding.add_custom_model(
existing_base_model,
pooling=PoolingType.MEAN,
normalization=True,
sources=ModelSource(hf=existing_base_model),
dim=384,
size_in_gb=0.47,
)
TextEmbedding.add_custom_model(
custom_model_name,
pooling=PoolingType.MEAN,
normalization=False,
sources=ModelSource(hf=existing_base_model),
dim=384,
size_in_gb=0.47,
)
with pytest.raises(ValueError, match=f"Model {custom_model_name} is already registered"):
TextEmbedding.add_custom_model(
custom_model_name,
pooling=PoolingType.MEAN,
normalization=True,
sources=ModelSource(hf=custom_model_name),
dim=384,
size_in_gb=0.47,
)
def test_do_not_add_existing_cross_encoder():
existing_base_model = "Xenova/ms-marco-MiniLM-L-6-v2"
custom_model_name = "Xenova/ms-marco-MiniLM-L-4-v2"
with pytest.raises(ValueError, match=f"Model {existing_base_model} is already registered"):
TextCrossEncoder.add_custom_model(
existing_base_model,
sources=ModelSource(hf=existing_base_model),
size_in_gb=0.08,
)
TextCrossEncoder.add_custom_model(
custom_model_name,
sources=ModelSource(hf=existing_base_model),
size_in_gb=0.08,
)
with pytest.raises(ValueError, match=f"Model {custom_model_name} is already registered"):
TextCrossEncoder.add_custom_model(
custom_model_name,
sources=ModelSource(hf=custom_model_name),
size_in_gb=0.08,
)
+15 -9
View File
@@ -8,7 +8,7 @@ from PIL import Image
from fastembed import ImageEmbedding
from tests.config import TEST_MISC_DIR
from tests.utils import delete_model_cache
from tests.utils import delete_model_cache, should_test_model
CANONICAL_VECTOR_VALUES = {
"Qdrant/clip-ViT-B-32-vision": np.array([-0.0098, 0.0128, -0.0274, 0.002, -0.0059]),
@@ -27,16 +27,18 @@ CANONICAL_VECTOR_VALUES = {
}
def test_embedding() -> None:
@pytest.mark.parametrize("model_name", ["Qdrant/clip-ViT-B-32-vision"])
def test_embedding(model_name: str) -> None:
is_ci = os.getenv("CI")
is_manual = os.getenv("GITHUB_EVENT_NAME") == "workflow_dispatch"
for model_desc in ImageEmbedding.list_supported_models():
if not is_ci and model_desc["size_in_GB"] > 1:
for model_desc in ImageEmbedding._list_supported_models():
if not should_test_model(model_desc, model_name, is_ci, is_manual):
continue
dim = model_desc["dim"]
dim = model_desc.dim
model = ImageEmbedding(model_name=model_desc["model"])
model = ImageEmbedding(model_name=model_desc.model)
images = [
TEST_MISC_DIR / "image.jpeg",
@@ -48,13 +50,13 @@ def test_embedding() -> None:
embeddings = np.stack(embeddings, axis=0)
assert embeddings.shape == (len(images), dim)
canonical_vector = CANONICAL_VECTOR_VALUES[model_desc["model"]]
canonical_vector = CANONICAL_VECTOR_VALUES[model_desc.model]
assert np.allclose(
embeddings[0, : canonical_vector.shape[0]], canonical_vector, atol=1e-3
), model_desc["model"]
), model_desc.model
assert np.allclose(embeddings[1], embeddings[2]), model_desc["model"]
assert np.allclose(embeddings[1], embeddings[2]), model_desc.model
if is_ci:
delete_model_cache(model.model._model_dir)
@@ -74,8 +76,12 @@ def test_batch_embedding(n_dims: int, model_name: str) -> None:
embeddings = list(model.embed(images, batch_size=10))
embeddings = np.stack(embeddings, axis=0)
assert np.allclose(embeddings[1], embeddings[2])
canonical_vector = CANONICAL_VECTOR_VALUES[model_name]
assert embeddings.shape == (len(test_images) * n_images, n_dims)
assert np.allclose(embeddings[0, : canonical_vector.shape[0]], canonical_vector, atol=1e-3)
if is_ci:
delete_model_cache(model.model._model_dir)
+32 -22
View File
@@ -6,7 +6,7 @@ import numpy as np
from fastembed.late_interaction.late_interaction_text_embedding import (
LateInteractionTextEmbedding,
)
from tests.utils import delete_model_cache
from tests.utils import delete_model_cache, should_test_model
# vectors are abridged and rounded for brevity
CANONICAL_COLUMN_VALUES = {
@@ -153,31 +153,37 @@ CANONICAL_QUERY_VALUES = {
docs = ["Hello World"]
def test_batch_embedding():
@pytest.mark.parametrize("model_name", ["answerdotai/answerai-colbert-small-v1"])
def test_batch_embedding(model_name: str):
is_ci = os.getenv("CI")
docs_to_embed = docs * 10
for model_name, expected_result in CANONICAL_COLUMN_VALUES.items():
print("evaluating", model_name)
model = LateInteractionTextEmbedding(model_name=model_name)
result = list(model.embed(docs_to_embed, batch_size=6))
model = LateInteractionTextEmbedding(model_name=model_name)
result = list(model.embed(docs_to_embed, batch_size=6))
expected_result = CANONICAL_COLUMN_VALUES[model_name]
for value in result:
token_num, abridged_dim = expected_result.shape
assert np.allclose(value[:, :abridged_dim], expected_result, atol=2e-3)
for value in result:
token_num, abridged_dim = expected_result.shape
assert np.allclose(value[:, :abridged_dim], expected_result, atol=2e-3)
if is_ci:
delete_model_cache(model.model._model_dir)
if is_ci:
delete_model_cache(model.model._model_dir)
def test_single_embedding():
@pytest.mark.parametrize("model_name", ["answerdotai/answerai-colbert-small-v1"])
def test_single_embedding(model_name: str):
is_ci = os.getenv("CI")
is_manual = os.getenv("GITHUB_EVENT_NAME") == "workflow_dispatch"
docs_to_embed = docs
for model_name, expected_result in CANONICAL_COLUMN_VALUES.items():
for model_desc in LateInteractionTextEmbedding._list_supported_models():
if not should_test_model(model_desc, model_name, is_ci, is_manual):
continue
print("evaluating", model_name)
model = LateInteractionTextEmbedding(model_name=model_name)
result = next(iter(model.embed(docs_to_embed, batch_size=6)))
expected_result = CANONICAL_COLUMN_VALUES[model_name]
token_num, abridged_dim = expected_result.shape
assert np.allclose(result[:, :abridged_dim], expected_result, atol=2e-3)
@@ -185,14 +191,20 @@ def test_single_embedding():
delete_model_cache(model.model._model_dir)
def test_single_embedding_query():
@pytest.mark.parametrize("model_name", ["answerdotai/answerai-colbert-small-v1"])
def test_single_embedding_query(model_name: str):
is_ci = os.getenv("CI")
is_manual = os.getenv("GITHUB_EVENT_NAME") == "workflow_dispatch"
queries_to_embed = docs
for model_name, expected_result in CANONICAL_QUERY_VALUES.items():
for model_desc in LateInteractionTextEmbedding._list_supported_models():
if not should_test_model(model_desc, model_name, is_ci, is_manual):
continue
print("evaluating", model_name)
model = LateInteractionTextEmbedding(model_name=model_name)
result = next(iter(model.query_embed(queries_to_embed)))
expected_result = CANONICAL_QUERY_VALUES[model_name]
token_num, abridged_dim = expected_result.shape
assert np.allclose(result[:, :abridged_dim], expected_result, atol=2e-3)
@@ -200,10 +212,11 @@ def test_single_embedding_query():
delete_model_cache(model.model._model_dir)
def test_parallel_processing():
@pytest.mark.parametrize("token_dim,model_name", [(96, "answerdotai/answerai-colbert-small-v1")])
def test_parallel_processing(token_dim: int, model_name: str):
is_ci = os.getenv("CI")
model = LateInteractionTextEmbedding(model_name="colbert-ir/colbertv2.0")
token_dim = 128
model = LateInteractionTextEmbedding(model_name=model_name)
docs = ["hello world", "flag embedding"] * 100
embeddings = list(model.embed(docs, batch_size=10, parallel=2))
embeddings = np.stack(embeddings, axis=0)
@@ -222,10 +235,7 @@ def test_parallel_processing():
delete_model_cache(model.model._model_dir)
@pytest.mark.parametrize(
"model_name",
["colbert-ir/colbertv2.0"],
)
@pytest.mark.parametrize("model_name", ["answerdotai/answerai-colbert-small-v1"])
def test_lazy_load(model_name: str):
is_ci = os.getenv("CI")
+83
View File
@@ -0,0 +1,83 @@
import os
import pytest
from PIL import Image
import numpy as np
from fastembed import LateInteractionMultimodalEmbedding
from tests.config import TEST_MISC_DIR
# vectors are abridged and rounded for brevity
CANONICAL_IMAGE_VALUES = {
"Qdrant/colpali-v1.3-fp16": np.array(
[
[-0.0345, -0.022, 0.0567, -0.0518, -0.0782, 0.1714, -0.1738],
[-0.1181, -0.099, 0.0268, 0.0774, 0.0228, 0.0563, -0.1021],
[-0.117, -0.0683, 0.0371, 0.0921, 0.0107, 0.0659, -0.0666],
[-0.1393, -0.0948, 0.037, 0.0951, -0.0126, 0.0678, -0.087],
[-0.0957, -0.081, 0.0404, 0.052, 0.0409, 0.0335, -0.064],
[-0.0626, -0.0445, 0.056, 0.0592, -0.0229, 0.0409, -0.0301],
[-0.1299, -0.0691, 0.1097, 0.0728, 0.0123, 0.0519, 0.0122],
]
),
}
CANONICAL_QUERY_VALUES = {
"Qdrant/colpali-v1.3-fp16": np.array(
[
[-0.0023, 0.1477, 0.1594, 0.046, -0.0196, 0.0554, 0.1567],
[-0.0139, -0.0057, 0.0932, 0.0052, -0.0678, 0.0131, 0.0537],
[0.0054, 0.0364, 0.2078, -0.074, 0.0355, 0.061, 0.1593],
[-0.0076, -0.0154, 0.2266, 0.0103, 0.0089, -0.024, 0.098],
[-0.0274, 0.0098, 0.2106, -0.0634, 0.0616, -0.0021, 0.0708],
[0.0074, 0.0025, 0.1631, -0.0802, 0.0418, -0.0219, 0.1022],
[-0.0165, -0.0106, 0.1672, -0.0768, 0.0389, -0.0038, 0.1137],
]
),
}
queries = ["hello world", "flag embedding"]
images = [
TEST_MISC_DIR / "image.jpeg",
str(TEST_MISC_DIR / "image.jpeg"),
Image.open((TEST_MISC_DIR / "image.jpeg")),
]
def test_batch_embedding():
if os.getenv("CI"):
pytest.skip("Colpali is too large to test in CI")
for model_name, expected_result in CANONICAL_IMAGE_VALUES.items():
print("evaluating", model_name)
model = LateInteractionMultimodalEmbedding(model_name=model_name)
result = list(model.embed_image(images, batch_size=2))
for value in result:
token_num, abridged_dim = expected_result.shape
assert np.allclose(value[:token_num, :abridged_dim], expected_result, atol=2e-3)
def test_single_embedding():
if os.getenv("CI"):
pytest.skip("Colpali is too large to test in CI")
for model_name, expected_result in CANONICAL_IMAGE_VALUES.items():
print("evaluating", model_name)
model = LateInteractionMultimodalEmbedding(model_name=model_name)
result = next(iter(model.embed_image(images, batch_size=6)))
token_num, abridged_dim = expected_result.shape
assert np.allclose(result[:token_num, :abridged_dim], expected_result, atol=2e-3)
def test_single_embedding_query():
if os.getenv("CI"):
pytest.skip("Colpali is too large to test in CI")
for model_name, expected_result in CANONICAL_QUERY_VALUES.items():
print("evaluating", model_name)
model = LateInteractionMultimodalEmbedding(model_name=model_name)
result = next(iter(model.embed_text(queries)))
token_num, abridged_dim = expected_result.shape
assert np.allclose(result[:token_num, :abridged_dim], expected_result, atol=2e-3)
+30 -19
View File
@@ -5,10 +5,10 @@ import numpy as np
from fastembed.sparse.bm25 import Bm25
from fastembed.sparse.sparse_text_embedding import SparseTextEmbedding
from tests.utils import delete_model_cache
from tests.utils import delete_model_cache, should_test_model
CANONICAL_COLUMN_VALUES = {
"prithvida/Splade_PP_en_v1": {
"prithivida/Splade_PP_en_v1": {
"indices": [
2040,
2047,
@@ -49,28 +49,41 @@ CANONICAL_COLUMN_VALUES = {
docs = ["Hello World"]
def test_batch_embedding() -> None:
@pytest.mark.parametrize("model_name", ["prithivida/Splade_PP_en_v1"])
def test_batch_embedding(model_name: str) -> None:
is_ci = os.getenv("CI")
docs_to_embed = docs * 10
for model_name, expected_result in CANONICAL_COLUMN_VALUES.items():
model = SparseTextEmbedding(model_name=model_name)
result = next(iter(model.embed(docs_to_embed, batch_size=6)))
assert result.indices.tolist() == expected_result["indices"]
model = SparseTextEmbedding(model_name=model_name)
result = next(iter(model.embed(docs_to_embed, batch_size=6)))
expected_result = CANONICAL_COLUMN_VALUES[model_name]
assert result.indices.tolist() == expected_result["indices"]
for i, value in enumerate(result.values):
assert pytest.approx(value, abs=0.001) == expected_result["values"][i]
if is_ci:
delete_model_cache(model.model._model_dir)
for i, value in enumerate(result.values):
assert pytest.approx(value, abs=0.001) == expected_result["values"][i]
if is_ci:
delete_model_cache(model.model._model_dir)
def test_single_embedding() -> None:
@pytest.mark.parametrize("model_name", ["prithivida/Splade_PP_en_v1"])
def test_single_embedding(model_name: str) -> None:
is_ci = os.getenv("CI")
for model_name, expected_result in CANONICAL_COLUMN_VALUES.items():
is_manual = os.getenv("GITHUB_EVENT_NAME") == "workflow_dispatch"
for model_desc in SparseTextEmbedding._list_supported_models():
if (
model_desc.model not in CANONICAL_COLUMN_VALUES
): # attention models and bm25 are also parts of
# SparseTextEmbedding, however, they have their own tests
continue
if not should_test_model(model_desc, model_name, is_ci, is_manual):
continue
model = SparseTextEmbedding(model_name=model_name)
passage_result = next(iter(model.embed(docs, batch_size=6)))
query_result = next(iter(model.query_embed(docs)))
expected_result = CANONICAL_COLUMN_VALUES[model_name]
for result in [passage_result, query_result]:
assert result.indices.tolist() == expected_result["indices"]
@@ -80,9 +93,10 @@ def test_single_embedding() -> None:
delete_model_cache(model.model._model_dir)
def test_parallel_processing() -> None:
@pytest.mark.parametrize("model_name", ["prithivida/Splade_PP_en_v1"])
def test_parallel_processing(model_name: str) -> None:
is_ci = os.getenv("CI")
model = SparseTextEmbedding(model_name="prithivida/Splade_PP_en_v1")
model = SparseTextEmbedding(model_name=model_name)
docs = ["hello world", "flag embedding"] * 30
sparse_embeddings_duo = list(model.embed(docs, batch_size=10, parallel=2))
sparse_embeddings_all = list(model.embed(docs, batch_size=10, parallel=0))
@@ -172,10 +186,7 @@ def test_disable_stemmer_behavior(disable_stemmer: bool) -> None:
assert result == expected, f"Expected {expected}, but got {result}"
@pytest.mark.parametrize(
"model_name",
["prithivida/Splade_PP_en_v1"],
)
@pytest.mark.parametrize("model_name", ["prithivida/Splade_PP_en_v1"])
def test_lazy_load(model_name: str) -> None:
is_ci = os.getenv("CI")
model = SparseTextEmbedding(model_name=model_name, lazy_load=True)
+25 -38
View File
@@ -4,7 +4,7 @@ import numpy as np
import pytest
from fastembed.rerank.cross_encoder import TextCrossEncoder
from tests.utils import delete_model_cache
from tests.utils import delete_model_cache, should_test_model
CANONICAL_SCORE_VALUES = {
"Xenova/ms-marco-MiniLM-L-6-v2": np.array([8.500708, -2.541011]),
@@ -15,44 +15,37 @@ CANONICAL_SCORE_VALUES = {
"jinaai/jina-reranker-v2-base-multilingual": np.array([1.6533, -1.6455]),
}
SELECTED_MODELS = {
"Xenova": "Xenova/ms-marco-MiniLM-L-6-v2",
"BAAI": "BAAI/bge-reranker-base",
"jinaai": "jinaai/jina-reranker-v1-tiny-en",
}
@pytest.mark.parametrize(
"model_name",
[model_name for model_name in CANONICAL_SCORE_VALUES],
)
@pytest.mark.parametrize("model_name", ["Xenova/ms-marco-MiniLM-L-6-v2"])
def test_rerank(model_name: str) -> None:
is_ci = os.getenv("CI")
is_manual = os.getenv("GITHUB_EVENT_NAME") == "workflow_dispatch"
model = TextCrossEncoder(model_name=model_name)
for model_desc in TextCrossEncoder._list_supported_models():
if not should_test_model(model_desc, model_name, is_ci, is_manual):
continue
query = "What is the capital of France?"
documents = ["Paris is the capital of France.", "Berlin is the capital of Germany."]
scores = np.array(list(model.rerank(query, documents)))
model = TextCrossEncoder(model_name=model_name)
pairs = [(query, doc) for doc in documents]
scores2 = np.array(list(model.rerank_pairs(pairs)))
assert np.allclose(
scores, scores2, atol=1e-5
), f"Model: {model_name}, Scores: {scores}, Scores2: {scores2}"
query = "What is the capital of France?"
documents = ["Paris is the capital of France.", "Berlin is the capital of Germany."]
scores = np.array(list(model.rerank(query, documents)))
canonical_scores = CANONICAL_SCORE_VALUES[model_name]
assert np.allclose(
scores, canonical_scores, atol=1e-3
), f"Model: {model_name}, Scores: {scores}, Expected: {canonical_scores}"
if is_ci:
delete_model_cache(model.model._model_dir)
pairs = [(query, doc) for doc in documents]
scores2 = np.array(list(model.rerank_pairs(pairs)))
assert np.allclose(
scores, scores2, atol=1e-5
), f"Model: {model_name}, Scores: {scores}, Scores2: {scores2}"
canonical_scores = CANONICAL_SCORE_VALUES[model_name]
assert np.allclose(
scores, canonical_scores, atol=1e-3
), f"Model: {model_name}, Scores: {scores}, Expected: {canonical_scores}"
if is_ci:
delete_model_cache(model.model._model_dir)
@pytest.mark.parametrize(
"model_name",
[model_name for model_name in SELECTED_MODELS.values()],
)
@pytest.mark.parametrize("model_name", ["Xenova/ms-marco-MiniLM-L-6-v2"])
def test_batch_rerank(model_name: str) -> None:
is_ci = os.getenv("CI")
@@ -78,10 +71,7 @@ def test_batch_rerank(model_name: str) -> None:
delete_model_cache(model.model._model_dir)
@pytest.mark.parametrize(
"model_name",
["Xenova/ms-marco-MiniLM-L-6-v2"],
)
@pytest.mark.parametrize("model_name", ["Xenova/ms-marco-MiniLM-L-6-v2"])
def test_lazy_load(model_name: str) -> None:
is_ci = os.getenv("CI")
model = TextCrossEncoder(model_name=model_name, lazy_load=True)
@@ -95,10 +85,7 @@ def test_lazy_load(model_name: str) -> None:
delete_model_cache(model.model._model_dir)
@pytest.mark.parametrize(
"model_name",
[model_name for model_name in SELECTED_MODELS.values()],
)
@pytest.mark.parametrize("model_name", ["Xenova/ms-marco-MiniLM-L-6-v2"])
def test_rerank_pairs_parallel(model_name: str) -> None:
is_ci = os.getenv("CI")
+73 -85
View File
@@ -4,7 +4,7 @@ import numpy as np
import pytest
from fastembed import TextEmbedding
from fastembed.text.multitask_embedding import Task
from fastembed.text.multitask_embedding import JinaEmbeddingV3, Task
from tests.utils import delete_model_cache
@@ -60,51 +60,42 @@ CANONICAL_VECTOR_VALUES = {
docs = ["Hello World", "Follow the white rabbit."]
def test_batch_embedding():
@pytest.mark.parametrize("dim,model_name", [(1024, "jinaai/jina-embeddings-v3")])
def test_batch_embedding(dim: int, model_name: str):
is_ci = os.getenv("CI")
is_manual = os.getenv("GITHUB_EVENT_NAME") == "workflow_dispatch"
if is_ci and not is_manual:
pytest.skip("Skipping multitask models in CI non-manual mode")
docs_to_embed = docs * 10
default_task = Task.RETRIEVAL_PASSAGE
for model_desc in TextEmbedding.list_supported_models():
if not is_ci and model_desc["size_in_GB"] > 1:
continue
model = TextEmbedding(model_name=model_name)
model_name = model_desc["model"]
dim = model_desc["dim"]
embeddings = list(model.embed(documents=docs_to_embed, batch_size=6))
embeddings = np.stack(embeddings, axis=0)
if model_name not in CANONICAL_VECTOR_VALUES.keys():
continue
assert embeddings.shape == (len(docs_to_embed), dim)
model = TextEmbedding(model_name=model_name)
canonical_vector = CANONICAL_VECTOR_VALUES[model_name][default_task]["vectors"]
assert np.allclose(
embeddings[: len(docs), : canonical_vector.shape[1]], canonical_vector, atol=1e-4
), model_name
print(f"evaluating {model_name} default task")
embeddings = list(model.embed(documents=docs_to_embed, batch_size=6))
embeddings = np.stack(embeddings, axis=0)
assert embeddings.shape == (len(docs_to_embed), dim)
canonical_vector = CANONICAL_VECTOR_VALUES[model_name][default_task]["vectors"]
assert np.allclose(
embeddings[: len(docs), : canonical_vector.shape[1]], canonical_vector, atol=1e-4
), model_desc["model"]
if is_ci:
delete_model_cache(model.model._model_dir)
if is_ci:
delete_model_cache(model.model._model_dir)
def test_single_embedding():
is_ci = os.getenv("CI")
is_manual = os.getenv("GITHUB_EVENT_NAME") == "workflow_dispatch"
if is_ci and not is_manual:
pytest.skip("Skipping multitask models in CI non-manual mode")
for model_desc in TextEmbedding.list_supported_models():
if not is_ci and model_desc["size_in_GB"] > 1:
continue
model_name = model_desc["model"]
dim = model_desc["dim"]
if model_name not in CANONICAL_VECTOR_VALUES.keys():
continue
for model_desc in JinaEmbeddingV3._list_supported_models():
# todo: once we add more models, we should not test models >1GB size locally
model_name = model_desc.model
dim = model_desc.dim
model = TextEmbedding(model_name=model_name)
@@ -118,26 +109,41 @@ def test_single_embedding():
canonical_vector = task["vectors"]
assert np.allclose(
embeddings[: len(docs), : canonical_vector.shape[1]], canonical_vector, atol=1e-4
), model_desc["model"]
embeddings[:, : canonical_vector.shape[1]], canonical_vector, atol=1e-4
), model_desc.model
classification_embeddings = list(model.embed(documents=docs, task_id=Task.CLASSIFICATION))
classification_embeddings = np.stack(classification_embeddings, axis=0)
assert classification_embeddings.shape == (len(docs), dim)
model = TextEmbedding(model_name=model_name, task_id=Task.CLASSIFICATION)
default_embeddings = list(model.embed(documents=docs))
default_embeddings = np.stack(default_embeddings, axis=0)
assert default_embeddings.shape == (len(docs), dim)
assert np.allclose(
classification_embeddings,
default_embeddings,
atol=1e-4,
), model_desc.model
if is_ci:
delete_model_cache(model.model._model_dir)
def test_single_embedding_query():
is_ci = os.getenv("CI")
is_manual = os.getenv("GITHUB_EVENT_NAME") == "workflow_dispatch"
if is_ci and not is_manual:
pytest.skip("Skipping multitask models in CI non-manual mode")
task_id = Task.RETRIEVAL_QUERY
for model_desc in TextEmbedding.list_supported_models():
if not is_ci and model_desc["size_in_GB"] > 1:
continue
model_name = model_desc["model"]
dim = model_desc["dim"]
if model_name not in CANONICAL_VECTOR_VALUES.keys():
continue
for model_desc in JinaEmbeddingV3._list_supported_models():
# todo: once we add more models, we should not test models >1GB size locally
model_name = model_desc.model
dim = model_desc.dim
model = TextEmbedding(model_name=model_name)
@@ -150,8 +156,8 @@ def test_single_embedding_query():
canonical_vector = CANONICAL_VECTOR_VALUES[model_name][task_id]["vectors"]
assert np.allclose(
embeddings[: len(docs), : canonical_vector.shape[1]], canonical_vector, atol=1e-4
), model_desc["model"]
embeddings[:, : canonical_vector.shape[1]], canonical_vector, atol=1e-4
), model_desc.model
if is_ci:
delete_model_cache(model.model._model_dir)
@@ -159,17 +165,17 @@ def test_single_embedding_query():
def test_single_embedding_passage():
is_ci = os.getenv("CI")
is_manual = os.getenv("GITHUB_EVENT_NAME") == "workflow_dispatch"
if is_ci and not is_manual:
pytest.skip("Skipping multitask models in CI non-manual mode")
task_id = Task.RETRIEVAL_PASSAGE
for model_desc in TextEmbedding.list_supported_models():
if not is_ci and model_desc["size_in_GB"] > 1:
continue
for model_desc in JinaEmbeddingV3._list_supported_models():
# todo: once we add more models, we should not test models >1GB size locally
model_name = model_desc["model"]
dim = model_desc["dim"]
if model_name not in CANONICAL_VECTOR_VALUES.keys():
continue
model_name = model_desc.model
dim = model_desc.dim
model = TextEmbedding(model_name=model_name)
@@ -182,21 +188,22 @@ def test_single_embedding_passage():
canonical_vector = CANONICAL_VECTOR_VALUES[model_name][task_id]["vectors"]
assert np.allclose(
embeddings[: len(docs), : canonical_vector.shape[1]], canonical_vector, atol=1e-4
), model_desc["model"]
embeddings[:, : canonical_vector.shape[1]], canonical_vector, atol=1e-4
), model_desc.model
if is_ci:
delete_model_cache(model.model._model_dir)
def test_parallel_processing():
@pytest.mark.parametrize("dim,model_name", [(1024, "jinaai/jina-embeddings-v3")])
def test_parallel_processing(dim: int, model_name: str):
is_ci = os.getenv("CI")
is_manual = os.getenv("GITHUB_EVENT_NAME") == "workflow_dispatch"
if is_ci and not is_manual:
pytest.skip("Skipping in CI non-manual mode")
docs = ["Hello World", "Follow the white rabbit."] * 10
model_name = "jinaai/jina-embeddings-v3"
dim = 1024
model = TextEmbedding(model_name=model_name)
task_id = Task.SEPARATION
@@ -216,33 +223,14 @@ def test_parallel_processing():
delete_model_cache(model.model._model_dir)
def test_task_assignment():
is_ci = os.getenv("CI")
for model_desc in TextEmbedding.list_supported_models():
if not is_ci and model_desc["size_in_GB"] > 1:
continue
model_name = model_desc["model"]
if model_name not in CANONICAL_VECTOR_VALUES.keys():
continue
model = TextEmbedding(model_name=model_name)
for i, task_id in enumerate(Task):
_ = list(model.embed(documents=docs, batch_size=1, task_id=i))
assert model.model._current_task_id == task_id
if is_ci:
delete_model_cache(model.model._model_dir)
@pytest.mark.parametrize(
"model_name",
["jinaai/jina-embeddings-v3"],
)
@pytest.mark.parametrize("model_name", ["jinaai/jina-embeddings-v3"])
def test_lazy_load(model_name: str):
is_ci = os.getenv("CI")
is_manual = os.getenv("GITHUB_EVENT_NAME") == "workflow_dispatch"
if is_ci and not is_manual:
pytest.skip("Skipping in CI non-manual mode")
model = TextEmbedding(model_name=model_name, lazy_load=True)
assert not hasattr(model.model, "model")
+19 -26
View File
@@ -5,7 +5,7 @@ import numpy as np
import pytest
from fastembed.text.text_embedding import TextEmbedding
from tests.utils import delete_model_cache
from tests.utils import delete_model_cache, should_test_model
CANONICAL_VECTOR_VALUES = {
"BAAI/bge-small-en": np.array([-0.0232, -0.0255, 0.0174, -0.0639, -0.0006]),
@@ -52,7 +52,7 @@ CANONICAL_VECTOR_VALUES = {
[0.0802303, 0.3700881, -4.3053818, 0.4431803, -0.271572]
),
"thenlper/gte-large": np.array(
[-0.01920587, 0.00113156, -0.00708992, -0.00632304, -0.04025577]
[-0.00986551, -0.00018734, 0.00605892, -0.03289612, -0.0387564],
),
"mixedbread-ai/mxbai-embed-large-v1": np.array(
[0.02295546, 0.03196154, 0.016512, -0.04031524, -0.0219634]
@@ -72,38 +72,37 @@ CANONICAL_VECTOR_VALUES = {
MULTI_TASK_MODELS = ["jinaai/jina-embeddings-v3"]
def test_embedding() -> None:
@pytest.mark.parametrize("model_name", ["BAAI/bge-small-en-v1.5"])
def test_embedding(model_name: str) -> None:
is_ci = os.getenv("CI")
is_mac = platform.system() == "Darwin"
is_manual = os.getenv("GITHUB_EVENT_NAME") == "workflow_dispatch"
for model_desc in TextEmbedding.list_supported_models():
if (
(not is_ci and model_desc["size_in_GB"] > 1)
or model_desc["model"] in MULTI_TASK_MODELS
or (is_mac and model_desc["model"] == "nomic-ai/nomic-embed-text-v1.5-Q")
for model_desc in TextEmbedding._list_supported_models():
if model_desc.model in MULTI_TASK_MODELS or (
is_mac and model_desc.model == "nomic-ai/nomic-embed-text-v1.5-Q"
):
continue
if not should_test_model(model_desc, model_name, is_ci, is_manual):
continue
dim = model_desc["dim"]
dim = model_desc.dim
model = TextEmbedding(model_name=model_desc["model"])
model = TextEmbedding(model_name=model_desc.model)
docs = ["hello world", "flag embedding"]
embeddings = list(model.embed(docs))
embeddings = np.stack(embeddings, axis=0)
assert embeddings.shape == (2, dim)
canonical_vector = CANONICAL_VECTOR_VALUES[model_desc["model"]]
canonical_vector = CANONICAL_VECTOR_VALUES[model_desc.model]
assert np.allclose(
embeddings[0, : canonical_vector.shape[0]], canonical_vector, atol=1e-3
), model_desc["model"]
), model_desc.model
if is_ci:
delete_model_cache(model.model._model_dir)
@pytest.mark.parametrize(
"n_dims,model_name",
[(384, "BAAI/bge-small-en-v1.5"), (768, "jinaai/jina-embeddings-v2-base-en")],
)
@pytest.mark.parametrize("n_dims,model_name", [(384, "BAAI/bge-small-en-v1.5")])
def test_batch_embedding(n_dims: int, model_name: str) -> None:
is_ci = os.getenv("CI")
model = TextEmbedding(model_name=model_name)
@@ -112,15 +111,12 @@ def test_batch_embedding(n_dims: int, model_name: str) -> None:
embeddings = list(model.embed(docs, batch_size=10))
embeddings = np.stack(embeddings, axis=0)
assert embeddings.shape == (200, n_dims)
assert embeddings.shape == (len(docs), n_dims)
if is_ci:
delete_model_cache(model.model._model_dir)
@pytest.mark.parametrize(
"n_dims,model_name",
[(384, "BAAI/bge-small-en-v1.5"), (768, "jinaai/jina-embeddings-v2-base-en")],
)
@pytest.mark.parametrize("n_dims,model_name", [(384, "BAAI/bge-small-en-v1.5")])
def test_parallel_processing(n_dims: int, model_name: str) -> None:
is_ci = os.getenv("CI")
model = TextEmbedding(model_name=model_name)
@@ -135,7 +131,7 @@ def test_parallel_processing(n_dims: int, model_name: str) -> None:
embeddings_3 = list(model.embed(docs, batch_size=10, parallel=0))
embeddings_3 = np.stack(embeddings_3, axis=0)
assert embeddings.shape == (200, n_dims)
assert embeddings.shape == (len(docs), n_dims)
assert np.allclose(embeddings, embeddings_2, atol=1e-3)
assert np.allclose(embeddings, embeddings_3, atol=1e-3)
@@ -143,10 +139,7 @@ def test_parallel_processing(n_dims: int, model_name: str) -> None:
delete_model_cache(model.model._model_dir)
@pytest.mark.parametrize(
"model_name",
["BAAI/bge-small-en-v1.5"],
)
@pytest.mark.parametrize("model_name", ["BAAI/bge-small-en-v1.5"])
def test_lazy_load(model_name: str) -> None:
is_ci = os.getenv("CI")
model = TextEmbedding(model_name=model_name, lazy_load=True)
+56
View File
@@ -0,0 +1,56 @@
from fastembed import TextEmbedding, LateInteractionTextEmbedding, SparseTextEmbedding
from fastembed.sparse.bm25 import Bm25
from fastembed.rerank.cross_encoder import TextCrossEncoder
text_embedder = TextEmbedding(cache_dir="models")
late_interaction_embedder = LateInteractionTextEmbedding(model_name="", cache_dir="models")
reranker = TextCrossEncoder(model_name="", cache_dir="models")
sparse_embedder = SparseTextEmbedding(model_name="", cache_dir="models")
bm25_embedder = Bm25(
model_name="",
k=1.0,
b=1.0,
avg_len=1.0,
language="",
token_max_length=1,
disable_stemmer=False,
specific_model_path="models",
)
text_embedder.list_supported_models()
text_embedder.embed(documents=[""], batch_size=1, parallel=1)
text_embedder.embed(documents="", parallel=None, task_id=1)
text_embedder.query_embed(query=[""], batch_size=1, parallel=1)
text_embedder.query_embed(query="", parallel=None)
text_embedder.passage_embed(texts=[""], batch_size=1, parallel=1)
text_embedder.passage_embed(texts=[""], parallel=None)
late_interaction_embedder.list_supported_models()
late_interaction_embedder.embed(documents=[""], batch_size=1, parallel=1)
late_interaction_embedder.embed(documents="", parallel=None)
late_interaction_embedder.query_embed(query=[""], batch_size=1, parallel=1)
late_interaction_embedder.query_embed(query="", parallel=None)
late_interaction_embedder.passage_embed(texts=[""], batch_size=1, parallel=1)
late_interaction_embedder.passage_embed(texts=[""], parallel=None)
reranker.list_supported_models()
reranker.rerank(query="", documents=[""], batch_size=1, parallel=1)
reranker.rerank(query="", documents=[""], parallel=None)
reranker.rerank_pairs(pairs=[("", "")], batch_size=1, parallel=1)
reranker.rerank_pairs(pairs=[("", "")], parallel=None)
sparse_embedder.list_supported_models()
sparse_embedder.embed(documents=[""], batch_size=1, parallel=1)
sparse_embedder.embed(documents="", batch_size=1, parallel=None)
sparse_embedder.query_embed(query=[""], batch_size=1, parallel=1)
sparse_embedder.query_embed(query="", batch_size=1, parallel=None)
sparse_embedder.passage_embed(texts=[""], batch_size=1, parallel=1)
sparse_embedder.passage_embed(texts=[""], batch_size=1, parallel=None)
bm25_embedder.list_supported_models()
bm25_embedder.embed(documents=[""], batch_size=1, parallel=1)
bm25_embedder.embed(documents="", batch_size=1, parallel=None)
bm25_embedder.query_embed(query=[""], batch_size=1, parallel=1)
bm25_embedder.query_embed(query="", batch_size=1, parallel=None)
bm25_embedder.raw_embed(documents=[""])
+31 -1
View File
@@ -3,7 +3,9 @@ import traceback
from pathlib import Path
from types import TracebackType
from typing import Union, Callable, Any, Type
from typing import Union, Callable, Any, Type, Optional
from fastembed.common.model_description import BaseModelDescription
def delete_model_cache(model_dir: Union[str, Path]) -> None:
@@ -35,3 +37,31 @@ def delete_model_cache(model_dir: Union[str, Path]) -> None:
if model_dir.exists():
# todo: PermissionDenied is raised on blobs removal in Windows, with blobs > 2GB
shutil.rmtree(model_dir, onerror=on_error)
def should_test_model(
model_desc: BaseModelDescription,
autotest_model_name: str,
is_ci: Optional[str],
is_manual: bool,
):
"""Determine if a model should be tested based on environment
Tests can be run either in ci or locally.
Testing all models each time in ci is too long.
The testing scheme in ci and on a local machine are different, therefore, there are 3 possible scenarious.
1) Run lightweight tests in ci:
- test only one model that has been manually chosen as a representative for a certain class family
2) Run heavyweight (manual) tests in ci:
- test all models
Running tests in ci each time is too expensive, however, it's fine to run it one time with a manual dispatch
3) Run tests locally:
- test all models, which are not too heavy, since network speed might be a bottleneck
"""
if not is_ci:
if model_desc.size_in_GB > 1:
return False
elif not is_manual and model_desc.model != autotest_model_name:
return False
return True