Compare commits

...
109 Commits
Author SHA1 Message Date
George d4121a5b73 new: gpu package (#224) 2026-03-23 23:30:41 +07:00
George Panchuk 4f7c82a7aa add eofl 2026-03-23 23:30:10 +07:00
George Panchuk bb9698d825 sync publih with main 2026-03-23 23:30:10 +07:00
George Panchuk e88b40d820 fix: workflow dispatch can only be triggered from the default branch 2026-03-23 23:30:10 +07:00
George Panchuk 3ea6fa67ce refactoring: alter workflow names 2026-03-23 23:30:10 +07:00
George Panchuk 5b6d9269f5 fix: do not run windows and mac os tests on gpu branch 2026-03-23 23:30:10 +07:00
George Panchuk 5e679579eb new: gpu package publish workflow 2026-03-23 23:30:10 +07:00
George Panchuk 6fa442b960 bump version to 0.8.0 2026-03-23 23:30:02 +07:00
Alexey Masolov 52ebfba27c fix: respect HF_HUB_OFFLINE in download_model to avoid network calls (#614)
When HF_HUB_OFFLINE is set to a truthy value (1, true, yes, on),
download_model() should treat local_files_only=True to avoid any
network calls. Currently, even with the local-cache-first pass (which
may fail due to missing metadata), the retry loop still calls
download_files_from_huggingface() without local_files_only, which
triggers model_info() — a network API call that immediately fails in
offline mode. This causes an unnecessary fallback to GCS download from
storage.googleapis.com.

By setting local_files_only=True when HF_HUB_OFFLINE is enabled:

1. The HF local cache pass works if the model is cached
2. The retry loop skips the network-dependent HF path entirely
3. retrieve_model_gcs() only checks for local fast-* directories
4. No network calls are attempted at all

The truthy value check aligns with huggingface_hub's own parsing of
HF_HUB_OFFLINE, which accepts "1", "true", "yes", "on" (case-insensitive).

This is critical for air-gapped / restricted environments where both
HuggingFace and Google Cloud Storage are unreachable.

Made-with: Cursor
2026-03-23 22:40:03 +07:00
George ea55268e01 fix: fix onnxruntime 1.24, uncap pillow (#611)
* fix: fix onnxruntime 1.24, uncap pillow

* fix: fix python3.10 onnxruntime version

* fix: fix onnxruntime for 3.14, update onnx dep
2026-03-13 00:50:10 +07:00
Kacper ŁukawskiandGeorge Panchuk 800f3887b7 Model: ModernVBERT/colmodernvbert (#588)
* Add ColModernVBERT to LateInteractionMultimodalEmbedding registry

* Implement image processing based on Idefics3ImageProcessor logic

* Fix padding support

* Implement ColModernVBERT logic

* Remove TODOs

* Handle empty pixel values with proper image_size

* Add ColModernVBERT tests

* Run pre-commit

* mypy fixes

* mypy fixes

* mypy fixes

* mypy fixes

* Fix typo in the class name

* Add processor_config.json to additional files

* Fix mypy errors

* Refactor onnx_embed_image

* Fix mypy errors

* fix: colmodernvbert tests and query processing

* fix: remove Union references

* fix: fix exit stack, update tests, implement token count

* fix: uncomment colpali in tests

* fix: lowercase models to cache

* fix: fix models to cache

* refactor: move colmodernvbert related onnx embed to its class

---------

Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
2026-01-09 18:52:50 +07:00
Bastian Hofmann 020d535f9c Update logo and favicon (#589) 2025-12-18 15:49:31 +01:00
George 685fd9b5a1 new: use cuda if available (#537)
* new: use cuda if available

* fix: fix warning msg

* fix: add missing import
2025-12-10 20:23:34 +07:00
George b304a2aff0 new: drop python3.9, replace optional and union with | (#574)
* new: drop python3.9, replace optional and union with |

* new: remove python 3.9 from pyproject

* refactor: replace remaining union and optional with |

* new: remove optional and union in dataclasses

* fix: add typealias to numpy type

* new: replace union with | in token count
2025-12-10 19:01:01 +07:00
George c715416361 fix: update colbert description (#534) 2025-12-09 19:22:54 +07:00
George 428381cb04 bump version to v0.7.4 2025-12-05 18:38:45 +07:00
George 3511b08831 fix: numpy dropped 3.10 as of 2.3.0 (#585)
* fix: numpy dropped 3.10 as of 2.3.0

* fix: update poetry lock
2025-12-05 18:08:20 +07:00
George b718cc6a88 new: add token count method (#583)
* new: add token count method

* fix: fix mypy

* fix: load model in token_count

* fix: remove debug code
2025-12-04 11:29:36 +07:00
George 2ba8990260 new: try unlocking huggingface hub and pillow (#582)
* new: try unlocking huggingface hub and pillow

* new: adjust pillow version for 3.9

* fix: fix pillow for python3.13
2025-12-01 18:21:28 +07:00
George dab185fd9d fix: fix onnx version for python3.13 (#580) 2025-11-25 21:42:10 +07:00
George ec0e3128ee new: expose some onnx session options (#578)
* new: expose some onnx session options

* fix: fix extra session options is None case

* fix: fix missing params

* new: add tests
2025-11-25 17:49:02 +07:00
George 44e332999c new: try loading models from cache before making any network calls (#577) 2025-11-25 12:07:50 +07:00
George 533b54cee5 tests: introduce model cache to tests (#573)
* tests: introduce model cache to tests

* fix: fix not cached model deletion

* new: do not run CI tests on mac os and windows on python 3.10-3.12

* fix: lowercase cache keys, bm25 caching

* tests: do not run parallel processing on all cpus in sparse text embed

* fix: fix models to cache names, do not run parallel=0

* fix: fix sparse embedding tests

* fix: bm42 language by lower case model name
2025-11-12 18:54:33 +07:00
George Panchuk ba1f6053bd bump version to v0.7.3 2025-08-29 14:15:25 +03:00
George 4dc76e3859 fix: fix colbert query postprocessing (#557)
* fix: fix colbert query postprocessing

* fix: improve colbert single embedding tests
2025-08-29 13:25:43 +03:00
George 6efe06b172 new: decouple colbert query and document tokenizer (#556) 2025-08-29 13:25:18 +03:00
George Panchuk 887239239b bump version to v0.7.2 2025-08-25 16:29:23 +03:00
Kacper ŁukawskiandGeorge ca023be0c0 feat: MUVERA embeddings (#542)
* Implement MuveraEmbedding

* Add random generator parameter for reproducibility in MuveraEmbedding

* Document random_seed parameter

* Remove unnecessary module docstring from muvera_embedding.py

* refactor: clean up constructor parameters and improve formatting in MuveraEmbedding

* refactor: rename muvera_embedding.py to muvera.py and update related references

* feat: enhance MuveraEmbedding with multi-vector model support and improve parameter defaults

* feat: add embedding_size property to MuveraEmbedding

* feat: update MuveraPostprocessor to use model description for embedding size and add Jupyter notebook for MUVERA usage

* fix: fix types, doctest, rename variables, refactor (#545)

* fix: fix types, doctest, rename variables, refactor

* fix: fix python3.9 compatibility

* fix: make get_output_dimension protected

* Optimize muvera (#551)

* vectorize operations

* fix: fill empty clusters with dataset vectors

* rollback get_output_dimension

* fix: fix type hints

* fix: review comments

* tests: add tests

---------

Co-authored-by: George <george.panchuk@qdrant.tech>
2025-08-21 02:59:02 +03:00
Anush d1ddc8142d docs: Updated README.md (#550) 2025-08-11 13:29:45 +05:30
Andrey Vasnetsov faf3d9fe18 batch inference should return same shape as individual inference (#547) 2025-08-06 22:44:28 +02:00
George acec31277b fix: fix mypy colpali (#533) 2025-06-16 11:55:29 +03:00
George Panchuk cb902149c8 bump version to 0.7.1 2025-06-16 12:23:35 +04:00
George a260022ae6 fix: propagate local files only and specific model path into embed parallel (#524) 2025-05-20 14:46:42 +04:00
George d5da56299a new: add embedding size property (#521)
* new: add embedding size property

* fix: format exception message

* new: replace embedding size property with get_embedding_size classmethod

* fix: fix missed parts

* chore: fix docstrings

* new: add embedding_size property
2025-05-20 14:46:32 +04:00
George 4e5575f4a7 fix: raise exception if pooling is incorrect in custom model (#522) 2025-05-19 13:49:22 +03:00
George 4736f46548 fix: check lowercase model name for warnings (#523) 2025-05-19 13:49:12 +03:00
Andrey Vasnetsov c85e8c278f remove unused function (#516) 2025-05-13 17:04:51 +03:00
George 0df605fc92 fix: remove onnxruntime cap as not needed anymore (#517)
* fix: remove onnxruntime cap as not needed anymore

* fix: fix mypy complaints in python3.13
2025-05-13 17:54:54 +04:00
Andrey VasnetsovandGeorge 04bc7a3039 MiniCOIL v1 (#513)
* add token embeddings

* fix parallel worker init

* implement minicoil

* fix mypy

* fix mypy

* register minicoil

* rollback suggested "fix"

* add minicoil test

* some pr issues (#514)

* some pr issues

* revert query embed refactor

* test: add query embed tests

* nit

* Update tests/test_sparse_embeddings.py

---------

Co-authored-by: Andrey Vasnetsov <andrey@vasnetsov.com>

* review

* fix: revert change to colbert query_embed

---------

Co-authored-by: George <george.panchuk@qdrant.tech>
2025-05-13 14:22:50 +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
Dmitrii Ogn f1a3a6d082 Update pillow (#455)
* Update pillow

* relaxation of pillow update
2025-01-30 17:05:09 +01:00
Hossam HagagandGeorge Panchuk 993dcd5f68 chore: Add missing type hints in functions (#453)
* chore: Add missing type hints in functions

* add missing import, small type refactor

---------

Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
2025-01-29 12:08:20 +01:00
Hossam HagagandGeorge Panchuk 73e1e5ecb9 chore: Add missing returns in defs (#451)
* chore: Add missing returns in defs

* remove return type from init

* remove incorrect ndarray specifier

---------

Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
2025-01-29 08:33:49 +01:00
Dmitrii OgnandGeorge Panchuk 105d6cfb97 E5 pooling fix (#445)
* HF sources for all models

* Proper normalization for e5 models

* Rollback to origin/master

* Warning

* Tests fix

* Logging + model refactoring

* fix: refactor warnings, make e5-large non-normalized

* remove redundant code, update canonical values for e5

* align warning style

---------

Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
2025-01-28 23:14:43 +01:00
Hossam HagagandGeorge Panchuk bb815405aa chore: Add any to kwargs (#450)
* new: Add mypy and pyright deps

* chore: Add any to kwargs

* chore: Add any to args

* add missing kwargs

---------

Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
2025-01-28 22:49:37 +01:00
Dmitrii OgnandGeorge Panchuk 314842121d Load from local dir (#443)
* HF sources for all models

* Specific_model_path model path support

* Fix hf download

* fix: rollback incorrect model replacement

* refactor: remove redundant type imports

* refactor: replace List with list

* fix: remove redundant param in late interaction text embedding

* Update fastembed/common/model_management.py

* fix: rollback post process onnx output

---------

Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
2025-01-28 12:46:16 +01:00
Hossam Hagag c2f6fd1c90 Fix paraphrase minilm (#436)
* fix: Fix minilm paraphrase by adding it to pool models

* tests: Updated minilm paraphrase canonical vector

* chore: Added a warning message for updating the model

* chore: Added version where model will be removed
2025-01-27 23:27:47 +01:00
Hossam HagagandGeorge b05877de93 new: Added jina embedding v3 (#428)
* new: Added jina embedding v3

* refactor: Changed dim to int value

* new: Updated notice

* new: Extended text embedding with query embed and passage embed

* fix: Fix lazy load in query and passage embed

* tests: Added test for multitask embeddings

* nit: Remove cache dir from tests

* tests: Updated tests

* improve: Improve task selection

* fix: Fix ci

* fix: Update fastembed/text/multitask_embedding.py

Co-authored-by: George <george.panchuk@qdrant.tech>

* Update fastembed/text/multitask_embedding.py

Co-authored-by: George <george.panchuk@qdrant.tech>

* fix: Pass task id using kwargs to parallel processor

* tests: Added test for task assignment

* prefer enums over ints

* tests: Added test for parallel

* improve: Updated model description

* fix: Fix ci

* fix: Fix ci

* refactor: Refactor query_embed and passage_embed

* tests: Added task propagation to parallel

* refactor: Set default task as retrieval passage

* chore: Update default task in tests

---------

Co-authored-by: George <george.panchuk@qdrant.tech>
2025-01-27 23:27:23 +01:00
54f6cd9cbc Improve progress bar new (#440)
* improve: Improve progress bar

* fix: Fix error downloading when internet connection down

* new: Added file hash computation to track new versions

* refactor: Removed redundant hash check
fix: Fix ci

* new: Verify using hf_api

* new: Improve progress bar

* refactor new progress bar (#446)

* refactor

* chore: Remove redundant enable progress bar

---------

Co-authored-by: hh-space-invader <h.hagag.ali@gmail.com>

* refactor comments

---------

Co-authored-by: George <george.panchuk@qdrant.tech>
2025-01-27 22:34:39 +01:00
Hossam Hagag ae37da3bd4 fix: Update nomic ai model (#441)
* fix: Updated nomic ai with mean pooling

* chore: Updated warning message

* nit

* fix: Fix ci
2025-01-27 11:25:04 +01:00
George Panchuk fa11d0f0c7 bump version to 0.5.1 2025-01-16 11:11:43 +01:00
George 50289c62ed new: move onnx dependency to dev (#439)
* new: move onnx dependency to dev

* update poetry install, add more groups in pyproject
2025-01-15 17:39:06 +01:00
Dmitrii Ogn 3c10b6625b V0.5.0 (#430)
* Bump version
2024-12-24 16:32:53 +00:00
Dmitrii Ogn e89654d435 Hf sources (#429)
* HF sources for all models
2024-12-24 14:10:16 +00:00
Hossam Hagag cec8d54502 new: Provide userwarning when specifying providers and cuda (#425)
* new: Provide userwarning when specifying providers and cuda

* Updated warning message
2024-12-24 12:37:58 +02:00
Hossam Hagag 55b985c1ad new: Added multi-gpu example (#422)
* new: Added multi-gpu example

* improve: Updated multi-gpu example

* improve: Updated fastembed multi gpu docs example
2024-12-17 13:09:34 +01:00
Dmitrii OgnandGeorge c8b1a18cfc Cross encoders parallelism (#419)
* Merge master

* rerank_pairs interface + parallelism support

* remove test notebook

* Removed unused code

* New tests for cross encoders and new interface

* Importing Self fix. We will need it for mypy support in newer versions

* Removed Self typing

* Removed non-needed changes from text

* Isort + black

* wip: start reviewing (#420)

Co-authored-by: Dmitrii Ogn <dimitriy_rudenko@mail.ru>

* Test fix

* Update fastembed/rerank/cross_encoder/text_cross_encoder.py

Co-authored-by: George <george.panchuk@qdrant.tech>

* Update fastembed/rerank/cross_encoder/text_cross_encoder.py

Co-authored-by: George <george.panchuk@qdrant.tech>

* Update fastembed/rerank/cross_encoder/text_cross_encoder.py

Co-authored-by: George <george.panchuk@qdrant.tech>

* Update fastembed/rerank/cross_encoder/text_cross_encoder_base.py

Co-authored-by: George <george.panchuk@qdrant.tech>

* Test for parallel processing + bugfix of PosixPath passing

* Removed non-needed import and added docstring

* Typing fix + argument passing

* Test parametrization
Moved to selected models set to test

* Run base test on all models

* Typing fix + improvement of input_names check

* nit: fix post process, update docstring, update tokenize, remove redundant imports

---------

Co-authored-by: George <george.panchuk@qdrant.tech>
2024-12-16 21:59:34 +03:00
Hossam HagagandGeorge 3b5e4c8722 new: Added jina clip v1 (#408)
* WIP: Added jina clip text embedding

* WIP: Added preprocess for jina clip

* WIP: Added jina clip vision (not sure if it works yet)

* improve: Improved mean pooling if the output doesnt have seq length

* fix: Fixed jina clip text

* nit

* fix: Fixed jina clip image preprocessor

* fix: Fix type hints
new: added resize2square

* tests: Add jina clip vision test case

* nit

* refactor: Update fastembed/image/transform/operators.py

Co-authored-by: George <george.panchuk@qdrant.tech>

* fix: Fix indentation

* refactor: Refactored how we call padding for image

* fix: Fix pad to image when resized size larger than new square canvas

* refactor: minor refactor

* refactor: Refactor some functions in preprocess image

* fix: Fix to pad image with specified fill color

* refactor: Change resize to classmethod

* fix: Fix jina clip text v1

* fix: fix pad to square for some rectangular images (#421)

---------

Co-authored-by: George <george.panchuk@qdrant.tech>
2024-12-16 12:45:09 +02:00
George 516170cbaf new: add python 3.13 support (#404) 2024-12-11 19:18:54 +00:00
Hossam HagagandGeorge Panchuk 0f79d3f9d8 feat: Added a toggle to disable stemmer in bm25 (#416)
* feat: Added a toggle to disable stemmer in bm25

* refactor: Refactored how to disable stemming in bm25

* refactor: Refactored the way of disabling stemmer in bm25

* new: Added english fallback if language = None

* tests: Added test case for disable stemmer

* fix: Fix language to be only string

* tests: Updated bm25 toggle stemmer tests

* refactor: fix stopwords type

* fix: fix param propagation in parallel embed in bm25

---------

Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
2024-12-10 21:12:12 +01:00
Hossam Hagag 2ef9c38b8b Tsk 409 support gte models (#415)
* new: Added support for gte base model

* tests: Added test cannonical vectors for gte model
2024-12-04 09:25:35 +02:00
Hossam Hagag da30f934d8 fix: Fix colbert model shape mismatch (#413)
* fix: Fix colbert model shape mismatch

* refactor: Added the truncation after tokenizer init
2024-11-28 13:59:52 +02:00
Hossam Hagag adfc03ed0d Improve models cache progressbar (#406)
* chore: Remove typing hints of Python less than 3.9

* chore: Removed optional from cache as it cannot be undefined

* improve: Turned off progress bar of huggingface models if cached
2024-11-21 12:47:46 +02:00
Hossam Hagag e9dc3b1060 Support jina embeddings v2 models (#405)
* new: Added support for jinaai/jina-embeddings-v2-base-zh

* new: Added support for jinaai/jina-embeddings-v2-base-es
2024-11-19 14:54:51 +01:00
George 1343e55076 new: drop python 3.8 support, update type hints, ci (#403) 2024-11-15 15:54:19 +01:00
amietn 9841666bd5 Remove numpy<2 dependency (#362)
This causes an incompatibility with spaCy because no matching
versions of numpy can be found when using both libraries at once.

It looks like the tests pass again without the numpy<2 requirement
so there is no reason to keep this requirement anymore.
2024-11-13 11:43:02 +01:00
Hossam HagagandGeorge Panchuk 860e2ad691 Tsk 375 add jina rerankers (#379)
* feat: Added jina reranker models

* chore: Added jina reranker canonical score values

* chore: added rounding of the output for easier reproducability

* chore: Added jina reranker models in batch test

* chore: remove redundant np.round

* chore: test only <1gb files in local

* chore: Updated docs to add rerankers

* fix: recompute canonical values with fp16

* new: extend NOTICE with jina reranker v2

---------

Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
2024-11-13 10:48:45 +01:00
Hossam HagagandGeorge d141e29784 Fix type hint (#392)
* fix: Fix return of onnx_embed

* fix: Fix type hint of start method in worker class

* fix: Fix not passing kwargs in _preprocess_onnx_input and tokenize as base class

* fix: Fix not passing kwargs in _preprocess_onnx_input as base class

* fix: change tokenize in simpleTokenizer to classmethod

* chore: Changed query argument to Iterable to match base class

* chore: changed mask token id and pad token id to be int

* review suggestions (#398)

---------

Co-authored-by: George <george.panchuk@qdrant.tech>
2024-11-13 10:32:46 +01:00
Hossam HagagandGeorge 7c935717d2 improve: Changed the way we are adding the query and document markers in colbert (#391)
* improve: Changed the way we are adding the query and document markers in colbert

* fix: Truncate the inout_ids and attention_mask when adding query and document markers to original input length

* fix: Fix broadcast issue

* chore: Remove redundant if condition

* nit

* refactor (#397)

---------

Co-authored-by: George <george.panchuk@qdrant.tech>
2024-11-13 10:23:26 +01:00
paulmartrencharproandGeorge Panchuk 8413066f7b Add rerankers to the list of supported models (Supported_Models.ipynb) (#393)
* Add rerankers to the list of supported models (Supported_Models.ipynb)

* fix: update imports

---------

Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
2024-11-13 09:59:28 +01:00
George faed7f1320 fix: fix model cleanup in tests (#400)
* fix: fix model cleanup in tests
2024-11-12 19:08:39 +01:00
George 5b895420b1 Update GitHub templates (#389)
* new: add PR template, decouple bug template with new model request

* new: add feature request, model request
2024-11-08 11:45:21 +01:00
GeorgeandAnush e868bbaebc new: add reranker example to readme (#390)
* new: add reranker example to readme

* Update README.md

Co-authored-by: Anush  <anushshetty90@gmail.com>

* fix: compute the scores

---------

Co-authored-by: Anush <anushshetty90@gmail.com>
2024-11-07 17:31:13 +01:00
George Panchuk a5f3f11829 bump version to v0.4.2 2024-11-07 12:03:34 +01:00
Hossam HagagandGeorge 3fc0e2b382 chore: Add notice file for jina ai models (#380)
* chore: Add notice file for jina ai models

* chore: Update notice

* Update NOTICE

Co-authored-by: George <george.panchuk@qdrant.tech>

* chore: added jina embeddings v3

* chore: removed unsupported models

---------

Co-authored-by: George <george.panchuk@qdrant.tech>
2024-11-05 14:22:03 +02:00
Hossam Hagag 12b06ece51 chore: Lock onnxruntime version to be < 1.20.0 (#386) 2024-11-04 22:53:50 +02:00
Hossam Hagag 1deb830328 Tsk 374 add jina colbert v2 (#378)
* feat: Added support for jina-colbert-v2

* chore: Generalized query marker and document marker

* nit: remove github action on dispatch

* chore: updated license

* fix: Fix attention mask to be all 1 in xlmrobertatokenizer

* feat: Added class for JinaColbertV2

* feat: Added jina colbert

* chore: Change tolerance of the test

* chore: Changed encoding of attention mask to 1 to be only in queries

* chore: Changed the replacable token to be ' @' as its considered as one token

* chore: Removed redundant functions

* chore: Updated supported models docs

* nit: Remove print statement

* nit: visual stuff

* fix: Fix dimention of jina colbert in description

* fix: canonical query and document values for jina colbert
2024-11-03 11:02:41 +02:00
George Panchuk aba8fb43cf bump version to v0.4.1 2024-10-21 23:56:19 +04:00
Dmitrii OgnandGeorge Panchuk 69ffaa0f48 Pystemmer -> py-rust_stemmers (#366)
* bump version to 0.4.0

* py-rust-stemmers support instead of snowball stemmer and pystemmer

* py-rust-stemmers support instead of snowball stemmer and pystemmer

---------

Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
2024-10-21 23:46:20 +04:00
99 changed files with 13246 additions and 2179 deletions
+20 -18
View File
@@ -1,6 +1,6 @@
name: Bug/New Model Request
description: File a bug report/Request a new Model
title: "[Bug/Model Request]: "
name: Bug
description: File a bug report
title: "[Bug]: "
body:
- type: markdown
attributes:
@@ -10,11 +10,22 @@ body:
id: what-happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen?
placeholder: Tell us what you see!
value: "A bug happened!"
description: Describe the error you encountered.
placeholder: <Description>
validations:
required: true
- type: textarea
id: expected
attributes:
label: What is the expected behaviour?
description: Describe the way you expected the code to behave.
placeholder: <Description>
- type: textarea
id: code-snippet
attributes:
label: A minimal reproducible example
description: It would really help us to fix the problem if you could provide a code snippet that reproduces the issue.
placeholder: <Code snippet>
- type: textarea
id: python-version
attributes:
@@ -23,21 +34,12 @@ body:
placeholder: Python3.10
validations:
required: true
- type: dropdown
- type: textarea
id: version
attributes:
label: Version
label: FastEmbed version
description: What version of FastEmbed are you running? python -c "import fastembed; print(fastembed.__version__)". If you're not on the latest, please upgrade and see if the problem persists.
options:
- 0.2.7 (Latest)
- 0.2.6
- 0.2.5
- 0.2.4
- 0.2.3
- 0.2.2
- 0.2.1
- 0.1.x
default: 0
placeholder: v0.7.4
validations:
required: true
- type: dropdown
+1 -1
View File
@@ -1,4 +1,4 @@
blank_issues_enabled: false
blank_issues_enabled: true
contact_links:
- name: GitHub Community Support
url: https://github.com/qdrant/fastembed/discussions
@@ -0,0 +1,22 @@
name: Feature
description: New functionality request
title: "[Feature]: "
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this report!
- type: textarea
id: feature-description
attributes:
label: What feature would you like to request?
description: Please provide the description of the feature you would like to request.
placeholder: <Description>
validations:
required: true
- type: textarea
id: additional-info
attributes:
label: Is there any additional information you would like to provide?
description: Please provide any additional information that you think might be useful.
placeholder: <Info>
+22
View File
@@ -0,0 +1,22 @@
name: Model
description: Request a new model
title: "[Model]: "
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this report!
- type: textarea
id: model-name
attributes:
label: Which model would you like to support?
description: Please provide the name of the model you would like to see supported.
placeholder: Link to the model (e.g. on HuggingFace)
validations:
required: true
- type: textarea
id: motivation
attributes:
label: What are the main advantages of this model?
description: Please describe the main advantages of this model comparing to the existing ones and provide links to benchmarks if there are any.
placeholder: <Description>
+19
View File
@@ -0,0 +1,19 @@
### All Submissions:
* [ ] Have you followed the guidelines in our Contributing document?
* [ ] Have you checked to ensure there aren't other open [Pull Requests](../../../pulls) for the same update/change?
<!-- You can erase any parts of this template not applicable to your Pull Request. -->
### New Feature Submissions:
* [ ] Does your submission pass the existing tests?
* [ ] Have you added tests for your feature?
* [ ] Have you installed `pre-commit` with `pip3 install pre-commit` and set up hooks with `pre-commit install`?
### New models submission:
* [ ] Have you added an explanation of why it's important to include this model?
* [ ] Have you added tests for the new model? Were canonical values for tests computed via the original model?
* [ ] Have you added the code snippet for how canonical values were computed?
* [ ] Have you successfully ran tests with your changes locally?
+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
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.9.x'
python-version: '3.10.x'
- name: Install dependencies
run: |
python -m pip install poetry
+9 -8
View File
@@ -1,9 +1,11 @@
name: Tests
run-name: Tests (gpu)
on:
push:
branches: [ master, main, gpu ]
pull_request:
branches: [ master, main, gpu ]
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
@@ -14,15 +16,12 @@ jobs:
strategy:
matrix:
python-version:
- '3.8.x'
- '3.9.x'
- '3.10.x'
- '3.11.x'
- '3.12.x'
- '3.13.x'
os:
- ubuntu-latest
- macos-latest
- windows-latest
runs-on: ${{ matrix.os }}
@@ -38,8 +37,10 @@ jobs:
run: |
python -m pip install poetry
poetry config virtualenvs.create false
poetry install --no-interaction --no-ansi --without docs
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.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
+22
View File
@@ -0,0 +1,22 @@
Copyright 2024 Qdrant
This product includes software developed by Qdrant
This distribution includes the following Jina AI models, each with its respective license:
- jinaai/jina-colbert-v2
- License: cc-by-nc-4.0
- jinaai/jina-reranker-v2-base-multilingual
- License: cc-by-nc-4.0
- jinaai/jina-embeddings-v3
- License: cc-by-nc-4.0
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.
+91 -22
View File
@@ -28,10 +28,10 @@ pip install fastembed-gpu
```python
from fastembed import TextEmbedding
from typing import List
# Example list of documents
documents: List[str] = [
documents: list[str] = [
"This is built to be faster and lighter than other embedding libraries e.g. Transformers, Sentence-Transformers, etc.",
"fastembed is supported by and maintained by Qdrant.",
]
@@ -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,58 @@ 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
query = "Who is maintaining Qdrant?"
documents: list[str] = [
"This is built to be faster and lighter than other embedding libraries e.g. Transformers, Sentence-Transformers, etc.",
"fastembed is supported by and maintained by Qdrant.",
]
encoder = TextCrossEncoder(model_name="Xenova/ms-marco-MiniLM-L-6-v2")
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
@@ -177,36 +246,36 @@ pip install qdrant-client[fastembed-gpu]
You might have to use quotes ```pip install 'qdrant-client[fastembed]'``` on zsh.
```python
from qdrant_client import QdrantClient
from qdrant_client import QdrantClient, models
# Initialize the client
client = QdrantClient("localhost", port=6333) # For production
# client = QdrantClient(":memory:") # For small experiments
# client = QdrantClient(":memory:") # For experimentation
# Prepare your documents, metadata, and IDs
docs = ["Qdrant has Langchain integrations", "Qdrant also has Llama Index integrations"]
metadata = [
{"source": "Langchain-docs"},
{"source": "Llama-index-docs"},
model_name = "sentence-transformers/all-MiniLM-L6-v2"
payload = [
{"document": "Qdrant has Langchain integrations", "source": "Langchain-docs", },
{"document": "Qdrant also has Llama Index integrations", "source": "LlamaIndex-docs"},
]
docs = [models.Document(text=data["document"], model=model_name) for data in payload]
ids = [42, 2]
# If you want to change the model:
# client.set_model("sentence-transformers/all-MiniLM-L6-v2")
# List of supported models: https://qdrant.github.io/fastembed/examples/Supported_Models
# Use the new add() instead of upsert()
# This internally calls embed() of the configured embedding model
client.add(
collection_name="demo_collection",
documents=docs,
metadata=metadata,
ids=ids
client.create_collection(
"demo_collection",
vectors_config=models.VectorParams(
size=client.get_embedding_size(model_name), distance=models.Distance.COSINE)
)
search_result = client.query(
client.upload_collection(
collection_name="demo_collection",
query_text="This is a query document"
vectors=docs,
ids=ids,
payload=payload,
)
search_result = client.query_points(
collection_name="demo_collection",
query=models.Document(text="This is a query document", model=model_name)
).points
print(search_result)
```
+1 -3
View File
@@ -65,15 +65,13 @@
}
],
"source": [
"from typing import List\n",
"\n",
"import numpy as np\n",
"\n",
"from fastembed import TextEmbedding\n",
"\n",
"\n",
"# Example list of documents\n",
"documents: List[str] = [\n",
"documents: list[str] = [\n",
" \"This is built to be faster and lighter than other embedding libraries e.g. Transformers, Sentence-Transformers, etc.\",\n",
" \"fastembed is supported by and maintained by Qdrant.\",\n",
"]\n",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

+14 -3
View File
@@ -54,7 +54,14 @@
},
{
"data": {
"text/plain": "[{'model': 'colbert-ir/colbertv2.0',\n 'dim': 128,\n 'description': 'Late interaction model',\n 'size_in_GB': 0.44,\n 'sources': {'hf': 'colbert-ir/colbertv2.0'},\n 'model_file': 'model.onnx'}]"
"text/plain": [
"[{'model': 'colbert-ir/colbertv2.0',\n",
" 'dim': 128,\n",
" 'description': 'Late interaction model',\n",
" 'size_in_GB': 0.44,\n",
" 'sources': {'hf': 'colbert-ir/colbertv2.0'},\n",
" 'model_file': 'model.onnx'}]"
]
},
"execution_count": 1,
"metadata": {},
@@ -212,7 +219,9 @@
"outputs": [
{
"data": {
"text/plain": "((26, 128), (32, 128))"
"text/plain": [
"((26, 128), (32, 128))"
]
},
"execution_count": 18,
"metadata": {},
@@ -271,7 +280,9 @@
"import numpy as np\n",
"\n",
"\n",
"def compute_relevance_scores(query_embedding: np.array, document_embeddings: np.array, k: int):\n",
"def compute_relevance_scores(\n",
" query_embedding: np.array, document_embeddings: np.array, k: int\n",
") -> list[int]:\n",
" \"\"\"\n",
" Compute relevance scores for top-k documents given a query.\n",
"\n",
+1 -5
View File
@@ -388,8 +388,6 @@
}
],
"source": [
"from typing import List\n",
"\n",
"import numpy as np\n",
"\n",
"from fastembed import TextEmbedding\n",
@@ -407,9 +405,7 @@
"id": "iPtoHf7GeV-i"
},
"outputs": [],
"source": [
"documents: List[str] = list(np.repeat(\"Demonstrating GPU acceleration in fastembed\", 500))"
]
"source": "documents: list[str] = list(np.repeat(\"Demonstrating GPU acceleration in fastembed\", 500))"
},
{
"cell_type": "code",
+88
View File
@@ -0,0 +1,88 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Fastembed Multi-GPU Tutorial\n",
"This tutorial demonstrates how to leverage multi-GPU support in Fastembed. Fastembed supports embedding text and images utilizing modern GPUs for acceleration. Let's explore how to use Fastembed with multiple GPUs step by step."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"#### Prerequisites\n",
"To get started, ensure you have the following installed:\n",
"- Python 3.9 or later\n",
"- Fastembed (`pip install fastembed-gpu`)\n",
"- Refer to [this](https://github.com/qdrant/fastembed/blob/main/docs/examples/FastEmbed_GPU.ipynb) tutorial if you have issues with GPU dependencies\n",
"- Access to a multi-GPU server"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Multi-GPU using cuda argument with TextEmbedding Model"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from fastembed import TextEmbedding\n",
"\n",
"# define the documents to embed\n",
"docs = [\"hello world\", \"flag embedding\"] * 100\n",
"\n",
"# define gpu ids\n",
"device_ids = [0, 1]\n",
"\n",
"if __name__ == \"__main__\":\n",
" # initialize a TextEmbedding model using CUDA\n",
" text_model = TextEmbedding(\n",
" model_name=\"sentence-transformers/all-MiniLM-L6-v2\",\n",
" cuda=True,\n",
" device_ids=device_ids,\n",
" lazy_load=True,\n",
" )\n",
"\n",
" # generate embeddings\n",
" text_embeddings = list(text_model.embed(docs, batch_size=2, parallel=len(device_ids)))\n",
" print(text_embeddings)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"In this snippet:\n",
"- `cuda=True` enables GPU acceleration.\n",
"- `device_ids=[0, 1]` specifies GPUs to use. Replace `[0, 1]` with available GPU IDs.\n",
"- `lazy_load=True`\n",
"\n",
"**NOTE**: When using multi-GPU settings, it is important to configure `parallel` and `lazy_load` properly to avoid inefficiencies:\n",
"\n",
"`parallel`: This parameter enables multi-GPU support by spawning child processes for each GPU specified in device_ids. To ensure proper utilization, the value of `parallel` must match the number of GPUs in device_ids. If using a single GPU, this parameter is not necessary.\n",
"\n",
"`lazy_load`: Enabling `lazy_load` prevents redundant memory usage. Without `lazy_load`, the model is initially loaded into the memory of the first GPU by the main process. When child processes are spawned for each GPU, the model is reloaded on the first GPU, causing redundant memory consumption and inefficiencies."
]
}
],
"metadata": {
"kernelspec": {
"display_name": ".venv",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.10.15"
}
},
"nbformat": 4,
"nbformat_minor": 2
}
@@ -36,7 +36,7 @@
"outputs": [],
"source": [
"import time\n",
"from typing import Callable, List, Tuple\n",
"from typing import Callable\n",
"\n",
"import matplotlib.pyplot as plt\n",
"import torch.nn.functional as F\n",
@@ -64,6 +64,7 @@
],
"source": [
"import fastembed\n",
"\n",
"fastembed.__version__"
]
},
@@ -98,7 +99,7 @@
}
],
"source": [
"documents: List[str] = [\n",
"documents: list[str] = [\n",
" \"Chandrayaan-3 is India's third lunar mission\",\n",
" \"It aimed to land a rover on the Moon's surface - joining the US, China and Russia\",\n",
" \"The mission is a follow-up to Chandrayaan-2, which had partial success\",\n",
@@ -151,11 +152,11 @@
" HuggingFace Transformer implementation of FlagEmbedding\n",
" \"\"\"\n",
"\n",
" def __init__(self, model_id: str):\n",
" def __init__(self, model_id: str) -> None:\n",
" self.model = AutoModel.from_pretrained(model_id)\n",
" self.tokenizer = AutoTokenizer.from_pretrained(model_id)\n",
"\n",
" def embed(self, texts: List[str]):\n",
" def embed(self, texts: list[str]):\n",
" encoded_input = self.tokenizer(\n",
" texts, max_length=512, padding=True, truncation=True, return_tensors=\"pt\"\n",
" )\n",
@@ -254,7 +255,7 @@
"\n",
"def calculate_time_stats(\n",
" embed_func: Callable, documents: list, k: int\n",
") -> Tuple[float, float, float]:\n",
") -> tuple[float, float, float]:\n",
" times = []\n",
" for _ in range(k):\n",
" # Timing the embed_func call\n",
@@ -309,7 +310,7 @@
],
"source": [
"def plot_character_per_second_comparison(\n",
" hf_stats: Tuple[float, float, float], fst_stats: Tuple[float, float, float], documents: list\n",
" hf_stats: tuple[float, float, float], fst_stats: tuple[float, float, float], documents: list\n",
"):\n",
" # Calculating total characters in documents\n",
" total_characters = sum(len(doc) for doc in documents)\n",
@@ -44,7 +44,7 @@
},
{
"cell_type": "code",
"execution_count": 21,
"execution_count": null,
"metadata": {
"ExecuteTime": {
"end_time": "2024-03-30T00:45:24.814968Z",
@@ -58,8 +58,6 @@
},
"outputs": [],
"source": [
"from typing import List\n",
"\n",
"import numpy as np\n",
"from datasets import load_dataset\n",
"from peft import AutoPeftModelForCausalLM\n",
@@ -72,11 +70,11 @@
},
{
"cell_type": "code",
"execution_count": 23,
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"hf_token = <YOUR_HF_TOKEN_HERE> # Get your token from https://huggingface.co/settings/token, needed for Gemma weights"
"hf_token = \"<YOUR_HF_TOKEN_HERE>\" # Get your token from https://huggingface.co/settings/token, needed for Gemma weights"
]
},
{
@@ -246,7 +244,7 @@
},
"outputs": [],
"source": [
"context_embeddings: List[np.ndarray] = list(\n",
"context_embeddings: list[np.ndarray] = list(\n",
" embedding_model.embed(contexts)\n",
") # Note the list() call - this is a generator"
]
+11 -12
View File
@@ -50,7 +50,6 @@
"outputs": [],
"source": [
"import json\n",
"from typing import List, Tuple\n",
"\n",
"import numpy as np\n",
"import pandas as pd\n",
@@ -489,11 +488,11 @@
}
],
"source": [
"def make_sparse_embedding(texts: List[str]):\n",
"def make_sparse_embedding(texts: list[str]) -> list[SparseEmbedding]:\n",
" return list(sparse_model.embed(texts, batch_size=32))\n",
"\n",
"\n",
"sparse_embedding: List[SparseEmbedding] = make_sparse_embedding(\n",
"sparse_embedding: list[SparseEmbedding] = make_sparse_embedding(\n",
" [\"Fastembed is a great library for text embeddings!\"]\n",
")\n",
"sparse_embedding"
@@ -616,7 +615,7 @@
}
],
"source": [
"def get_tokens_and_weights(sparse_embedding, model_name):\n",
"def get_tokens_and_weights(sparse_embedding, model_name) -> dict[str, float]:\n",
" # Find the tokenizer for the model\n",
" tokenizer_source = None\n",
" for model_info in SparseTextEmbedding.list_supported_models():\n",
@@ -627,7 +626,7 @@
" raise ValueError(f\"Model {model_name} not found in the supported models.\")\n",
"\n",
" tokenizer = AutoTokenizer.from_pretrained(tokenizer_source)\n",
" token_weight_dict = {}\n",
" token_weight_dict: dict[str, float] = {}\n",
" for i in range(len(sparse_embedding.indices)):\n",
" token = tokenizer.decode([sparse_embedding.indices[i]])\n",
" weight = sparse_embedding.values[i]\n",
@@ -662,7 +661,7 @@
},
"outputs": [],
"source": [
"def make_dense_embedding(texts: List[str]):\n",
"def make_dense_embedding(texts: list[str]):\n",
" return list(dense_model.embed(texts))\n",
"\n",
"\n",
@@ -872,7 +871,7 @@
},
"outputs": [],
"source": [
"def make_points(df: pd.DataFrame) -> List[PointStruct]:\n",
"def make_points(df: pd.DataFrame) -> list[PointStruct]:\n",
" sparse_vectors = df[\"sparse_embedding\"].tolist()\n",
" product_texts = df[\"combined_text\"].tolist()\n",
" dense_vectors = df[\"dense_embedding\"].tolist()\n",
@@ -899,7 +898,7 @@
" return points\n",
"\n",
"\n",
"points: List[PointStruct] = make_points(df)"
"points: list[PointStruct] = make_points(df)"
]
},
{
@@ -942,8 +941,8 @@
"source": [
"def search(query_text: str):\n",
" # # Compute sparse and dense vectors\n",
" query_sparse_vectors: List[SparseEmbedding] = make_sparse_embedding([query_text])\n",
" query_dense_vector: List[np.ndarray] = make_dense_embedding([query_text])\n",
" query_sparse_vectors: list[SparseEmbedding] = make_sparse_embedding([query_text])\n",
" query_dense_vector: list[np.ndarray] = make_dense_embedding([query_text])\n",
"\n",
" search_results = client.search_batch(\n",
" collection_name=collection_name,\n",
@@ -1075,7 +1074,7 @@
"metadata": {},
"outputs": [],
"source": [
"def rank_list(search_result: List[ScoredPoint]):\n",
"def rank_list(search_result: list[ScoredPoint]):\n",
" return [(point.id, rank + 1) for rank, point in enumerate(search_result)]\n",
"\n",
"\n",
@@ -1149,7 +1148,7 @@
],
"source": [
"def find_point_by_id(\n",
" client: QdrantClient, collection_name: str, rrf_rank_list: List[Tuple[int, float]]\n",
" client: QdrantClient, collection_name: str, rrf_rank_list: list[tuple[int, float]]\n",
"):\n",
" return client.retrieve(\n",
" collection_name=collection_name, ids=[item[0] for item in rrf_rank_list]\n",
+14 -9
View File
@@ -47,7 +47,7 @@
},
{
"cell_type": "code",
"execution_count": 2,
"execution_count": null,
"metadata": {
"ExecuteTime": {
"end_time": "2024-03-30T00:49:20.516644Z",
@@ -56,8 +56,7 @@
},
"outputs": [],
"source": [
"from fastembed import SparseTextEmbedding, SparseEmbedding\n",
"from typing import List"
"from fastembed import SparseTextEmbedding, SparseEmbedding"
]
},
{
@@ -134,7 +133,7 @@
},
{
"cell_type": "code",
"execution_count": 5,
"execution_count": null,
"metadata": {
"ExecuteTime": {
"end_time": "2024-03-30T00:49:28.624109Z",
@@ -143,7 +142,7 @@
},
"outputs": [],
"source": [
"documents: List[str] = [\n",
"documents: list[str] = [\n",
" \"Chandrayaan-3 is India's third lunar mission\",\n",
" \"It aimed to land a rover on the Moon's surface - joining the US, China and Russia\",\n",
" \"The mission is a follow-up to Chandrayaan-2, which had partial success\",\n",
@@ -157,7 +156,7 @@
" \"Chandrayaan-3 was launched from the Satish Dhawan Space Centre in Sriharikota\",\n",
" \"Chandrayaan-3 was launched earlier in the year 2023\",\n",
"]\n",
"sparse_embeddings_list: List[SparseEmbedding] = list(\n",
"sparse_embeddings_list: list[SparseEmbedding] = list(\n",
" model.embed(documents, batch_size=6)\n",
") # batch_size is optional, notice the generator"
]
@@ -235,7 +234,9 @@
"source": [
"# Let's print the first 5 features and their weights for better understanding.\n",
"for i in range(5):\n",
" print(f\"Token at index {sparse_embeddings_list[0].indices[i]} has weight {sparse_embeddings_list[0].values[i]}\")"
" print(\n",
" f\"Token at index {sparse_embeddings_list[0].indices[i]} has weight {sparse_embeddings_list[0].values[i]}\"\n",
" )"
]
},
{
@@ -261,7 +262,9 @@
"import json\n",
"from transformers import AutoTokenizer\n",
"\n",
"tokenizer = AutoTokenizer.from_pretrained(SparseTextEmbedding.list_supported_models()[0][\"sources\"][\"hf\"])"
"tokenizer = AutoTokenizer.from_pretrained(\n",
" SparseTextEmbedding.list_supported_models()[0][\"sources\"][\"hf\"]\n",
")"
]
},
{
@@ -326,7 +329,9 @@
" token_weight_dict[token] = weight\n",
"\n",
" # Sort the dictionary by weights\n",
" token_weight_dict = dict(sorted(token_weight_dict.items(), key=lambda item: item[1], reverse=True))\n",
" token_weight_dict = dict(\n",
" sorted(token_weight_dict.items(), key=lambda item: item[1], reverse=True)\n",
" )\n",
" return token_weight_dict\n",
"\n",
"\n",
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -26,14 +26,14 @@ pip install fastembed
```python
from fastembed import TextEmbedding
documents: List[str] = [
documents: list[str] = [
"passage: Hello, World!",
"query: Hello, World!",
"passage: This is an example passage.",
"fastembed is supported by and maintained by Qdrant."
]
embedding_model = TextEmbedding()
embeddings: List[np.ndarray] = embedding_model.embed(documents)
embeddings: list[np.ndarray] = embedding_model.embed(documents)
```
## Usage with Qdrant
+2 -3
View File
@@ -41,7 +41,6 @@
"metadata": {},
"outputs": [],
"source": [
"from typing import List\n",
"import numpy as np\n",
"from fastembed import TextEmbedding"
]
@@ -71,7 +70,7 @@
],
"source": [
"# Example list of documents\n",
"documents: List[str] = [\n",
"documents: list[str] = [\n",
" \"Maharana Pratap was a Rajput warrior king from Mewar\",\n",
" \"He fought against the Mughal Empire led by Akbar\",\n",
" \"The Battle of Haldighati in 1576 was his most famous battle\",\n",
@@ -87,7 +86,7 @@
"embedding_model = TextEmbedding(model_name=\"BAAI/bge-small-en\")\n",
"\n",
"# We'll use the passage_embed method to get the embeddings for the documents\n",
"embeddings: List[np.ndarray] = list(\n",
"embeddings: list[np.ndarray] = list(\n",
" embedding_model.passage_embed(documents)\n",
") # notice that we are casting the generator to a list\n",
"\n",
+4 -3
View File
@@ -46,7 +46,6 @@
"metadata": {},
"outputs": [],
"source": [
"from typing import List\n",
"from qdrant_client import QdrantClient"
]
},
@@ -67,7 +66,7 @@
"outputs": [],
"source": [
"# Example list of documents\n",
"documents: List[str] = [\n",
"documents: list[str] = [\n",
" \"Maharana Pratap was a Rajput warrior king from Mewar\",\n",
" \"He fought against the Mughal Empire led by Akbar\",\n",
" \"The Battle of Haldighati in 1576 was his most famous battle\",\n",
@@ -199,7 +198,9 @@
}
],
"source": [
"search_result = client.query(collection_name=\"demo_collection\", query_text=\"This is a query document\")\n",
"search_result = client.query(\n",
" collection_name=\"demo_collection\", query_text=\"This is a query document\"\n",
")\n",
"print(search_result)"
]
},
+11 -5
View File
@@ -19,7 +19,7 @@
"outputs": [],
"source": [
"from pathlib import Path\n",
"from typing import List, Tuple, Any\n",
"from typing import Any\n",
"\n",
"import numpy as np\n",
"import time\n",
@@ -91,9 +91,11 @@
" return last_hidden.sum(dim=1) / attention_mask.sum(dim=1)[..., None]\n",
"\n",
"\n",
"def hf_embed(model_id: str, inputs: List[str]):\n",
"def hf_embed(model_id: str, inputs: list[str]):\n",
" # Tokenize the input texts\n",
" batch_dict = hf_tokenizer(inputs, max_length=512, padding=True, truncation=True, return_tensors=\"pt\")\n",
" batch_dict = hf_tokenizer(\n",
" inputs, max_length=512, padding=True, truncation=True, return_tensors=\"pt\"\n",
" )\n",
"\n",
" outputs = hf_model(**batch_dict)\n",
" embeddings = average_pool(outputs.last_hidden_state, batch_dict[\"attention_mask\"])\n",
@@ -133,7 +135,9 @@
"optimization_config = AutoOptimizationConfig.O4()\n",
"optimizer = ORTOptimizer.from_pretrained(model)\n",
"\n",
"optimizer.optimize(save_dir=save_dir, optimization_config=optimization_config, use_external_data_format=True)\n",
"optimizer.optimize(\n",
" save_dir=save_dir, optimization_config=optimization_config, use_external_data_format=True\n",
")\n",
"model = ORTModelForFeatureExtraction.from_pretrained(save_dir)\n",
"\n",
"tokenizer.save_pretrained(save_dir)\n",
@@ -171,7 +175,9 @@
"metadata": {},
"outputs": [],
"source": [
"def measure_pipeline_time(pipeline, input_texts: List[str], num_runs=10, **kwargs: Any) -> Tuple[float, float]:\n",
"def measure_pipeline_time(\n",
" pipeline, input_texts: list[str], num_runs=10, **kwargs: Any\n",
") -> tuple[float, float]:\n",
" \"\"\"Measures the time it takes to run the pipeline on the input texts.\"\"\"\n",
" times = []\n",
" total_chars = sum(len(text) for text in input_texts)\n",
File diff suppressed because one or more lines are too long
+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 Any
@dataclass(frozen=True)
class ModelSource:
hf: str | None = None
url: str | None = 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: int | None = None
tasks: dict[str, Any] | None = 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: bool | None = None
vocab_size: int | None = None
class PoolingType(str, Enum):
CLS = "CLS"
MEAN = "MEAN"
DISABLED = "DISABLED"
+222 -36
View File
@@ -1,29 +1,70 @@
import os
import time
import json
import shutil
import tarfile
from copy import deepcopy
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, TypeVar, Generic
import requests
from huggingface_hub import snapshot_download
from huggingface_hub.utils import RepositoryNotFoundError
from huggingface_hub import snapshot_download, model_info, list_repo_tree
from huggingface_hub.hf_api import RepoFile
from huggingface_hub.utils import (
RepositoryNotFoundError,
disable_progress_bars,
enable_progress_bars,
)
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
def list_supported_models(cls) -> List[Dict[str, Any]]:
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.
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.
@@ -34,10 +75,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__}.")
@@ -93,22 +134,73 @@ class ModelManagement:
def download_files_from_huggingface(
cls,
hf_source_repo: str,
cache_dir: Optional[str] = None,
extra_patterns: Optional[List[str]] = None,
cache_dir: str,
extra_patterns: list[str],
local_files_only: bool = False,
**kwargs,
**kwargs: Any,
) -> str:
"""
Downloads a model from HuggingFace Hub.
Args:
hf_source_repo (str): Name of the model on HuggingFace Hub, e.g. "qdrant/all-MiniLM-L6-v2-onnx".
cache_dir (Optional[str]): The path to the cache directory.
extra_patterns (Optional[List[str]]): extra patterns to allow in the snapshot download, typically
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.
Returns:
Path: The path to the model directory.
"""
def _verify_files_from_metadata(
model_dir: Path, stored_metadata: dict[str, Any], repo_files: list[RepoFile]
) -> bool:
try:
for rel_path, meta in stored_metadata.items():
file_path = model_dir / rel_path
if not file_path.exists():
return False
if repo_files: # online verification
file_info = next((f for f in repo_files if f.path == file_path.name), None)
if (
not file_info
or file_info.size != meta["size"]
or file_info.blob_id != meta["blob_id"]
):
return False
else: # offline verification
if file_path.stat().st_size != meta["size"]:
return False
return True
except (OSError, KeyError) as e:
logger.error(f"Error verifying files: {str(e)}")
return False
def _collect_file_metadata(
model_dir: Path, repo_files: list[RepoFile]
) -> dict[str, dict[str, int | str]]:
meta: dict[str, dict[str, 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:
repo_file = file_info_map.get(file_path.name)
if repo_file:
meta[str(file_path.relative_to(model_dir))] = {
"size": repo_file.size,
"blob_id": repo_file.blob_id,
}
return meta
def _save_file_metadata(model_dir: Path, meta: dict[str, dict[str, int | str]]) -> None:
try:
if not model_dir.exists():
model_dir.mkdir(parents=True, exist_ok=True)
(model_dir / cls.METADATA_FILE).write_text(json.dumps(meta))
except (OSError, ValueError) as e:
logger.warning(f"Error saving metadata: {str(e)}")
allow_patterns = [
"config.json",
"tokenizer.json",
@@ -116,10 +208,54 @@ class ModelManagement:
"special_tokens_map.json",
"preprocessor_config.json",
]
if extra_patterns is not None:
allow_patterns.extend(extra_patterns)
return snapshot_download(
allow_patterns.extend(extra_patterns)
snapshot_dir = Path(cache_dir) / f"models--{hf_source_repo.replace('/', '--')}"
metadata_file = snapshot_dir / cls.METADATA_FILE
if local_files_only:
disable_progress_bars()
if metadata_file.exists():
metadata = json.loads(metadata_file.read_text())
verified = _verify_files_from_metadata(snapshot_dir, metadata, repo_files=[])
if not verified:
logger.warning(
"Local file sizes do not match the metadata."
) # do not raise, still make an attempt to load the model
result = snapshot_download(
repo_id=hf_source_repo,
allow_patterns=allow_patterns,
cache_dir=cache_dir,
local_files_only=local_files_only,
**kwargs,
)
return result
repo_revision = model_info(hf_source_repo).sha
repo_tree = list(list_repo_tree(hf_source_repo, revision=repo_revision, repo_type="model"))
allowed_extensions = {".json", ".onnx", ".txt"}
repo_files = (
[
f
for f in repo_tree
if isinstance(f, RepoFile) and Path(f.path).suffix in allowed_extensions
]
if repo_tree
else []
)
verified_metadata = False
if snapshot_dir.exists() and metadata_file.exists():
metadata = json.loads(metadata_file.read_text())
verified_metadata = _verify_files_from_metadata(snapshot_dir, metadata, repo_files)
if verified_metadata:
disable_progress_bars()
result = snapshot_download(
repo_id=hf_source_repo,
allow_patterns=allow_patterns,
cache_dir=cache_dir,
@@ -127,8 +263,26 @@ class ModelManagement:
**kwargs,
)
if (
not verified_metadata
): # metadata is not up-to-date, update it and check whether the files have been
# downloaded correctly
metadata = _collect_file_metadata(snapshot_dir, repo_files)
download_successful = _verify_files_from_metadata(
snapshot_dir, metadata, repo_files=[]
) # offline verification
if not download_successful:
raise ValueError(
"Files have been corrupted during downloading process. "
"Please check your internet connection and try again."
)
_save_file_metadata(snapshot_dir, metadata)
return result
@classmethod
def decompress_to_cache(cls, targz_path: str, cache_dir: str):
def decompress_to_cache(cls, targz_path: str, cache_dir: str) -> str:
"""
Decompresses a .tar.gz file to a cache directory.
@@ -166,9 +320,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
@@ -210,14 +369,12 @@ class ModelManagement:
return model_dir
@classmethod
def download_model(
cls, model: Dict[str, Any], cache_dir: Path, retries: int = 3, **kwargs
) -> 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:
```
{
@@ -238,23 +395,48 @@ class ModelManagement:
Path: The path to the downloaded model directory.
"""
local_files_only = kwargs.get("local_files_only", False)
hf_offline = os.environ.get("HF_HUB_OFFLINE", "").strip().upper()
if not local_files_only and hf_offline in {"1", "TRUE", "YES", "ON"}:
local_files_only = True
kwargs["local_files_only"] = True
specific_model_path: str | None = kwargs.pop("specific_model_path", None)
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
extra_patterns = [model.model_file]
extra_patterns.extend(model.additional_files)
if hf_source:
try:
cache_kwargs = deepcopy(kwargs)
cache_kwargs["local_files_only"] = True
return Path(
cls.download_files_from_huggingface(
hf_source,
cache_dir=cache_dir,
extra_patterns=extra_patterns,
**cache_kwargs,
)
)
except Exception:
pass
finally:
enable_progress_bars()
sleep = 3.0
while retries > 0:
retries -= 1
if hf_source:
extra_patterns = [model["model_file"]]
extra_patterns.extend(model.get("additional_files", []))
if hf_source and not local_files_only:
# we have already tried loading with `local_files_only=True` via hf and we failed
try:
return Path(
cls.download_files_from_huggingface(
hf_source,
cache_dir=str(cache_dir),
cache_dir=cache_dir,
extra_patterns=extra_patterns,
**kwargs,
)
@@ -265,12 +447,15 @@ class ModelManagement:
f"Could not download model from HuggingFace: {e} "
"Falling back to other sources."
)
finally:
enable_progress_bars()
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:
@@ -279,11 +464,12 @@ class ModelManagement:
if local_files_only:
logger.error("Could not find model in cache_dir")
break
else:
logger.error(
f"Could not download model from either source, sleeping for {sleep} seconds, {retries} retries left."
)
time.sleep(sleep)
sleep *= 3
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.")
+88 -35
View File
@@ -1,22 +1,15 @@
import warnings
from dataclasses import dataclass
from pathlib import Path
from typing import (
Any,
Dict,
Generic,
Iterable,
Optional,
Sequence,
Tuple,
Type,
TypeVar,
)
from typing import Any, Generic, Iterable, 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, Device
from fastembed.parallel_processor import Worker
# Holds type of the embedding result
@@ -25,26 +18,38 @@ 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: NDArray[np.int64] | None = None
input_ids: NDArray[np.int64] | None = None
metadata: dict[str, Any] | None = None
class OnnxModel(Generic[T]):
EXPOSED_SESSION_OPTIONS = ("enable_cpu_mem_arena",)
@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]:
def _post_process_onnx_output(self, output: OnnxOutputContext, **kwargs: Any) -> Iterable[T]:
"""Post-process the ONNX model output to convert it into a usable format.
Args:
output (OnnxOutputContext): The raw output from the ONNX model.
**kwargs: Additional keyword arguments that may be needed by specific implementations.
Returns:
Iterable[T]: Post-processed output as an iterable of type T.
"""
raise NotImplementedError("Subclasses must implement this method")
def __init__(self) -> None:
self.model = None
self.tokenizer = None
self.model: ort.InferenceSession | None = None
self.tokenizer: Tokenizer | None = None
def _preprocess_onnx_input(
self, onnx_input: Dict[str, np.ndarray], **kwargs
) -> Dict[str, np.ndarray]:
self, onnx_input: dict[str, NumpyArray], **kwargs: Any
) -> dict[str, NumpyArray]:
"""
Preprocess the onnx input.
"""
@@ -54,17 +59,30 @@ class OnnxModel(Generic[T]):
self,
model_dir: Path,
model_file: str,
threads: Optional[int],
providers: Optional[Sequence[OnnxProvider]] = None,
cuda: bool = False,
device_id: Optional[int] = None,
threads: int | None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_id: int | None = None,
extra_session_options: dict[str, Any] | None = None,
) -> None:
model_path = model_dir / model_file
# List of Execution Providers: https://onnxruntime.ai/docs/execution-providers
available_providers = ort.get_available_providers()
cuda_available = "CUDAExecutionProvider" in available_providers
explicit_cuda = cuda is True or cuda == Device.CUDA
if explicit_cuda and providers is not None:
warnings.warn(
f"`cuda` and `providers` are mutually exclusive parameters, "
f"cuda: {cuda}, providers: {providers}. If you'd like to use providers, cuda should be one of "
f"[False, Device.CPU, Device.AUTO].",
category=UserWarning,
stacklevel=6,
)
if providers is not None:
onnx_providers = list(providers)
elif cuda:
elif explicit_cuda or (cuda == Device.AUTO and cuda_available):
if device_id is None:
onnx_providers = ["CUDAExecutionProvider"]
else:
@@ -72,8 +90,7 @@ class OnnxModel(Generic[T]):
else:
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]
@@ -90,10 +107,14 @@ class OnnxModel(Generic[T]):
so.intra_op_num_threads = threads
so.inter_op_num_threads = threads
if extra_session_options is not None:
self.add_extra_session_options(so, extra_session_options)
self.model = ort.InferenceSession(
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(
@@ -103,33 +124,65 @@ class OnnxModel(Generic[T]):
RuntimeWarning,
)
@classmethod
def _select_exposed_session_options(cls, model_kwargs: dict[str, Any]) -> dict[str, Any]:
"""A convenience method to select the exposed session options in models
Args:
model_kwargs (dict[str, Any]): The model kwargs.
Returns:
dict[str, Any]: a dict with filtered exposed session options.
"""
return {k: v for k, v in model_kwargs.items() if k in cls.EXPOSED_SESSION_OPTIONS}
@classmethod
def add_extra_session_options(
cls, session_options: ort.SessionOptions, extra_options: dict[str, Any]
) -> None:
"""Add extra session options to the existing options object in-place
Args:
session_options (ort.SessionOptions): The existing session options object.
extra_options (dict[str, Any]): The extra session options available in cls.EXPOSED_SESSION_OPTIONS.
Returns:
None
"""
for option in extra_options:
assert (
option in cls.EXPOSED_SESSION_OPTIONS
), f"{option} is unknown or not exposed (exposed options: {cls.EXPOSED_SESSION_OPTIONS})"
if "enable_cpu_mem_arena" in extra_options:
session_options.enable_cpu_mem_arena = extra_options["enable_cpu_mem_arena"]
def load_onnx_model(self) -> None:
raise NotImplementedError("Subclasses must implement this method")
def onnx_embed(self, *args, **kwargs) -> OnnxOutputContext:
def onnx_embed(self, *args: Any, **kwargs: Any) -> OnnxOutputContext:
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,
) -> OnnxModel:
**kwargs: Any,
) -> OnnxModel[T]:
raise NotImplementedError()
def __init__(
self,
model_name: str,
cache_dir: str,
**kwargs,
**kwargs: Any,
):
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]]:
def process(self, items: Iterable[tuple[int, Any]]) -> Iterable[tuple[int, Any]]:
raise NotImplementedError("Subclasses must implement this method")
+12 -10
View File
@@ -1,12 +1,13 @@
import json
from typing import Any
from pathlib import Path
from typing import Tuple
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}")
@@ -35,9 +36,9 @@ def load_tokenizer(model_dir: Path) -> Tuple[Tokenizer, dict]:
with open(str(tokenizer_config_path)) as tokenizer_config_file:
tokenizer_config = json.load(tokenizer_config_file)
assert (
"model_max_length" in tokenizer_config or "max_length" in tokenizer_config
), "Models without model_max_length or max_length are not supported."
assert "model_max_length" in tokenizer_config or "max_length" in tokenizer_config, (
"Models without model_max_length or max_length are not supported."
)
if "model_max_length" not in tokenizer_config:
max_context = tokenizer_config["max_length"]
elif "max_length" not in tokenizer_config:
@@ -49,9 +50,10 @@ def load_tokenizer(model_dir: Path) -> Tuple[Tokenizer, dict]:
tokenizer = Tokenizer.from_file(str(tokenizer_path))
tokenizer.enable_truncation(max_length=max_context)
tokenizer.enable_padding(
pad_id=config.get("pad_token_id", 0), pad_token=tokenizer_config["pad_token"]
)
if not tokenizer.padding:
tokenizer.enable_padding(
pad_id=config.get("pad_token_id", 0), pad_token=tokenizer_config["pad_token"]
)
for token in tokens_map.values():
if isinstance(token, str):
@@ -59,7 +61,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):
+23 -12
View File
@@ -1,16 +1,27 @@
import os
import sys
from enum import Enum
from pathlib import Path
from typing import Any, TypeAlias
import numpy as np
from numpy.typing import NDArray
from PIL import Image
from typing import Any, Dict, Iterable, Tuple, Union
if sys.version_info >= (3, 10):
from typing import TypeAlias
else:
from typing_extensions import TypeAlias
PathInput: TypeAlias = Union[str, os.PathLike]
PilInput: TypeAlias = Union[Image.Image, Iterable[Image.Image]]
ImageInput: TypeAlias = Union[PathInput, Iterable[PathInput], PilInput]
class Device(str, Enum):
CPU = "cpu"
CUDA = "cuda"
AUTO = "auto"
OnnxProvider: TypeAlias = Union[str, Tuple[str, Dict[Any, Any]]]
PathInput: TypeAlias = str | Path
ImageInput: TypeAlias = PathInput | Image.Image
OnnxProvider: TypeAlias = str | tuple[str, dict[Any, Any]]
NumpyArray: TypeAlias = (
NDArray[np.float64]
| NDArray[np.float32]
| NDArray[np.float16]
| NDArray[np.int8]
| NDArray[np.int64]
| NDArray[np.int32]
)
+25 -11
View File
@@ -1,16 +1,21 @@
import os
import tempfile
from itertools import islice
from pathlib import Path
from typing import Generator, Iterable, Optional, Union
import unicodedata
import sys
import numpy as np
import re
from typing import Set
import tempfile
import unicodedata
from pathlib import Path
from itertools import islice
from typing import Iterable, TypeVar
import numpy as np
from numpy.typing import NDArray
from fastembed.common.types import NumpyArray
T = TypeVar("T")
def normalize(input_array, p=2, dim=1, eps=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
@@ -18,7 +23,16 @@ def normalize(input_array, p=2, dim=1, eps=1e-12) -> np.ndarray:
return normalized_array
def iter_batch(iterable: Union[Iterable, Generator], size: int) -> Iterable:
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))
[[1, 2, 3], [4, 5]]
@@ -31,7 +45,7 @@ def iter_batch(iterable: Union[Iterable, Generator], size: int) -> Iterable:
yield b
def define_cache_dir(cache_dir: Optional[str] = None) -> Path:
def define_cache_dir(cache_dir: str | None = None) -> Path:
"""
Define the cache directory for fastembed
"""
@@ -45,7 +59,7 @@ def define_cache_dir(cache_dir: Optional[str] = None) -> Path:
return cache_path
def get_all_punctuation() -> Set[str]:
def get_all_punctuation() -> set[str]:
return set(
chr(i) for i in range(sys.maxunicode) if unicodedata.category(chr(i)).startswith("P")
)
+4 -4
View File
@@ -1,4 +1,4 @@
from typing import Optional
from typing import Any
from loguru import logger
@@ -17,8 +17,8 @@ class JinaEmbedding(TextEmbedding):
def __init__(
self,
model_name: str = "jinaai/jina-embeddings-v2-base-en",
cache_dir: Optional[str] = None,
threads: Optional[int] = None,
**kwargs,
cache_dir: str | None = None,
threads: int | None = None,
**kwargs: Any,
):
super().__init__(model_name, cache_dir, threads, **kwargs)
+60 -22
View File
@@ -1,22 +1,23 @@
from typing import Any, Dict, Iterable, List, Optional, Sequence, Type
import numpy as np
from typing import Any, Iterable, Sequence, Type
from dataclasses import asdict
from fastembed.common.types import NumpyArray, Device
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):
EMBEDDINGS_REGISTRY: List[Type[ImageEmbeddingBase]] = [OnnxImageEmbedding]
EMBEDDINGS_REGISTRY: list[Type[ImageEmbeddingBase]] = [OnnxImageEmbedding]
@classmethod
def list_supported_models(cls) -> List[Dict[str, Any]]:
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.
list[dict[str, Any]]: A list of dictionaries containing the model information.
Example:
```
@@ -35,26 +36,30 @@ 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__(
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,
cache_dir: str | None = None,
threads: int | None = None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_ids: list[int] | None = None,
lazy_load: bool = False,
**kwargs,
**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):
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,
@@ -72,16 +77,49 @@ class ImageEmbedding(ImageEmbeddingBase):
"Please check the supported models using `ImageEmbedding.list_supported_models()`"
)
@property
def embedding_size(self) -> int:
"""Get the embedding size of the current model"""
if self._embedding_size is None:
self._embedding_size = self.get_embedding_size(self.model_name)
return self._embedding_size
@classmethod
def get_embedding_size(cls, model_name: str) -> int:
"""Get the embedding size of the passed model
Args:
model_name (str): The name of the model to get embedding size for.
Returns:
int: The size of the embedding.
Raises:
ValueError: If the model name is not found in the supported models.
"""
descriptions = cls._list_supported_models()
embedding_size: int | None = None
for description in descriptions:
if description.model.lower() == model_name.lower():
embedding_size = description.dim
break
if embedding_size is None:
model_names = [description.model for description in descriptions]
raise ValueError(
f"Embedding size for model {model_name} was None. "
f"Available model names: {model_names}"
)
return embedding_size
def embed(
self,
images: ImageInput,
images: ImageInput | Iterable[ImageInput],
batch_size: int = 16,
parallel: Optional[int] = None,
**kwargs,
) -> Iterable[np.ndarray]:
parallel: int | None = None,
**kwargs: Any,
) -> 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
+23 -12
View File
@@ -1,31 +1,32 @@
from typing import Iterable, Optional
import numpy as np
from typing import Iterable, Any
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,
cache_dir: Optional[str] = None,
threads: Optional[int] = None,
**kwargs,
cache_dir: str | None = None,
threads: int | None = 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)
self._embedding_size: int | None = None
def embed(
self,
images: ImageInput,
images: ImageInput | Iterable[ImageInput],
batch_size: int = 16,
parallel: Optional[int] = None,
**kwargs,
) -> Iterable[np.ndarray]:
parallel: int | None = None,
**kwargs: Any,
) -> Iterable[NumpyArray]:
"""
Embeds a list of images into a list of embeddings.
@@ -39,6 +40,16 @@ 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()
@classmethod
def get_embedding_size(cls, model_name: str) -> int:
"""Returns embedding size of the chosen model."""
raise NotImplementedError("Subclasses must implement this method")
@property
def embedding_size(self) -> int:
"""Returns embedding size for the current model"""
raise NotImplementedError("Subclasses must implement this method")
+94 -77
View File
@@ -1,73 +1,78 @@
from typing import Any, Dict, Iterable, List, Optional, Sequence, Type
from typing import Any, Iterable, Sequence, Type
import numpy as np
from fastembed.common.types import NumpyArray, Device
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",
},
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,
cache_dir: Optional[str] = None,
threads: Optional[int] = None,
providers: Optional[Sequence[OnnxProvider]] = None,
cuda: bool = False,
device_ids: Optional[List[int]] = None,
cache_dir: str | None = None,
threads: int | None = None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_ids: list[int] | None = None,
lazy_load: bool = False,
device_id: Optional[int] = None,
**kwargs,
device_id: int | None = None,
specific_model_path: str | None = None,
**kwargs: Any,
):
"""
Args:
@@ -78,13 +83,15 @@ class OnnxImageEmbedding(ImageEmbeddingBase, OnnxImageModel[np.ndarray]):
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.
cuda (Union[bool, Device], optional): Whether to use cuda for inference. Mutually exclusive with `providers`
Defaults to Device.AUTO.
device_ids (Optional[list[int]], optional): The list of device ids to use for data parallel processing in
workers. Should be used with `cuda` equals to `True`, `Device.AUTO` or `Device.CUDA`, 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.
specific_model_path (Optional[str], optional): The specific path to the onnx model dir if it should be imported from somewhere else
Raises:
ValueError: If the model_name is not in the format <org>/<model> e.g. BAAI/bge-base-en.
@@ -93,23 +100,27 @@ class OnnxImageEmbedding(ImageEmbeddingBase, OnnxImageModel[np.ndarray]):
super().__init__(model_name, cache_dir, threads, **kwargs)
self.providers = providers
self.lazy_load = lazy_load
self._extra_session_options = self._select_exposed_session_options(kwargs)
# 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: int | None = 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._specific_model_path = specific_model_path
self._model_dir = self.download_model(
self.model_description, self.cache_dir, local_files_only=self._local_files_only
self.model_description,
self.cache_dir,
local_files_only=self._local_files_only,
specific_model_path=self._specific_model_path,
)
if not self.lazy_load:
@@ -121,30 +132,31 @@ 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,
device_id=self.device_id,
extra_session_options=self._extra_session_options,
)
@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: ImageInput | Iterable[ImageInput],
batch_size: int = 16,
parallel: Optional[int] = None,
**kwargs,
) -> Iterable[np.ndarray]:
parallel: int | None = None,
**kwargs: Any,
) -> 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.
@@ -170,28 +182,33 @@ class OnnxImageEmbedding(ImageEmbeddingBase, OnnxImageModel[np.ndarray]):
providers=self.providers,
cuda=self.cuda,
device_ids=self.device_ids,
local_files_only=self._local_files_only,
specific_model_path=self._specific_model_path,
extra_session_options=self._extra_session_options,
**kwargs,
)
@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
) -> 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, **kwargs: Any
) -> Iterable[NumpyArray]:
return normalize(output.model_output)
class OnnxImageEmbeddingWorker(ImageEmbeddingWorker):
def init_embedding(self, model_name: str, cache_dir: str, **kwargs) -> OnnxImageEmbedding:
class OnnxImageEmbeddingWorker(ImageEmbeddingWorker[NumpyArray]):
def init_embedding(self, model_name: str, cache_dir: str, **kwargs: Any) -> OnnxImageEmbedding:
return OnnxImageEmbedding(
model_name=model_name,
cache_dir=cache_dir,
+52 -27
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, Dict, Iterable, List, Optional, Sequence, Tuple, Type
from typing import Any, Iterable, Sequence, Type
import numpy as np
from PIL import Image
from fastembed.image.transform.operators import Compose
from fastembed.common.types import NumpyArray, Device
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,28 @@ 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]:
def _post_process_onnx_output(self, output: OnnxOutputContext, **kwargs: Any) -> Iterable[T]:
"""Post-process the ONNX model output to convert it into a usable format.
Args:
output (OnnxOutputContext): The raw output from the ONNX model.
**kwargs: Additional keyword arguments that may be needed by specific implementations.
Returns:
Iterable[T]: Post-processed output as an iterable of type T.
"""
raise NotImplementedError("Subclasses must implement this method")
def __init__(self) -> None:
super().__init__()
self.processor = None
self.processor: Compose | None = None
def _preprocess_onnx_input(
self, onnx_input: Dict[str, np.ndarray], **kwargs
) -> Dict[str, np.ndarray]:
self, onnx_input: dict[str, NumpyArray], **kwargs: Any
) -> dict[str, NumpyArray]:
"""
Preprocess the onnx input.
"""
@@ -40,10 +51,11 @@ class OnnxImageModel(OnnxModel[T]):
self,
model_dir: Path,
model_file: str,
threads: Optional[int],
providers: Optional[Sequence[OnnxProvider]] = None,
cuda: bool = False,
device_id: Optional[int] = None,
threads: int | None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_id: int | None = None,
extra_session_options: dict[str, Any] | None = None,
) -> None:
super()._load_onnx_model(
model_dir=model_dir,
@@ -52,25 +64,30 @@ class OnnxImageModel(OnnxModel[T]):
providers=providers,
cuda=cuda,
device_id=device_id,
extra_session_options=extra_session_options,
)
self.processor = load_preprocessor(model_dir=model_dir)
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) -> OnnxOutputContext:
with contextlib.ExitStack():
def onnx_embed(self, images: list[ImageInput], **kwargs: Any) -> OnnxOutputContext:
with contextlib.ExitStack() as stack:
image_files = [
Image.open(image) if not isinstance(image, Image.Image) else image
stack.enter_context(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,13 +95,16 @@ class OnnxImageModel(OnnxModel[T]):
self,
model_name: str,
cache_dir: str,
images: ImageInput,
images: ImageInput | Iterable[ImageInput],
batch_size: int = 256,
parallel: Optional[int] = None,
providers: Optional[Sequence[OnnxProvider]] = None,
cuda: bool = False,
device_ids: Optional[List[int]] = None,
**kwargs,
parallel: int | None = None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_ids: list[int] | None = None,
local_files_only: bool = False,
specific_model_path: str | None = None,
extra_session_options: dict[str, Any] | None = None,
**kwargs: Any,
) -> Iterable[T]:
is_small = False
@@ -100,7 +120,7 @@ class OnnxImageModel(OnnxModel[T]):
self.load_onnx_model()
for batch in iter_batch(images, 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()
@@ -110,9 +130,14 @@ class OnnxImageModel(OnnxModel[T]):
"model_name": model_name,
"cache_dir": cache_dir,
"providers": providers,
"local_files_only": local_files_only,
"specific_model_path": specific_model_path,
**kwargs,
}
if extra_session_options is not None:
params.update(extra_session_options)
pool = ParallelWorkerPool(
num_workers=parallel or 1,
worker=self._get_worker_class(),
@@ -121,11 +146,11 @@ 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, **kwargs) # type: ignore
class ImageEmbeddingWorker(EmbeddingWorker):
def process(self, items: Iterable[Tuple[int, Any]]) -> Iterable[Tuple[int, Any]]:
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)
yield idx, embeddings
+134 -37
View File
@@ -1,8 +1,8 @@
from typing import Sized, Tuple, 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 +13,9 @@ def convert_to_rgb(image: Image.Image) -> Image.Image:
def center_crop(
image: Union[Image.Image, np.ndarray],
size: Tuple[int, int],
) -> np.ndarray:
image: Image.Image | NumpyArray,
size: tuple[int, int],
) -> NumpyArray:
if isinstance(image, np.ndarray):
_, orig_height, orig_width = image.shape
else:
@@ -40,7 +40,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,45 +61,42 @@ 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: float | list[float],
std: 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_list = 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_list) != 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_list)}"
)
image = ((image.T - mean) / std).T
return image
mean_arr = np.array(mean_list, dtype=np.float32)
std_list = std if isinstance(std, list) else [std] * num_channels
if len(std_list) != num_channels:
raise ValueError(
f"std must have the same number of channels as the image, image has {num_channels} channels, got {len(std_list)}"
)
std_arr = np.array(std_list, dtype=np.float32)
image_upd = ((image.T - mean_arr) / std_arr).T
return image_upd
def resize(
image: Image,
size: Union[int, Tuple[int, int]],
resample: Image.Resampling = Image.Resampling.BILINEAR,
) -> Image:
image: Image.Image,
size: int | tuple[int, int],
resample: int | Image.Resampling = Image.Resampling.BILINEAR,
) -> Image.Image:
if isinstance(size, tuple):
return image.resize(size, resample)
@@ -114,11 +111,111 @@ def resize(
return image.resize(new_size, resample)
def rescale(image: np.ndarray, scale: float, dtype=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]):
def pil2ndarray(image: Image.Image | NumpyArray) -> NumpyArray:
if isinstance(image, Image.Image):
return np.asarray(image).transpose((2, 0, 1))
return image
def pad2square(
image: Image.Image,
size: int,
fill_color: str | int | tuple[int, ...] = 0,
) -> Image.Image:
height, width = image.height, image.width
left, right = 0, width
top, bottom = 0, height
crop_required = False
if width > size:
left = (width - size) // 2
right = left + size
crop_required = True
if height > size:
top = (height - size) // 2
bottom = top + size
crop_required = True
new_image = Image.new(mode="RGB", size=(size, size), color=fill_color)
new_image.paste(image.crop((left, top, right, bottom)) if crop_required else image)
return new_image
def resize_longest_edge(
image: Image.Image,
max_size: int,
resample: int | Image.Resampling = Image.Resampling.LANCZOS,
) -> Image.Image:
height, width = image.height, image.width
aspect_ratio = width / height
if width >= height:
# Width is longer
new_width = max_size
new_height = int(new_width / aspect_ratio)
else:
# Height is longer
new_height = max_size
new_width = int(new_height * aspect_ratio)
# Ensure even dimensions
if new_height % 2 != 0:
new_height += 1
if new_width % 2 != 0:
new_width += 1
return image.resize((new_width, new_height), resample)
def crop_ndarray(
image: NumpyArray,
x1: int,
y1: int,
x2: int,
y2: int,
channel_first: bool = True,
) -> NumpyArray:
if channel_first:
# (C, H, W) format
return image[:, y1:y2, x1:x2]
else:
# (H, W, C) format
return image[y1:y2, x1:x2, :]
def resize_ndarray(
image: NumpyArray,
size: tuple[int, int],
resample: int | Image.Resampling = Image.Resampling.LANCZOS,
channel_first: bool = True,
) -> NumpyArray:
# Convert to PIL-friendly format (H, W, C)
if channel_first:
img_hwc = image.transpose((1, 2, 0))
else:
img_hwc = image
# Handle different dtypes
if img_hwc.dtype == np.float32 or img_hwc.dtype == np.float64:
# Assume normalized, scale to 0-255 for PIL
img_hwc_scaled = (img_hwc * 255).astype(np.uint8)
pil_img = Image.fromarray(img_hwc_scaled, mode="RGB")
resized = pil_img.resize(size, resample)
result = np.array(resized).astype(np.float32) / 255.0
else:
# uint8 or similar
pil_img = Image.fromarray(img_hwc.astype(np.uint8), mode="RGB")
resized = pil_img.resize(size, resample)
result = np.array(resized)
# Convert back to original format
if channel_first:
result = result.transpose((2, 0, 1))
return result
+342 -41
View File
@@ -1,126 +1,332 @@
from typing import Any, Dict, List, Tuple, Union
from typing import Any
import math
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,
crop_ndarray,
normalize,
pil2ndarray,
rescale,
resize,
resize_longest_edge,
resize_ndarray,
pad2square,
)
class Transform:
def __call__(self, images: List) -> Union[List[Image.Image], List[np.ndarray]]:
def __call__(self, images: list[Any]) -> list[Image.Image] | list[NumpyArray]:
raise NotImplementedError("Subclasses must implement this method")
class ConvertToRGB(Transform):
def __call__(self, images: List[Image.Image]) -> List[Image.Image]:
def __call__(self, images: list[Image.Image]) -> list[Image.Image]:
return [convert_to_rgb(image=image) for image in images]
class CenterCrop(Transform):
def __init__(self, size: Tuple[int, int]):
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]
class Normalize(Transform):
def __init__(self, mean: Union[float, List[float]], std: Union[float, List[float]]):
def __init__(self, mean: float | list[float], std: float | list[float]):
self.mean = mean
self.std = std
def __call__(self, images: List[np.ndarray]) -> List[np.ndarray]:
return [normalize(image, mean=self.mean, std=self.std) for image in images]
def __call__( # type: ignore[override]
self, images: list[NumpyArray] | list[list[NumpyArray]]
) -> list[NumpyArray] | list[list[NumpyArray]]:
if images and isinstance(images[0], list):
# Nested structure from ImageSplitter
return [
[normalize(image, mean=self.mean, std=self.std) for image in img_patches] # type: ignore[arg-type]
for img_patches in images
]
else:
# Flat structure (backward compatibility)
return [normalize(image, mean=self.mean, std=self.std) for image in images] # type: ignore[arg-type]
class Resize(Transform):
def __init__(
self,
size: Union[int, Tuple[int, int]],
size: int | tuple[int, int],
resample: Image.Resampling = Image.Resampling.BICUBIC,
):
self.size = size
self.resample = resample
def __call__(self, images: List[Image.Image]) -> List[Image.Image]:
return [
resize(image, size=self.size, resample=self.resample) for image in images
]
def __call__(self, images: list[Image.Image]) -> list[Image.Image]:
return [resize(image, size=self.size, resample=self.resample) for image in images]
class Rescale(Transform):
def __init__(self, scale: float = 1 / 255):
self.scale = scale
def __call__(self, images: List[np.ndarray]) -> List[np.ndarray]:
return [rescale(image, scale=self.scale) for image in images]
def __call__( # type: ignore[override]
self, images: list[NumpyArray] | list[list[NumpyArray]]
) -> list[NumpyArray] | list[list[NumpyArray]]:
if images and isinstance(images[0], list):
# Nested structure from ImageSplitter
return [
[rescale(image, scale=self.scale) for image in img_patches] # type: ignore[arg-type]
for img_patches in images
]
else:
# Flat structure (backward compatibility)
return [rescale(image, scale=self.scale) for image in images] # type: ignore[arg-type]
class PILtoNDarray(Transform):
def __call__(
self, images: List[Union[Image.Image, np.ndarray]]
) -> List[np.ndarray]:
def __call__(self, images: list[Image.Image | NumpyArray]) -> list[NumpyArray]:
return [pil2ndarray(image) for image in images]
class PadtoSquare(Transform):
def __init__(
self,
size: int,
fill_color: str | int | tuple[int, ...],
):
self.size = size
self.fill_color = fill_color
def __call__(self, images: list[Image.Image]) -> list[Image.Image]:
return [
pad2square(image=image, size=self.size, fill_color=self.fill_color) for image in images
]
class ResizeLongestEdge(Transform):
"""Resize images so the longest edge equals target size, preserving aspect ratio."""
def __init__(
self,
size: int,
resample: Image.Resampling = Image.Resampling.LANCZOS,
):
self.size = size
self.resample = resample
def __call__(self, images: list[Image.Image]) -> list[Image.Image]:
return [resize_longest_edge(image, self.size, self.resample) for image in images]
class ResizeForVisionEncoder(Transform):
"""
Resize both dimensions to be multiples of vision_encoder_max_size.
Preserves aspect ratio approximately.
Works on numpy arrays in (C, H, W) format.
"""
def __init__(
self,
max_size: int,
resample: Image.Resampling = Image.Resampling.LANCZOS,
):
self.max_size = max_size
self.resample = resample
def __call__(self, images: list[NumpyArray]) -> list[NumpyArray]:
result = []
for image in images:
# Assume (C, H, W) format
_, height, width = image.shape
aspect_ratio = width / height
if width >= height:
# Calculate new width as multiple of max_size
new_width = math.ceil(width / self.max_size) * self.max_size
new_height = int(new_width / aspect_ratio)
new_height = math.ceil(new_height / self.max_size) * self.max_size
else:
# Calculate new height as multiple of max_size
new_height = math.ceil(height / self.max_size) * self.max_size
new_width = int(new_height * aspect_ratio)
new_width = math.ceil(new_width / self.max_size) * self.max_size
# Resize using the ndarray resize function
resized = resize_ndarray(
image,
size=(new_width, new_height), # PIL expects (width, height)
resample=self.resample,
channel_first=True,
)
result.append(resized)
return result
class ImageSplitter(Transform):
"""
Split images into grid of patches plus a global view.
If image dimensions exceed max_size:
- Divide into ceil(H/max_size) x ceil(W/max_size) patches
- Each patch is cropped from the image
- Add a global view (original resized to max_size x max_size)
If image is smaller than max_size:
- Return single image unchanged
Works on numpy arrays in (C, H, W) format.
"""
def __init__(
self,
max_size: int,
resample: Image.Resampling = Image.Resampling.LANCZOS,
):
self.max_size = max_size
self.resample = resample
def __call__(self, images: list[NumpyArray]) -> list[list[NumpyArray]]: # type: ignore[override]
result = []
for image in images:
# Assume (C, H, W) format
_, height, width = image.shape
max_height = max_width = self.max_size
frames = []
if height > max_height or width > max_width:
# Calculate the number of splits needed
num_splits_h = math.ceil(height / max_height)
num_splits_w = math.ceil(width / max_width)
# Calculate optimal patch dimensions
optimal_height = math.ceil(height / num_splits_h)
optimal_width = math.ceil(width / num_splits_w)
# Generate patches in grid order (row by row)
for r in range(num_splits_h):
for c in range(num_splits_w):
# Calculate crop coordinates
start_x = c * optimal_width
start_y = r * optimal_height
end_x = min(start_x + optimal_width, width)
end_y = min(start_y + optimal_height, height)
# Crop the patch
cropped = crop_ndarray(
image, x1=start_x, y1=start_y, x2=end_x, y2=end_y, channel_first=True
)
frames.append(cropped)
# Add global view (resized to max_size x max_size)
global_view = resize_ndarray(
image,
size=(max_width, max_height), # PIL expects (width, height)
resample=self.resample,
channel_first=True,
)
frames.append(global_view)
else:
# Image is small enough, no splitting needed
frames.append(image)
# Append (not extend) to preserve per-image grouping
result.append(frames)
return result
class SquareResize(Transform):
"""
Resize images to square dimensions (max_size x max_size).
Works on numpy arrays in (C, H, W) format.
"""
def __init__(
self,
size: int,
resample: Image.Resampling = Image.Resampling.LANCZOS,
):
self.size = size
self.resample = resample
def __call__(self, images: list[NumpyArray]) -> list[list[NumpyArray]]: # type: ignore[override]
return [
[
resize_ndarray(
image, size=(self.size, self.size), resample=self.resample, channel_first=True
)
]
for image in images
]
class Compose:
def __init__(self, transforms: List[Transform]):
def __init__(self, transforms: list[Transform]):
self.transforms = transforms
def __call__(
self, images: Union[List[Image.Image], List[np.ndarray]]
) -> Union[List[np.ndarray], List[Image.Image]]:
self, images: list[Image.Image] | list[NumpyArray]
) -> list[NumpyArray] | list[Image.Image]:
for transform in self.transforms:
images = transform(images)
return images
@classmethod
def from_config(cls, config: Dict[str, Any]) -> "Compose":
def from_config(cls, config: dict[str, Any]) -> "Compose":
"""Creates processor from a config dict.
Args:
config (Dict[str, Any]): Configuration dictionary.
config (dict[str, Any]): Configuration dictionary.
Valid keys:
- do_resize
- resize_mode
- size
- fill_color
- do_center_crop
- crop_size
- do_rescale
- rescale_factor
- do_normalize
- image_mean
- mean
- image_std
- std
- resample
- interpolation
Valid size keys (nested):
- {"height", "width"}
- {"shortest_edge"}
- {"longest_edge"}
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)
cls._get_center_crop(transforms, config)
cls._get_pil2ndarray(transforms, config)
cls._get_image_splitting(transforms, config)
cls._get_rescale(transforms, config)
cls._get_normalize(transforms, config)
return cls(transforms=transforms)
@staticmethod
def _get_convert_to_rgb(transforms: List[Transform], config: Dict[str, Any]):
def _get_convert_to_rgb(transforms: list[Transform], config: dict[str, Any]) -> None:
transforms.append(ConvertToRGB())
@staticmethod
def _get_resize(transforms: List[Transform], config: Dict[str, Any]):
@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:
@@ -161,38 +367,133 @@ class Compose:
resample=config.get("resample", Image.Resampling.BICUBIC),
)
)
elif mode == "JinaCLIPImageProcessor":
interpolation = config.get("interpolation")
if isinstance(interpolation, str):
resample = cls._interpolation_resolver(interpolation)
else:
resample = interpolation or Image.Resampling.BICUBIC
if "size" in config:
resize_mode = config.get("resize_mode", "shortest")
if resize_mode == "shortest":
transforms.append(
Resize(
size=config["size"],
resample=resample,
)
)
elif mode == "Idefics3ImageProcessor":
if config.get("do_resize", False):
size = config.get("size", {})
if "longest_edge" not in size:
raise ValueError(
"Size dictionary must contain 'longest_edge' key for Idefics3ImageProcessor"
)
# Handle resample parameter - can be int enum or PIL.Image.Resampling
resample = config.get("resample", Image.Resampling.LANCZOS)
if isinstance(resample, int):
resample = Image.Resampling(resample)
transforms.append(
ResizeLongestEdge(
size=size["longest_edge"],
resample=resample,
)
)
else:
raise ValueError(f"Preprocessor {mode} is not supported")
@staticmethod
def _get_center_crop(transforms: List[Transform], config: Dict[str, Any]):
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
elif mode == "JinaCLIPImageProcessor":
pass
elif mode == "Idefics3ImageProcessor":
pass
else:
raise ValueError(f"Preprocessor {mode} is not supported")
@staticmethod
def _get_pil2ndarray(transforms: List[Transform], config: Dict[str, Any]):
def _get_pil2ndarray(transforms: list[Transform], config: dict[str, Any]) -> None:
transforms.append(PILtoNDarray())
@classmethod
def _get_image_splitting(cls, transforms: list[Transform], config: dict[str, Any]) -> None:
"""
Add image splitting transforms for Idefics3.
Handles conditional logic: splitting vs square resize.
Must be called AFTER PILtoNDarray.
"""
mode = config.get("image_processor_type", "CLIPImageProcessor")
if mode == "Idefics3ImageProcessor":
do_splitting = config.get("do_image_splitting", False)
max_size = config.get("max_image_size", {}).get("longest_edge", 512)
resample = config.get("resample", Image.Resampling.LANCZOS)
if isinstance(resample, int):
resample = Image.Resampling(resample)
if do_splitting:
transforms.append(ResizeForVisionEncoder(max_size, resample))
transforms.append(ImageSplitter(max_size, resample))
else:
transforms.append(SquareResize(max_size, resample))
@staticmethod
def _get_rescale(transforms: List[Transform], config: Dict[str, Any]):
def _get_rescale(transforms: list[Transform], config: dict[str, Any]) -> None:
if config.get("do_rescale", True):
rescale_factor = config.get("rescale_factor", 1 / 255)
transforms.append(Rescale(scale=rescale_factor))
@staticmethod
def _get_normalize(transforms: List[Transform], config: Dict[str, Any]):
def _get_normalize(transforms: list[Transform], config: dict[str, Any]) -> None:
if config.get("do_normalize", False):
transforms.append(Normalize(mean=config["image_mean"], std=config["image_std"]))
elif "mean" in config and "std" in config:
transforms.append(Normalize(mean=config["mean"], std=config["std"]))
@staticmethod
def _get_pad2square(transforms: list[Transform], config: dict[str, Any]) -> None:
mode = config.get("image_processor_type", "CLIPImageProcessor")
if mode == "CLIPImageProcessor":
pass
elif mode == "ConvNextFeatureExtractor":
pass
elif mode == "JinaCLIPImageProcessor":
transforms.append(
Normalize(mean=config["image_mean"], std=config["image_std"])
PadtoSquare(
size=config["size"],
fill_color=config.get("fill_color", 0),
)
)
@staticmethod
def _interpolation_resolver(resample: str | None = None) -> Image.Resampling:
interpolation_map = {
"nearest": Image.Resampling.NEAREST,
"lanczos": Image.Resampling.LANCZOS,
"bilinear": Image.Resampling.BILINEAR,
"bicubic": Image.Resampling.BICUBIC,
"box": Image.Resampling.BOX,
"hamming": Image.Resampling.HAMMING,
}
if resample and (method := interpolation_map.get(resample.lower())):
return method
raise ValueError(f"Unknown interpolation method: {resample}")
+148 -104
View File
@@ -1,134 +1,154 @@
import string
from typing import Any, Dict, Iterable, List, Optional, Sequence, Type, Union
from typing import Any, Iterable, Sequence, Type
import numpy as np
from tokenizers import Encoding
from tokenizers import Encoding, Tokenizer
from fastembed.common.preprocessor_utils import load_tokenizer
from fastembed.common.types import NumpyArray, Device
from fastembed.common import OnnxProvider
from fastembed.common.onnx_model import OnnxOutputContext
from fastembed.common.utils import define_cache_dir
from fastembed.common.utils import define_cache_dir, iter_batch
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="Text embeddings, Unimodal (text), English, 512 input tokens truncation, 2023 year",
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), English, 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 = 32
MIN_QUERY_LENGTH = 31 # it's 32, we add one additional special token in the beginning
MASK_TOKEN = "[MASK]"
def _post_process_onnx_output(
self, output: OnnxOutputContext, is_doc: bool = True
) -> Iterable[np.ndarray]:
self, output: OnnxOutputContext, is_doc: bool = True, **kwargs: Any
) -> Iterable[NumpyArray]:
if not is_doc:
return output.model_output.astype(np.float32)
for embedding in output.model_output:
yield embedding
else:
if output.input_ids is None or output.attention_mask is None:
raise ValueError(
"input_ids and attention_mask must be provided for document post-processing"
)
if output.input_ids is None or output.attention_mask is None:
raise ValueError(
"input_ids and attention_mask must be provided for document post-processing"
)
for i, token_sequence in enumerate(output.input_ids):
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
for i, token_sequence in enumerate(output.input_ids):
for j, token_id in enumerate(token_sequence):
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)
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
output.model_output *= np.expand_dims(output.attention_mask, 2).astype(np.float32)
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)
for embedding, attention_mask in zip(output.model_output, output.attention_mask):
yield embedding[attention_mask == 1]
def _preprocess_onnx_input(
self, onnx_input: Dict[str, np.ndarray], is_doc: bool = True
) -> Dict[str, np.ndarray]:
if is_doc:
onnx_input["input_ids"][:, 1] = self.DOCUMENT_MARKER_TOKEN_ID
else:
onnx_input["input_ids"][:, 1] = self.QUERY_MARKER_TOKEN_ID
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"].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) -> List[Encoding]:
def tokenize(self, documents: list[str], is_doc: bool = True, **kwargs: Any) -> list[Encoding]:
return (
self._tokenize_documents(documents=documents)
if is_doc
else self._tokenize_query(query=next(iter(documents)))
)
def _tokenize_query(self, query: str) -> List[Encoding]:
# ". " is added to a query to be replaced with a special query token
query = [f". {query}"]
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:
prev_padding = None
if self.tokenizer.padding:
prev_padding = self.tokenizer.padding
self.tokenizer.enable_padding(
pad_token=self.MASK_TOKEN,
pad_id=self.mask_token_id,
length=self.MIN_QUERY_LENGTH,
)
encoded = self.tokenizer.encode_batch(query)
if prev_padding is None:
self.tokenizer.no_padding()
else:
self.tokenizer.enable_padding(**prev_padding)
def _tokenize_query(self, query: str) -> list[Encoding]:
assert self.query_tokenizer is not None
encoded = self.query_tokenizer.encode_batch([query])
return encoded
def _tokenize_documents(self, documents: List[str]) -> List[Encoding]:
# ". " is added to a document to be replaced with a special document token
documents = [". " + doc for doc in documents]
encoded = self.tokenizer.encode_batch(documents)
def _tokenize_documents(self, documents: list[str]) -> list[Encoding]:
encoded = self.tokenizer.encode_batch(documents) # type: ignore[union-attr]
return encoded
def token_count(
self,
texts: str | Iterable[str],
batch_size: int = 1024,
is_doc: bool = True,
include_extension: bool = False,
**kwargs: Any,
) -> int:
if not hasattr(self, "model") or self.model is None:
self.load_onnx_model() # loads the tokenizer as well
token_num = 0
texts = [texts] if isinstance(texts, str) else texts
tokenizer = self.tokenizer if is_doc else self.query_tokenizer
assert tokenizer is not None
for batch in iter_batch(texts, batch_size):
for tokens in tokenizer.encode_batch(batch):
if is_doc:
token_num += sum(tokens.attention_mask)
else:
attend_count = sum(tokens.attention_mask)
if include_extension:
token_num += max(attend_count, self.MIN_QUERY_LENGTH)
else:
token_num += attend_count
if include_extension:
token_num += len(
batch
) # add 1 for each cls.DOC_MARKER_TOKEN_ID or cls.QUERY_MARKER_TOKEN_ID
return token_num
@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
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,
cache_dir: str | None = None,
threads: int | None = None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_ids: list[int] | None = None,
lazy_load: bool = False,
device_id: Optional[int] = None,
**kwargs,
device_id: int | None = None,
specific_model_path: str | None = None,
**kwargs: Any,
):
"""
Args:
@@ -139,13 +159,15 @@ class Colbert(LateInteractionTextEmbeddingBase, OnnxTextModel[np.ndarray]):
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.
cuda (Union[bool, Device], optional): Whether to use cuda for inference. Mutually exclusive with `providers`
Defaults to Device.AUTO.
device_ids (Optional[list[int]], optional): The list of device ids to use for data parallel processing in
workers. Should be used with `cuda` equals to `True`, `Device.AUTO` or `Device.CUDA`, 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.
specific_model_path (Optional[str], optional): The specific path to the onnx model dir if it should be imported from somewhere else
Raises:
ValueError: If the model_name is not in the format <org>/<model> e.g. BAAI/bge-base-en.
@@ -154,28 +176,34 @@ class Colbert(LateInteractionTextEmbeddingBase, OnnxTextModel[np.ndarray]):
super().__init__(model_name, cache_dir, threads, **kwargs)
self.providers = providers
self.lazy_load = lazy_load
self._extra_session_options = self._select_exposed_session_options(kwargs)
# 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: int | None = 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._specific_model_path = specific_model_path
self._model_dir = self.download_model(
self.model_description, self.cache_dir, local_files_only=self._local_files_only
self.model_description,
self.cache_dir,
local_files_only=self._local_files_only,
specific_model_path=self._specific_model_path,
)
self.mask_token_id = None
self.pad_token_id = None
self.skip_list = set()
self.mask_token_id: int | None = None
self.pad_token_id: int | None = None
self.skip_list: set[int] = set()
self.query_tokenizer: Tokenizer | None = None
if not self.lazy_load:
self.load_onnx_model()
@@ -183,26 +211,39 @@ 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,
extra_session_options=self._extra_session_options,
)
self.mask_token_id = self.special_token_to_id["[MASK]"]
self.query_tokenizer, _ = load_tokenizer(model_dir=self._model_dir)
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 = {
self.tokenizer.encode(symbol, add_special_tokens=False).ids[0]
for symbol in string.punctuation
}
current_max_length = self.tokenizer.truncation["max_length"]
# ensure not to overflow after adding document-marker
self.tokenizer.enable_truncation(max_length=current_max_length - 1)
self.query_tokenizer.enable_truncation(max_length=current_max_length - 1)
self.query_tokenizer.enable_padding(
pad_token=self.MASK_TOKEN,
pad_id=self.mask_token_id,
length=self.MIN_QUERY_LENGTH,
)
def embed(
self,
documents: Union[str, Iterable[str]],
documents: str | Iterable[str],
batch_size: int = 256,
parallel: Optional[int] = None,
**kwargs,
) -> Iterable[np.ndarray]:
parallel: int | None = None,
**kwargs: Any,
) -> 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.
@@ -227,10 +268,13 @@ class Colbert(LateInteractionTextEmbeddingBase, OnnxTextModel[np.ndarray]):
providers=self.providers,
cuda=self.cuda,
device_ids=self.device_ids,
local_files_only=self._local_files_only,
specific_model_path=self._specific_model_path,
extra_session_options=self._extra_session_options,
**kwargs,
)
def query_embed(self, query: Union[str, List[str]], **kwargs) -> Iterable[np.ndarray]:
def query_embed(self, query: str | Iterable[str], **kwargs: Any) -> Iterable[NumpyArray]:
if isinstance(query, str):
query = [query]
@@ -243,12 +287,12 @@ 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):
def init_embedding(self, model_name: str, cache_dir: str, **kwargs) -> Colbert:
class ColbertEmbeddingWorker(TextEmbeddingWorker[NumpyArray]):
def init_embedding(self, model_name: str, cache_dir: str, **kwargs: Any) -> Colbert:
return Colbert(
model_name=model_name,
cache_dir=cache_dir,
@@ -0,0 +1,58 @@
from typing import Any, Type
from fastembed.common.types import NumpyArray
from fastembed.late_interaction.colbert import Colbert, ColbertEmbeddingWorker
from fastembed.common.model_description import DenseModelDescription, ModelSource
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"],
)
]
class JinaColbert(Colbert):
QUERY_MARKER_TOKEN_ID = 250002
DOCUMENT_MARKER_TOKEN_ID = 250003
MIN_QUERY_LENGTH = 31 # it's 32, we add one additional special token in the beginning
MASK_TOKEN = "<mask>"
@classmethod
def _get_worker_class(cls) -> Type[ColbertEmbeddingWorker]:
return JinaColbertEmbeddingWorker
@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_jina_colbert_models
def _preprocess_onnx_input(
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
if not is_doc:
onnx_input["attention_mask"][:] = 1
return onnx_input
class JinaColbertEmbeddingWorker(ColbertEmbeddingWorker):
def init_embedding(self, model_name: str, cache_dir: str, **kwargs: Any) -> JinaColbert:
return JinaColbert(
model_name=model_name,
cache_dir=cache_dir,
threads=1,
**kwargs,
)
@@ -1,33 +1,34 @@
from typing import Iterable, Optional, Union
import numpy as np
from typing import Iterable, Any
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,
cache_dir: Optional[str] = None,
threads: Optional[int] = None,
**kwargs,
cache_dir: str | None = None,
threads: int | None = 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)
self._embedding_size: int | None = None
def embed(
self,
documents: Union[str, Iterable[str]],
documents: str | Iterable[str],
batch_size: int = 256,
parallel: Optional[int] = None,
**kwargs,
) -> Iterable[np.ndarray]:
parallel: int | None = None,
**kwargs: Any,
) -> Iterable[NumpyArray]:
raise NotImplementedError()
def passage_embed(self, texts: Iterable[str], **kwargs) -> 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,15 +37,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
) -> Iterable[np.ndarray]:
def query_embed(self, query: str | Iterable[str], **kwargs: Any) -> Iterable[NumpyArray]:
"""
Embeds queries
@@ -52,11 +51,30 @@ 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)
@classmethod
def get_embedding_size(cls, model_name: str) -> int:
"""Returns embedding size of the chosen model."""
raise NotImplementedError("Subclasses must implement this method")
@property
def embedding_size(self) -> int:
"""Returns embedding size for the current model"""
raise NotImplementedError("Subclasses must implement this method")
def token_count(
self,
texts: str | Iterable[str],
batch_size: int = 1024,
**kwargs: Any,
) -> int:
"""Returns the number of tokens in the texts."""
raise NotImplementedError("Subclasses must implement this method")
@@ -1,26 +1,26 @@
from typing import Any, Dict, Iterable, List, Optional, Sequence, Type, Union
import numpy as np
from typing import Any, Iterable, Sequence, Type
from dataclasses import asdict
from fastembed.common.model_description import DenseModelDescription
from fastembed.common.types import NumpyArray, Device
from fastembed.common import OnnxProvider
from fastembed.late_interaction.colbert import Colbert
from fastembed.late_interaction.jina_colbert import JinaColbert
from fastembed.late_interaction.late_interaction_embedding_base import (
LateInteractionTextEmbeddingBase,
)
class LateInteractionTextEmbedding(LateInteractionTextEmbeddingBase):
EMBEDDINGS_REGISTRY: List[Type[LateInteractionTextEmbeddingBase]] = [
Colbert,
]
EMBEDDINGS_REGISTRY: list[Type[LateInteractionTextEmbeddingBase]] = [Colbert, JinaColbert]
@classmethod
def list_supported_models(cls) -> List[Dict[str, Any]]:
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.
list[dict[str, Any]]: A list of dictionaries containing the model information.
Example:
```
@@ -39,26 +39,30 @@ 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__(
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,
cache_dir: str | None = None,
threads: int | None = None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_ids: list[int] | None = None,
lazy_load: bool = False,
**kwargs,
**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):
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,
@@ -76,13 +80,47 @@ class LateInteractionTextEmbedding(LateInteractionTextEmbeddingBase):
"Please check the supported models using `LateInteractionTextEmbedding.list_supported_models()`"
)
@property
def embedding_size(self) -> int:
"""Get the embedding size of the current model"""
if self._embedding_size is None:
self._embedding_size = self.get_embedding_size(self.model_name)
return self._embedding_size
@classmethod
def get_embedding_size(cls, model_name: str) -> int:
"""Get the embedding size of the passed model
Args:
model_name (str): The name of the model to get embedding size for.
Returns:
int: The size of the embedding.
Raises:
ValueError: If the model name is not found in the supported models.
"""
descriptions = cls._list_supported_models()
embedding_size: int | None = None
for description in descriptions:
if description.model.lower() == model_name.lower():
embedding_size = description.dim
break
if embedding_size is None:
model_names = [description.model for description in descriptions]
raise ValueError(
f"Embedding size for model {model_name} was None. "
f"Available model names: {model_names}"
)
return embedding_size
def embed(
self,
documents: Union[str, Iterable[str]],
documents: str | Iterable[str],
batch_size: int = 256,
parallel: Optional[int] = None,
**kwargs,
) -> Iterable[np.ndarray]:
parallel: int | None = None,
**kwargs: Any,
) -> 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.
@@ -100,7 +138,7 @@ class LateInteractionTextEmbedding(LateInteractionTextEmbeddingBase):
"""
yield from self.model.embed(documents, batch_size, parallel, **kwargs)
def query_embed(self, query: Union[str, Iterable[str]], **kwargs) -> Iterable[np.ndarray]:
def query_embed(self, query: str | Iterable[str], **kwargs: Any) -> Iterable[NumpyArray]:
"""
Embeds queries
@@ -108,8 +146,35 @@ 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
yield from self.model.query_embed(query, **kwargs)
def token_count(
self,
texts: str | Iterable[str],
batch_size: int = 1024,
is_doc: bool = True,
include_extension: bool = False,
**kwargs: Any,
) -> int:
"""Returns the number of tokens in the texts.
Args:
texts (str | Iterable[str]): The list of texts to embed.
batch_size (int): Batch size for encoding
is_doc (bool): Whether the texts are documents (disable embedding a query with include_mask=True).
include_extension (bool): Turn on to count DOC / QUERY marker tokens, and [MASK] token in query mode.
Returns:
int: Sum of number of tokens in the texts.
"""
return self.model.token_count(
texts,
batch_size=batch_size,
is_doc=is_doc,
include_extension=include_extension,
**kwargs,
)
@@ -0,0 +1,83 @@
from dataclasses import asdict
from typing import Iterable, Any, Type
from fastembed.common.model_description import DenseModelDescription, ModelSource
from fastembed.common.onnx_model import OnnxOutputContext
from fastembed.common.types import NumpyArray
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 = [
DenseModelDescription(
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=ModelSource(hf="xenova/jina-embeddings-v2-small-en"),
model_file="onnx/model.onnx",
),
]
class TokenEmbeddingsModel(OnnxTextEmbedding, LateInteractionTextEmbeddingBase):
@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_token_embeddings_models
@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 [asdict(model) for model in cls._list_supported_models()]
@classmethod
def _get_worker_class(cls) -> Type[TextEmbeddingWorker[NumpyArray]]:
return TokensEmbeddingWorker
def _post_process_onnx_output(
self, output: OnnxOutputContext, **kwargs: Any
) -> Iterable[NumpyArray]:
# Size: (batch_size, sequence_length, hidden_size)
embeddings = output.model_output
# Size: (batch_size, sequence_length)
assert output.attention_mask is not None
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: str | Iterable[str],
batch_size: int = 256,
parallel: int | None = None,
**kwargs: Any,
) -> Iterable[NumpyArray]:
yield from super().embed(documents, batch_size=batch_size, parallel=parallel, **kwargs)
class TokensEmbeddingWorker(TextEmbeddingWorker[NumpyArray]):
def init_embedding(
self, model_name: str, cache_dir: str, **kwargs: Any
) -> TokenEmbeddingsModel:
return TokenEmbeddingsModel(
model_name=model_name,
cache_dir=cache_dir,
threads=1,
**kwargs,
)
@@ -0,0 +1,5 @@
from fastembed.late_interaction_multimodal.late_interaction_multimodal_embedding import (
LateInteractionMultimodalEmbedding,
)
__all__ = ["LateInteractionMultimodalEmbedding"]
@@ -0,0 +1,532 @@
import contextlib
from typing import Any, Iterable, Type, Optional, Sequence
import json
import numpy as np
from tokenizers import Encoding
from PIL import Image
from fastembed.common import ImageInput
from fastembed.common.model_description import DenseModelDescription, ModelSource
from fastembed.common.onnx_model import OnnxOutputContext
from fastembed.common.types import NumpyArray, OnnxProvider
from fastembed.common.utils import define_cache_dir, iter_batch
from fastembed.late_interaction_multimodal.late_interaction_multimodal_embedding_base import (
LateInteractionMultimodalEmbeddingBase,
)
from fastembed.late_interaction_multimodal.onnx_multimodal_model import (
OnnxMultimodalModel,
TextEmbeddingWorker,
ImageEmbeddingWorker,
)
supported_colmodernvbert_models: list[DenseModelDescription] = [
DenseModelDescription(
model="Qdrant/colmodernvbert",
dim=128,
description="The late-interaction version of ModernVBERT, CPU friendly, English, 2025.",
license="mit",
size_in_GB=1.0,
sources=ModelSource(hf="Qdrant/colmodernvbert"),
additional_files=["processor_config.json"],
model_file="model.onnx",
),
]
class ColModernVBERT(LateInteractionMultimodalEmbeddingBase, OnnxMultimodalModel[NumpyArray]):
"""
The ModernVBERT/colmodernvbert model implementation. This model uses
bidirectional attention, which proves to work better for retrieval.
See: https://huggingface.co/ModernVBERT/colmodernvbert
"""
VISUAL_PROMPT_PREFIX = (
"<|begin_of_text|>User:<image>Describe the image.<end_of_utterance>\nAssistant:"
)
QUERY_AUGMENTATION_TOKEN = "<end_of_utterance>"
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
self._extra_session_options = self._select_exposed_session_options(kwargs)
# 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._specific_model_path = specific_model_path
self._model_dir = self.download_model(
self.model_description,
self.cache_dir,
local_files_only=self._local_files_only,
specific_model_path=self._specific_model_path,
)
self.mask_token_id = None
self.pad_token_id = None
self.image_seq_len: Optional[int] = None
self.max_image_size: Optional[int] = None
self.image_size: Optional[int] = 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_colmodernvbert_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,
extra_session_options=self._extra_session_options,
)
# Load image processing configuration
processor_config_path = self._model_dir / "processor_config.json"
with open(processor_config_path) as f:
processor_config = json.load(f)
self.image_seq_len = processor_config.get("image_seq_len", 64)
preprocessor_config_path = self._model_dir / "preprocessor_config.json"
with open(preprocessor_config_path) as f:
preprocessor_config = json.load(f)
self.max_image_size = preprocessor_config.get("max_image_size", {}).get(
"longest_edge", 512
)
# Load model configuration
config_path = self._model_dir / "config.json"
with open(config_path) as f:
model_config = json.load(f)
vision_config = model_config.get("vision_config", {})
self.image_size = vision_config.get("image_size", 512)
def _preprocess_onnx_text_input(
self, onnx_input: dict[str, NumpyArray], **kwargs: Any
) -> dict[str, 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.
"""
batch_size, seq_length = onnx_input["input_ids"].shape
empty_image_placeholder: NumpyArray = np.zeros(
(batch_size, seq_length, 3, self.image_size, self.image_size),
dtype=np.float32, # type: ignore[type-var,arg-type,assignment]
)
onnx_input["pixel_values"] = empty_image_placeholder
return onnx_input
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]:
# Add query augmentation tokens (matching process_queries logic from colpali-engine)
augmented_queries = [doc + self.QUERY_AUGMENTATION_TOKEN * 10 for doc in documents]
encoded = self.tokenizer.encode_batch(augmented_queries) # type: ignore[union-attr]
return encoded
def token_count(
self,
texts: str | Iterable[str],
batch_size: int = 1024,
include_extension: bool = False,
**kwargs: Any,
) -> int:
if not hasattr(self, "model") or self.model is None:
self.load_onnx_model() # loads the tokenizer as well
token_num = 0
texts = [texts] if isinstance(texts, str) else texts
assert self.tokenizer is not None
tokenize_func = self.tokenize if include_extension else self.tokenizer.encode_batch
for batch in iter_batch(texts, batch_size):
token_num += sum([sum(encoding.attention_mask) for encoding in tokenize_func(batch)])
return token_num
def onnx_embed_image(self, images: list[ImageInput], **kwargs: Any) -> OnnxOutputContext:
with contextlib.ExitStack() as stack:
image_files = [
stack.enter_context(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"
processed = self.processor(image_files)
encoded, attention_mask, metadata = self._process_nested_patches(processed) # type: ignore[arg-type]
onnx_input = {"pixel_values": encoded, "attention_mask": attention_mask}
onnx_input = self._preprocess_onnx_image_input(onnx_input, **kwargs)
model_output = self.model.run(None, onnx_input) # type: ignore[union-attr]
return OnnxOutputContext(
model_output=model_output[0],
attention_mask=attention_mask, # type: ignore[arg-type]
metadata=metadata,
)
@staticmethod
def _process_nested_patches(
processed: list[list[NumpyArray]],
) -> tuple[NumpyArray, NumpyArray, dict[str, Any]]:
"""
Process nested image patches (from ImageSplitter).
Args:
processed: List of patch lists, one per image [[img1_patches], [img2_patches], ...]
Returns:
tuple: (encoded array, attention_mask, metadata)
- encoded: (batch_size, max_patches, C, H, W)
- attention_mask: (batch_size, max_patches) with 1 for real patches, 0 for padding
- metadata: Dict with 'patch_counts' key
"""
patch_counts = [len(patches) for patches in processed]
max_patches = max(patch_counts)
# Get dimensions from first patch
channels, height, width = processed[0][0].shape
batch_size = len(processed)
# Create padded array
encoded = np.zeros(
(batch_size, max_patches, channels, height, width), dtype=processed[0][0].dtype
)
# Create attention mask (1 for real patches, 0 for padding)
attention_mask = np.zeros((batch_size, max_patches), dtype=np.int64)
# Fill in patches and attention mask
for i, patches in enumerate(processed):
for j, patch in enumerate(patches):
encoded[i, j] = patch
attention_mask[i, j] = 1
metadata = {"patch_counts": patch_counts}
return encoded, attention_mask, metadata # type: ignore[return-value]
def _preprocess_onnx_image_input(
self, onnx_input: dict[str, np.ndarray], **kwargs: Any
) -> dict[str, NumpyArray]:
"""
Add text input placeholders for image data, following Idefics3 processing logic.
Constructs input_ids dynamically based on the actual number of image patches,
using the same token expansion logic as Idefics3Processor.
Args:
onnx_input: Dict with 'pixel_values' (batch, num_patches, C, H, W)
and 'attention_mask' (batch, num_patches) indicating real patches
**kwargs: Additional arguments
Returns:
Updated onnx_input with 'input_ids' and updated 'attention_mask' for token sequence
"""
# The attention_mask in onnx_input has a shape of (batch_size, num_patches),
# and should be used to create an attention mask matching the input_ids shape.
patch_attention_mask = onnx_input["attention_mask"]
pixel_values = onnx_input["pixel_values"]
batch_size = pixel_values.shape[0]
batch_input_ids = []
# Build input_ids for each image based on its actual patch count
for i in range(batch_size):
# Count real patches (non-padded) from attention mask
patch_count = int(np.sum(patch_attention_mask[i]))
# Compute rows/cols from patch count
rows, cols = self._compute_rows_cols_from_patches(patch_count)
# Build input_ids for this image
input_ids = self._build_input_ids_for_image(rows, cols)
batch_input_ids.append(input_ids)
# Pad sequences to max length in batch
max_len = max(len(ids) for ids in batch_input_ids)
# Get padding config from tokenizer
padding_direction = self.tokenizer.padding["direction"] # type: ignore[index,union-attr]
pad_token_id = self.tokenizer.padding["pad_id"] # type: ignore[index,union-attr]
# Initialize with pad token
padded_input_ids = np.full((batch_size, max_len), pad_token_id, dtype=np.int64)
attention_mask = np.zeros((batch_size, max_len), dtype=np.int64)
for i, input_ids in enumerate(batch_input_ids):
seq_len = len(input_ids)
if padding_direction == "left":
# Left padding: place tokens at the END of the array
start_idx = max_len - seq_len
padded_input_ids[i, start_idx:] = input_ids
attention_mask[i, start_idx:] = 1
else:
# Right padding: place tokens at the START of the array
padded_input_ids[i, :seq_len] = input_ids
attention_mask[i, :seq_len] = 1
onnx_input["input_ids"] = padded_input_ids
# Update attention_mask with token-level data
onnx_input["attention_mask"] = attention_mask
return onnx_input
@staticmethod
def _compute_rows_cols_from_patches(patch_count: int) -> tuple[int, int]:
if patch_count <= 1:
return 0, 0
# Subtract 1 for the global image
grid_patches = patch_count - 1
# Find rows and cols (assume square or near-square grid)
rows = int(grid_patches**0.5)
cols = grid_patches // rows
# Verify the calculation
if rows * cols + 1 != patch_count:
# Handle non-square grids
for r in range(1, grid_patches + 1):
if grid_patches % r == 0:
c = grid_patches // r
if r * c + 1 == patch_count:
return r, c
# Fallback: treat as unsplit
return 0, 0
return rows, cols
def _create_single_image_prompt_string(self) -> str:
return (
"<fake_token_around_image>"
+ "<global-img>"
+ "<image>" * self.image_seq_len # type: ignore[operator]
+ "<fake_token_around_image>"
)
def _create_split_image_prompt_string(self, rows: int, cols: int) -> str:
text_split_images = ""
# Add tokens for each patch in the grid
for n_h in range(rows):
for n_w in range(cols):
text_split_images += (
"<fake_token_around_image>"
+ f"<row_{n_h + 1}_col_{n_w + 1}>"
+ "<image>" * self.image_seq_len # type: ignore[operator]
)
text_split_images += "\n"
# Add global image at the end
text_split_images += (
"\n<fake_token_around_image>"
+ "<global-img>"
+ "<image>" * self.image_seq_len # type: ignore[operator]
+ "<fake_token_around_image>"
)
return text_split_images
def _build_input_ids_for_image(self, rows: int, cols: int) -> np.ndarray:
# Create the appropriate image prompt string
if rows == 0 and cols == 0:
image_prompt_tokens = self._create_single_image_prompt_string()
else:
image_prompt_tokens = self._create_split_image_prompt_string(rows, cols)
# Replace <image> in visual prompt with expanded tokens
# The visual prompt is: "<|begin_of_text|>User:<image>Describe the image.<end_of_utterance>\nAssistant:"
expanded_prompt = self.VISUAL_PROMPT_PREFIX.replace("<image>", image_prompt_tokens)
# Tokenize the complete prompt
encoded = self.tokenizer.encode(expanded_prompt) # type: ignore[union-attr]
# Convert to numpy array
return np.array(encoded.ids, dtype=np.int64)
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 embed_text(
self,
documents: 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,
local_files_only=self._local_files_only,
specific_model_path=self._specific_model_path,
extra_session_options=self._extra_session_options,
**kwargs,
)
def embed_image(
self,
images: 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,
local_files_only=self._local_files_only,
specific_model_path=self._specific_model_path,
extra_session_options=self._extra_session_options,
**kwargs,
)
@classmethod
def _get_text_worker_class(cls) -> Type[TextEmbeddingWorker[NumpyArray]]:
return ColModernVBERTTextEmbeddingWorker
@classmethod
def _get_image_worker_class(cls) -> Type[ImageEmbeddingWorker[NumpyArray]]:
return ColModernVBERTImageEmbeddingWorker
class ColModernVBERTTextEmbeddingWorker(TextEmbeddingWorker[NumpyArray]):
def init_embedding(self, model_name: str, cache_dir: str, **kwargs: Any) -> ColModernVBERT:
return ColModernVBERT(
model_name=model_name,
cache_dir=cache_dir,
threads=1,
**kwargs,
)
class ColModernVBERTImageEmbeddingWorker(ImageEmbeddingWorker[NumpyArray]):
def init_embedding(self, model_name: str, cache_dir: str, **kwargs: Any) -> ColModernVBERT:
return ColModernVBERT(
model_name=model_name,
cache_dir=cache_dir,
threads=1,
**kwargs,
)
@@ -0,0 +1,327 @@
from typing import Any, Iterable, Sequence, Type
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, Device
from fastembed.common.utils import define_cache_dir, iter_batch
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: str | None = None,
threads: int | None = None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_ids: list[int] | None = None,
lazy_load: bool = False,
device_id: int | None = None,
specific_model_path: str | None = 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 (Union[bool, Device], optional): Whether to use cuda for inference. Mutually exclusive with `providers`
Defaults to Device.AUTO.
device_ids (Optional[list[int]], optional): The list of device ids to use for data parallel processing in
workers. Should be used with `cuda` equals to `True`, `Device.AUTO` or `Device.CUDA`, 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
self._extra_session_options = self._select_exposed_session_options(kwargs)
# 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: int | None = 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._specific_model_path = specific_model_path
self._model_dir = self.download_model(
self.model_description,
self.cache_dir,
local_files_only=self._local_files_only,
specific_model_path=self._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,
extra_session_options=self._extra_session_options,
)
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 token_count(
self,
texts: str | Iterable[str],
batch_size: int = 1024,
include_extension: bool = False,
**kwargs: Any,
) -> int:
if not hasattr(self, "model") or self.model is None:
self.load_onnx_model() # loads the tokenizer as well
token_num = 0
texts = [texts] if isinstance(texts, str) else texts
assert self.tokenizer is not None
tokenize_func = self.tokenize if include_extension else self.tokenizer.encode_batch
for batch in iter_batch(texts, batch_size):
token_num += sum([sum(encoding.attention_mask) for encoding in tokenize_func(batch)])
return token_num
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() # type: ignore[index]
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: str | Iterable[str],
batch_size: int = 256,
parallel: int | None = 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,
local_files_only=self._local_files_only,
specific_model_path=self._specific_model_path,
extra_session_options=self._extra_session_options,
**kwargs,
)
def embed_image(
self,
images: ImageInput | Iterable[ImageInput],
batch_size: int = 16,
parallel: int | None = 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,
local_files_only=self._local_files_only,
specific_model_path=self._specific_model_path,
extra_session_options=self._extra_session_options,
**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,189 @@
from typing import Any, Iterable, Sequence, Type
from dataclasses import asdict
from fastembed.common import OnnxProvider, ImageInput
from fastembed.common.types import NumpyArray, Device
from fastembed.late_interaction_multimodal.colpali import ColPali
from fastembed.late_interaction_multimodal.colmodernvbert import ColModernVBERT
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,
ColModernVBERT,
]
@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: str | None = None,
threads: int | None = None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_ids: list[int] | None = 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()`"
)
@property
def embedding_size(self) -> int:
"""Get the embedding size of the current model"""
if self._embedding_size is None:
self._embedding_size = self.get_embedding_size(self.model_name)
return self._embedding_size
@classmethod
def get_embedding_size(cls, model_name: str) -> int:
"""Get the embedding size of the passed model
Args:
model_name (str): The name of the model to get embedding size for.
Returns:
int: The size of the embedding.
Raises:
ValueError: If the model name is not found in the supported models.
"""
descriptions = cls._list_supported_models()
embedding_size: int | None = None
for description in descriptions:
if description.model.lower() == model_name.lower():
embedding_size = description.dim
break
if embedding_size is None:
model_names = [description.model for description in descriptions]
raise ValueError(
f"Embedding size for model {model_name} was None. "
f"Available model names: {model_names}"
)
return embedding_size
def embed_text(
self,
documents: str | Iterable[str],
batch_size: int = 256,
parallel: int | None = 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: ImageInput | Iterable[ImageInput],
batch_size: int = 16,
parallel: int | None = 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)
def token_count(
self,
texts: str | Iterable[str],
batch_size: int = 1024,
include_extension: bool = False,
**kwargs: Any,
) -> int:
"""Returns the number of tokens in the texts.
Args:
texts (str | Iterable[str]): The list of texts to embed.
batch_size (int): Batch size for encoding
include_extension (bool): Whether to include tokens added by preprocessing
Returns:
int: Sum of number of tokens in the texts.
"""
return self.model.token_count(
texts, batch_size=batch_size, include_extension=include_extension, **kwargs
)
@@ -0,0 +1,86 @@
from typing import Iterable, 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: str | None = None,
threads: int | None = 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)
self._embedding_size: int | None = None
def embed_text(
self,
documents: str | Iterable[str],
batch_size: int = 256,
parallel: int | None = 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: ImageInput | Iterable[ImageInput],
batch_size: int = 16,
parallel: int | None = 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()
@classmethod
def get_embedding_size(cls, model_name: str) -> int:
"""Returns embedding size of the chosen model."""
raise NotImplementedError("Subclasses must implement this method")
@property
def embedding_size(self) -> int:
"""Returns embedding size for the current model"""
raise NotImplementedError("Subclasses must implement this method")
def token_count(
self,
texts: str | Iterable[str],
**kwargs: Any,
) -> int:
"""Returns the number of tokens in the texts."""
raise NotImplementedError("Subclasses must implement this method")
@@ -0,0 +1,291 @@
import contextlib
import os
from multiprocessing import get_all_start_methods
from pathlib import Path
from typing import Any, Iterable, Sequence, Type
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, Device
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: list[str] | None = None
def __init__(self) -> None:
super().__init__()
self.tokenizer: Tokenizer | None = None
self.processor: Compose | None = 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: int | None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_id: int | None = None,
extra_session_options: dict[str, Any] | None = None,
) -> None:
super()._load_onnx_model(
model_dir=model_dir,
model_file=model_file,
threads=threads,
providers=providers,
cuda=cuda,
device_id=device_id,
extra_session_options=extra_session_options,
)
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: str | Iterable[str],
batch_size: int = 256,
parallel: int | None = None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_ids: list[int] | None = None,
local_files_only: bool = False,
specific_model_path: str | None = None,
extra_session_options: dict[str, Any] | None = 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,
"local_files_only": local_files_only,
"specific_model_path": specific_model_path,
**kwargs,
}
if extra_session_options is not None:
params.update(extra_session_options)
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() as stack:
image_files = [
stack.enter_context(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: Iterable[ImageInput] | ImageInput,
batch_size: int = 256,
parallel: int | None = None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_ids: list[int] | None = None,
local_files_only: bool = False,
specific_model_path: str | None = None,
extra_session_options: dict[str, Any] | None = 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,
"local_files_only": local_files_only,
"specific_model_path": specific_model_path,
**kwargs,
}
if extra_session_options is not None:
params.update(extra_session_options)
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
+17 -15
View File
@@ -1,14 +1,16 @@
import logging
import os
from collections import defaultdict
from copy import deepcopy
from enum import Enum
from multiprocessing import Queue, get_context
from multiprocessing.context import BaseContext
from multiprocessing.process import BaseProcess
from multiprocessing.sharedctypes import Synchronized as BaseValue
from queue import Empty
from typing import Any, Dict, Iterable, List, Optional, Tuple, Type
from copy import deepcopy
from typing import Any, Iterable, Type
from fastembed.common.types import Device
# Single item should be processed in less than:
processing_timeout = 10 * 60 # seconds
@@ -24,10 +26,10 @@ class QueueSignals(str, Enum):
class Worker:
@classmethod
def start(cls, **kwargs: Any) -> "Worker":
def start(cls, *args: Any, **kwargs: Any) -> "Worker":
raise NotImplementedError()
def process(self, items: Iterable[Tuple[int, Any]]) -> Iterable[Tuple[int, Any]]:
def process(self, items: Iterable[tuple[int, Any]]) -> Iterable[tuple[int, Any]]:
raise NotImplementedError()
@@ -37,7 +39,7 @@ def _worker(
output_queue: Queue,
num_active_workers: BaseValue,
worker_id: int,
kwargs: Optional[Dict[str, Any]] = None,
kwargs: dict[str, Any] | None = None,
) -> None:
"""
A worker that pulls data pints off the input queue, and places the execution result on the output queue.
@@ -92,21 +94,21 @@ class ParallelWorkerPool:
self,
num_workers: int,
worker: Type[Worker],
start_method: Optional[str] = None,
device_ids: Optional[List[int]] = None,
cuda: bool = False,
start_method: str | None = None,
device_ids: list[int] | None = None,
cuda: bool | Device = Device.AUTO,
):
self.worker_class = worker
self.num_workers = num_workers
self.input_queue: Optional[Queue] = None
self.output_queue: Optional[Queue] = None
self.input_queue: Queue | None = None
self.output_queue: Queue | None = None
self.ctx: BaseContext = get_context(start_method)
self.processes: List[BaseProcess] = []
self.processes: list[BaseProcess] = []
self.queue_size = self.num_workers * max_internal_batch_size
self.emergency_shutdown = False
self.device_ids = device_ids
self.cuda = cuda
self.num_active_workers: Optional[BaseValue] = None
self.num_active_workers: BaseValue | None = None
def start(self, **kwargs: Any) -> None:
self.input_queue = self.ctx.Queue(self.queue_size)
@@ -139,7 +141,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):
@@ -150,7 +152,7 @@ class ParallelWorkerPool:
def semi_ordered_map(
self, stream: Iterable[Any], *args: Any, **kwargs: Any
) -> Iterable[Tuple[int, Any]]:
) -> Iterable[tuple[int, Any]]:
try:
self.start(**kwargs)
@@ -219,7 +221,7 @@ class ParallelWorkerPool:
f"Worker PID: {process.pid} terminated unexpectedly with code {process.exitcode}"
)
def join_or_terminate(self, timeout: Optional[int] = 1) -> None:
def join_or_terminate(self, timeout: int = 1) -> None:
"""
Emergency shutdown
@param timeout:
+3
View File
@@ -0,0 +1,3 @@
from fastembed.postprocess.muvera import Muvera
__all__ = ["Muvera"]
+362
View File
@@ -0,0 +1,362 @@
import numpy as np
from fastembed.common.types import NumpyArray
from fastembed.late_interaction.late_interaction_embedding_base import (
LateInteractionTextEmbeddingBase,
)
from fastembed.late_interaction_multimodal.late_interaction_multimodal_embedding_base import (
LateInteractionMultimodalEmbeddingBase,
)
MultiVectorModel = LateInteractionTextEmbeddingBase | LateInteractionMultimodalEmbeddingBase
MAX_HAMMING_DISTANCE = 65 # 64 bits + 1
POPCOUNT_LUT = np.array([bin(x).count("1") for x in range(256)], dtype=np.uint8)
def hamming_distance_matrix(ids: np.ndarray) -> np.ndarray:
"""Compute full Hamming distance matrix
Args:
ids: shape (n,) - array of ids, only size of the array matters
Return:
np.ndarray (n, n) - hamming distance matrix
"""
n = len(ids)
xor_vals = np.bitwise_xor(ids[:, None], ids[None, :]) # (n, n) uint64
bytes_view = xor_vals.view(np.uint8).reshape(n, n, 8) # (n, n, 8)
return POPCOUNT_LUT[bytes_view].sum(axis=2)
class SimHashProjection:
"""
SimHash projection component for MUVERA clustering.
This class implements locality-sensitive hashing using random hyperplanes
to partition the vector space into 2^k_sim clusters. Each vector is assigned
to a cluster based on which side of k_sim random hyperplanes it falls on.
Attributes:
k_sim (int): Number of SimHash functions (hyperplanes)
dim (int): Dimensionality of input vectors
simhash_vectors (np.ndarray): Random hyperplane normal vectors of shape (dim, k_sim)
"""
def __init__(self, k_sim: int, dim: int, random_generator: np.random.Generator):
"""
Initialize SimHash projection with random hyperplanes.
Args:
k_sim (int): Number of SimHash functions, determines 2^k_sim clusters
dim (int): Dimensionality of input vectors
random_generator (np.random.Generator): Random number generator for reproducibility
"""
self.k_sim = k_sim
self.dim = dim
# Generate k_sim random hyperplanes (normal vectors) from standard normal distribution
self.simhash_vectors = random_generator.normal(size=(dim, k_sim))
def get_cluster_ids(self, vectors: np.ndarray) -> np.ndarray:
"""
Compute the cluster IDs for a given vector using SimHash.
The cluster ID is determined by computing the dot product of the vector
with each hyperplane normal vector, taking the sign, and interpreting
the resulting binary string as an integer.
Args:
vectors (np.ndarray): Input vectors of shape (n, dim,)
Returns:
np.ndarray: Cluster IDs in range [0, 2^k_sim - 1]
Raises:
AssertionError: If a vector shape doesn't match expected dimensionality
"""
dot_product = (
vectors @ self.simhash_vectors
) # (token_num, dim) x (dim, k_sim) -> (token_num, k_sim)
cluster_ids = (dot_product > 0) @ (1 << np.arange(self.k_sim))
return cluster_ids
class Muvera:
"""
MUVERA (Multi-Vector Retrieval Architecture) algorithm implementation.
This class creates Fixed Dimensional Encodings (FDEs) from variable-length
sequences of vectors by using SimHash clustering and random projections.
The process involves:
1. Clustering vectors using multiple SimHash projections
2. Computing cluster centers (with different strategies for docs vs queries)
3. Applying random projections for dimensionality reduction
4. Concatenating results from all projections
Attributes:
k_sim (int): Number of SimHash functions per projection
dim (int): Input vector dimensionality
dim_proj (int): Output dimensionality after random projection
r_reps (int): Number of random projection repetitions
random_seed (int): Random seed for consistent random matrix generation
simhash_projections (List[SimHashProjection]): SimHash instances for clustering
dim_reduction_projections (np.ndarray): Random projection matrices of shape (R_reps, d, d_proj)
"""
def __init__(
self,
dim: int,
k_sim: int = 5,
dim_proj: int = 16,
r_reps: int = 20,
random_seed: int = 42,
):
"""
Initialize MUVERA algorithm with specified parameters.
Args:
dim (int): Dimensionality of individual input vectors
k_sim (int, optional): Number of SimHash functions (creates 2^k_sim clusters).
Defaults to 5.
dim_proj (int, optional): Dimensionality after random projection (must be <= dim).
Defaults to 16.
r_reps (int, optional): Number of random projection repetitions for robustness.
Defaults to 20.
random_seed (int, optional): Seed for random number generator to ensure
reproducible results. Defaults to 42.
Raises:
ValueError: If dim_proj > dim (cannot project to higher dimensionality)
"""
if dim_proj > dim:
raise ValueError(
f"Cannot project to a higher dimensionality (dim_proj={dim_proj} > dim={dim})"
)
self.k_sim = k_sim
self.dim = dim
self.dim_proj = dim_proj
self.r_reps = r_reps
# Create r_reps independent SimHash projections for robustness
generator = np.random.default_rng(random_seed)
self.simhash_projections = [
SimHashProjection(k_sim=self.k_sim, dim=self.dim, random_generator=generator)
for _ in range(r_reps)
]
# Random projection matrices with entries from {-1, +1} for each repetition
self.dim_reduction_projections = generator.choice([-1, 1], size=(r_reps, dim, dim_proj))
@classmethod
def from_multivector_model(
cls,
model: MultiVectorModel,
k_sim: int = 5,
dim_proj: int = 16,
r_reps: int = 20, # noqa[naming]
random_seed: int = 42,
) -> "Muvera":
"""
Create a Muvera instance from a multi-vector embedding model.
This class method provides a convenient way to initialize a MUVERA
that is compatible with a given multi-vector model by automatically extracting
the embedding dimensionality from the model.
Args:
model (MultiVectorModel): A late interaction text or multimodal embedding model
that provides multi-vector embeddings. Must have an
`embedding_size` attribute specifying the dimensionality
of individual vectors.
k_sim (int, optional): Number of SimHash functions (creates 2^k_sim clusters).
Defaults to 5.
dim_proj (int, optional): Dimensionality after random projection (must be <= model's
embedding_size). Defaults to 16.
r_reps (int, optional): Number of random projection repetitions for robustness.
Defaults to 20.
random_seed (int, optional): Seed for random number generator to ensure
reproducible results. Defaults to 42.
Returns:
Muvera: A configured MUVERA instance ready to process embeddings from the given model.
Raises:
ValueError: If dim_proj > model.embedding_size (cannot project to higher dimensionality)
Example:
>>> from fastembed import LateInteractionTextEmbedding
>>> model = LateInteractionTextEmbedding(model_name="colbert-ir/colbertv2.0")
>>> muvera = Muvera.from_multivector_model(
... model=model,
... k_sim=6,
... dim_proj=32
... )
>>> # Now use postprocessor with embeddings from the model
>>> embeddings = np.array(list(model.embed(["sample text"])))
>>> fde = muvera.process_document(embeddings[0])
"""
return cls(
dim=model.embedding_size,
k_sim=k_sim,
dim_proj=dim_proj,
r_reps=r_reps,
random_seed=random_seed,
)
def _get_output_dimension(self) -> int:
"""
Get the output dimension of the MUVERA algorithm.
Returns:
int: Output dimension (r_reps * num_partitions * dim_proj) where b = 2^k_sim
"""
num_partitions = 2**self.k_sim
return self.r_reps * num_partitions * self.dim_proj
@property
def embedding_size(self) -> int:
return self._get_output_dimension()
def process_document(self, vectors: NumpyArray) -> NumpyArray:
"""
Encode a document's vectors into a Fixed Dimensional Encoding (FDE).
Uses document-specific settings: normalizes cluster centers by vector count
and fills empty clusters using Hamming distance-based selection.
Args:
vectors (NumpyArray): Document vectors of shape (n_tokens, dim)
Returns:
NumpyArray: Fixed dimensional encodings of shape (r_reps * b * dim_proj,)
"""
return self.process(vectors, fill_empty_clusters=True, normalize_by_count=True)
def process_query(self, vectors: NumpyArray) -> NumpyArray:
"""
Encode a query's vectors into a Fixed Dimensional Encoding (FDE).
Uses query-specific settings: no normalization by count and no empty
cluster filling to preserve query vector magnitudes.
Args:
vectors (NumpyArray]): Query vectors of shape (n_tokens, dim)
Returns:
NumpyArray: Fixed dimensional encoding of shape (r_reps * b * dim_proj,)
"""
return self.process(vectors, fill_empty_clusters=False, normalize_by_count=False)
def process(
self,
vectors: NumpyArray,
fill_empty_clusters: bool = True,
normalize_by_count: bool = True,
) -> NumpyArray:
"""
Core encoding method that transforms variable-length vector sequences into FDEs.
The encoding process:
1. For each of r_reps random projections:
a. Assign vectors to clusters using SimHash
b. Compute cluster centers (sum of vectors in each cluster)
c. Optionally normalize by cluster size
d. Fill empty clusters using Hamming distance if requested
e. Apply random projection for dimensionality reduction
f. Flatten cluster centers into a vector
2. Concatenate all projection results
Args:
vectors (np.ndarray): Input vectors of shape (n_vectors, dim)
fill_empty_clusters (bool): Whether to fill empty clusters using nearest
vectors based on Hamming distance of cluster IDs
normalize_by_count (bool): Whether to normalize cluster centers by the
number of vectors assigned to each cluster
Returns:
np.ndarray: Fixed dimensional encoding of shape (r_reps * b * dim_proj)
where B = 2^k_sim is the number of clusters
Raises:
AssertionError: If input vectors don't have expected dimensionality
"""
assert (
vectors.shape[1] == self.dim
), f"Expected vectors of shape (n, {self.dim}), got {vectors.shape}"
# Store results from each random projection
output_vectors = []
# num of space partitions in SimHash
num_partitions = 2**self.k_sim
cluster_center_ids = np.arange(num_partitions)
precomputed_hamming_matrix = (
hamming_distance_matrix(cluster_center_ids) if fill_empty_clusters else None
)
for projection_index, simhash in enumerate(self.simhash_projections):
# Initialize cluster centers and count vectors assigned to each cluster
cluster_centers = np.zeros((num_partitions, self.dim))
cluster_center_id_to_vectors: dict[int, list[int]] = {
cluster_center_id: [] for cluster_center_id in cluster_center_ids
}
cluster_vector_counts = None
empty_mask = None
# Assign each vector to its cluster and accumulate cluster centers
vector_cluster_ids = simhash.get_cluster_ids(vectors)
for cluster_id, (vec_idx, vec) in zip(vector_cluster_ids, enumerate(vectors)):
cluster_centers[cluster_id] += vec
cluster_center_id_to_vectors[cluster_id].append(vec_idx)
if normalize_by_count or fill_empty_clusters:
cluster_vector_counts = np.bincount(vector_cluster_ids, minlength=num_partitions)
empty_mask = cluster_vector_counts == 0
if normalize_by_count:
assert empty_mask is not None
assert cluster_vector_counts is not None
non_empty_mask = ~empty_mask
cluster_centers[non_empty_mask] /= cluster_vector_counts[non_empty_mask][:, None]
# Fill empty clusters using vectors with minimum Hamming distance
if fill_empty_clusters:
assert empty_mask is not None
assert precomputed_hamming_matrix is not None
masked_hamming = np.where(
empty_mask[None, :], MAX_HAMMING_DISTANCE, precomputed_hamming_matrix
)
nearest_non_empty = np.argmin(masked_hamming, axis=1)
fill_vectors = np.array(
[
vectors[cluster_center_id_to_vectors[cluster_id][0]]
for cluster_id in nearest_non_empty[empty_mask]
]
).reshape(-1, self.dim)
cluster_centers[empty_mask] = fill_vectors
# Apply random projection for dimensionality reduction if needed
if self.dim_proj < self.dim:
dim_reduction_projection = self.dim_reduction_projections[
projection_index
] # Get projection matrix for this repetition
projected_centers = (1 / np.sqrt(self.dim_proj)) * (
cluster_centers @ dim_reduction_projection
)
# Flatten cluster centers into a single vector and add to output
output_vectors.append(projected_centers.flatten())
continue
# If no projection needed (dim_proj == dim), use original cluster centers
output_vectors.append(cluster_centers.flatten())
# Concatenate results from all R_reps projections into final FDE
return np.concatenate(output_vectors)
if __name__ == "__main__":
v_arrs = np.random.randn(10, 100, 128)
muvera = Muvera(128, 4, 8, 20, 42)
for v_arr in v_arrs:
muvera.process(v_arr) # type: ignore
+1
View File
@@ -0,0 +1 @@
partial
@@ -0,0 +1,47 @@
from typing import Sequence, Any
from fastembed.common import OnnxProvider
from fastembed.common.model_description import BaseModelDescription
from fastembed.common.types import Device
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: str | None = None,
threads: int | None = None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_ids: list[int] | None = None,
lazy_load: bool = False,
device_id: int | None = None,
specific_model_path: str | None = 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)
@@ -1,67 +1,92 @@
from typing import List, Iterable, Dict, Any, Sequence, Optional
from typing import Any, Iterable, Sequence, Type
from loguru import logger
from fastembed.common import OnnxProvider
from fastembed.rerank.cross_encoder.onnx_text_model import OnnxCrossEncoderModel
from fastembed.rerank.cross_encoder.text_cross_encoder_base import TextCrossEncoderBase
from fastembed.common.onnx_model import OnnxOutputContext
from fastembed.common.types import Device
from fastembed.common.utils import define_cache_dir
from fastembed.rerank.cross_encoder.onnx_text_model import (
OnnxCrossEncoderModel,
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",
},
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
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,
cache_dir: str | None = None,
threads: int | None = None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_ids: list[int] | None = None,
lazy_load: bool = False,
device_id: Optional[int] = None,
**kwargs,
device_id: int | None = None,
specific_model_path: str | None = None,
**kwargs: Any,
):
"""
Args:
@@ -72,13 +97,15 @@ class OnnxTextCrossEncoder(TextCrossEncoderBase, OnnxCrossEncoderModel):
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.
cuda (Union[bool, Device], optional): Whether to use cuda for inference. Mutually exclusive with `providers`
Defaults to Device.AUTO.
device_ids (Optional[list[int]], optional): The list of device ids to use for data parallel processing in
workers. Should be used with `cuda` equals to `True`, `Device.AUTO` or `Device.CUDA`, 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.
specific_model_path (Optional[str], optional): The specific path to the onnx model dir if it should be imported from somewhere else
Raises:
ValueError: If the model_name is not in the format <org>/<model> e.g. Xenova/ms-marco-MiniLM-L-6-v2.
@@ -86,6 +113,7 @@ class OnnxTextCrossEncoder(TextCrossEncoderBase, OnnxCrossEncoderModel):
super().__init__(model_name, cache_dir, threads, **kwargs)
self.providers = providers
self.lazy_load = lazy_load
self._extra_session_options = self._select_exposed_session_options(kwargs)
# List of device ids, that can be used for data parallel processing in workers
self.device_ids = device_ids
@@ -98,17 +126,20 @@ class OnnxTextCrossEncoder(TextCrossEncoderBase, OnnxCrossEncoderModel):
)
# This device_id will be used if we need to load model in current process
self.device_id: int | None = 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._specific_model_path = specific_model_path
self._model_dir = self.download_model(
self.model_description, self.cache_dir, local_files_only=self._local_files_only
self.model_description,
self.cache_dir,
local_files_only=self._local_files_only,
specific_model_path=self._specific_model_path,
)
if not self.lazy_load:
@@ -117,11 +148,12 @@ 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,
device_id=self.device_id,
extra_session_options=self._extra_session_options,
)
def rerank(
@@ -129,7 +161,7 @@ class OnnxTextCrossEncoder(TextCrossEncoderBase, OnnxCrossEncoderModel):
query: str,
documents: Iterable[str],
batch_size: int = 64,
**kwargs,
**kwargs: Any,
) -> Iterable[float]:
"""Reranks documents based on their relevance to a given query.
@@ -145,3 +177,63 @@ class OnnxTextCrossEncoder(TextCrossEncoderBase, OnnxCrossEncoderModel):
yield from self._rerank_documents(
query=query, documents=documents, batch_size=batch_size, **kwargs
)
def rerank_pairs(
self,
pairs: Iterable[tuple[str, str]],
batch_size: int = 64,
parallel: int | None = None,
**kwargs: Any,
) -> Iterable[float]:
yield from self._rerank_pairs(
model_name=self.model_name,
cache_dir=str(self.cache_dir),
pairs=pairs,
batch_size=batch_size,
parallel=parallel,
providers=self.providers,
cuda=self.cuda,
device_ids=self.device_ids,
local_files_only=self._local_files_only,
specific_model_path=self._specific_model_path,
extra_session_options=self._extra_session_options,
**kwargs,
)
@classmethod
def _get_worker_class(cls) -> Type[TextRerankerWorker]:
return TextCrossEncoderWorker
def _post_process_onnx_output(
self, output: OnnxOutputContext, **kwargs: Any
) -> Iterable[float]:
return (float(elem) for elem in output.model_output)
def token_count(
self, pairs: Iterable[tuple[str, str]], batch_size: int = 1024, **kwargs: Any
) -> int:
"""Returns the number of tokens in the pairs.
Args:
pairs: Iterable of tuples, where each tuple contains a query and a document to be tokenized
batch_size: Batch size for tokenizing
Returns:
token count: overall number of tokens in the pairs
"""
return self._token_count(pairs, batch_size=batch_size, **kwargs)
class TextCrossEncoderWorker(TextRerankerWorker):
def init_embedding(
self,
model_name: str,
cache_dir: str,
**kwargs: Any,
) -> OnnxTextCrossEncoder:
return OnnxTextCrossEncoder(
model_name=model_name,
cache_dir=cache_dir,
threads=1,
**kwargs,
)
+158 -24
View File
@@ -1,25 +1,39 @@
from typing import Sequence, Optional, List, Dict, Iterable
import os
from multiprocessing import get_all_start_methods
from pathlib import Path
from typing import Any, Iterable, Sequence, Type
import numpy as np
from tokenizers import Encoding
from fastembed.common.onnx_model import OnnxModel, OnnxProvider
from fastembed.common.onnx_model import (
EmbeddingWorker,
OnnxModel,
OnnxOutputContext,
OnnxProvider,
)
from fastembed.common.types import NumpyArray, Device
from fastembed.common.preprocessor_utils import load_tokenizer
from fastembed.common.utils import iter_batch
from fastembed.parallel_processor import ParallelWorkerPool
class OnnxCrossEncoderModel(OnnxModel):
ONNX_OUTPUT_NAMES: Optional[List[str]] = None
class OnnxCrossEncoderModel(OnnxModel[float]):
ONNX_OUTPUT_NAMES: list[str] | None = None
@classmethod
def _get_worker_class(cls) -> Type["TextRerankerWorker"]:
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,
threads: int | None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_id: int | None = None,
extra_session_options: dict[str, Any] | None = None,
) -> None:
super()._load_onnx_model(
model_dir=model_dir,
@@ -28,43 +42,163 @@ class OnnxCrossEncoderModel(OnnxModel):
providers=providers,
cuda=cuda,
device_id=device_id,
extra_session_options=extra_session_options,
)
self.tokenizer, _ = load_tokenizer(model_dir=model_dir)
assert self.tokenizer is not None
def tokenize(self, query: str, documents: List[str], **kwargs) -> List[Encoding]:
return self.tokenizer.encode_batch([(query, doc) for doc in documents])
def tokenize(self, pairs: list[tuple[str, str]], **_: Any) -> list[Encoding]:
return self.tokenizer.encode_batch(pairs) # type: ignore[union-attr]
def onnx_embed(self, query: str, documents: List[str], **kwargs) -> List[float]:
tokenized_input = self.tokenize(query, documents, **kwargs)
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),
"attention_mask": np.array(
[enc.attention_mask for enc in tokenized_input], dtype=np.int64
),
}
input_names = {node.name for node in self.model.get_inputs()}
if "token_type_ids" in input_names:
inputs["token_type_ids"] = np.array(
[enc.type_ids for enc in tokenized_input], dtype=np.int64
)
if "attention_mask" in input_names:
inputs["attention_mask"] = np.array(
[enc.attention_mask for enc in tokenized_input], dtype=np.int64
)
return inputs
def onnx_embed(self, query: str, documents: list[str], **kwargs: Any) -> OnnxOutputContext:
pairs = [(query, doc) for doc in documents]
return self.onnx_embed_pairs(pairs, **kwargs)
def onnx_embed_pairs(self, pairs: list[tuple[str, str]], **kwargs: Any) -> OnnxOutputContext:
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)
return outputs[0][:, 0].tolist()
outputs = self.model.run(self.ONNX_OUTPUT_NAMES, onnx_input) # type: ignore[union-attr]
relevant_output = outputs[0]
scores: NumpyArray = relevant_output[:, 0]
return OnnxOutputContext(model_output=scores)
def _rerank_documents(
self, query: str, documents: Iterable[str], batch_size: int, **kwargs
self, query: str, documents: Iterable[str], batch_size: int, **kwargs: Any
) -> Iterable[float]:
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.onnx_embed(query, batch, **kwargs)
yield from self._post_process_onnx_output(self.onnx_embed(query, batch, **kwargs))
def _rerank_pairs(
self,
model_name: str,
cache_dir: str,
pairs: Iterable[tuple[str, str]],
batch_size: int,
parallel: int | None = None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_ids: list[int] | None = None,
local_files_only: bool = False,
specific_model_path: str | None = None,
extra_session_options: dict[str, Any] | None = None,
**kwargs: Any,
) -> Iterable[float]:
is_small = False
if isinstance(pairs, tuple):
pairs = [pairs]
is_small = True
if isinstance(pairs, list):
if len(pairs) < 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(pairs, batch_size):
yield from self._post_process_onnx_output(self.onnx_embed_pairs(batch, **kwargs))
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,
"local_files_only": local_files_only,
"specific_model_path": specific_model_path,
**kwargs,
}
if extra_session_options is not None:
params.update(extra_session_options)
pool = ParallelWorkerPool(
num_workers=parallel or 1,
worker=self._get_worker_class(),
cuda=cuda,
device_ids=device_ids,
start_method=start_method,
)
for batch in pool.ordered_map(iter_batch(pairs, batch_size), **params):
yield from self._post_process_onnx_output(batch) # type: ignore
def _post_process_onnx_output(
self, output: OnnxOutputContext, **kwargs: Any
) -> Iterable[float]:
"""Post-process the ONNX model output to convert it into a usable format.
Args:
output (OnnxOutputContext): The raw output from the ONNX model.
**kwargs: Additional keyword arguments that may be needed by specific implementations.
Returns:
Iterable[float]: Post-processed output as an iterable of float values.
"""
raise NotImplementedError("Subclasses must implement this method")
def _preprocess_onnx_input(
self, onnx_input: Dict[str, np.ndarray], **kwargs
) -> Dict[str, np.ndarray]:
self, onnx_input: dict[str, NumpyArray], **kwargs: Any
) -> dict[str, NumpyArray]:
"""
Preprocess the onnx input.
"""
return onnx_input
def _token_count(
self, pairs: Iterable[tuple[str, str]], batch_size: int = 1024, **_: Any
) -> int:
if not hasattr(self, "model") or self.model is None:
self.load_onnx_model() # loads the tokenizer as well
token_num = 0
assert self.tokenizer is not None
for batch in iter_batch(pairs, batch_size):
for tokens in self.tokenizer.encode_batch(batch):
token_num += sum(tokens.attention_mask)
return token_num
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)
yield idx, onnx_output
@@ -1,21 +1,30 @@
from typing import Any, Dict, Iterable, List, Optional, Sequence, Type
from typing import Any, Iterable, Sequence, Type
from dataclasses import asdict
from fastembed.common import OnnxProvider
from fastembed.common.types import Device
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.rerank.cross_encoder.onnx_text_cross_encoder import OnnxTextCrossEncoder
from fastembed.common import OnnxProvider
from fastembed.common.model_description import (
ModelSource,
BaseModelDescription,
)
class TextCrossEncoder(TextCrossEncoderBase):
CROSS_ENCODER_REGISTRY: List[Type[TextCrossEncoderBase]] = [
CROSS_ENCODER_REGISTRY: list[Type[TextCrossEncoderBase]] = [
OnnxTextCrossEncoder,
CustomTextCrossEncoder,
]
@classmethod
def list_supported_models(cls) -> List[Dict[str, Any]]:
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.
list[BaseModelDescription]: A list of dictionaries containing the model information.
Example:
```
@@ -33,27 +42,31 @@ 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__(
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,
cache_dir: str | None = None,
threads: int | None = None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_ids: list[int] | None = None,
lazy_load: bool = False,
**kwargs,
**kwargs: Any,
):
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,
@@ -72,7 +85,7 @@ class TextCrossEncoder(TextCrossEncoderBase):
)
def rerank(
self, query: str, documents: Iterable[str], batch_size: int = 64, **kwargs
self, query: str, documents: Iterable[str], batch_size: int = 64, **kwargs: Any
) -> Iterable[float]:
"""Rerank a list of documents based on a query.
@@ -85,3 +98,81 @@ class TextCrossEncoder(TextCrossEncoderBase):
Iterable of scores for each document
"""
yield from self.model.rerank(query, documents, batch_size=batch_size, **kwargs)
def rerank_pairs(
self,
pairs: Iterable[tuple[str, str]],
batch_size: int = 64,
parallel: int | None = None,
**kwargs: Any,
) -> Iterable[float]:
"""
Rerank a list of query-document pairs.
Args:
pairs (Iterable[tuple[str, str]]): An iterable of tuples, where each tuple contains a query and a document
to be scored together.
batch_size (int, optional): The number of query-document pairs to process in a single batch. Defaults to 64.
parallel (Optional[int], optional): The number of parallel processes to use for reranking.
If None, parallelization is disabled. Defaults to None.
**kwargs (Any): Additional arguments to pass to the underlying reranking model.
Returns:
Iterable[float]: An iterable of scores corresponding to each query-document pair in the input.
Higher scores indicate a stronger match between the query and the document.
Example:
>>> encoder = TextCrossEncoder("Xenova/ms-marco-MiniLM-L-6-v2")
>>> pairs = [("What is AI?", "Artificial intelligence is ..."), ("What is ML?", "Machine learning is ...")]
>>> scores = list(encoder.rerank_pairs(pairs))
>>> print(list(map(lambda x: round(x, 2), scores)))
[-1.24, -10.6]
"""
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: list[str] | None = 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 [],
)
)
def token_count(
self, pairs: Iterable[tuple[str, str]], batch_size: int = 1024, **kwargs: Any
) -> int:
"""Returns the number of tokens in the pairs.
Args:
pairs: Iterable of tuples, where each tuple contains a query and a document to be tokenized
batch_size: Batch size for tokenizing
Returns:
token count: overall number of tokens in the pairs
"""
return self.model.token_count(pairs, batch_size=batch_size, **kwargs)
@@ -1,15 +1,16 @@
from typing import Iterable, Optional
from typing import Any, Iterable
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,
cache_dir: Optional[str] = None,
threads: Optional[int] = None,
**kwargs,
cache_dir: str | None = None,
threads: int | None = None,
**kwargs: Any,
):
self.model_name = model_name
self.cache_dir = cache_dir
@@ -21,9 +22,9 @@ class TextCrossEncoderBase(ModelManagement):
query: str,
documents: Iterable[str],
batch_size: int = 64,
**kwargs,
**kwargs: Any,
) -> Iterable[float]:
"""Reranks a list of documents given a query.
"""Rerank a list of documents given a query.
Args:
query (str): The query to rerank the documents.
@@ -32,6 +33,31 @@ class TextCrossEncoderBase(ModelManagement):
**kwargs: Additional keyword argument to pass to the rerank method.
Yields:
Iterable[float]: The scores of reranked the documents.
Iterable[float]: The scores of the reranked the documents.
"""
raise NotImplementedError("This method should be overridden by subclasses")
def rerank_pairs(
self,
pairs: Iterable[tuple[str, str]],
batch_size: int = 64,
parallel: int | None = None,
**kwargs: Any,
) -> Iterable[float]:
"""Rerank query-document pairs.
Args:
pairs (Iterable[tuple[str, str]]): Query-document pairs to rerank
batch_size (int): The batch size to use for reranking.
parallel: 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 rerank method.
Yields:
Iterable[float]: Scores for each individual pair
"""
raise NotImplementedError("This method should be overridden by subclasses")
def token_count(self, pairs: Iterable[tuple[str, str]], **kwargs: Any) -> int:
"""Returns the number of tokens in the pairs."""
raise NotImplementedError("This method should be overridden by subclasses")
+84 -59
View File
@@ -2,12 +2,11 @@ import os
from collections import defaultdict
from multiprocessing import get_all_start_methods
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple, Type, Union
from typing import Any, Iterable, Type
import mmh3
import numpy as np
from snowballstemmer import stemmer as get_stemmer
from py_rust_stemmers import SnowballStemmer
from fastembed.common.utils import (
define_cache_dir,
iter_batch,
@@ -20,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",
@@ -35,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",
),
]
@@ -93,6 +82,8 @@ class Bm25(SparseTextEmbeddingBase):
b (float, optional): The b parameter in the BM25 formula. Defines the importance of the document length.
Defaults to 0.75.
avg_len (float, optional): The average length of the documents in the corpus. Defaults to 256.0.
language (str): Specifies the language for the stemmer.
disable_stemmer (bool): Disable the stemmer.
Raises:
ValueError: If the model_name is not in the format <org>/<model> e.g. BAAI/bge-base-en.
"""
@@ -100,13 +91,15 @@ class Bm25(SparseTextEmbeddingBase):
def __init__(
self,
model_name: str,
cache_dir: Optional[str] = None,
cache_dir: str | None = None,
k: float = 1.2,
b: float = 0.75,
avg_len: float = 256.0,
language: str = "english",
token_max_length: int = 40,
**kwargs,
disable_stemmer: bool = False,
specific_model_path: str | None = None,
**kwargs: Any,
):
super().__init__(model_name, cache_dir, **kwargs)
@@ -120,30 +113,40 @@ 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._specific_model_path = specific_model_path
self._model_dir = self.download_model(
model_description, self.cache_dir, local_files_only=self._local_files_only
model_description,
self.cache_dir,
local_files_only=self._local_files_only,
specific_model_path=self._specific_model_path,
)
self.token_max_length = token_max_length
self.punctuation = set(get_all_punctuation())
self.stopwords = set(self._load_stopwords(self._model_dir, self.language))
self.disable_stemmer = disable_stemmer
if disable_stemmer:
self.stopwords: set[str] = set()
self.stemmer = None
else:
self.stopwords = set(self._load_stopwords(self._model_dir, self.language))
self.stemmer = SnowballStemmer(language)
self.stemmer = get_stemmer(language)
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
@classmethod
def _load_stopwords(cls, model_dir: Path, language: str) -> List[str]:
def _load_stopwords(cls, model_dir: Path, language: str) -> list[str]:
stopwords_path = model_dir / f"{language}.txt"
if not stopwords_path.exists():
return []
@@ -155,9 +158,11 @@ class Bm25(SparseTextEmbeddingBase):
self,
model_name: str,
cache_dir: str,
documents: Union[str, Iterable[str]],
documents: str | Iterable[str],
batch_size: int = 256,
parallel: Optional[int] = None,
parallel: int | None = None,
local_files_only: bool = False,
specific_model_path: str | None = None,
) -> Iterable[SparseEmbedding]:
is_small = False
@@ -183,6 +188,11 @@ class Bm25(SparseTextEmbeddingBase):
"k": self.k,
"b": self.b,
"avg_len": self.avg_len,
"language": self.language,
"token_max_length": self.token_max_length,
"disable_stemmer": self.disable_stemmer,
"local_files_only": local_files_only,
"specific_model_path": specific_model_path,
}
pool = ParallelWorkerPool(
num_workers=parallel or 1,
@@ -191,14 +201,14 @@ 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,
documents: Union[str, Iterable[str]],
documents: str | Iterable[str],
batch_size: int = 256,
parallel: Optional[int] = None,
**kwargs,
parallel: int | None = None,
**kwargs: Any,
) -> Iterable[SparseEmbedding]:
"""
Encode a list of documents into list of embeddings.
@@ -221,21 +231,25 @@ class Bm25(SparseTextEmbeddingBase):
documents=documents,
batch_size=batch_size,
parallel=parallel,
local_files_only=self._local_files_only,
specific_model_path=self._specific_model_path,
)
def _stem(self, tokens: List[str]) -> List[str]:
stemmed_tokens = []
def _stem(self, tokens: list[str]) -> list[str]:
stemmed_tokens: list[str] = []
for token in tokens:
lower_token = token.lower()
if token in self.punctuation:
continue
if token.lower() in self.stopwords:
if lower_token in self.stopwords:
continue
if len(token) > self.token_max_length:
continue
stemmed_token = self.stemmer.stemWord(token.lower())
stemmed_token = self.stemmer.stem_word(lower_token) if self.stemmer else lower_token
if stemmed_token:
stemmed_tokens.append(stemmed_token)
@@ -243,9 +257,9 @@ class Bm25(SparseTextEmbeddingBase):
def raw_embed(
self,
documents: List[str],
) -> List[SparseEmbedding]:
embeddings = []
documents: list[str],
) -> list[SparseEmbedding]:
embeddings: list[SparseEmbedding] = []
for document in documents:
document = remove_non_alphanumeric(document)
tokens = self.tokenizer.tokenize(document)
@@ -254,7 +268,16 @@ class Bm25(SparseTextEmbeddingBase):
embeddings.append(SparseEmbedding.from_dict(token_id2value))
return embeddings
def _term_frequency(self, tokens: List[str]) -> Dict[int, float]:
def token_count(self, texts: str | Iterable[str], **kwargs: Any) -> int:
token_num = 0
texts = [texts] if isinstance(texts, str) else texts
for text in texts:
document = remove_non_alphanumeric(text)
tokens = self.tokenizer.tokenize(document)
token_num += len(tokens)
return token_num
def _term_frequency(self, tokens: list[str]) -> dict[int, float]:
"""Calculate the term frequency part of the BM25 formula.
(
@@ -264,13 +287,13 @@ class Bm25(SparseTextEmbeddingBase):
)
Args:
tokens (List[str]): The list of tokens in the document.
tokens (list[str]): The list of tokens in the document.
Returns:
Dict[int, float]: The token_id to term frequency mapping.
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
@@ -288,7 +311,7 @@ class Bm25(SparseTextEmbeddingBase):
def compute_token_id(cls, token: str) -> int:
return abs(mmh3.hash(token))
def query_embed(self, query: Union[str, Iterable[str]], **kwargs) -> Iterable[SparseEmbedding]:
def query_embed(self, query: str | Iterable[str], **kwargs: Any) -> Iterable[SparseEmbedding]:
"""To emulate BM25 behaviour, we don't need to use weights in the query, and
it's enough to just hash the tokens and assign a weight of 1.0 to them.
"""
@@ -316,7 +339,7 @@ class Bm25Worker(Worker):
self,
model_name: str,
cache_dir: str,
**kwargs,
**kwargs: Any,
):
self.model = self.init_embedding(model_name, cache_dir, **kwargs)
@@ -324,11 +347,13 @@ 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
@staticmethod
def init_embedding(model_name: str, cache_dir: str, **kwargs) -> Bm25:
def init_embedding(model_name: str, cache_dir: str, **kwargs: Any) -> Bm25:
return Bm25(model_name=model_name, cache_dir=cache_dir, **kwargs)
+100 -71
View File
@@ -1,40 +1,48 @@
import math
import string
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Type, Union
from typing import Any, Iterable, Sequence, Type
import mmh3
import numpy as np
from snowballstemmer import stemmer as get_stemmer
from py_rust_stemmers import SnowballStemmer
from fastembed.common import OnnxProvider
from fastembed.common.onnx_model import OnnxOutputContext
from fastembed.common.types import Device
from fastembed.common.utils import define_cache_dir
from fastembed.sparse.sparse_embedding_base import (
SparseEmbedding,
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 = {
_MODEL_TO_LANGUAGE = {
"Qdrant/bm42-all-minilm-l6-v2-attentions": "english",
}
MODEL_TO_LANGUAGE = {
model_name.lower(): language for model_name, language in _MODEL_TO_LANGUAGE.items()
}
def get_language_by_model_name(model_name: str) -> str:
return MODEL_TO_LANGUAGE[model_name.lower()]
class Bm42(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
@@ -58,15 +66,16 @@ class Bm42(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
def __init__(
self,
model_name: str,
cache_dir: Optional[str] = None,
threads: Optional[int] = None,
providers: Optional[Sequence[OnnxProvider]] = None,
cache_dir: str | None = None,
threads: int | None = None,
providers: Sequence[OnnxProvider] | None = None,
alpha: float = 0.5,
cuda: bool = False,
device_ids: Optional[List[int]] = None,
cuda: bool | Device = Device.AUTO,
device_ids: list[int] | None = None,
lazy_load: bool = False,
device_id: Optional[int] = None,
**kwargs,
device_id: int | None = None,
specific_model_path: str | None = None,
**kwargs: Any,
):
"""
Args:
@@ -79,13 +88,15 @@ class Bm42(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
alpha (float, optional): Parameter, that defines the importance of the token weight in the document
versus the importance of the token frequency in the corpus. Defaults to 0.5, based on empirical testing.
It is recommended to only change this parameter based on training data for a specific dataset.
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.
cuda (Union[bool, Device], optional): Whether to use cuda for inference. Mutually exclusive with `providers`
Defaults to Device.AUTO.
device_ids (Optional[list[int]], optional): The list of device ids to use for data parallel processing in
workers. Should be used with `cuda` equals to `True`, `Device.AUTO` or `Device.CUDA`, 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.
specific_model_path (Optional[str], optional): The specific path to the onnx model dir if it should be imported from somewhere else
Raises:
ValueError: If the model_name is not in the format <org>/<model> e.g. BAAI/bge-base-en.
@@ -94,33 +105,37 @@ class Bm42(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
super().__init__(model_name, cache_dir, threads, **kwargs)
self.providers = providers
self.lazy_load = lazy_load
self._extra_session_options = self._select_exposed_session_options(kwargs)
# 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: int | None = 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._specific_model_path = specific_model_path
self._model_dir = self.download_model(
self.model_description, self.cache_dir, local_files_only=self._local_files_only
self.model_description,
self.cache_dir,
local_files_only=self._local_files_only,
specific_model_path=self._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.stemmer = get_stemmer(MODEL_TO_LANGUAGE[model_name])
self.stopwords = set(self._load_stopwords(self._model_dir))
self.stemmer = SnowballStemmer(get_language_by_model_name(self.model_name))
self.alpha = alpha
if not self.lazy_load:
@@ -129,51 +144,53 @@ 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,
extra_session_options=self._extra_session_options,
)
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 = []
def _filter_pair_tokens(self, tokens: list[tuple[str, Any]]) -> list[tuple[str, Any]]:
result: list[tuple[str, Any]] = []
for token, value in tokens:
if token in self.stopwords or token in self.punctuation:
continue
result.append((token, value))
return result
def _stem_pair_tokens(self, tokens: List[Tuple[str, Any]]) -> List[Tuple[str, Any]]:
result = []
def _stem_pair_tokens(self, tokens: list[tuple[str, Any]]) -> list[tuple[str, Any]]:
result: list[tuple[str, Any]] = []
for token, value in tokens:
processed_token = self.stemmer.stemWord(token)
processed_token = self.stemmer.stem_word(token)
result.append((processed_token, value))
return result
@classmethod
def _aggregate_weights(
cls, tokens: List[Tuple[str, List[int]]], weights: List[float]
) -> List[Tuple[str, float]]:
result = []
cls, tokens: list[tuple[str, list[int]]], weights: list[float]
) -> list[tuple[str, float]]:
result: list[tuple[str, float]] = []
for token, idxs in tokens:
sum_weight = sum(weights[idx] for idx in idxs)
result.append((token, sum_weight))
return result
def _reconstruct_bpe(
self, bpe_tokens: Iterable[Tuple[int, str]]
) -> List[Tuple[str, List[int]]]:
result = []
acc = ""
acc_idx = []
self, bpe_tokens: Iterable[tuple[int, str]]
) -> list[tuple[str, list[int]]]:
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:
@@ -195,13 +212,13 @@ class Bm42(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
return result
def _rescore_vector(self, vector: Dict[str, float]) -> Dict[int, float]:
def _rescore_vector(self, vector: dict[str, float]) -> dict[int, float]:
"""
Orders all tokens in the vector by their importance and generates a new score based on the importance order.
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))
@@ -213,11 +230,13 @@ class Bm42(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
return new_vector
def _post_process_onnx_output(self, output: OnnxOutputContext) -> Iterable[SparseEmbedding]:
def _post_process_onnx_output(
self, output: OnnxOutputContext, **kwargs: Any
) -> Iterable[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
@@ -236,7 +255,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)
@@ -246,16 +265,16 @@ 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
@classmethod
def _load_stopwords(cls, model_dir: Path) -> List[str]:
def _load_stopwords(cls, model_dir: Path) -> list[str]:
stopwords_path = model_dir / "stopwords.txt"
if not stopwords_path.exists():
return []
@@ -265,10 +284,10 @@ class Bm42(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
def embed(
self,
documents: Union[str, Iterable[str]],
documents: str | Iterable[str],
batch_size: int = 256,
parallel: Optional[int] = None,
**kwargs,
parallel: int | None = None,
**kwargs: Any,
) -> Iterable[SparseEmbedding]:
"""
Encode a list of documents into list of embeddings.
@@ -295,17 +314,20 @@ class Bm42(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
cuda=self.cuda,
device_ids=self.device_ids,
alpha=self.alpha,
local_files_only=self._local_files_only,
specific_model_path=self._specific_model_path,
extra_session_options=self._extra_session_options,
)
@classmethod
def _query_rehash(cls, tokens: Iterable[str]) -> Dict[int, float]:
result = {}
def _query_rehash(cls, tokens: Iterable[str]) -> dict[int, float]:
result: dict[int, float] = {}
for token in tokens:
token_id = abs(mmh3.hash(token))
result[token_id] = 1.0
return result
def query_embed(self, query: Union[str, Iterable[str]], **kwargs) -> Iterable[SparseEmbedding]:
def query_embed(self, query: str | Iterable[str], **kwargs: Any) -> Iterable[SparseEmbedding]:
"""
To emulate BM25 behaviour, we don't need to use smart weights in the query, and
it's enough to just hash the tokens and assign a weight of 1.0 to them.
@@ -318,7 +340,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)
@@ -327,12 +349,19 @@ 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
def token_count(
self, texts: str | Iterable[str], batch_size: int = 1024, **kwargs: Any
) -> int:
if not hasattr(self, "model") or self.model is None:
self.load_onnx_model() # loads the tokenizer as well
return self._token_count(texts, batch_size=batch_size, **kwargs)
class Bm42TextEmbeddingWorker(TextEmbeddingWorker):
def init_embedding(self, model_name: str, cache_dir: str, **kwargs) -> Bm42:
class Bm42TextEmbeddingWorker(TextEmbeddingWorker[SparseEmbedding]):
def init_embedding(self, model_name: str, cache_dir: str, **kwargs: Any) -> Bm42:
return Bm42(
model_name=model_name,
cache_dir=cache_dir,
+372
View File
@@ -0,0 +1,372 @@
from pathlib import Path
from typing import Any, Sequence, Iterable, Type
import numpy as np
from numpy.typing import NDArray
from py_rust_stemmers import SnowballStemmer
from tokenizers import Tokenizer
from fastembed.common.model_description import SparseModelDescription, ModelSource
from fastembed.common.onnx_model import OnnxOutputContext
from fastembed.common import OnnxProvider
from fastembed.common.types import Device
from fastembed.common.utils import define_cache_dir
from fastembed.sparse.sparse_embedding_base import (
SparseEmbedding,
SparseTextEmbeddingBase,
)
from fastembed.sparse.utils.minicoil_encoder import Encoder
from fastembed.sparse.utils.sparse_vectors_converter import SparseVectorConverter, WordEmbedding
from fastembed.sparse.utils.vocab_resolver import VocabResolver, VocabTokenizer
from fastembed.text.onnx_text_model import OnnxTextModel, TextEmbeddingWorker
MINICOIL_MODEL_FILE = "minicoil.triplet.model.npy"
MINICOIL_VOCAB_FILE = "minicoil.triplet.model.vocab"
STOPWORDS_FILE = "stopwords.txt"
supported_minicoil_models: list[SparseModelDescription] = [
SparseModelDescription(
model="Qdrant/minicoil-v1",
vocab_size=19125,
description="Sparse embedding model, that resolves semantic meaning of the words, "
"while keeping exact keyword match behavior. "
"Based on jinaai/jina-embeddings-v2-small-en-tokens",
license="apache-2.0",
size_in_GB=0.09,
sources=ModelSource(hf="Qdrant/minicoil-v1"),
model_file="onnx/model.onnx",
additional_files=[
STOPWORDS_FILE,
MINICOIL_MODEL_FILE,
MINICOIL_VOCAB_FILE,
],
requires_idf=True,
),
]
_MODEL_TO_LANGUAGE = {
"Qdrant/minicoil-v1": "english",
}
MODEL_TO_LANGUAGE = {
model_name.lower(): language for model_name, language in _MODEL_TO_LANGUAGE.items()
}
def get_language_by_model_name(model_name: str) -> str:
return MODEL_TO_LANGUAGE[model_name.lower()]
class MiniCOIL(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
"""
MiniCOIL is a sparse embedding model, that resolves semantic meaning of the words,
while keeping exact keyword match behavior.
Each vocabulary token is converted into 4d component of a sparse vector, which is then weighted by the token frequency in the corpus.
If the token is not found in the corpus, it is treated exactly like in BM25.
`
The model is based on `jinaai/jina-embeddings-v2-small-en-tokens`
"""
def __init__(
self,
model_name: str,
cache_dir: str | None = None,
threads: int | None = None,
providers: Sequence[OnnxProvider] | None = None,
k: float = 1.2,
b: float = 0.75,
avg_len: float = 150.0,
cuda: bool | Device = Device.AUTO,
device_ids: list[int] | None = None,
lazy_load: bool = False,
device_id: int | None = None,
specific_model_path: str | None = 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 providers to use for onnxruntime.
k (float, optional): The k parameter in the BM25 formula. Defines the saturation of the term frequency.
I.e. defines how fast the moment when additional terms stop to increase the score. Defaults to 1.2.
b (float, optional): The b parameter in the BM25 formula. Defines the importance of the document length.
Defaults to 0.75.
avg_len (float, optional): The average length of the documents in the corpus. Defaults to 150.0.
cuda (Union[bool, Device], optional): Whether to use cuda for inference. Mutually exclusive with `providers`
Defaults to Device.AUTO.
device_ids (Optional[list[int]], optional): The list of device ids to use for data parallel processing in
workers. Should be used with `cuda` equals to `True`, `Device.AUTO` or `Device.CUDA`, 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.
specific_model_path (Optional[str], optional): The specific path to the onnx model dir if it should be imported from somewhere else
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
self.device_ids = device_ids
self.cuda = cuda
self.device_id = device_id
self._extra_session_options = self._select_exposed_session_options(kwargs)
self.k = k
self.b = b
self.avg_len = avg_len
# Initialize class attributes
self.tokenizer: Tokenizer | None = None
self.invert_vocab: dict[int, str] = {}
self.special_tokens: set[str] = set()
self.special_tokens_ids: set[int] = set()
self.stopwords: set[str] = set()
self.vocab_resolver: VocabResolver | None = None
self.encoder: Encoder | None = None
self.output_dim: int | None = None
self.sparse_vector_converter: SparseVectorConverter | None = None
self.model_description = self._get_model_description(model_name)
self.cache_dir = str(define_cache_dir(cache_dir))
self._specific_model_path = specific_model_path
self._model_dir = self.download_model(
self.model_description,
self.cache_dir,
local_files_only=self._local_files_only,
specific_model_path=self._specific_model_path,
)
if not self.lazy_load:
self.load_onnx_model()
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,
extra_session_options=self._extra_session_options,
)
assert self.tokenizer is not None
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))
stemmer = SnowballStemmer(get_language_by_model_name(self.model_name))
self.vocab_resolver = VocabResolver(
tokenizer=VocabTokenizer(self.tokenizer),
stopwords=self.stopwords,
stemmer=stemmer,
)
self.vocab_resolver.load_json_vocab(str(self._model_dir / MINICOIL_VOCAB_FILE))
weights = np.load(str(self._model_dir / MINICOIL_MODEL_FILE), mmap_mode="r")
self.encoder = Encoder(weights)
self.output_dim = self.encoder.output_dim
self.sparse_vector_converter = SparseVectorConverter(
stopwords=self.stopwords,
stemmer=stemmer,
k=self.k,
b=self.b,
avg_len=self.avg_len,
)
def token_count(
self, texts: str | Iterable[str], batch_size: int = 1024, **kwargs: Any
) -> int:
return self._token_count(texts, batch_size=batch_size, **kwargs)
def embed(
self,
documents: str | Iterable[str],
batch_size: int = 256,
parallel: int | None = None,
**kwargs: Any,
) -> Iterable[SparseEmbedding]:
"""
Encode a list of documents into list of embeddings.
We use mean pooling with attention so that the model can handle variable-length inputs.
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,
k=self.k,
b=self.b,
avg_len=self.avg_len,
is_query=False,
local_files_only=self._local_files_only,
specific_model_path=self._specific_model_path,
extra_session_options=self._extra_session_options,
**kwargs,
)
def query_embed(self, query: str | Iterable[str], **kwargs: Any) -> Iterable[SparseEmbedding]:
"""
Encode a list of queries into list of embeddings.
"""
yield from self._embed_documents(
model_name=self.model_name,
cache_dir=str(self.cache_dir),
documents=query,
providers=self.providers,
cuda=self.cuda,
device_ids=self.device_ids,
k=self.k,
b=self.b,
avg_len=self.avg_len,
is_query=True,
local_files_only=self._local_files_only,
specific_model_path=self._specific_model_path,
**kwargs,
)
@classmethod
def _load_stopwords(cls, model_dir: Path) -> list[str]:
stopwords_path = model_dir / STOPWORDS_FILE
if not stopwords_path.exists():
return []
with open(stopwords_path, "r") as f:
return f.read().splitlines()
@classmethod
def _list_supported_models(cls) -> list[SparseModelDescription]:
"""Lists the supported models.
Returns:
list[SparseModelDescription]: A list of SparseModelDescription objects containing the model information.
"""
return supported_minicoil_models
def _post_process_onnx_output(
self, output: OnnxOutputContext, is_query: bool = False, **kwargs: Any
) -> Iterable[SparseEmbedding]:
if output.input_ids is None:
raise ValueError("input_ids must be provided for document post-processing")
assert self.vocab_resolver is not None
assert self.encoder is not None
assert self.sparse_vector_converter is not None
# Size: (batch_size, sequence_length, hidden_size)
embeddings = output.model_output
# Size: (batch_size, sequence_length)
assert output.attention_mask is not None
masks = output.attention_mask
vocab_size = self.vocab_resolver.vocab_size()
embedding_size = self.encoder.output_dim
# For each document we only select those embeddings that are not masked out
for i in range(embeddings.shape[0]):
# Size: (sequence_length, hidden_size)
token_embeddings = embeddings[i, masks[i] == 1]
# Size: (sequence_length)
token_ids: NDArray[np.int64] = output.input_ids[i, masks[i] == 1]
word_ids_array, counts, oov, forms = self.vocab_resolver.resolve_tokens(token_ids)
# Size: (1, words)
word_ids_array_expanded: NDArray[np.int64] = np.expand_dims(word_ids_array, axis=0)
# Size: (1, words, embedding_size)
token_embeddings_array: NDArray[np.float32] = np.expand_dims(token_embeddings, axis=0)
assert word_ids_array_expanded.shape[1] == token_embeddings_array.shape[1]
# Size of word_ids_mapping: (unique_words, 2) - [vocab_id, batch_id]
# Size of embeddings: (unique_words, embedding_size)
ids_mapping, minicoil_embeddings = self.encoder.forward(
word_ids_array_expanded, token_embeddings_array
)
# Size of counts: (unique_words)
words_ids: list[int] = ids_mapping[:, 0].tolist() # type: ignore[assignment]
sentence_result: dict[str, WordEmbedding] = {}
words = [self.vocab_resolver.lookup_word(word_id) for word_id in words_ids]
for word, word_id, emb in zip(words, words_ids, minicoil_embeddings.tolist()): # type: ignore[arg-type]
if word_id == 0:
continue
sentence_result[word] = WordEmbedding(
word=word,
forms=forms[word],
count=int(counts[word_id]),
word_id=int(word_id),
embedding=emb, # type: ignore[arg-type]
)
for oov_word, count in oov.items():
# {
# "word": oov_word,
# "forms": [oov_word],
# "count": int(count),
# "word_id": -1,
# "embedding": [1]
# }
sentence_result[oov_word] = WordEmbedding(
word=oov_word, forms=[oov_word], count=int(count), word_id=-1, embedding=[1]
)
if not is_query:
yield self.sparse_vector_converter.embedding_to_vector(
sentence_result, vocab_size=vocab_size, embedding_size=embedding_size
)
else:
yield self.sparse_vector_converter.embedding_to_vector_query(
sentence_result, vocab_size=vocab_size, embedding_size=embedding_size
)
@classmethod
def _get_worker_class(cls) -> Type["MiniCoilTextEmbeddingWorker"]:
return MiniCoilTextEmbeddingWorker
class MiniCoilTextEmbeddingWorker(TextEmbeddingWorker[SparseEmbedding]):
def init_embedding(self, model_name: str, cache_dir: str, **kwargs: Any) -> MiniCOIL:
return MiniCOIL(
model_name=model_name,
cache_dir=cache_dir,
threads=1,
**kwargs,
)
+24 -21
View File
@@ -1,40 +1,43 @@
from dataclasses import dataclass
from typing import Dict, Iterable, Optional, Union
from typing import Iterable, 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: 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)}
def as_dict(self) -> dict[int, float]:
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":
def from_dict(cls, data: dict[int, float]) -> "SparseEmbedding":
if len(data) == 0:
return cls(values=np.array([]), indices=np.array([]))
indices, values = zip(*data.items())
return cls(values=np.array(values), indices=np.array(indices))
class SparseTextEmbeddingBase(ModelManagement):
class SparseTextEmbeddingBase(ModelManagement[SparseModelDescription]):
def __init__(
self,
model_name: str,
cache_dir: Optional[str] = None,
threads: Optional[int] = None,
**kwargs,
cache_dir: str | None = None,
threads: int | None = None,
**kwargs: Any,
):
self.model_name = model_name
self.cache_dir = cache_dir
@@ -43,16 +46,14 @@ class SparseTextEmbeddingBase(ModelManagement):
def embed(
self,
documents: Union[str, Iterable[str]],
documents: str | Iterable[str],
batch_size: int = 256,
parallel: Optional[int] = None,
**kwargs,
parallel: int | None = None,
**kwargs: Any,
) -> Iterable[SparseEmbedding]:
raise NotImplementedError()
def passage_embed(
self, texts: Iterable[str], **kwargs
) -> Iterable[SparseEmbedding]:
def passage_embed(self, texts: Iterable[str], **kwargs: Any) -> Iterable[SparseEmbedding]:
"""
Embeds a list of text passages into a list of embeddings.
@@ -67,9 +68,7 @@ class SparseTextEmbeddingBase(ModelManagement):
# 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
) -> Iterable[SparseEmbedding]:
def query_embed(self, query: str | Iterable[str], **kwargs: Any) -> Iterable[SparseEmbedding]:
"""
Embeds queries
@@ -83,5 +82,9 @@ 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)
def token_count(self, texts: str | Iterable[str], **kwargs: Any) -> int:
"""Returns the number of tokens in the texts."""
raise NotImplementedError("Subclasses must implement this method")
+41 -19
View File
@@ -1,26 +1,30 @@
from typing import Any, Dict, Iterable, List, Optional, Sequence, Type, Union
from typing import Any, Iterable, Sequence, Type
from dataclasses import asdict
from fastembed.common import OnnxProvider
from fastembed.common.types import Device
from fastembed.sparse.bm25 import Bm25
from fastembed.sparse.bm42 import Bm42
from fastembed.sparse.minicoil import MiniCOIL
from fastembed.sparse.sparse_embedding_base import (
SparseEmbedding,
SparseTextEmbeddingBase,
)
from fastembed.sparse.splade_pp import SpladePP
import warnings
from fastembed.common.model_description import SparseModelDescription
class SparseTextEmbedding(SparseTextEmbeddingBase):
EMBEDDINGS_REGISTRY: List[Type[SparseTextEmbeddingBase]] = [SpladePP, Bm42, Bm25]
EMBEDDINGS_REGISTRY: list[Type[SparseTextEmbeddingBase]] = [SpladePP, Bm42, Bm25, MiniCOIL]
@classmethod
def list_supported_models(cls) -> List[Dict[str, Any]]:
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.
list[dict[str, Any]]: A list of dictionaries containing the model information.
Example:
```
@@ -38,24 +42,28 @@ 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__(
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,
cache_dir: str | None = None,
threads: int | None = None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_ids: list[int] | None = None,
lazy_load: bool = False,
**kwargs,
**kwargs: Any,
):
super().__init__(model_name, cache_dir, threads, **kwargs)
if model_name == "prithvida/Splade_PP_en_v1":
if model_name.lower() == "prithvida/Splade_PP_en_v1".lower():
warnings.warn(
"The right spelling is prithivida/Splade_PP_en_v1. "
"Support of this name will be removed soon, please fix the model_name",
@@ -65,8 +73,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,
@@ -86,10 +94,10 @@ class SparseTextEmbedding(SparseTextEmbeddingBase):
def embed(
self,
documents: Union[str, Iterable[str]],
documents: str | Iterable[str],
batch_size: int = 256,
parallel: Optional[int] = None,
**kwargs,
parallel: int | None = None,
**kwargs: Any,
) -> Iterable[SparseEmbedding]:
"""
Encode a list of documents into list of embeddings.
@@ -108,7 +116,7 @@ class SparseTextEmbedding(SparseTextEmbeddingBase):
"""
yield from self.model.embed(documents, batch_size, parallel, **kwargs)
def query_embed(self, query: Union[str, Iterable[str]], **kwargs) -> Iterable[SparseEmbedding]:
def query_embed(self, query: str | Iterable[str], **kwargs: Any) -> Iterable[SparseEmbedding]:
"""
Embeds queries
@@ -119,3 +127,17 @@ class SparseTextEmbedding(SparseTextEmbeddingBase):
Iterable[SparseEmbedding]: The sparse embeddings.
"""
yield from self.model.query_embed(query, **kwargs)
def token_count(
self, texts: str | Iterable[str], batch_size: int = 1024, **kwargs: Any
) -> int:
"""Returns the number of tokens in the texts.
Args:
texts (str | Iterable[str]): The list of texts to embed.
batch_size (int): Batch size for encoding
Returns:
int: Sum of number of tokens in the texts.
"""
return self.model.token_count(texts, batch_size=batch_size, **kwargs)
+65 -49
View File
@@ -1,43 +1,43 @@
from typing import Any, Dict, Iterable, List, Optional, Sequence, Type, Union
from typing import Any, Iterable, Sequence, Type
import numpy as np
from fastembed.common import OnnxProvider
from fastembed.common.onnx_model import OnnxOutputContext
from fastembed.common.types import Device
from fastembed.common.utils import define_cache_dir
from fastembed.sparse.sparse_embedding_base import (
SparseEmbedding,
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",
),
]
class SpladePP(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
def _post_process_onnx_output(self, output: OnnxOutputContext) -> Iterable[SparseEmbedding]:
def _post_process_onnx_output(
self, output: OnnxOutputContext, **kwargs: Any
) -> Iterable[SparseEmbedding]:
if output.attention_mask is None:
raise ValueError("attention_mask must be provided for document post-processing")
@@ -54,26 +54,32 @@ class SpladePP(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
scores = row_scores[indices]
yield SparseEmbedding(values=scores, indices=indices)
def token_count(
self, texts: str | Iterable[str], batch_size: int = 1024, **kwargs: Any
) -> int:
return self._token_count(texts, batch_size=batch_size, **kwargs)
@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
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,
cache_dir: str | None = None,
threads: int | None = None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_ids: list[int] | None = None,
lazy_load: bool = False,
device_id: Optional[int] = None,
**kwargs,
device_id: int | None = None,
specific_model_path: str | None = None,
**kwargs: Any,
):
"""
Args:
@@ -84,13 +90,15 @@ class SpladePP(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
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.
cuda (Union[bool, Device], optional): Whether to use cuda for inference. Mutually exclusive with `providers`
Defaults to Device.
device_ids (Optional[list[int]], optional): The list of device ids to use for data parallel processing in
workers. Should be used with `cuda` equals to `True`, `Device.AUTO` or `Device.CUDA`, 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.
specific_model_path (Optional[str], optional): The specific path to the onnx model dir if it should be imported from somewhere else
Raises:
ValueError: If the model_name is not in the format <org>/<model> e.g. BAAI/bge-base-en.
@@ -98,24 +106,28 @@ class SpladePP(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
super().__init__(model_name, cache_dir, threads, **kwargs)
self.providers = providers
self.lazy_load = lazy_load
self._extra_session_options = self._select_exposed_session_options(kwargs)
# 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: int | None = 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._specific_model_path = specific_model_path
self._model_dir = self.download_model(
self.model_description, self.cache_dir, local_files_only=self._local_files_only
self.model_description,
self.cache_dir,
local_files_only=self._local_files_only,
specific_model_path=self._specific_model_path,
)
if not self.lazy_load:
@@ -124,19 +136,20 @@ 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,
device_id=self.device_id,
extra_session_options=self._extra_session_options,
)
def embed(
self,
documents: Union[str, Iterable[str]],
documents: str | Iterable[str],
batch_size: int = 256,
parallel: Optional[int] = None,
**kwargs,
parallel: int | None = None,
**kwargs: Any,
) -> Iterable[SparseEmbedding]:
"""
Encode a list of documents into list of embeddings.
@@ -162,16 +175,19 @@ class SpladePP(SparseTextEmbeddingBase, OnnxTextModel[SparseEmbedding]):
providers=self.providers,
cuda=self.cuda,
device_ids=self.device_ids,
local_files_only=self._local_files_only,
specific_model_path=self._specific_model_path,
extra_session_options=self._extra_session_options,
**kwargs,
)
@classmethod
def _get_worker_class(cls) -> Type[TextEmbeddingWorker]:
def _get_worker_class(cls) -> Type[TextEmbeddingWorker[SparseEmbedding]]:
return SpladePPEmbeddingWorker
class SpladePPEmbeddingWorker(TextEmbeddingWorker):
def init_embedding(self, model_name: str, cache_dir: str, **kwargs) -> SpladePP:
class SpladePPEmbeddingWorker(TextEmbeddingWorker[SparseEmbedding]):
def init_embedding(self, model_name: str, cache_dir: str, **kwargs: Any) -> SpladePP:
return SpladePP(
model_name=model_name,
cache_dir=cache_dir,
+146
View File
@@ -0,0 +1,146 @@
"""
Pure numpy implementation of encoder model for a single word.
This model is not trainable, and should only be used for inference.
"""
import numpy as np
from fastembed.common.types import NumpyArray
class Encoder:
"""
Encoder(768, 4, 10000)
Will look like this:
Per-word
Encoder Matrix
┌─────────────────────┐
│ Token Embedding(768)├──────┐ (10k, 768, 4)
└─────────────────────┘ │ ┌─────────┐
│ │ │
┌─────────────────────┐ │ ┌─┴───────┐ │
│ │ │ │ │ │
└─────────────────────┘ │ ┌─┴───────┐ │ │ ┌─────────┐
└────►│ │ │ ├─────►│Tanh │
┌─────────────────────┐ │ │ │ │ └─────────┘
│ │ │ │ ├─┘
└─────────────────────┘ │ ├─┘
│ │
┌─────────────────────┐ └─────────┘
│ │
└─────────────────────┘
Final linear transformation is accompanied by a non-linear activation function: Tanh.
Tanh is used to ensure that the output is in the range [-1, 1].
It would be easier to visually interpret the output of the model, assuming that each dimension
would need to encode a type of semantic cluster.
"""
def __init__(
self,
weights: NumpyArray,
):
self.weights = weights
self.vocab_size, self.input_dim, self.output_dim = weights.shape
self.encoder_weights: NumpyArray = weights
# Activation function
self.activation = np.tanh
@staticmethod
def convert_vocab_ids(vocab_ids: NumpyArray) -> NumpyArray:
"""
Convert vocab_ids of shape (batch_size, seq_len) into (batch_size, seq_len, 2)
by appending batch_id alongside each vocab_id.
"""
batch_size, seq_len = vocab_ids.shape
batch_ids = np.arange(batch_size, dtype=vocab_ids.dtype).reshape(batch_size, 1)
batch_ids = np.repeat(batch_ids, seq_len, axis=1)
# Stack vocab_ids and batch_ids along the last dimension
combined: NumpyArray = np.stack((vocab_ids, batch_ids), axis=2).astype(np.int32)
return combined
@classmethod
def avg_by_vocab_ids(
cls, vocab_ids: NumpyArray, embeddings: NumpyArray
) -> tuple[NumpyArray, NumpyArray]:
"""
Takes:
vocab_ids: (batch_size, seq_len) int array
embeddings: (batch_size, seq_len, input_dim) float array
Returns:
unique_flattened_vocab_ids: (total_unique, 2) array of [vocab_id, batch_id]
unique_flattened_embeddings: (total_unique, input_dim) averaged embeddings
"""
input_dim = embeddings.shape[2]
# Flatten vocab_ids and embeddings
# flattened_vocab_ids: (batch_size*seq_len, 2)
flattened_vocab_ids = cls.convert_vocab_ids(vocab_ids).reshape(-1, 2)
# flattened_embeddings: (batch_size*seq_len, input_dim)
flattened_embeddings = embeddings.reshape(-1, input_dim)
# Find unique (vocab_id, batch_id) pairs
unique_flattened_vocab_ids, inverse_indices = np.unique(
flattened_vocab_ids, axis=0, return_inverse=True
)
# Prepare arrays to accumulate sums
unique_count = unique_flattened_vocab_ids.shape[0]
unique_flattened_embeddings = np.zeros((unique_count, input_dim), dtype=np.float32)
unique_flattened_count = np.zeros(unique_count, dtype=np.int32)
# Use np.add.at to accumulate sums based on inverse indices
np.add.at(unique_flattened_embeddings, inverse_indices, flattened_embeddings)
np.add.at(unique_flattened_count, inverse_indices, 1)
# Compute averages
unique_flattened_embeddings /= unique_flattened_count[:, None]
return unique_flattened_vocab_ids.astype(np.int32), unique_flattened_embeddings.astype(
np.float32
)
def forward(
self, vocab_ids: NumpyArray, embeddings: NumpyArray
) -> tuple[NumpyArray, NumpyArray]:
"""
Args:
vocab_ids: (batch_size, seq_len) int array
embeddings: (batch_size, seq_len, input_dim) float array
Returns:
unique_flattened_vocab_ids_and_batch_ids: (total_unique, 2)
unique_flattened_encoded: (total_unique, output_dim)
"""
# Average embeddings for duplicate vocab_ids
unique_flattened_vocab_ids_and_batch_ids, unique_flattened_embeddings = (
self.avg_by_vocab_ids(vocab_ids, embeddings)
)
# Select the encoder weights for each unique vocab_id
unique_flattened_vocab_ids = unique_flattened_vocab_ids_and_batch_ids[:, 0].astype(
np.int32
)
# unique_encoder_weights: (total_unique, input_dim, output_dim)
unique_encoder_weights = self.encoder_weights[unique_flattened_vocab_ids]
# Compute linear transform: (total_unique, output_dim)
# Using Einstein summation for matrix multiplication:
# 'bi,bio->bo' means: for each "b" (batch element), multiply embeddings (b,i) by weights (b,i,o) -> (b,o)
unique_flattened_encoded = np.einsum(
"bi,bio->bo", unique_flattened_embeddings, unique_encoder_weights
)
# Apply Tanh activation and ensure float32 type
unique_flattened_encoded = self.activation(unique_flattened_encoded).astype(np.float32)
return unique_flattened_vocab_ids_and_batch_ids.astype(np.int32), unique_flattened_encoded
@@ -0,0 +1,244 @@
import copy
from dataclasses import dataclass
import mmh3
import numpy as np
from py_rust_stemmers import SnowballStemmer
from fastembed.common.utils import get_all_punctuation, remove_non_alphanumeric
from fastembed.sparse.sparse_embedding_base import SparseEmbedding
GAP = 32000
INT32_MAX = 2**31 - 1
@dataclass
class WordEmbedding:
word: str
forms: list[str]
count: int
word_id: int
embedding: list[float]
class SparseVectorConverter:
def __init__(
self,
stopwords: set[str],
stemmer: SnowballStemmer,
k: float = 1.2,
b: float = 0.75,
avg_len: float = 150.0,
):
punctuation = set(get_all_punctuation())
special_tokens = {"[CLS]", "[SEP]", "[PAD]", "[UNK]", "[MASK]"}
self.stemmer = stemmer
self.unwanted_tokens = punctuation | special_tokens | stopwords
self.k = k
self.b = b
self.avg_len = avg_len
@classmethod
def unkn_word_token_id(
cls, word: str, shift: int
) -> int: # 2-3 words can collide in 1 index with this mapping, not considering mm3 collisions
token_hash = abs(mmh3.hash(word))
range_size = INT32_MAX - shift
remapped_hash = shift + (token_hash % range_size)
return remapped_hash
def bm25_tf(self, num_occurrences: int, sentence_len: int) -> float:
res = num_occurrences * (self.k + 1)
res /= num_occurrences + self.k * (1 - self.b + self.b * sentence_len / self.avg_len)
return res
@classmethod
def normalize_vector(cls, vector: list[float]) -> list[float]:
norm = sum([x**2 for x in vector]) ** 0.5
if norm < 1e-8:
return vector
return [x / norm for x in vector]
def clean_words(
self, sentence_embedding: dict[str, WordEmbedding], token_max_length: int = 40
) -> dict[str, WordEmbedding]:
"""
Clean miniCOIL-produced sentence_embedding, as unknown to the miniCOIL's stemmer tokens should fully resemble
our BM25 token representation.
sentence_embedding = {"": {"word": "", "word_id": -1, "count": 2, "embedding": [1], "forms": [""]},
"9": {"word": "9", "word_id": -1, "count": 2, "embedding": [1], "forms": ["9"]},
"bat": {"word": "bat", "word_id": 2, "count": 3, "embedding": [0.2, 0.1, -0.2, -0.2], "forms": ["bats", "bat"]},
"9°9": {"word": "9°9", "word_id": -1, "count": 1, "embedding": [1], "forms": ["9°9"]},
"screech": {"word": "screech", "word_id": -1, "count": 1, "embedding": [1], "forms": ["screech"]},
"screeched": {"word": "screeched", "word_id": -1, "count": 1, "embedding": [1], "forms": ["screeched"]}
}
cleaned_embedding_ground_truth = {
"9": {"word": "9", "word_id": -1, "count": 6, "embedding": [1], "forms": ["", "9", "9°9", "9°9"]},
"bat": {"word": "bat", "word_id": 2, "count": 3, "embedding": [0.2, 0.1, -0.2, -0.2], "forms": ["bats", "bat"]},
"screech": {"word": "screech", "word_id": -1, "count": 2, "embedding": [1], "forms": ["screech", "screeched"]}
}
"""
new_sentence_embedding: dict[str, WordEmbedding] = {}
for word, embedding in sentence_embedding.items():
# embedding = {
# "word": "vector",
# "forms": ["vector", "vectors"],
# "count": 2,
# "word_id": 1231,
# "embedding": [0.1, 0.2, 0.3, 0.4]
# }
if embedding.word_id > 0:
# Known word, no need to clean
new_sentence_embedding[word] = embedding
else:
# Unknown word
if word in self.unwanted_tokens:
continue
# Example complex word split:
# word = `word^vec`
word_cleaned = remove_non_alphanumeric(word).strip()
# word_cleaned = `word vec`
if len(word_cleaned) > 0:
# Subwords: ['word', 'vec']
for subword in word_cleaned.split():
stemmed_subword: str = self.stemmer.stem_word(subword)
if (
len(stemmed_subword) <= token_max_length
and stemmed_subword not in self.unwanted_tokens
):
if stemmed_subword not in new_sentence_embedding:
new_sentence_embedding[stemmed_subword] = copy.deepcopy(embedding)
new_sentence_embedding[stemmed_subword].word = stemmed_subword
else:
new_sentence_embedding[stemmed_subword].count += embedding.count
new_sentence_embedding[stemmed_subword].forms += embedding.forms
return new_sentence_embedding
def embedding_to_vector(
self,
sentence_embedding: dict[str, WordEmbedding],
embedding_size: int,
vocab_size: int,
) -> SparseEmbedding:
"""
Convert miniCOIL sentence embedding to Qdrant sparse vector
Example input:
```
{
"vector": WordEmbedding({ // Vocabulary word, encoded with miniCOIL normally
"word": "vector",
"forms": ["vector", "vectors"],
"count": 2,
"word_id": 1231,
"embedding": [0.1, 0.2, 0.3, 0.4]
}),
"axiotic": WordEmbedding({ // Out-of-vocabulary word, fallback to BM25
"word": "axiotic",
"forms": ["axiotics"],
"count": 1,
"word_id": -1,
})
}
```
"""
indices: list[int] = []
values: list[float] = []
# Example:
# vocab_size = 10000
# embedding_size = 4
# GAP = 32000
#
# We want to start random words section from the bucket, that is guaranteed to not
# include any vocab words.
# We need (vocab_size * embedding_size) slots for vocab words.
# Therefore we need (vocab_size * embedding_size) // GAP + 1 buckets for vocab words.
# Therefore, we can start random words from bucket (vocab_size * embedding_size) // GAP + 1 + 1
# ID at which the scope of OOV words starts
unknown_words_shift = ((vocab_size * embedding_size) // GAP + 2) * GAP
sentence_embedding_cleaned = self.clean_words(sentence_embedding)
# Calculate sentence length after cleaning
sentence_len = 0
for embedding in sentence_embedding_cleaned.values():
sentence_len += embedding.count
for embedding in sentence_embedding_cleaned.values():
word_id = embedding.word_id
num_occurrences = embedding.count
tf = self.bm25_tf(num_occurrences, sentence_len)
if (
word_id > 0
): # miniCOIL starts with ID 1, we generally won't have word_id == 0 (UNK), as we don't add
# these words to sentence_embedding
embedding_values = embedding.embedding
normalized_embedding = self.normalize_vector(embedding_values)
for val_id, value in enumerate(normalized_embedding):
indices.append(
word_id * embedding_size + val_id
) # since miniCOIL IDs start with 1
values.append(value * tf)
else:
indices.append(self.unkn_word_token_id(embedding.word, unknown_words_shift))
values.append(tf)
return SparseEmbedding(
indices=np.array(indices, dtype=np.int32),
values=np.array(values, dtype=np.float32),
)
def embedding_to_vector_query(
self,
sentence_embedding: dict[str, WordEmbedding],
embedding_size: int,
vocab_size: int,
) -> SparseEmbedding:
"""
Same as `embedding_to_vector`, but no TF
"""
indices: list[int] = []
values: list[float] = []
# ID at which the scope of OOV words starts
unknown_words_shift = ((vocab_size * embedding_size) // GAP + 2) * GAP
sentence_embedding_cleaned = self.clean_words(sentence_embedding)
for embedding in sentence_embedding_cleaned.values():
word_id = embedding.word_id
tf = 1.0
if word_id >= 0: # miniCOIL starts with ID 1
embedding_values = embedding.embedding
normalized_embedding = self.normalize_vector(embedding_values)
for val_id, value in enumerate(normalized_embedding):
indices.append(
word_id * embedding_size + val_id
) # since miniCOIL IDs start with 1
values.append(value * tf)
else:
indices.append(self.unkn_word_token_id(embedding.word, unknown_words_shift))
values.append(tf)
return SparseEmbedding(
indices=np.array(indices, dtype=np.int32),
values=np.array(values, dtype=np.float32),
)
+3 -3
View File
@@ -1,11 +1,11 @@
# This code is a modified copy of the `NLTKWordTokenizer` class from `NLTK` library.
import re
from typing import List
class SimpleTokenizer:
def tokenize(text: str) -> List[str]:
@staticmethod
def tokenize(text: str) -> list[str]:
text = re.sub(r"[^\w]", " ", text.lower())
text = re.sub(r"\s+", " ", text)
@@ -80,7 +80,7 @@ class WordTokenizer:
]
@classmethod
def tokenize(cls, text: str) -> List[str]:
def tokenize(cls, text: str) -> list[str]:
"""Return a tokenized copy of `text`.
>>> s = '''Good muffins cost $3.88 (roughly 3,36 euros)\nin New York.'''
+202
View File
@@ -0,0 +1,202 @@
from collections import defaultdict
from typing import Iterable
from py_rust_stemmers import SnowballStemmer
import numpy as np
from tokenizers import Tokenizer
from numpy.typing import NDArray
from fastembed.common.types import NumpyArray
class VocabTokenizerBase:
def tokenize(self, sentence: str) -> NumpyArray:
raise NotImplementedError()
def convert_ids_to_tokens(self, token_ids: NumpyArray) -> list[str]:
raise NotImplementedError()
class VocabTokenizer(VocabTokenizerBase):
def __init__(self, tokenizer: Tokenizer):
self.tokenizer = tokenizer
def tokenize(self, sentence: str) -> NumpyArray:
return np.array(self.tokenizer.encode(sentence).ids)
def convert_ids_to_tokens(self, token_ids: NumpyArray) -> list[str]:
return [self.tokenizer.id_to_token(token_id) for token_id in token_ids]
class VocabResolver:
def __init__(self, tokenizer: VocabTokenizerBase, stopwords: set[str], stemmer: SnowballStemmer):
# Word to id mapping
self.vocab: dict[str, int] = {}
# Id to word mapping
self.words: list[str] = []
# Lemma to word mapping
self.stem_mapping: dict[str, str] = {}
self.tokenizer: VocabTokenizerBase = tokenizer
self.stemmer = stemmer
self.stopwords: set[str] = stopwords
def tokenize(self, sentence: str) -> NumpyArray:
return self.tokenizer.tokenize(sentence)
def lookup_word(self, word_id: int) -> str:
if word_id == 0:
return "UNK"
return self.words[word_id - 1]
def convert_ids_to_tokens(self, token_ids: NumpyArray) -> list[str]:
return self.tokenizer.convert_ids_to_tokens(token_ids)
def vocab_size(self) -> int:
# We need +1 for UNK token
return len(self.vocab) + 1
def save_vocab(self, path: str) -> None:
with open(path, "w") as f:
for word in self.words:
f.write(word + "\n")
def save_json_vocab(self, path: str) -> None:
import json
with open(path, "w") as f:
json.dump({"vocab": self.words, "stem_mapping": self.stem_mapping}, f, indent=2)
def load_json_vocab(self, path: str) -> None:
import json
with open(path, "r") as f:
data = json.load(f)
self.words = data["vocab"]
self.vocab = {word: idx + 1 for idx, word in enumerate(self.words)}
self.stem_mapping = data["stem_mapping"]
def add_word(self, word: str) -> None:
if word not in self.vocab:
self.vocab[word] = len(self.vocab) + 1
self.words.append(word)
stem = self.stemmer.stem_word(word)
if stem not in self.stem_mapping:
self.stem_mapping[stem] = word
else:
existing_word = self.stem_mapping[stem]
if len(existing_word) > len(word):
# Prefer shorter words for the same stem
# Example: "swim" is preferred over "swimming"
self.stem_mapping[stem] = word
def load_vocab(self, path: str) -> None:
with open(path, "r") as f:
for line in f:
self.add_word(line.strip())
@classmethod
def _reconstruct_bpe(
cls, bpe_tokens: Iterable[tuple[int, str]]
) -> list[tuple[str, list[int]]]:
result: list[tuple[str, list[int]]] = []
acc: str = ""
acc_idx: list[int] = []
continuing_subword_prefix = "##"
continuing_subword_prefix_len = len(continuing_subword_prefix)
for idx, token in bpe_tokens:
if token.startswith(continuing_subword_prefix):
acc += token[continuing_subword_prefix_len:]
acc_idx.append(idx)
else:
if acc:
result.append((acc, acc_idx))
acc_idx = []
acc = token
acc_idx.append(idx)
if acc:
result.append((acc, acc_idx))
return result
def resolve_tokens(
self, token_ids: NDArray[np.int64]
) -> tuple[NDArray[np.int64], dict[int, int], dict[str, int], dict[str, list[str]]]:
"""
Mark known tokens (including composed tokens) with vocab ids.
Args:
token_ids: (seq_len) - list of ids of tokens
Example:
[
101, 3897, 19332, 12718, 23348,
1010, 1996, 7151, 2296, 4845,
2359, 2005, 4234, 1010, 4332,
2871, 3191, 2062, 102
]
returns:
- token_ids with vocab ids
[
0, 151, 151, 0, 0,
912, 0, 0, 0, 332,
332, 332, 0, 7121, 191,
0, 0, 332, 0
]
- counts of each token
{
151: 1,
332: 3,
7121: 1,
191: 1,
912: 1
}
- oov counts of each token
{
"the": 1,
"a": 1,
"[CLS]": 1,
"[SEP]": 1,
...
}
- forms of each token
{
"hello": ["hello"],
"world": ["worlds", "world", "worlding"],
}
"""
tokens = self.convert_ids_to_tokens(token_ids)
tokens_mapping = self._reconstruct_bpe(enumerate(tokens))
counts: dict[int, int] = defaultdict(int)
oov_count: dict[str, int] = defaultdict(int)
forms: dict[str, list[str]] = defaultdict(list)
for token, mapped_token_ids in tokens_mapping:
vocab_id = 0
if token in self.stopwords:
vocab_id = 0
elif token in self.vocab:
vocab_id = self.vocab[token]
forms[token].append(token)
elif token in self.stem_mapping:
vocab_id = self.vocab[self.stem_mapping[token]]
forms[self.stem_mapping[token]].append(token)
else:
stem = self.stemmer.stem_word(token)
if stem in self.stem_mapping:
vocab_id = self.vocab[self.stem_mapping[stem]]
forms[self.stem_mapping[stem]].append(token)
for token_id in mapped_token_ids:
token_ids[token_id] = vocab_id
if vocab_id == 0:
oov_count[token] += 1
else:
counts[vocab_id] += 1
return token_ids, counts, oov_count, forms
+23 -21
View File
@@ -1,41 +1,43 @@
from typing import Any, Dict, Iterable, List, Type
import numpy as np
from typing import Any, Iterable, Type
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):
@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 supported_clip_models
def _post_process_onnx_output(self, output: OnnxOutputContext) -> Iterable[np.ndarray]:
def _post_process_onnx_output(
self, output: OnnxOutputContext, **kwargs: Any
) -> Iterable[NumpyArray]:
return output.model_output
@@ -44,7 +46,7 @@ class CLIPEmbeddingWorker(OnnxTextEmbeddingWorker):
self,
model_name: str,
cache_dir: str,
**kwargs,
**kwargs: Any,
) -> OnnxTextEmbedding:
return CLIPOnnxEmbedding(
model_name=model_name,
+97
View File
@@ -0,0 +1,97 @@
from typing import 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, Device
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: str | None = None,
threads: int | None = None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_ids: list[int] | None = None,
lazy_load: bool = False,
device_id: int | None = None,
specific_model_path: str | None = 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, **kwargs: Any
) -> Iterable[NumpyArray]:
return self._normalize(self._pool(output.model_output, output.attention_mask))
def _pool(
self, embeddings: NumpyArray, attention_mask: NDArray[np.int64] | None = 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
raise ValueError(
f"Unsupported pooling type {self._pooling}. "
f"Supported types are: {PoolingType.CLS}, {PoolingType.MEAN}, {PoolingType.DISABLED}."
)
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
)
-72
View File
@@ -1,72 +0,0 @@
from typing import Any, Dict, List, Type
import numpy as np
from fastembed.text.onnx_embedding import OnnxTextEmbedding, OnnxTextEmbeddingWorker
from fastembed.text.onnx_text_model import TextEmbeddingWorker
supported_multilingual_e5_models = [
{
"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"],
},
{
"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",
},
]
class E5OnnxEmbedding(OnnxTextEmbedding):
@classmethod
def _get_worker_class(cls) -> Type["TextEmbeddingWorker"]:
return E5OnnxEmbeddingWorker
@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_multilingual_e5_models
def _preprocess_onnx_input(
self, onnx_input: Dict[str, np.ndarray], **kwargs
) -> Dict[str, np.ndarray]:
"""
Preprocess the onnx input.
"""
onnx_input.pop("token_type_ids", None)
return onnx_input
class E5OnnxEmbeddingWorker(OnnxTextEmbeddingWorker):
def init_embedding(
self,
model_name: str,
cache_dir: str,
**kwargs,
) -> E5OnnxEmbedding:
return E5OnnxEmbedding(
model_name=model_name,
cache_dir=cache_dir,
threads=1,
**kwargs,
)
+109
View File
@@ -0,0 +1,109 @@
from enum import Enum
from typing import Any, Type, Iterable
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.common.model_description import DenseModelDescription, ModelSource
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=ModelSource(hf="jinaai/jina-embeddings-v3"),
model_file="onnx/model.onnx",
additional_files=["onnx/model.onnx_data"],
),
]
class Task(int, Enum):
RETRIEVAL_QUERY = 0
RETRIEVAL_PASSAGE = 1
SEPARATION = 2
CLASSIFICATION = 3
TEXT_MATCHING = 4
class JinaEmbeddingV3(PooledNormalizedEmbedding):
PASSAGE_TASK = Task.RETRIEVAL_PASSAGE
QUERY_TASK = Task.RETRIEVAL_QUERY
def __init__(self, *args: Any, task_id: int | None = None, **kwargs: Any):
super().__init__(*args, **kwargs)
self.default_task_id: Task | int = task_id if task_id is not None else self.PASSAGE_TASK
@classmethod
def _get_worker_class(cls) -> Type[OnnxTextEmbeddingWorker]:
return JinaEmbeddingV3Worker
@classmethod
def _list_supported_models(cls) -> list[DenseModelDescription]:
return supported_multitask_models
def _preprocess_onnx_input(
self,
onnx_input: dict[str, NumpyArray],
task_id: int | Task | None = 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(
self,
documents: str | Iterable[str],
batch_size: int = 256,
parallel: int | None = None,
task_id: int | None = 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: 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: Any) -> Iterable[NumpyArray]:
yield from super().embed(texts, task_id=self.PASSAGE_TASK, **kwargs)
class JinaEmbeddingV3Worker(OnnxTextEmbeddingWorker):
def init_embedding(
self,
model_name: str,
cache_dir: str,
**kwargs: Any,
) -> JinaEmbeddingV3:
return JinaEmbeddingV3(
model_name=model_name,
cache_dir=cache_dir,
threads=1,
**kwargs,
)
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
+231 -191
View File
@@ -1,196 +1,213 @@
from typing import Any, Dict, Iterable, List, Optional, Sequence, Type, Union
from typing import Any, Iterable, Sequence, Type
import numpy as np
from fastembed.common import OnnxProvider
from fastembed.common.types import NumpyArray, OnnxProvider, Device
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": {
"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": {
"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": {
"url": "https://storage.googleapis.com/qdrant-fastembed/fast-bge-small-zh-v1.5.tar.gz",
},
"model_file": "model_optimized.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": "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",
},
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."""
@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 __init__(
self,
model_name: str = "BAAI/bge-small-en-v1.5",
cache_dir: Optional[str] = None,
threads: Optional[int] = None,
providers: Optional[Sequence[OnnxProvider]] = None,
cuda: bool = False,
device_ids: Optional[List[int]] = None,
cache_dir: str | None = None,
threads: int | None = None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_ids: list[int] | None = None,
lazy_load: bool = False,
device_id: Optional[int] = None,
**kwargs,
device_id: int | None = None,
specific_model_path: str | None = None,
**kwargs: Any,
):
"""
Args:
@@ -201,13 +218,15 @@ class OnnxTextEmbedding(TextEmbeddingBase, OnnxTextModel[np.ndarray]):
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.
cuda (Union[bool, Device], optional): Whether to use cuda for inference. Mutually exclusive with `providers`
Defaults to Device.AUTO.
device_ids (Optional[list[int]], optional): The list of device ids to use for data parallel processing in
workers. Should be used with `cuda` equals to `True`, `Device.AUTO` or `Device.CUDA`, 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.
specific_model_path (Optional[str], optional): The specific path to the onnx model dir if it should be imported from somewhere else
Raises:
ValueError: If the model_name is not in the format <org>/<model> e.g. BAAI/bge-base-en.
@@ -215,23 +234,26 @@ class OnnxTextEmbedding(TextEmbeddingBase, OnnxTextModel[np.ndarray]):
super().__init__(model_name, cache_dir, threads, **kwargs)
self.providers = providers
self.lazy_load = lazy_load
self._extra_session_options = self._select_exposed_session_options(kwargs)
# 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: int | None = 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._specific_model_path = specific_model_path
self._model_dir = self.download_model(
self.model_description, self.cache_dir, local_files_only=self._local_files_only
self.model_description,
self.cache_dir,
local_files_only=self._local_files_only,
specific_model_path=self._specific_model_path,
)
if not self.lazy_load:
@@ -239,11 +261,11 @@ class OnnxTextEmbedding(TextEmbeddingBase, OnnxTextModel[np.ndarray]):
def embed(
self,
documents: Union[str, Iterable[str]],
documents: str | Iterable[str],
batch_size: int = 256,
parallel: Optional[int] = None,
**kwargs,
) -> Iterable[np.ndarray]:
parallel: int | None = None,
**kwargs: Any,
) -> 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.
@@ -268,42 +290,60 @@ class OnnxTextEmbedding(TextEmbeddingBase, OnnxTextModel[np.ndarray]):
providers=self.providers,
cuda=self.cuda,
device_ids=self.device_ids,
local_files_only=self._local_files_only,
specific_model_path=self._specific_model_path,
extra_session_options=self._extra_session_options,
**kwargs,
)
@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
) -> 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, **kwargs: Any
) -> Iterable[NumpyArray]:
embeddings = output.model_output
return normalize(embeddings[:, 0]).astype(np.float32)
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)
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,
extra_session_options=self._extra_session_options,
)
def token_count(
self, texts: str | Iterable[str], batch_size: int = 1024, **kwargs: Any
) -> int:
return self._token_count(texts, batch_size=batch_size, **kwargs)
class OnnxTextEmbeddingWorker(TextEmbeddingWorker):
class OnnxTextEmbeddingWorker(TextEmbeddingWorker[NumpyArray]):
def init_embedding(
self,
model_name: str,
cache_dir: str,
**kwargs,
**kwargs: Any,
) -> OnnxTextEmbedding:
return OnnxTextEmbedding(
model_name=model_name,
+66 -32
View File
@@ -1,12 +1,13 @@
import os
from multiprocessing import get_all_start_methods
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Type, Union
from typing import Any, Iterable, Sequence, Type
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, Device
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
@@ -14,23 +15,32 @@ from fastembed.parallel_processor import ParallelWorkerPool
class OnnxTextModel(OnnxModel[T]):
ONNX_OUTPUT_NAMES: Optional[List[str]] = None
ONNX_OUTPUT_NAMES: list[str] | None = 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]:
def _post_process_onnx_output(self, output: OnnxOutputContext, **kwargs: Any) -> Iterable[T]:
"""Post-process the ONNX model output to convert it into a usable format.
Args:
output (OnnxOutputContext): The raw output from the ONNX model.
**kwargs: Additional keyword arguments that may be needed by specific implementations.
Returns:
Iterable[T]: Post-processed output as an iterable of type T.
"""
raise NotImplementedError("Subclasses must implement this method")
def __init__(self) -> None:
super().__init__()
self.tokenizer = None
self.special_token_to_id = {}
self.tokenizer: Tokenizer | None = None
self.special_token_to_id: dict[str, int] = {}
def _preprocess_onnx_input(
self, onnx_input: Dict[str, np.ndarray], **kwargs
) -> Dict[str, np.ndarray]:
self, onnx_input: dict[str, NumpyArray], **kwargs: Any
) -> dict[str, NumpyArray | NDArray[np.int64]]:
"""
Preprocess the onnx input.
"""
@@ -40,10 +50,11 @@ class OnnxTextModel(OnnxModel[T]):
self,
model_dir: Path,
model_file: str,
threads: Optional[int],
providers: Optional[Sequence[OnnxProvider]] = None,
cuda: bool = False,
device_id: Optional[int] = None,
threads: int | None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_id: int | None = None,
extra_session_options: dict[str, Any] | None = None,
) -> None:
super()._load_onnx_model(
model_dir=model_dir,
@@ -52,25 +63,26 @@ class OnnxTextModel(OnnxModel[T]):
providers=providers,
cuda=cuda,
device_id=device_id,
extra_session_options=extra_session_options,
)
self.tokenizer, self.special_token_to_id = load_tokenizer(model_dir=model_dir)
def load_onnx_model(self) -> None:
raise NotImplementedError("Subclasses must implement this method")
def tokenize(self, documents: List[str], **kwargs) -> List[Encoding]:
return self.tokenizer.encode_batch(documents)
def tokenize(self, documents: list[str], **kwargs: Any) -> list[Encoding]:
return self.tokenizer.encode_batch(documents) # type: ignore[union-attr]
def onnx_embed(
self,
documents: List[str],
**kwargs,
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])
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:
@@ -79,10 +91,9 @@ class OnnxTextModel(OnnxModel[T]):
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_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),
@@ -93,13 +104,16 @@ class OnnxTextModel(OnnxModel[T]):
self,
model_name: str,
cache_dir: str,
documents: Union[str, Iterable[str]],
documents: 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,
parallel: int | None = None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_ids: list[int] | None = None,
local_files_only: bool = False,
specific_model_path: str | None = None,
extra_session_options: dict[str, Any] | None = None,
**kwargs: Any,
) -> Iterable[T]:
is_small = False
@@ -115,7 +129,9 @@ 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), **kwargs
)
else:
if parallel == 0:
parallel = os.cpu_count()
@@ -125,9 +141,14 @@ class OnnxTextModel(OnnxModel[T]):
"model_name": model_name,
"cache_dir": cache_dir,
"providers": providers,
"local_files_only": local_files_only,
"specific_model_path": specific_model_path,
**kwargs,
}
if extra_session_options is not None:
params.update(extra_session_options)
pool = ParallelWorkerPool(
num_workers=parallel or 1,
worker=self._get_worker_class(),
@@ -136,11 +157,24 @@ 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, **kwargs) # type: ignore
def _token_count(self, texts: str | Iterable[str], batch_size: int = 1024, **_: Any) -> int:
if not hasattr(self, "model") or self.model is None:
self.load_onnx_model() # loads the tokenizer as well
token_num = 0
assert self.tokenizer is not None
texts = [texts] if isinstance(texts, str) else texts
for batch in iter_batch(texts, batch_size):
for tokens in self.tokenizer.encode_batch(batch):
token_num += sum(tokens.attention_mask)
return token_num
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
+95 -51
View File
@@ -1,80 +1,124 @@
from typing import Any, Dict, Iterable, List, Type
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",
},
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):
@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 supported_pooled_models
def _post_process_onnx_output(self, output: OnnxOutputContext) -> Iterable[np.ndarray]:
def _post_process_onnx_output(
self, output: OnnxOutputContext, **kwargs: Any
) -> 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):
@@ -82,7 +126,7 @@ class PooledEmbeddingWorker(OnnxTextEmbeddingWorker):
self,
model_name: str,
cache_dir: str,
**kwargs,
**kwargs: Any,
) -> OnnxTextEmbedding:
return PooledEmbedding(
model_name=model_name,
+124 -58
View File
@@ -1,86 +1,152 @@
from typing import Any, Dict, Iterable, List, Type
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",
},
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):
@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 supported_pooled_normalized_models
def _post_process_onnx_output(self, output: OnnxOutputContext) -> Iterable[np.ndarray]:
def _post_process_onnx_output(
self, output: OnnxOutputContext, **kwargs: Any
) -> 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):
@@ -88,7 +154,7 @@ class PooledNormalizedEmbeddingWorker(OnnxTextEmbeddingWorker):
self,
model_name: str,
cache_dir: str,
**kwargs,
**kwargs: Any,
) -> OnnxTextEmbedding:
return PooledNormalizedEmbedding(
model_name=model_name,
+164 -43
View File
@@ -1,70 +1,116 @@
from typing import Any, Dict, Iterable, List, Optional, Sequence, Type, Union
import warnings
from typing import Any, Iterable, Sequence, Type
from dataclasses import asdict
import numpy as np
from fastembed.common import OnnxProvider
from fastembed.common.types import NumpyArray, OnnxProvider, Device
from fastembed.text.clip_embedding import CLIPOnnxEmbedding
from fastembed.text.e5_onnx_embedding import E5OnnxEmbedding
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):
EMBEDDINGS_REGISTRY: List[Type[TextEmbeddingBase]] = [
EMBEDDINGS_REGISTRY: list[Type[TextEmbeddingBase]] = [
OnnxTextEmbedding,
E5OnnxEmbedding,
CLIPOnnxEmbedding,
PooledNormalizedEmbedding,
PooledEmbedding,
JinaEmbeddingV3,
CustomTextEmbedding,
]
@classmethod
def list_supported_models(cls) -> List[Dict[str, Any]]:
"""
Lists the supported models.
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": "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",
}
}
]
```
list[dict[str, Any]]: A list of dictionaries containing the model information.
"""
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: 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: list[str] | None = None,
) -> None:
registered_models = cls._list_supported_models()
for registered_model in registered_models:
if model.lower() == registered_model.model.lower():
raise ValueError(
f"Model {model} is already registered in TextEmbedding, if you still want to add this model, "
f"please use another model name"
)
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,
model_name: str = "BAAI/bge-small-en-v1.5",
cache_dir: Optional[str] = None,
threads: Optional[int] = None,
providers: Optional[Sequence[OnnxProvider]] = None,
cuda: bool = False,
device_ids: Optional[List[int]] = None,
cache_dir: str | None = None,
threads: int | None = None,
providers: Sequence[OnnxProvider] | None = None,
cuda: bool | Device = Device.AUTO,
device_ids: list[int] | None = None,
lazy_load: bool = False,
**kwargs,
**kwargs: Any,
):
super().__init__(model_name, cache_dir, threads, **kwargs)
if model_name.lower() == "nomic-ai/nomic-embed-text-v1.5-Q".lower():
warnings.warn(
"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.lower() in {
"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2".lower(),
"thenlper/gte-large".lower(),
"intfloat/multilingual-e5-large".lower(),
"sentence-transformers/paraphrase-multilingual-mpnet-base-v2".lower(),
}:
warnings.warn(
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,
@@ -78,17 +124,51 @@ class TextEmbedding(TextEmbeddingBase):
return
raise ValueError(
f"Model {model_name} is not supported in TextEmbedding."
f"Model {model_name} is not supported in TextEmbedding. "
"Please check the supported models using `TextEmbedding.list_supported_models()`"
)
@property
def embedding_size(self) -> int:
"""Get the embedding size of the current model"""
if self._embedding_size is None:
self._embedding_size = self.get_embedding_size(self.model_name)
return self._embedding_size
@classmethod
def get_embedding_size(cls, model_name: str) -> int:
"""Get the embedding size of the passed model
Args:
model_name (str): The name of the model to get embedding size for.
Returns:
int: The size of the embedding.
Raises:
ValueError: If the model name is not found in the supported models.
"""
descriptions = cls._list_supported_models()
embedding_size: int | None = None
for description in descriptions:
if description.model.lower() == model_name.lower():
embedding_size = description.dim
break
if embedding_size is None:
model_names = [description.model for description in descriptions]
raise ValueError(
f"Embedding size for model {model_name} was None. "
f"Available model names: {model_names}"
)
return embedding_size
def embed(
self,
documents: Union[str, Iterable[str]],
documents: str | Iterable[str],
batch_size: int = 256,
parallel: Optional[int] = None,
**kwargs,
) -> Iterable[np.ndarray]:
parallel: int | None = None,
**kwargs: Any,
) -> 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.
@@ -105,3 +185,44 @@ class TextEmbedding(TextEmbeddingBase):
List of embeddings, one per document
"""
yield from self.model.embed(documents, batch_size, parallel, **kwargs)
def query_embed(self, query: str | Iterable[str], **kwargs: Any) -> Iterable[NumpyArray]:
"""
Embeds queries
Args:
query (Union[str, Iterable[str]]): The query to embed, or an iterable e.g. list of queries.
Returns:
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[NumpyArray]:
"""
Embeds a list of text passages into a list of embeddings.
Args:
texts (Iterable[str]): The list of texts to embed.
**kwargs: Additional keyword argument to pass to the embed method.
Yields:
Iterable[SparseEmbedding]: The sparse embeddings.
"""
# This is model-specific, so that different models can have specialized implementations
yield from self.model.passage_embed(texts, **kwargs)
def token_count(
self, texts: str | Iterable[str], batch_size: int = 1024, **kwargs: Any
) -> int:
"""Returns the number of tokens in the texts.
Args:
texts (str | Iterable[str]): The list of texts to embed.
batch_size (int): Batch size for encoding
Returns:
int: Sum of number of tokens in the texts.
"""
return self.model.token_count(texts, batch_size=batch_size, **kwargs)
+31 -18
View File
@@ -1,33 +1,34 @@
from typing import Iterable, Optional, Union
import numpy as np
from typing import Iterable, Any
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,
cache_dir: Optional[str] = None,
threads: Optional[int] = None,
**kwargs,
cache_dir: str | None = None,
threads: int | None = 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)
self._embedding_size: int | None = None
def embed(
self,
documents: Union[str, Iterable[str]],
documents: str | Iterable[str],
batch_size: int = 256,
parallel: Optional[int] = None,
**kwargs,
) -> Iterable[np.ndarray]:
parallel: int | None = None,
**kwargs: Any,
) -> Iterable[NumpyArray]:
raise NotImplementedError()
def passage_embed(self, texts: Iterable[str], **kwargs) -> 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,15 +37,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
) -> Iterable[np.ndarray]:
def query_embed(self, query: str | Iterable[str], **kwargs: Any) -> Iterable[NumpyArray]:
"""
Embeds queries
@@ -52,11 +51,25 @@ 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)
@classmethod
def get_embedding_size(cls, model_name: str) -> int:
"""Returns embedding size of the passed model."""
raise NotImplementedError("Subclasses must implement this method")
@property
def embedding_size(self) -> int:
"""Returns embedding size for the current model"""
raise NotImplementedError("Subclasses must implement this method")
def token_count(self, texts: str | Iterable[str], **kwargs: Any) -> int:
"""Returns the number of tokens in the texts."""
raise NotImplementedError("Subclasses must implement this method")
+1
View File
@@ -13,6 +13,7 @@ copyright: |
theme:
name: material
logo: assets/favicon.png
favicon: assets/favicon.png
custom_dir: docs/overrides
icon:
repo: fontawesome/brands/github
Generated
+4392
View File
File diff suppressed because it is too large Load Diff
+39 -16
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "fastembed"
version = "0.3.6"
name = "fastembed-gpu"
version = "0.8.0"
description = "Fast, light, accurate library built for retrieval embedding generation"
authors = ["Qdrant Team <info@qdrant.tech>", "NirantK <nirant.bits@gmail.com>"]
license = "Apache License"
@@ -11,40 +11,63 @@ repository = "https://github.com/qdrant/fastembed"
keywords = ["vector", "embedding", "neural", "search", "qdrant", "sentence-transformers"]
[tool.poetry.dependencies]
python = ">=3.8.0,<3.13"
onnx = "^1.15.0"
onnxruntime = "^1.17.0"
python = ">=3.10.0"
numpy = [
{ version = ">=1.21,<2.3.0", python = "3.10" },
{ version = ">=1.21", python = "3.11" },
{ version = ">=1.26", python = "3.12" },
{ version = ">=2.1.0", python = "3.13" },
{ version = ">=2.3.0", python = ">=3.14" },
]
onnxruntime-gpu = [
{ version = ">=1.17.0,!=1.20.0,<1.24", python = "3.10" },
{ version = ">=1.17.0,!=1.20.0,!=1.24.0,!=1.24.1", python = ">=3.11,<3.13" },
{ version = ">1.21.0,!=1.24.0,!=1.24.1", python = "3.13" },
{ version = ">=1.24.2", python = ">=3.14" },
]
tqdm = "^4.66"
requests = "^2.31"
tokenizers = ">=0.15,<1.0"
huggingface-hub = ">=0.20,<1.0"
huggingface-hub = ">=0.20,<2.0"
loguru = "^0.7.2"
numpy = [
{ version = ">=1.21, <2", python = "<3.12" },
{ version = ">=1.26, <2", python = ">=3.12" }
pillow = [
{ version = ">=10.3.0,<13.0", python = ">=3.10,<3.13" },
{ version = ">=11.0.0,<13.0", python = "3.13" },
{ version = ">=12.0.0,<13.0", python = ">=3.14" },
]
pillow = "^10.3.0"
snowballstemmer = "^2.2.0"
PyStemmer = "^2.2.0"
mmh3 = "^4.1.0"
mmh3 = ">=4.1.0,<6.0.0"
py-rust-stemmers = "^0.1.0"
[tool.poetry.group.dev.dependencies]
[tool.poetry.group.test.dependencies]
pytest = "^7.4.2"
ruff = ">=0.3.1,<1.0"
[tool.poetry.group.dev.dependencies]
notebook = ">=7.0.2"
pre-commit = {version = "^3.6.2", python = ">=3.9,<3.12" }
pre-commit = "^3.6.2"
onnx = [
{ version = ">=1.15.0", python = ">=3.10,<3.13" },
{ version = ">=1.18.0", python = "3.13" },
{ version = ">=1.20.0", python = ">=3.14" },
]
[tool.poetry.group.docs.dependencies]
mkdocs-material = "^9.5.10"
mkdocstrings = "^0.24.0"
pillow = "^10.2.0"
pillow = ">=10.3.0,<13.0.0"
cairosvg = "^2.7.1"
mknotebooks = "^0.8.0"
[tool.poetry.group.types.dependencies]
pyright = ">=1.1.293"
mypy = "^1.0.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.pyright]
typeCheckingMode = "strict"
[tool.ruff]
line-length = 99
+6 -6
View File
@@ -9,7 +9,7 @@
# %%
import time
from typing import Callable, List, Tuple
from typing import Callable
import matplotlib.pyplot as plt
import torch.nn.functional as F
@@ -23,7 +23,7 @@ from fastembed.embedding import DefaultEmbedding
# data is a list of strings, each string is a document.
# %%
documents: List[str] = [
documents: list[str] = [
"Chandrayaan-3 is India's third lunar mission",
"It aimed to land a rover on the Moon's surface - joining the US, China and Russia",
"The mission is a follow-up to Chandrayaan-2, which had partial success",
@@ -56,7 +56,7 @@ class HF:
self.model = AutoModel.from_pretrained(model_id)
self.tokenizer = AutoTokenizer.from_pretrained(model_id)
def embed(self, texts: List[str]):
def embed(self, texts: list[str]):
encoded_input = self.tokenizer(
texts, max_length=512, padding=True, truncation=True, return_tensors="pt"
)
@@ -88,7 +88,7 @@ embedding_model = DefaultEmbedding()
# %%
def calculate_time_stats(
embed_func: Callable, documents: list, k: int
) -> Tuple[float, float, float]:
) -> tuple[float, float, float]:
times = []
for _ in range(k):
# Timing the embed_func call
@@ -111,8 +111,8 @@ print(f"FastEmbed (Average, Max, Min): {fst_stats}")
# %%
def plot_character_per_second_comparison(
hf_stats: Tuple[float, float, float],
fst_stats: Tuple[float, float, float],
hf_stats: tuple[float, float, float],
fst_stats: tuple[float, float, float],
documents: list,
):
# Calculating total characters in documents
+113 -100
View File
@@ -1,104 +1,126 @@
import os
import shutil
from contextlib import contextmanager
import numpy as np
import pytest
from fastembed import SparseTextEmbedding
from tests.utils import delete_model_cache
_MODELS_TO_CACHE = ("Qdrant/bm42-all-minilm-l6-v2-attentions", "Qdrant/bm25")
MODELS_TO_CACHE = tuple([x.lower() for x in _MODELS_TO_CACHE])
@pytest.fixture(scope="module")
def model_cache():
is_ci = os.getenv("CI")
cache = {}
@contextmanager
def get_model(model_name: str):
lowercase_model_name = model_name.lower()
if lowercase_model_name not in cache:
cache[lowercase_model_name] = SparseTextEmbedding(lowercase_model_name)
yield cache[lowercase_model_name]
if lowercase_model_name not in MODELS_TO_CACHE:
print("deleting model")
model_inst = cache.pop(lowercase_model_name)
if is_ci:
delete_model_cache(model_inst.model._model_dir)
del model_inst
yield get_model
if is_ci:
for name, model in cache.items():
delete_model_cache(model.model._model_dir)
cache.clear()
@pytest.mark.parametrize("model_name", ["Qdrant/bm42-all-minilm-l6-v2-attentions", "Qdrant/bm25"])
def test_attention_embeddings(model_name):
is_ci = os.getenv("CI")
model = SparseTextEmbedding(model_name=model_name)
output = list(
model.query_embed(
[
"I must not fear. Fear is the mind-killer.",
]
def test_attention_embeddings(model_cache, model_name: str) -> None:
with model_cache(model_name) as model:
output = list(
model.query_embed(
[
"I must not fear. Fear is the mind-killer.",
]
)
)
)
assert len(output) == 1
assert len(output) == 1
for result in output:
assert len(result.indices) == len(result.values)
assert np.allclose(result.values, np.ones(len(result.values)))
for result in output:
assert len(result.indices) == len(result.values)
assert np.allclose(result.values, np.ones(len(result.values)))
quotes = [
"I must not fear. Fear is the mind-killer.",
"All animals are equal, but some animals are more equal than others.",
"It was a pleasure to burn.",
"The sky above the port was the color of television, tuned to a dead channel.",
"In the beginning, the universe was created."
" This has made a lot of people very angry and been widely regarded as a bad move.",
"It's a truth universally acknowledged that a zombie in possession of brains must be in want of more brains.",
"War is peace. Freedom is slavery. Ignorance is strength.",
"We're not in Infinity; we're in the suburbs.",
"I was a thousand times more evil than thou!",
"History is merely a list of surprises... It can only prepare us to be surprised yet again.",
".", # Empty string
]
quotes = [
"I must not fear. Fear is the mind-killer.",
"All animals are equal, but some animals are more equal than others.",
"It was a pleasure to burn.",
"The sky above the port was the color of television, tuned to a dead channel.",
"In the beginning, the universe was created."
" This has made a lot of people very angry and been widely regarded as a bad move.",
"It's a truth universally acknowledged that a zombie in possession of brains must be in want of more brains.",
"War is peace. Freedom is slavery. Ignorance is strength.",
"We're not in Infinity; we're in the suburbs.",
"I was a thousand times more evil than thou!",
"History is merely a list of surprises... It can only prepare us to be surprised yet again.",
".", # Empty string
]
output = list(model.embed(quotes))
output = list(model.embed(quotes))
assert len(output) == len(quotes)
assert len(output) == len(quotes)
for result in output[:-1]:
assert len(result.indices) == len(result.values)
assert len(result.indices) > 0
for result in output[:-1]:
assert len(result.indices) == len(result.values)
assert len(result.indices) > 0
assert len(output[-1].indices) == 0
assert len(output[-1].indices) == 0
# Test support for unknown languages
output = list(
model.query_embed(
[
"привет мир!",
]
# Test support for unknown languages
output = list(
model.query_embed(
[
"привет мир!",
]
)
)
)
assert len(output) == 1
assert len(output) == 1
for result in output:
assert len(result.indices) == len(result.values)
assert len(result.indices) == 2
if is_ci:
shutil.rmtree(model.model._model_dir)
for result in output:
assert len(result.indices) == len(result.values)
assert len(result.indices) == 2
@pytest.mark.parametrize("model_name", ["Qdrant/bm42-all-minilm-l6-v2-attentions", "Qdrant/bm25"])
def test_parallel_processing(model_name):
is_ci = os.getenv("CI")
def test_parallel_processing(model_cache, model_name: str) -> None:
with model_cache(model_name) as model:
docs = [
"hello world",
"attention embedding",
"Mangez-vous vraiment des grenouilles?",
] * 100
embeddings = list(model.embed(docs, batch_size=10, parallel=2))
model = SparseTextEmbedding(model_name=model_name)
embeddings_2 = list(model.embed(docs, batch_size=10, parallel=None))
docs = ["hello world", "attention embedding", "Mangez-vous vraiment des grenouilles?"] * 100
embeddings = list(model.embed(docs, batch_size=10, parallel=2))
embeddings_3 = list(model.embed(docs, batch_size=10, parallel=0))
embeddings_2 = list(model.embed(docs, batch_size=10, parallel=None))
assert len(embeddings) == len(docs)
embeddings_3 = list(model.embed(docs, batch_size=10, parallel=0))
assert len(embeddings) == len(docs)
for emb_1, emb_2, emb_3 in zip(embeddings, embeddings_2, embeddings_3):
assert np.allclose(emb_1.indices, emb_2.indices)
assert np.allclose(emb_1.indices, emb_3.indices)
assert np.allclose(emb_1.values, emb_2.values)
assert np.allclose(emb_1.values, emb_3.values)
if is_ci:
shutil.rmtree(model.model._model_dir)
for emb_1, emb_2, emb_3 in zip(embeddings, embeddings_2, embeddings_3):
assert np.allclose(emb_1.indices, emb_2.indices)
assert np.allclose(emb_1.indices, emb_3.indices)
assert np.allclose(emb_1.values, emb_2.values)
assert np.allclose(emb_1.values, emb_3.values)
@pytest.mark.parametrize("model_name", ["Qdrant/bm25"])
def test_multilanguage(model_name):
is_ci = os.getenv("CI")
def test_multilanguage(model_cache, model_name: str) -> None:
docs = ["Mangez-vous vraiment des grenouilles?", "Je suis au lit"]
model = SparseTextEmbedding(model_name=model_name, language="french")
@@ -109,43 +131,34 @@ def test_multilanguage(model_name):
assert embeddings[1].values.shape == (1,)
assert embeddings[1].indices.shape == (1,)
model = SparseTextEmbedding(model_name=model_name, language="english")
embeddings = list(model.embed(docs))[:2]
assert embeddings[0].values.shape == (5,)
assert embeddings[0].indices.shape == (5,)
with model_cache(model_name) as model: # language = "english"
embeddings = list(model.embed(docs))[:2]
assert embeddings[0].values.shape == (5,)
assert embeddings[0].indices.shape == (5,)
assert embeddings[1].values.shape == (4,)
assert embeddings[1].indices.shape == (4,)
if is_ci:
shutil.rmtree(model.model._model_dir)
assert embeddings[1].values.shape == (4,)
assert embeddings[1].indices.shape == (4,)
@pytest.mark.parametrize("model_name", ["Qdrant/bm25"])
def test_special_characters(model_name):
is_ci = os.getenv("CI")
docs = [
"Über den größten Flüssen Österreichs äußern sich Experten häufig: Öko-Systeme müssen geschützt werden!",
"L'élève français s'écrie : « Où est mon crayon ? J'ai besoin de finir cet exercice avant la récréation!",
"Într-o zi însorită, Ștefan și Ioana au mâncat mămăligă cu brânză și au băut țuică la cabană.",
"Üzgün öğretmen öğrencilere seslendi: Lütfen gürültü yapmayın, sınavınızı bitirmeye çalışıyorum!",
"Ο Ξενοφών είπε: «Ψάχνω για ένα ωραίο δώρο για τη γιαγιά μου. Ίσως ένα φυτό ή ένα βιβλίο;»",
"Hola! ¿Cómo estás? Estoy muy emocionado por el cumpleaños de mi hermano, ¡va a ser increíble! También quiero comprar un pastel de chocolate con fresas y un regalo especial: un libro titulado «Cien años de soledad",
]
model = SparseTextEmbedding(model_name=model_name, language="english")
embeddings = list(model.embed(docs))
for idx, shape in enumerate([14, 18, 15, 10, 15]):
assert embeddings[idx].values.shape == (shape,)
assert embeddings[idx].indices.shape == (shape,)
if is_ci:
shutil.rmtree(model.model._model_dir)
def test_special_characters(model_cache, model_name: str) -> None:
with model_cache(model_name) as model:
docs = [
"Über den größten Flüssen Österreichs äußern sich Experten häufig: Öko-Systeme müssen geschützt werden!",
"L'élève français s'écrie : « Où est mon crayon ? J'ai besoin de finir cet exercice avant la récréation!",
"Într-o zi însorită, Ștefan și Ioana au mâncat mămăligă cu brânză și au băut țuică la cabană.",
"Üzgün öğretmen öğrencilere seslendi: Lütfen gürültü yapmayın, sınavınızı bitirmeye çalışıyorum!",
"Ο Ξενοφών είπε: «Ψάχνω για ένα ωραίο δώρο για τη γιαγιά μου. Ίσως ένα φυτό ή ένα βιβλίο;»",
"Hola! ¿Cómo estás? Estoy muy emocionado por el cumpleaños de mi hermano, ¡va a ser increíble! También quiero comprar un pastel de chocolate con fresas y un regalo especial: un libro titulado «Cien años de soledad",
]
embeddings = list(model.embed(docs))
for idx, shape in enumerate([14, 18, 15, 10, 15]):
assert embeddings[idx].values.shape == (shape,)
assert embeddings[idx].indices.shape == (shape,)
@pytest.mark.parametrize("model_name", ["Qdrant/bm42-all-minilm-l6-v2-attentions"])
def test_lazy_load(model_name):
def test_lazy_load(model_name: str) -> None:
model = SparseTextEmbedding(model_name=model_name, lazy_load=True)
assert not hasattr(model.model, "model")
docs = ["hello world", "flag embedding"]
+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"]
+243
View File
@@ -0,0 +1,243 @@
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)
CustomTextEmbedding.SUPPORTED_MODELS.clear()
CustomTextEmbedding.POSTPROCESSING_MAPPING.clear()
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)
CustomTextCrossEncoder.SUPPORTED_MODELS.clear()
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)
CustomTextEmbedding.SUPPORTED_MODELS.clear()
CustomTextEmbedding.POSTPROCESSING_MAPPING.clear()
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,
)
CustomTextEmbedding.SUPPORTED_MODELS.clear()
CustomTextEmbedding.POSTPROCESSING_MAPPING.clear()
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,
)
CustomTextCrossEncoder.SUPPORTED_MODELS.clear()
+134 -76
View File
@@ -1,5 +1,5 @@
import os
import shutil
from contextlib import contextmanager
from io import BytesIO
import numpy as np
@@ -9,6 +9,7 @@ from PIL import Image
from fastembed import ImageEmbedding
from tests.config import TEST_MISC_DIR
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]),
@@ -21,92 +22,119 @@ CANONICAL_VECTOR_VALUES = {
"Qdrant/Unicom-ViT-B-32": np.array(
[0.0418, 0.0550, 0.0003, 0.0253, -0.0185, 0.0016, -0.0368, -0.0402, -0.0891, -0.0186]
),
"jinaai/jina-clip-v1": np.array(
[-0.029, 0.0216, 0.0396, 0.0283, -0.0023, 0.0151, 0.011, -0.0235, 0.0251, -0.0343]
),
}
_MODELS_TO_CACHE = ("Qdrant/clip-ViT-B-32-vision",)
MODELS_TO_CACHE = tuple([x.lower() for x in _MODELS_TO_CACHE])
def test_embedding():
@pytest.fixture(scope="module")
def model_cache():
is_ci = os.getenv("CI")
cache = {}
for model_desc in ImageEmbedding.list_supported_models():
if not is_ci and model_desc["size_in_GB"] > 1:
continue
@contextmanager
def get_model(model_name: str):
lowercase_model_name = model_name.lower()
if lowercase_model_name not in cache:
cache[lowercase_model_name] = ImageEmbedding(lowercase_model_name)
yield cache[lowercase_model_name]
if lowercase_model_name not in MODELS_TO_CACHE:
model_inst = cache.pop(lowercase_model_name)
if is_ci:
delete_model_cache(model_inst.model._model_dir)
del model_inst
dim = model_desc["dim"]
yield get_model
model = ImageEmbedding(model_name=model_desc["model"])
images = [
TEST_MISC_DIR / "image.jpeg",
str(TEST_MISC_DIR / "small_image.jpeg"),
Image.open((TEST_MISC_DIR / "small_image.jpeg")),
Image.open(BytesIO(requests.get("https://qdrant.tech/img/logo.png").content)),
]
embeddings = list(model.embed(images))
embeddings = np.stack(embeddings, axis=0)
assert embeddings.shape == (len(images), dim)
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"]
assert np.allclose(embeddings[1], embeddings[2]), model_desc["model"]
if is_ci:
shutil.rmtree(model.model._model_dir)
@pytest.mark.parametrize("n_dims,model_name", [(512, "Qdrant/clip-ViT-B-32-vision")])
def test_batch_embedding(n_dims, model_name):
is_ci = os.getenv("CI")
model = ImageEmbedding(model_name=model_name)
n_images = 32
test_images = [
TEST_MISC_DIR / "image.jpeg",
str(TEST_MISC_DIR / "small_image.jpeg"),
Image.open(TEST_MISC_DIR / "small_image.jpeg"),
]
images = test_images * n_images
embeddings = list(model.embed(images, batch_size=10))
embeddings = np.stack(embeddings, axis=0)
assert embeddings.shape == (len(test_images) * n_images, n_dims)
if is_ci:
shutil.rmtree(model.model._model_dir)
@pytest.mark.parametrize("n_dims,model_name", [(512, "Qdrant/clip-ViT-B-32-vision")])
def test_parallel_processing(n_dims, model_name):
is_ci = os.getenv("CI")
model = ImageEmbedding(model_name=model_name)
n_images = 32
test_images = [
TEST_MISC_DIR / "image.jpeg",
str(TEST_MISC_DIR / "small_image.jpeg"),
Image.open(TEST_MISC_DIR / "small_image.jpeg"),
]
images = test_images * n_images
embeddings = list(model.embed(images, batch_size=10, parallel=2))
embeddings = np.stack(embeddings, axis=0)
embeddings_2 = list(model.embed(images, batch_size=10, parallel=None))
embeddings_2 = np.stack(embeddings_2, axis=0)
embeddings_3 = list(model.embed(images, batch_size=10, parallel=0))
embeddings_3 = np.stack(embeddings_3, axis=0)
assert embeddings.shape == (n_images * len(test_images), n_dims)
assert np.allclose(embeddings, embeddings_2, atol=1e-3)
assert np.allclose(embeddings, embeddings_3, atol=1e-3)
if is_ci:
shutil.rmtree(model.model._model_dir)
for name, model in cache.items():
delete_model_cache(model.model._model_dir)
cache.clear()
@pytest.mark.parametrize("model_name", ["Qdrant/clip-ViT-B-32-vision"])
def test_lazy_load(model_name):
def test_embedding(model_cache, 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 should_test_model(model_desc, model_name, is_ci, is_manual):
continue
dim = model_desc.dim
with model_cache(model_desc.model) as model:
images = [
TEST_MISC_DIR / "image.jpeg",
str(TEST_MISC_DIR / "small_image.jpeg"),
Image.open((TEST_MISC_DIR / "small_image.jpeg")),
Image.open(BytesIO(requests.get("https://qdrant.tech/img/logo.png").content)),
]
embeddings = list(model.embed(images))
embeddings = np.stack(embeddings, axis=0)
assert embeddings.shape == (len(images), dim)
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
assert np.allclose(embeddings[1], embeddings[2]), model_desc.model
@pytest.mark.parametrize("n_dims,model_name", [(512, "Qdrant/clip-ViT-B-32-vision")])
def test_batch_embedding(model_cache, n_dims: int, model_name: str) -> None:
with model_cache(model_name) as model:
n_images = 32
test_images = [
TEST_MISC_DIR / "image.jpeg",
str(TEST_MISC_DIR / "small_image.jpeg"),
Image.open(TEST_MISC_DIR / "small_image.jpeg"),
]
images = test_images * n_images
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)
@pytest.mark.parametrize("n_dims,model_name", [(512, "Qdrant/clip-ViT-B-32-vision")])
def test_parallel_processing(model_cache, n_dims: int, model_name: str) -> None:
with model_cache(model_name) as model:
n_images = 32
test_images = [
TEST_MISC_DIR / "image.jpeg",
str(TEST_MISC_DIR / "small_image.jpeg"),
Image.open(TEST_MISC_DIR / "small_image.jpeg"),
]
images = test_images * n_images
embeddings = list(model.embed(images, batch_size=10, parallel=2))
embeddings = np.stack(embeddings, axis=0)
embeddings_2 = list(model.embed(images, batch_size=10, parallel=None))
embeddings_2 = np.stack(embeddings_2, axis=0)
embeddings_3 = list(model.embed(images, batch_size=10, parallel=0))
embeddings_3 = np.stack(embeddings_3, axis=0)
assert embeddings.shape == (n_images * len(test_images), n_dims)
assert np.allclose(embeddings, embeddings_2, atol=1e-3)
assert np.allclose(embeddings, embeddings_3, atol=1e-3)
@pytest.mark.parametrize("model_name", ["Qdrant/clip-ViT-B-32-vision"])
def test_lazy_load(model_name: str) -> None:
is_ci = os.getenv("CI")
model = ImageEmbedding(model_name=model_name, lazy_load=True)
assert not hasattr(model.model, "model")
images = [
@@ -115,3 +143,33 @@ def test_lazy_load(model_name):
]
list(model.embed(images))
assert hasattr(model.model, "model")
if is_ci:
delete_model_cache(model.model._model_dir)
def test_get_embedding_size() -> None:
assert ImageEmbedding.get_embedding_size(model_name="Qdrant/clip-ViT-B-32-vision") == 512
assert ImageEmbedding.get_embedding_size(model_name="Qdrant/clip-vit-b-32-vision") == 512
def test_embedding_size() -> None:
is_ci = os.getenv("CI")
model_name = "Qdrant/clip-ViT-B-32-vision"
model = ImageEmbedding(model_name=model_name, lazy_load=True)
assert model.embedding_size == 512
model_name = "Qdrant/clip-vit-b-32-vision"
model = ImageEmbedding(model_name=model_name, lazy_load=True)
assert model.embedding_size == 512
if is_ci:
delete_model_cache(model.model._model_dir)
@pytest.mark.parametrize("model_name", ["Qdrant/clip-ViT-B-32-vision"])
def test_session_options(model_cache, model_name) -> None:
with model_cache(model_name) as default_model:
default_session_options = default_model.model.model.get_session_options()
assert default_session_options.enable_cpu_mem_arena is True
model = ImageEmbedding(model_name=model_name, enable_cpu_mem_arena=False)
session_options = model.model.model.get_session_options()
assert session_options.enable_cpu_mem_arena is False
+201 -53
View File
@@ -1,5 +1,5 @@
import os
import shutil
from contextlib import contextmanager
import pytest
import numpy as np
@@ -7,6 +7,7 @@ import numpy as np
from fastembed.late_interaction.late_interaction_text_embedding import (
LateInteractionTextEmbedding,
)
from tests.utils import delete_model_cache, should_test_model
# vectors are abridged and rounded for brevity
CANONICAL_COLUMN_VALUES = {
@@ -28,6 +29,15 @@ CANONICAL_COLUMN_VALUES = {
[-0.07281, 0.04633, -0.04711, 0.00762, -0.07374],
]
),
"jinaai/jina-colbert-v2": np.array(
[
[0.0742, 0.0591, -0.2403, -0.1774, 0.02],
[0.1318, 0.0882, -0.1138, -0.2066, 0.146],
[-0.0183, -0.1354, -0.0139, -0.1079, -0.051],
[0.0003, -0.1184, -0.07, -0.0479, -0.0649],
[0.0766, 0.0452, -0.2343, -0.183, 0.0058],
]
),
}
CANONICAL_QUERY_VALUES = {
@@ -103,85 +113,165 @@ CANONICAL_QUERY_VALUES = {
[-0.03473, 0.04792, -0.07033, 0.02196, -0.05314],
]
),
"jinaai/jina-colbert-v2": np.array(
[
[0.0477, 0.0255, -0.2224, -0.1085, -0.03],
[0.0206, -0.0845, -0.0075, -0.1712, 0.0156],
[-0.0056, -0.0957, -0.0147, -0.1277, -0.0225],
[0.0486, -0.0499, -0.1609, 0.0194, 0.0274],
[0.0481, 0.0253, -0.2278, -0.1126, -0.0294],
[0.0599, -0.0678, -0.0956, -0.0757, 0.0236],
[0.0592, -0.0862, -0.0621, -0.1084, 0.0155],
[0.0874, -0.0714, -0.0772, -0.1414, 0.037],
[0.1009, -0.0552, -0.0669, -0.163, 0.0493],
[0.1135, -0.047, -0.0576, -0.1699, 0.0538],
[0.1228, -0.0428, -0.0507, -0.1725, 0.0562],
[0.1291, -0.0388, -0.042, -0.1753, 0.0569],
[0.1365, -0.0337, -0.0326, -0.1786, 0.0574],
[0.1439, -0.026, -0.024, -0.1831, 0.0574],
[0.1527, -0.0099, -0.0179, -0.1874, 0.057],
[0.1555, 0.0186, -0.023, -0.1801, 0.0539],
[0.1389, 0.054, -0.0345, -0.1636, 0.0429],
[0.1058, 0.0862, -0.0418, -0.1455, 0.0222],
[0.0713, 0.1061, -0.0438, -0.1288, 0.0002],
[0.0453, 0.1143, -0.0457, -0.1119, -0.019],
[0.0346, 0.1131, -0.0487, -0.0952, -0.0338],
[0.0355, 0.1073, -0.0493, -0.0823, -0.0438],
[0.0424, 0.1041, -0.0459, -0.0761, -0.048],
[0.048, 0.102, -0.0421, -0.0718, -0.0477],
[0.0474, 0.0989, -0.0413, -0.0654, -0.0431],
[0.0434, 0.095, -0.0415, -0.0589, -0.0345],
[0.0408, 0.0897, -0.0405, -0.0554, -0.0197],
[0.0433, 0.0811, -0.0407, -0.0545, 0.0055],
[0.0514, 0.0629, -0.0446, -0.0549, 0.0368],
[0.058, 0.048, -0.0527, -0.0607, 0.0568],
[0.0561, 0.0447, -0.0661, -0.0702, 0.0764],
[0.0204, -0.0856, -0.0386, -0.1232, -0.0332],
]
),
}
_MODELS_TO_CACHE = ("answerdotai/answerai-colbert-small-v1",)
MODELS_TO_CACHE = tuple([x.lower() for x in _MODELS_TO_CACHE])
@pytest.fixture(scope="module")
def model_cache():
is_ci = os.getenv("CI")
cache = {}
@contextmanager
def get_model(model_name: str):
lowercase_model_name = model_name.lower()
if lowercase_model_name not in cache:
cache[lowercase_model_name] = LateInteractionTextEmbedding(lowercase_model_name)
yield cache[lowercase_model_name]
if lowercase_model_name not in MODELS_TO_CACHE:
model_inst = cache.pop(lowercase_model_name)
if is_ci:
delete_model_cache(model_inst.model._model_dir)
del model_inst
yield get_model
if is_ci:
for name, model in cache.items():
delete_model_cache(model.model._model_dir)
cache.clear()
docs = ["Hello World"]
def test_batch_embedding():
is_ci = os.getenv("CI")
@pytest.mark.parametrize("model_name", ["answerdotai/answerai-colbert-small-v1"])
def test_batch_embedding(model_cache, model_name: str):
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)
with model_cache(model_name) as model:
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=10e-4)
if is_ci:
shutil.rmtree(model.model._model_dir)
assert np.allclose(value[:, :abridged_dim], expected_result, atol=2e-3)
def test_single_embedding():
@pytest.mark.parametrize("model_name", ["answerdotai/answerai-colbert-small-v1"])
def test_batch_inference_size_same_as_single_inference(model_cache, model_name: str):
with model_cache(model_name) as model:
docs_to_embed = [
"short document",
"A bit longer document, which should not affect the size",
]
result = list(model.embed(docs_to_embed, batch_size=1))
result_2 = list(model.embed(docs_to_embed, batch_size=2))
assert len(result[0]) == len(result_2[0])
@pytest.mark.parametrize("model_name", ["answerdotai/answerai-colbert-small-v1"])
def test_single_embedding(model_cache, 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)))
token_num, abridged_dim = expected_result.shape
assert np.allclose(result[:, :abridged_dim], expected_result, atol=10e-4)
if is_ci:
shutil.rmtree(model.model._model_dir)
with model_cache(model_desc.model) as model:
whole_result = list(model.embed(docs_to_embed, batch_size=6))
assert len(whole_result) == 1
result = whole_result[0]
expected_result = CANONICAL_COLUMN_VALUES[model_desc.model]
token_num, abridged_dim = expected_result.shape
assert np.allclose(result[:, :abridged_dim], expected_result, atol=2e-3)
def test_single_embedding_query():
@pytest.mark.parametrize("model_name", ["answerdotai/answerai-colbert-small-v1"])
def test_single_embedding_query(model_cache, 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():
print("evaluating", model_name)
model = LateInteractionTextEmbedding(model_name=model_name)
result = next(iter(model.query_embed(queries_to_embed)))
token_num, abridged_dim = expected_result.shape
assert np.allclose(result[:, :abridged_dim], expected_result, atol=10e-4)
for model_desc in LateInteractionTextEmbedding._list_supported_models():
if not should_test_model(model_desc, model_name, is_ci, is_manual):
continue
if is_ci:
shutil.rmtree(model.model._model_dir)
print("evaluating", model_desc.model)
with model_cache(model_desc.model) as model:
whole_result = list(model.query_embed(queries_to_embed))
assert len(whole_result) == 1
result = whole_result[0]
expected_result = CANONICAL_QUERY_VALUES[model_desc.model]
token_num, abridged_dim = expected_result.shape
assert np.allclose(result[:, :abridged_dim], expected_result, atol=2e-3)
def test_parallel_processing():
@pytest.mark.parametrize("token_dim,model_name", [(96, "answerdotai/answerai-colbert-small-v1")])
def test_parallel_processing(model_cache, token_dim: int, model_name: str):
with model_cache(model_name) as model:
docs = ["hello world", "flag embedding"] * 100
embeddings = list(model.embed(docs, batch_size=10, parallel=2))
embeddings_2 = list(model.embed(docs, batch_size=10, parallel=None))
# embeddings_3 = list(model.embed(docs, batch_size=10, parallel=0)) # inherits OnnxTextModel which
# # is tested in TextEmbedding, disabling it here to reduce number of requests to hf
# # multiprocessing is enough to test with `parallel=2`, and `parallel=None` is okay to tests since it reuses
# # model from cache
assert len(embeddings) == len(docs) and embeddings[0].shape[-1] == token_dim
for i in range(len(embeddings)):
assert np.allclose(embeddings[i], embeddings_2[i], atol=1e-3)
# assert np.allclose(embeddings[i], embeddings_3[i], atol=1e-3)
@pytest.mark.parametrize("model_name", ["answerdotai/answerai-colbert-small-v1"])
def test_lazy_load(model_name: str):
is_ci = os.getenv("CI")
model = LateInteractionTextEmbedding(model_name="colbert-ir/colbertv2.0")
token_dim = 128
docs = ["hello world", "flag embedding"] * 100
embeddings = list(model.embed(docs, batch_size=10, parallel=2))
embeddings = np.stack(embeddings, axis=0)
embeddings_2 = list(model.embed(docs, batch_size=10, parallel=None))
embeddings_2 = np.stack(embeddings_2, axis=0)
embeddings_3 = list(model.embed(docs, batch_size=10, parallel=0))
embeddings_3 = np.stack(embeddings_3, axis=0)
assert embeddings.shape[0] == len(docs) and embeddings.shape[-1] == token_dim
assert np.allclose(embeddings, embeddings_2, atol=1e-3)
assert np.allclose(embeddings, embeddings_3, atol=1e-3)
if is_ci:
shutil.rmtree(model.model._model_dir)
@pytest.mark.parametrize(
"model_name",
["colbert-ir/colbertv2.0"],
)
def test_lazy_load(model_name):
model = LateInteractionTextEmbedding(model_name=model_name, lazy_load=True)
assert not hasattr(model.model, "model")
@@ -194,3 +284,61 @@ def test_lazy_load(model_name):
model = LateInteractionTextEmbedding(model_name=model_name, lazy_load=True)
list(model.passage_embed(docs))
if is_ci:
delete_model_cache(model.model._model_dir)
def test_get_embedding_size():
model_name = "answerdotai/answerai-colbert-small-v1"
assert LateInteractionTextEmbedding.get_embedding_size(model_name) == 96
model_name = "answerdotai/answerai-ColBERT-small-v1"
assert LateInteractionTextEmbedding.get_embedding_size(model_name) == 96
def test_embedding_size():
is_ci = os.getenv("CI")
model_name = "answerdotai/answerai-colbert-small-v1"
model = LateInteractionTextEmbedding(model_name=model_name, lazy_load=True)
assert model.embedding_size == 96
model_name = "answerdotai/answerai-ColBERT-small-v1"
model = LateInteractionTextEmbedding(model_name=model_name, lazy_load=True)
assert model.embedding_size == 96
if is_ci:
delete_model_cache(model.model._model_dir)
@pytest.mark.parametrize("model_name", ["answerdotai/answerai-ColBERT-small-v1"])
def test_session_options(model_cache, model_name) -> None:
with model_cache(model_name) as default_model:
default_session_options = default_model.model.model.get_session_options()
assert default_session_options.enable_cpu_mem_arena is True
model = LateInteractionTextEmbedding(model_name=model_name, enable_cpu_mem_arena=False)
session_options = model.model.model.get_session_options()
assert session_options.enable_cpu_mem_arena is False
@pytest.mark.parametrize("model_name", ["answerdotai/answerai-colbert-small-v1"])
def test_token_count(model_cache, model_name) -> None:
with model_cache(model_name) as model:
documents = ["short doc", "it is a long document to check attention mask for paddings"]
short_doc_token_count = model.token_count(documents[0])
long_doc_token_count = model.token_count(documents[1])
documents_token_count = model.token_count(documents)
assert short_doc_token_count + long_doc_token_count == documents_token_count
# 2 is 2*DOC_MARKER_TOKEN_ID for each document
assert short_doc_token_count + long_doc_token_count + 2 == model.token_count(
documents, include_extension=True
)
assert short_doc_token_count + long_doc_token_count == model.token_count(
documents, batch_size=1
)
assert short_doc_token_count + long_doc_token_count == model.token_count(
documents, is_doc=False
)
# query min length is 32
assert model.token_count(documents, is_doc=False, include_extension=True) == 64
very_long_query = "It's a very long query which definitely contains more than 32 tokens and we're using it to check whether the method can handle large query properly without cutting it to 32 tokens"
assert model.token_count(very_long_query, is_doc=False, include_extension=True) > 32
+165
View File
@@ -0,0 +1,165 @@
import os
from contextlib import contextmanager
import pytest
from PIL import Image
import numpy as np
from fastembed import LateInteractionMultimodalEmbedding
from tests.config import TEST_MISC_DIR
from tests.utils import delete_model_cache
# 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],
]
),
"Qdrant/colmodernvbert": np.array(
[
[0.11614, -0.15793, -0.11194, 0.0688, 0.08001, 0.10575, -0.07871],
[0.10094, -0.13301, -0.12069, 0.10932, 0.04645, 0.09884, 0.04048],
[0.13106, -0.18613, -0.13469, 0.10566, 0.03659, 0.07712, -0.03916],
[0.09754, -0.09596, -0.04839, 0.14991, 0.05692, 0.10569, -0.08349],
[0.02576, -0.15651, -0.09977, 0.09707, 0.13412, 0.09994, -0.09931],
[-0.06741, -0.1787, -0.19677, -0.07618, 0.13102, -0.02131, -0.02437],
[-0.02776, -0.10187, -0.13793, 0.03835, 0.04766, 0.04701, -0.15635],
]
),
}
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],
]
),
"Qdrant/colmodernvbert": np.array(
[
[0.05, 0.06557, 0.04026, 0.14981, 0.1842, 0.0263, -0.18706],
[-0.05664, -0.14028, 0.00649, -0.02849, 0.09034, -0.01494, 0.10693],
[-0.10147, -0.00716, 0.09084, -0.08236, -0.01849, -0.00972, -0.00461],
[-0.1233, -0.10814, -0.02337, -0.00329, 0.05984, 0.09934, 0.09846],
[-0.07053, -0.13119, -0.06487, 0.01508, 0.07459, 0.07655, 0.14821],
[0.00526, -0.13842, -0.05837, -0.02721, 0.13009, 0.05076, 0.17962],
[0.00924, -0.14383, -0.03057, -0.03691, 0.11718, 0.037, 0.13344],
]
),
}
queries = ["hello world", "flag embedding"]
images = [
TEST_MISC_DIR / "image.jpeg",
str(TEST_MISC_DIR / "image.jpeg"),
Image.open((TEST_MISC_DIR / "image.jpeg")),
]
_MODELS_TO_CACHE = ("Qdrant/colmodernvbert",)
MODELS_TO_CACHE = tuple(model_name.lower() for model_name in _MODELS_TO_CACHE)
@pytest.fixture(scope="module")
def model_cache():
is_ci = os.getenv("CI")
cache = {}
@contextmanager
def get_model(model_name: str):
lowercase_model_name = model_name.lower()
if lowercase_model_name not in cache:
cache[lowercase_model_name] = LateInteractionMultimodalEmbedding(lowercase_model_name)
yield cache[lowercase_model_name]
if lowercase_model_name not in MODELS_TO_CACHE:
model_inst = cache.pop(lowercase_model_name)
if is_ci:
delete_model_cache(model_inst.model._model_dir)
del model_inst
yield get_model
if is_ci:
for _, model in cache.items():
delete_model_cache(model.model._model_dir)
cache.clear()
def test_batch_embedding(model_cache):
for model_name, expected_result in CANONICAL_IMAGE_VALUES.items():
if model_name.lower() == "Qdrant/colpali-v1.3-fp16".lower() and os.getenv("CI"):
continue # colpali is too large for ci
print("evaluating", model_name)
with model_cache(model_name) as model:
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(model_cache):
for model_name, expected_result in CANONICAL_IMAGE_VALUES.items():
if model_name.lower() == "Qdrant/colpali-v1.3-fp16".lower() and os.getenv("CI"):
continue # colpali is too large for ci
print("evaluating", model_name)
with model_cache(model_name) as model:
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(model_cache):
for model_name, expected_result in CANONICAL_QUERY_VALUES.items():
if model_name.lower() == "Qdrant/colpali-v1.3-fp16".lower() and os.getenv("CI"):
continue # colpali is too large for ci
print("evaluating", model_name)
with model_cache(model_name) as model:
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)
def test_get_embedding_size():
model_name = "Qdrant/colpali-v1.3-fp16"
assert LateInteractionMultimodalEmbedding.get_embedding_size(model_name) == 128
model_name = "Qdrant/ColPali-v1.3-fp16"
assert LateInteractionMultimodalEmbedding.get_embedding_size(model_name) == 128
model_name = "Qdrant/colmodernvbert"
assert LateInteractionMultimodalEmbedding.get_embedding_size(model_name) == 128
def test_embedding_size():
model_name = "Qdrant/colmodernvbert"
model = LateInteractionMultimodalEmbedding(model_name=model_name, lazy_load=True)
assert model.embedding_size == 128
def test_token_count(model_cache) -> None:
model_name = "Qdrant/colmodernvbert"
with model_cache(model_name) as model:
documents = ["short doc", "it is a long document to check attention mask for paddings"]
short_doc_token_count = model.token_count(documents[0])
long_doc_token_count = model.token_count(documents[1])
documents_token_count = model.token_count(documents)
assert short_doc_token_count + long_doc_token_count == documents_token_count
assert short_doc_token_count + long_doc_token_count == model.token_count(
documents, batch_size=1
)
assert short_doc_token_count + long_doc_token_count < model.token_count(
documents, include_extension=True
)
+4 -3
View File
@@ -1,4 +1,5 @@
import pytest
from fastembed import (
TextEmbedding,
SparseTextEmbedding,
@@ -13,7 +14,7 @@ CACHE_DIR = "../model_cache"
@pytest.mark.skip(reason="Requires a multi-gpu server")
@pytest.mark.parametrize("device_id", [None, 0, 1])
def test_gpu_via_providers(device_id):
def test_gpu_via_providers(device_id: int | None) -> None:
docs = ["hello world", "flag embedding"]
device_id = device_id if device_id is not None else 0
@@ -85,7 +86,7 @@ def test_gpu_via_providers(device_id):
@pytest.mark.skip(reason="Requires a multi-gpu server")
@pytest.mark.parametrize("device_ids", [None, [0], [1], [0, 1]])
def test_gpu_cuda_device_ids(device_ids):
def test_gpu_cuda_device_ids(device_ids: list[int] | None) -> None:
docs = ["hello world", "flag embedding"]
device_id = device_ids[0] if device_ids else 0
embedding_model = TextEmbedding(
@@ -170,7 +171,7 @@ def test_gpu_cuda_device_ids(device_ids):
@pytest.mark.parametrize(
"device_ids,parallel", [(None, None), (None, 2), ([1], None), ([1], 1), ([1], 2), ([0, 1], 2)]
)
def test_multi_gpu_parallel_inference(device_ids, parallel):
def test_multi_gpu_parallel_inference(device_ids: list[int] | None, parallel: int) -> None:
docs = ["hello world", "flag embedding"] * 100
batch_size = 5
+38
View File
@@ -0,0 +1,38 @@
import numpy as np
from fastembed import LateInteractionTextEmbedding
from fastembed.postprocess import Muvera
CANONICAL_VALUES = [-2.61810007e-04, 1.89005750e00, -2.32070747e00]
CANONICAL_QUERY_VALUES = [
-0.85783903,
1.1077204,
-0.09522747,
] # part of the values are zeros, should be compared with the result of nonzero mask
DIM = 128
K_SIM = 5
DIM_PROJ = 16
R_REPS = 20
def test_single_input():
model = LateInteractionTextEmbedding("colbert-ir/colbertv2.0", lazy_load=True)
random_generator = np.random.default_rng(42)
multivector = random_generator.random((10, 128))
for muvera in (
Muvera(dim=DIM, k_sim=K_SIM, dim_proj=DIM_PROJ, r_reps=R_REPS, random_seed=42),
Muvera.from_multivector_model(model, k_sim=K_SIM, dim_proj=DIM_PROJ, r_reps=R_REPS),
):
fde = muvera.process(multivector)
assert fde.shape[0] == muvera.embedding_size
assert np.allclose(fde[:3], CANONICAL_VALUES)
fde_doc = muvera.process_document(multivector)
assert fde_doc.shape[0] == muvera.embedding_size
assert np.allclose(fde, fde_doc)
fde_query = muvera.process_query(multivector)
assert fde_query.shape[0] == muvera.embedding_size
assert np.allclose(fde_query[np.nonzero(fde_query)][:3], CANONICAL_QUERY_VALUES)
+228 -75
View File
@@ -1,14 +1,15 @@
import os
import shutil
from contextlib import contextmanager
import pytest
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, should_test_model
CANONICAL_COLUMN_VALUES = {
"prithvida/Splade_PP_en_v1": {
"prithivida/Splade_PP_en_v1": {
"indices": [
2040,
2047,
@@ -43,118 +44,228 @@ CANONICAL_COLUMN_VALUES = {
2.1904349327087402,
1.0531445741653442,
],
}
},
"Qdrant/minicoil-v1": {
"indices": [80, 81, 82, 83, 6664, 6665, 6666, 6667],
"values": [
0.52634597,
0.8711344,
1.2264385,
0.52123857,
0.974713,
-0.97803956,
-0.94312465,
-0.12508166,
],
},
}
CANONICAL_QUERY_VALUES = {
"Qdrant/minicoil-v1": {
"indices": [80, 81, 82, 83, 6664, 6665, 6666, 6667],
"values": [
0.31389374,
0.5195128,
0.7314033,
0.3108479,
0.5812834,
-0.5832673,
-0.5624452,
-0.0745942,
],
},
}
_MODELS_TO_CACHE = (
"prithivida/Splade_PP_en_v1",
"Qdrant/minicoil-v1",
"Qdrant/bm25",
"Qdrant/bm42-all-minilm-l6-v2-attentions",
)
MODELS_TO_CACHE = tuple([x.lower() for x in _MODELS_TO_CACHE])
@pytest.fixture(scope="module")
def model_cache():
is_ci = os.getenv("CI")
cache = {}
@contextmanager
def get_model(model_name: str):
lowercase_model_name = model_name.lower()
if lowercase_model_name not in cache:
cache[lowercase_model_name] = SparseTextEmbedding(lowercase_model_name)
yield cache[lowercase_model_name]
if lowercase_model_name not in MODELS_TO_CACHE:
model_inst = cache.pop(lowercase_model_name)
if is_ci:
delete_model_cache(model_inst.model._model_dir)
del model_inst
yield get_model
if is_ci:
for name, model in cache.items():
delete_model_cache(model.model._model_dir)
cache.clear()
docs = ["Hello World"]
def test_batch_embedding():
is_ci = os.getenv("CI")
@pytest.mark.parametrize(
"model_name",
["prithivida/Splade_PP_en_v1", "Qdrant/minicoil-v1"],
)
def test_batch_embedding(model_cache, model_name: str) -> None:
docs_to_embed = docs * 10
for model_name, expected_result in CANONICAL_COLUMN_VALUES.items():
model = SparseTextEmbedding(model_name=model_name)
with model_cache(model_name) as model:
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:
shutil.rmtree(model.model._model_dir)
def test_single_embedding():
def test_single_embedding(model_cache) -> None:
is_ci = os.getenv("CI")
for model_name, expected_result in CANONICAL_COLUMN_VALUES.items():
model = SparseTextEmbedding(model_name=model_name)
is_manual = os.getenv("GITHUB_EVENT_NAME") == "workflow_dispatch"
passage_result = next(iter(model.embed(docs, batch_size=6)))
query_result = next(iter(model.query_embed(docs)))
for result in [passage_result, query_result]:
assert result.indices.tolist() == expected_result["indices"]
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_desc.model, is_ci, is_manual):
continue
for i, value in enumerate(result.values):
with model_cache(model_desc.model) as model:
passage_result = next(iter(model.embed(docs, batch_size=6)))
query_result = next(iter(model.query_embed(docs)))
expected_result = CANONICAL_COLUMN_VALUES[model_desc.model]
expected_query_result = CANONICAL_QUERY_VALUES.get(model_desc.model, expected_result)
assert passage_result.indices.tolist() == expected_result["indices"]
for i, value in enumerate(passage_result.values):
assert pytest.approx(value, abs=0.001) == expected_result["values"][i]
if is_ci:
shutil.rmtree(model.model._model_dir)
assert query_result.indices.tolist() == expected_query_result["indices"]
for i, value in enumerate(query_result.values):
assert pytest.approx(value, abs=0.001) == expected_query_result["values"][i]
def test_parallel_processing():
is_ci = os.getenv("CI")
model = SparseTextEmbedding(model_name="prithivida/Splade_PP_en_v1")
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))
sparse_embeddings = list(model.embed(docs, batch_size=10, parallel=None))
@pytest.mark.parametrize(
"model_name",
["prithivida/Splade_PP_en_v1", "Qdrant/minicoil-v1"],
)
def test_parallel_processing(model_cache, model_name: str) -> None:
with model_cache(model_name) as model:
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)) # inherits OnnxTextModel which
# is tested in TextEmbedding, disabling it here to reduce number of requests to hf
# multiprocessing is enough to test with `parallel=2`, and `parallel=None` is okay to tests since it reuses
# model from cache
sparse_embeddings = list(model.embed(docs, batch_size=10, parallel=None))
assert (
len(sparse_embeddings)
== len(sparse_embeddings_duo)
== len(sparse_embeddings_all)
== len(docs)
)
for sparse_embedding, sparse_embedding_duo, sparse_embedding_all in zip(
sparse_embeddings, sparse_embeddings_duo, sparse_embeddings_all
):
assert (
sparse_embedding.indices.tolist()
== sparse_embedding_duo.indices.tolist()
== sparse_embedding_all.indices.tolist()
len(sparse_embeddings)
== len(sparse_embeddings_duo)
# == len(sparse_embeddings_all)
== len(docs)
)
assert np.allclose(sparse_embedding.values, sparse_embedding_duo.values, atol=1e-3)
assert np.allclose(sparse_embedding.values, sparse_embedding_all.values, atol=1e-3)
if is_ci:
shutil.rmtree(model.model._model_dir)
for (
sparse_embedding,
sparse_embedding_duo,
# sparse_embedding_all
) in zip(
sparse_embeddings,
sparse_embeddings_duo,
# sparse_embeddings_all
):
assert (
sparse_embedding.indices.tolist() == sparse_embedding_duo.indices.tolist()
# == sparse_embedding_all.indices.tolist()
)
assert np.allclose(sparse_embedding.values, sparse_embedding_duo.values, atol=1e-3)
# assert np.allclose(sparse_embedding.values, sparse_embedding_all.values, atol=1e-3)
@pytest.fixture
def bm25_instance():
ci = os.getenv("CI", True)
model = Bm25("Qdrant/bm25", language="english")
yield model
if ci:
shutil.rmtree(model._model_dir)
def test_stem_with_stopwords_and_punctuation(model_cache) -> None:
with model_cache("Qdrant/bm25") as model:
bm25_instance = model.model
# Setup
original_stopwords = bm25_instance.stopwords.copy()
original_punctuation = bm25_instance.punctuation.copy()
bm25_instance.stopwords = {"the", "is", "a"}
bm25_instance.punctuation = {".", ",", "!"}
# Test data
tokens = ["The", "quick", "brown", "fox", "is", "a", "test", "sentence", ".", "!"]
# Execute
result = bm25_instance._stem(tokens)
# Assert
expected = ["quick", "brown", "fox", "test", "sentenc"]
assert result == expected, f"Expected {expected}, but got {result}"
bm25_instance.stopwords = original_stopwords
bm25_instance.punctuation = original_punctuation
def test_stem_with_stopwords_and_punctuation(bm25_instance):
def test_stem_case_insensitive_stopwords(model_cache) -> None:
with model_cache("Qdrant/bm25") as model:
bm25_instance = model.model
original_stopwords = bm25_instance.stopwords.copy()
original_punctuation = bm25_instance.punctuation.copy()
# Setup
bm25_instance.stopwords = {"the", "is", "a"}
bm25_instance.punctuation = {".", ",", "!"}
# Test data
tokens = ["THE", "Quick", "Brown", "Fox", "IS", "A", "Test", "Sentence", ".", "!"]
# Execute
result = bm25_instance._stem(tokens)
# Assert
expected = ["quick", "brown", "fox", "test", "sentenc"]
assert result == expected, f"Expected {expected}, but got {result}"
bm25_instance.stopwords = original_stopwords
bm25_instance.punctuation = original_punctuation
@pytest.mark.parametrize("disable_stemmer", [True, False])
def test_disable_stemmer_behavior(disable_stemmer: bool) -> None:
# Setup
bm25_instance.stopwords = {"the", "is", "a"}
bm25_instance.punctuation = {".", ",", "!"}
model = Bm25("Qdrant/bm25", language="english", disable_stemmer=disable_stemmer)
model.stopwords = {"the", "is", "a"}
model.punctuation = {".", ",", "!"}
# Test data
tokens = ["The", "quick", "brown", "fox", "is", "a", "test", "sentence", ".", "!"]
# Execute
result = bm25_instance._stem(tokens)
result = model._stem(tokens)
# Assert
expected = ["quick", "brown", "fox", "test", "sentenc"]
if disable_stemmer:
expected = ["quick", "brown", "fox", "test", "sentence"] # no stemming, lower case only
else:
expected = ["quick", "brown", "fox", "test", "sentenc"]
assert result == expected, f"Expected {expected}, but got {result}"
def test_stem_case_insensitive_stopwords(bm25_instance):
# Setup
bm25_instance.stopwords = {"the", "is", "a"}
bm25_instance.punctuation = {".", ",", "!"}
# Test data
tokens = ["THE", "Quick", "Brown", "Fox", "IS", "A", "Test", "Sentence", ".", "!"]
# Execute
result = bm25_instance._stem(tokens)
# Assert
expected = ["quick", "brown", "fox", "test", "sentenc"]
assert result == expected, f"Expected {expected}, but got {result}"
@pytest.mark.parametrize(
"model_name",
["prithivida/Splade_PP_en_v1"],
)
def test_lazy_load(model_name):
@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)
assert not hasattr(model.model, "model")
@@ -167,3 +278,45 @@ def test_lazy_load(model_name):
model = SparseTextEmbedding(model_name=model_name, lazy_load=True)
list(model.passage_embed(docs))
if is_ci:
delete_model_cache(model.model._model_dir)
@pytest.mark.parametrize(
"model_name",
[
"prithivida/Splade_PP_en_v1",
"Qdrant/minicoil-v1",
"Qdrant/bm42-all-minilm-l6-v2-attentions",
],
)
def test_session_options(model_cache, model_name) -> None:
with model_cache(model_name) as default_model:
default_session_options = default_model.model.model.get_session_options()
assert default_session_options.enable_cpu_mem_arena is True
model = SparseTextEmbedding(model_name=model_name, enable_cpu_mem_arena=False)
session_options = model.model.model.get_session_options()
assert session_options.enable_cpu_mem_arena is False
@pytest.mark.parametrize(
"model_name",
[
"prithivida/Splade_PP_en_v1",
"Qdrant/minicoil-v1",
"Qdrant/bm42-all-minilm-l6-v2-attentions",
"Qdrant/bm25",
],
)
def test_token_count(model_cache, model_name) -> None:
with model_cache(model_name) as model:
documents = [
"Name me a couple of cities were the capitals of Germany?",
"Berlin is the current capital of Germany, Bonn is a former capital of Germany.",
]
first_doc_token_count = model.token_count(documents[0])
second_doc_token_count = model.token_count(documents[1])
doc_token_count = model.token_count(documents)
assert first_doc_token_count + second_doc_token_count == doc_token_count
assert doc_token_count == model.token_count(documents, batch_size=1)
+119 -39
View File
@@ -1,71 +1,151 @@
import os
from contextlib import contextmanager
import numpy as np
import pytest
import shutil
from fastembed.rerank.cross_encoder import TextCrossEncoder
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]),
"Xenova/ms-marco-MiniLM-L-12-v2": np.array([9.330912, -2.0380247]),
"BAAI/bge-reranker-base": np.array([6.15733337, -3.65939403]),
"jinaai/jina-reranker-v1-tiny-en": np.array([2.5911, 0.1122]),
"jinaai/jina-reranker-v1-turbo-en": np.array([1.8295, -2.8908]),
"jinaai/jina-reranker-v2-base-multilingual": np.array([1.6533, -1.6455]),
}
def test_rerank():
is_ci = os.getenv("CI")
_MODELS_TO_CACHE = ("Xenova/ms-marco-MiniLM-L-6-v2",)
MODELS_TO_CACHE = tuple([x.lower() for x in _MODELS_TO_CACHE])
for model_desc in TextCrossEncoder.list_supported_models():
if not is_ci and model_desc["size_in_GB"] > 1:
@pytest.fixture(scope="module")
def model_cache():
is_ci = os.getenv("CI")
cache = {}
@contextmanager
def get_model(model_name: str):
lowercase_model_name = model_name.lower()
if lowercase_model_name not in cache:
cache[lowercase_model_name] = TextCrossEncoder(lowercase_model_name)
yield cache[lowercase_model_name]
if lowercase_model_name not in MODELS_TO_CACHE:
model_inst = cache.pop(lowercase_model_name)
if is_ci:
delete_model_cache(model_inst.model._model_dir)
del model_inst
yield get_model
if is_ci:
for name, model in cache.items():
delete_model_cache(model.model._model_dir)
cache.clear()
@pytest.mark.parametrize("model_name", ["Xenova/ms-marco-MiniLM-L-6-v2"])
def test_rerank(model_cache, model_name: str) -> None:
is_ci = os.getenv("CI")
is_manual = os.getenv("GITHUB_EVENT_NAME") == "workflow_dispatch"
for model_desc in TextCrossEncoder._list_supported_models():
if not should_test_model(model_desc, model_name, is_ci, is_manual):
continue
model_name = model_desc["model"]
model = TextCrossEncoder(model_name=model_name)
with model_cache(model_desc.model) as model:
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)))
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_desc.model}, Scores: {scores}, Scores2: {scores2}"
canonical_scores = CANONICAL_SCORE_VALUES[model_desc.model]
assert np.allclose(
scores, canonical_scores, atol=1e-3
), f"Model: {model_desc.model}, Scores: {scores}, Expected: {canonical_scores}"
@pytest.mark.parametrize("model_name", ["Xenova/ms-marco-MiniLM-L-6-v2"])
def test_batch_rerank(model_cache, model_name: str) -> None:
with model_cache(model_name) as model:
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)))
documents = ["Paris is the capital of France.", "Berlin is the capital of Germany."] * 50
scores = np.array(list(model.rerank(query, documents, batch_size=10)))
canonical_scores = CANONICAL_SCORE_VALUES[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}"
canonical_scores = np.tile(CANONICAL_SCORE_VALUES[model_name], 50)
assert scores.shape == canonical_scores.shape, f"Unexpected shape for model {model_name}"
assert np.allclose(
scores, canonical_scores, atol=1e-3
), f"Model: {model_name}, Scores: {scores}, Expected: {canonical_scores}"
if is_ci:
shutil.rmtree(model.model._model_dir)
@pytest.mark.parametrize(
"model_name",
["Xenova/ms-marco-MiniLM-L-6-v2", "Xenova/ms-marco-MiniLM-L-12-v2", "BAAI/bge-reranker-base"],
)
def test_batch_rerank(model_name):
@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)
query = "What is the capital of France?"
documents = ["Paris is the capital of France.", "Berlin is the capital of Germany."] * 50
scores = np.array(list(model.rerank(query, documents, batch_size=10)))
canonical_scores = np.tile(CANONICAL_SCORE_VALUES[model_name], 50)
assert scores.shape == canonical_scores.shape, f"Unexpected shape for model {model_name}"
assert np.allclose(
scores, canonical_scores, atol=1e-3
), f"Model: {model_name}, Scores: {scores}, Expected: {canonical_scores}"
if is_ci:
shutil.rmtree(model.model._model_dir)
@pytest.mark.parametrize(
"model_name",
["Xenova/ms-marco-MiniLM-L-6-v2"],
)
def test_lazy_load(model_name):
model = TextCrossEncoder(model_name=model_name, lazy_load=True)
assert not hasattr(model.model, "model")
query = "What is the capital of France?"
documents = ["Paris is the capital of France.", "Berlin is the capital of Germany."]
list(model.rerank(query, documents))
assert hasattr(model.model, "model")
if is_ci:
delete_model_cache(model.model._model_dir)
@pytest.mark.parametrize("model_name", ["Xenova/ms-marco-MiniLM-L-6-v2"])
def test_rerank_pairs_parallel(model_cache, model_name: str) -> None:
with model_cache(model_name) as model:
query = "What is the capital of France?"
documents = ["Paris is the capital of France.", "Berlin is the capital of Germany."] * 10
pairs = [(query, doc) for doc in documents]
scores_parallel = np.array(list(model.rerank_pairs(pairs, parallel=2, batch_size=10)))
scores_sequential = np.array(list(model.rerank_pairs(pairs, batch_size=10)))
assert np.allclose(
scores_parallel, scores_sequential, atol=1e-5
), f"Model: {model_name}, Scores (Parallel): {scores_parallel}, Scores (Sequential): {scores_sequential}"
canonical_scores = CANONICAL_SCORE_VALUES[model_name]
assert np.allclose(
scores_parallel[: len(canonical_scores)], canonical_scores, atol=1e-3
), f"Model: {model_name}, Scores (Parallel): {scores_parallel}, Expected: {canonical_scores}"
@pytest.mark.parametrize("model_name", ["Xenova/ms-marco-MiniLM-L-6-v2"])
def test_token_count(model_cache, model_name: str) -> None:
with model_cache(model_name) as model:
pairs = [
("What is the capital of France?", "Paris is the capital of France."),
(
"Name me a couple of cities were the capitals of Germany?",
"Berlin is the current capital of Germany, Bonn is a former capital of Germany.",
),
]
first_pair_token_count = model.token_count([pairs[0]])
second_pair_token_count = model.token_count([pairs[1]])
pairs_token_count = model.token_count(pairs)
assert first_pair_token_count + second_pair_token_count == pairs_token_count
assert pairs_token_count == model.token_count(pairs, batch_size=1)
@pytest.mark.parametrize("model_name", ["Xenova/ms-marco-MiniLM-L-6-v2"])
def test_session_options(model_cache, model_name) -> None:
with model_cache(model_name) as default_model:
default_session_options = default_model.model.model.get_session_options()
assert default_session_options.enable_cpu_mem_arena is True
model = TextCrossEncoder(model_name=model_name, enable_cpu_mem_arena=False)
session_options = model.model.model.get_session_options()
assert session_options.enable_cpu_mem_arena is False
+241
View File
@@ -0,0 +1,241 @@
import os
import numpy as np
import pytest
from fastembed import TextEmbedding
from fastembed.text.multitask_embedding import JinaEmbeddingV3, Task
from tests.utils import delete_model_cache
CANONICAL_VECTOR_VALUES = {
"jinaai/jina-embeddings-v3": [
{
"task_id": Task.RETRIEVAL_QUERY,
"vectors": np.array(
[
[0.0623, -0.0402, 0.1706, -0.0143, 0.0617],
[-0.1064, -0.0733, 0.0353, 0.0096, 0.0667],
]
),
},
{
"task_id": Task.RETRIEVAL_PASSAGE,
"vectors": np.array(
[
[0.0513, -0.0247, 0.1751, -0.0075, 0.0679],
[-0.0987, -0.0786, 0.09, 0.0087, 0.0577],
]
),
},
{
"task_id": Task.SEPARATION,
"vectors": np.array(
[
[0.094, -0.1065, 0.1305, 0.0547, 0.0556],
[0.0315, -0.1468, 0.065, 0.0568, 0.0546],
]
),
},
{
"task_id": Task.CLASSIFICATION,
"vectors": np.array(
[
[0.0606, -0.0877, 0.1384, 0.0065, 0.0722],
[-0.0502, -0.119, 0.032, 0.0514, 0.0689],
]
),
},
{
"task_id": Task.TEXT_MATCHING,
"vectors": np.array(
[
[0.0911, -0.0341, 0.1305, -0.026, 0.0576],
[-0.1432, -0.05, 0.0133, 0.0464, 0.0789],
]
),
},
]
}
docs = ["Hello World", "Follow the white rabbit."]
@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
model = TextEmbedding(model_name=model_name)
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_name
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 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)
for task in CANONICAL_VECTOR_VALUES[model_name]:
print(f"evaluating {model_name} task_id: {task['task_id']}")
embeddings = list(model.embed(documents=docs, task_id=task["task_id"]))
embeddings = np.stack(embeddings, axis=0)
assert embeddings.shape == (len(docs), dim)
canonical_vector = task["vectors"]
assert np.allclose(
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 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)
print(f"evaluating {model_name} query_embed task_id: {task_id}")
embeddings = list(model.query_embed(query=docs))
embeddings = np.stack(embeddings, axis=0)
assert embeddings.shape == (len(docs), dim)
canonical_vector = CANONICAL_VECTOR_VALUES[model_name][task_id]["vectors"]
assert np.allclose(
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_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 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)
print(f"evaluating {model_name} passage_embed task_id: {task_id}")
embeddings = list(model.passage_embed(texts=docs))
embeddings = np.stack(embeddings, axis=0)
assert embeddings.shape == (len(docs), dim)
canonical_vector = CANONICAL_VECTOR_VALUES[model_name][task_id]["vectors"]
assert np.allclose(
embeddings[:, : canonical_vector.shape[1]], canonical_vector, atol=1e-4
), model_desc.model
if is_ci:
delete_model_cache(model.model._model_dir)
@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 = TextEmbedding(model_name=model_name)
task_id = Task.SEPARATION
embeddings_1 = list(model.embed(docs, batch_size=10, parallel=None, task_id=task_id))
embeddings_1 = np.stack(embeddings_1, axis=0)
embeddings_2 = list(model.embed(docs, batch_size=10, parallel=1, task_id=task_id))
embeddings_2 = np.stack(embeddings_2, axis=0)
assert embeddings_1.shape[0] == len(docs) and embeddings_1.shape[-1] == dim
assert np.allclose(embeddings_1, embeddings_2, atol=1e-4)
canonical_vector = CANONICAL_VECTOR_VALUES[model_name][task_id]["vectors"]
assert np.allclose(embeddings_2[:2, : canonical_vector.shape[1]], canonical_vector, atol=1e-4)
if is_ci:
delete_model_cache(model.model._model_dir)
@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")
list(model.embed(docs))
assert hasattr(model.model, "model")
if is_ci:
delete_model_cache(model.model._model_dir)
+137 -67
View File
@@ -1,10 +1,12 @@
import os
import shutil
import platform
from contextlib import contextmanager
import numpy as np
import pytest
from fastembed.text.text_embedding import TextEmbedding
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]),
@@ -31,25 +33,27 @@ CANONICAL_VECTOR_VALUES = {
[-0.034478, 0.03102, 0.00673, 0.02611, -0.039362]
),
"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2": np.array(
[0.0094, 0.0184, 0.0328, 0.0072, -0.0351]
[0.0361, 0.1862, 0.2776, 0.2461, -0.1904]
),
"intfloat/multilingual-e5-large": np.array([0.0098, 0.0045, 0.0066, -0.0354, 0.0070]),
"intfloat/multilingual-e5-large": np.array([0.4544, -0.0968, 0.1054, -1.3753, 0.1500]),
"sentence-transformers/paraphrase-multilingual-mpnet-base-v2": np.array(
[-0.01341097, 0.0416553, -0.00480805, 0.02844842, 0.0505299]
[0.0047, 0.1334, -0.0102, 0.0714, 0.1930]
),
"jinaai/jina-embeddings-v2-small-en": np.array([-0.0455, -0.0428, -0.0122, 0.0613, 0.0015]),
"jinaai/jina-embeddings-v2-base-en": np.array([-0.0332, -0.0509, 0.0287, -0.0043, -0.0077]),
"jinaai/jina-embeddings-v2-base-de": np.array([-0.0085, 0.0417, 0.0342, 0.0309, -0.0149]),
"jinaai/jina-embeddings-v2-base-code": np.array([0.0145, -0.0164, 0.0136, -0.0170, 0.0734]),
"jinaai/jina-embeddings-v2-base-zh": np.array([0.0381, 0.0286, -0.0231, 0.0052, -0.0151]),
"jinaai/jina-embeddings-v2-base-es": np.array([-0.0108, -0.0092, -0.0373, 0.0171, -0.0301]),
"nomic-ai/nomic-embed-text-v1": np.array([0.3708, 0.2031, -0.3406, -0.2114, -0.3230]),
"nomic-ai/nomic-embed-text-v1.5": np.array(
[-0.15407836, -0.03053198, -3.9138033, 0.1910364, 0.13224715]
),
"nomic-ai/nomic-embed-text-v1.5-Q": np.array(
[-0.12525563, 0.38030425, -3.961622, 0.04176439, -0.0758301]
[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]
@@ -62,80 +66,100 @@ CANONICAL_VECTOR_VALUES = {
),
"snowflake/snowflake-arctic-embed-l": np.array([0.0189, -0.0673, 0.0183, 0.0124, 0.0146]),
"Qdrant/clip-ViT-B-32-text": np.array([0.0083, 0.0103, -0.0138, 0.0199, -0.0069]),
"thenlper/gte-base": np.array([0.0038, 0.0355, 0.0181, 0.0092, 0.0654]),
"jinaai/jina-clip-v1": np.array([-0.0862, -0.0101, -0.0056, 0.0375, -0.0472]),
}
MULTI_TASK_MODELS = ["jinaai/jina-embeddings-v3"]
def test_embedding():
_MODELS_TO_CACHE = ("BAAI/bge-small-en-v1.5",)
MODELS_TO_CACHE = tuple([x.lower() for x in _MODELS_TO_CACHE])
@pytest.fixture(scope="module")
def model_cache():
is_ci = os.getenv("CI")
cache = {}
for model_desc in TextEmbedding.list_supported_models():
if not is_ci and model_desc["size_in_GB"] > 1:
@contextmanager
def get_model(model_name: str):
lowercase_model_name = model_name.lower()
if lowercase_model_name not in cache:
cache[lowercase_model_name] = TextEmbedding(lowercase_model_name)
yield cache[lowercase_model_name]
if lowercase_model_name not in MODELS_TO_CACHE:
model_inst = cache.pop(lowercase_model_name)
if is_ci:
delete_model_cache(model_inst.model._model_dir)
del model_inst
yield get_model
if is_ci:
for name, model in cache.items():
delete_model_cache(model.model._model_dir)
cache.clear()
@pytest.mark.parametrize("model_name", ["BAAI/bge-small-en-v1.5"])
def test_embedding(model_cache, 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 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"])
docs = ["hello world", "flag embedding"]
embeddings = list(model.embed(docs))
with model_cache(model_desc.model) as 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]
assert np.allclose(
embeddings[0, : canonical_vector.shape[0]], canonical_vector, atol=1e-3
), model_desc.model
@pytest.mark.parametrize("n_dims,model_name", [(384, "BAAI/bge-small-en-v1.5")])
def test_batch_embedding(model_cache, n_dims: int, model_name: str) -> None:
with model_cache(model_name) as model:
docs = ["hello world", "flag embedding"] * 100
embeddings = list(model.embed(docs, batch_size=10))
embeddings = np.stack(embeddings, axis=0)
assert embeddings.shape == (2, dim)
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"]
if is_ci:
shutil.rmtree(model.model._model_dir)
assert embeddings.shape == (len(docs), n_dims)
@pytest.mark.parametrize(
"n_dims,model_name",
[(384, "BAAI/bge-small-en-v1.5"), (768, "jinaai/jina-embeddings-v2-base-en")],
)
def test_batch_embedding(n_dims, model_name):
@pytest.mark.parametrize("n_dims,model_name", [(384, "BAAI/bge-small-en-v1.5")])
def test_parallel_processing(model_cache, n_dims: int, model_name: str) -> None:
with model_cache(model_name) as model:
docs = ["hello world", "flag embedding"] * 100
embeddings = list(model.embed(docs, batch_size=10, parallel=2))
embeddings = np.stack(embeddings, axis=0)
embeddings_2 = list(model.embed(docs, batch_size=10, parallel=None))
embeddings_2 = np.stack(embeddings_2, axis=0)
embeddings_3 = list(model.embed(docs, batch_size=10, parallel=0))
embeddings_3 = np.stack(embeddings_3, axis=0)
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)
@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)
docs = ["hello world", "flag embedding"] * 100
embeddings = list(model.embed(docs, batch_size=10))
embeddings = np.stack(embeddings, axis=0)
assert embeddings.shape == (200, n_dims)
if is_ci:
shutil.rmtree(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")],
)
def test_parallel_processing(n_dims, model_name):
is_ci = os.getenv("CI")
model = TextEmbedding(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)
embeddings_2 = list(model.embed(docs, batch_size=10, parallel=None))
embeddings_2 = np.stack(embeddings_2, axis=0)
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 np.allclose(embeddings, embeddings_2, atol=1e-3)
assert np.allclose(embeddings, embeddings_3, atol=1e-3)
if is_ci:
shutil.rmtree(model.model._model_dir)
@pytest.mark.parametrize(
"model_name",
["BAAI/bge-small-en-v1.5"],
)
def test_lazy_load(model_name):
model = TextEmbedding(model_name=model_name, lazy_load=True)
assert not hasattr(model.model, "model")
docs = ["hello world", "flag embedding"]
@@ -147,3 +171,49 @@ def test_lazy_load(model_name):
model = TextEmbedding(model_name=model_name, lazy_load=True)
list(model.passage_embed(docs))
if is_ci:
delete_model_cache(model.model._model_dir)
def test_get_embedding_size() -> None:
assert TextEmbedding.get_embedding_size("sentence-transformers/all-MiniLM-L6-v2") == 384
assert TextEmbedding.get_embedding_size("sentence-transformers/all-minilm-l6-v2") == 384
def test_embedding_size() -> None:
is_ci = os.getenv("CI")
model_name = "sentence-transformers/all-MiniLM-L6-v2"
model = TextEmbedding(model_name=model_name, lazy_load=True)
assert model.embedding_size == 384
model_name = "sentence-transformers/all-minilm-l6-v2"
model = TextEmbedding(model_name=model_name, lazy_load=True)
assert model.embedding_size == 384
if is_ci:
delete_model_cache(model.model._model_dir)
@pytest.mark.parametrize("model_name", ["sentence-transformers/all-MiniLM-L6-v2"])
def test_session_options(model_cache, model_name) -> None:
with model_cache(model_name) as default_model:
default_session_options = default_model.model.model.get_session_options()
assert default_session_options.enable_cpu_mem_arena is True
model = TextEmbedding(model_name=model_name, enable_cpu_mem_arena=False)
session_options = model.model.model.get_session_options()
assert session_options.enable_cpu_mem_arena is False
@pytest.mark.parametrize("model_name", ["sentence-transformers/all-MiniLM-L6-v2"])
def test_token_count(model_cache, model_name) -> None:
with model_cache(model_name) as model:
documents = [
"Name me a couple of cities were the capitals of Germany?",
"Berlin is the current capital of Germany, Bonn is a former capital of Germany.",
]
first_doc_token_count = model.token_count(documents[0])
second_doc_token_count = model.token_count(documents[1])
doc_token_count = model.token_count(documents)
assert first_doc_token_count + second_doc_token_count == doc_token_count
assert doc_token_count == model.token_count(documents, batch_size=1)
+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=[""])
+67
View File
@@ -0,0 +1,67 @@
import shutil
import traceback
from pathlib import Path
from types import TracebackType
from typing import Callable, Any, Type
from fastembed.common.model_description import BaseModelDescription
def delete_model_cache(model_dir: str | Path) -> None:
"""Delete the model cache directory.
If a model was downloaded from the HuggingFace model hub, then _model_dir is the dir to snapshots, removing
it won't help to release the memory, because data is in blobs directory.
If a model was downloaded from GCS, then we can just remove model_dir
Args:
model_dir (Union[str, Path]): The path to the model cache directory.
"""
def on_error(
func: Callable[..., Any],
path: str,
exc_info: tuple[Type[BaseException], BaseException, TracebackType],
) -> None:
print("Failed to remove: ", path)
print("Exception: ", exc_info)
traceback.print_exception(*exc_info)
if isinstance(model_dir, str):
model_dir = Path(model_dir)
if model_dir.parent.parent.name.startswith("models--"):
model_dir = model_dir.parent.parent
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: str | None,
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 scenarios.
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