From 7412375d27fcea48197fac433f19407fcb88452b Mon Sep 17 00:00:00 2001 From: Mirochill <200482516+Mirochill@users.noreply.github.com> Date: Wed, 27 May 2026 11:08:32 +0200 Subject: [PATCH 1/8] Avoid ONNX session load for token counts --- fastembed/late_interaction/colbert.py | 9 +++++++-- .../late_interaction_multimodal/colmodernvbert.py | 4 ++-- fastembed/late_interaction_multimodal/colpali.py | 4 ++-- .../onnx_multimodal_model.py | 5 ++++- fastembed/rerank/cross_encoder/onnx_text_model.py | 12 +++++++++--- fastembed/sparse/bm42.py | 2 -- fastembed/text/onnx_text_model.py | 12 +++++++++--- tests/test_late_interaction_embeddings.py | 2 ++ tests/test_text_cross_encoder.py | 3 +++ tests/test_text_onnx_embeddings.py | 2 ++ 10 files changed, 40 insertions(+), 15 deletions(-) diff --git a/fastembed/late_interaction/colbert.py b/fastembed/late_interaction/colbert.py index 2db5c9c45..65cbe95eb 100644 --- a/fastembed/late_interaction/colbert.py +++ b/fastembed/late_interaction/colbert.py @@ -104,8 +104,10 @@ def token_count( 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 + if not hasattr(self, "tokenizer") or self.tokenizer is None: + self._load_tokenizer(model_dir=self._model_dir) + if self.query_tokenizer is None: + self._load_query_tokenizer() token_num = 0 texts = [texts] if isinstance(texts, str) else texts tokenizer = self.tokenizer if is_doc else self.query_tokenizer @@ -218,6 +220,9 @@ def load_onnx_model(self) -> None: device_id=self.device_id, extra_session_options=self._extra_session_options, ) + self._load_query_tokenizer() + + def _load_query_tokenizer(self) -> None: self.query_tokenizer, _ = load_tokenizer(model_dir=self._model_dir) assert self.tokenizer is not None diff --git a/fastembed/late_interaction_multimodal/colmodernvbert.py b/fastembed/late_interaction_multimodal/colmodernvbert.py index 20b8e4f7b..90570dc85 100644 --- a/fastembed/late_interaction_multimodal/colmodernvbert.py +++ b/fastembed/late_interaction_multimodal/colmodernvbert.py @@ -203,8 +203,8 @@ def token_count( 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 + if not hasattr(self, "tokenizer") or self.tokenizer is None: + self._load_tokenizer(model_dir=self._model_dir) token_num = 0 texts = [texts] if isinstance(texts, str) else texts assert self.tokenizer is not None diff --git a/fastembed/late_interaction_multimodal/colpali.py b/fastembed/late_interaction_multimodal/colpali.py index 075f1ef76..b993db03b 100644 --- a/fastembed/late_interaction_multimodal/colpali.py +++ b/fastembed/late_interaction_multimodal/colpali.py @@ -180,8 +180,8 @@ def token_count( 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 + if not hasattr(self, "tokenizer") or self.tokenizer is None: + self._load_tokenizer(model_dir=self._model_dir) token_num = 0 texts = [texts] if isinstance(texts, str) else texts assert self.tokenizer is not None diff --git a/fastembed/late_interaction_multimodal/onnx_multimodal_model.py b/fastembed/late_interaction_multimodal/onnx_multimodal_model.py index 934368957..e603a450d 100644 --- a/fastembed/late_interaction_multimodal/onnx_multimodal_model.py +++ b/fastembed/late_interaction_multimodal/onnx_multimodal_model.py @@ -75,10 +75,13 @@ def _load_onnx_model( device_id=device_id, extra_session_options=extra_session_options, ) - self.tokenizer, self.special_token_to_id = load_tokenizer(model_dir=model_dir) + self._load_tokenizer(model_dir=model_dir) assert self.tokenizer is not None self.processor = load_preprocessor(model_dir=model_dir) + def _load_tokenizer(self, model_dir: Path) -> None: + 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") diff --git a/fastembed/rerank/cross_encoder/onnx_text_model.py b/fastembed/rerank/cross_encoder/onnx_text_model.py index e32064d8f..e513a0399 100644 --- a/fastembed/rerank/cross_encoder/onnx_text_model.py +++ b/fastembed/rerank/cross_encoder/onnx_text_model.py @@ -44,9 +44,12 @@ def _load_onnx_model( device_id=device_id, extra_session_options=extra_session_options, ) - self.tokenizer, _ = load_tokenizer(model_dir=model_dir) + self._load_tokenizer(model_dir=model_dir) assert self.tokenizer is not None + def _load_tokenizer(self, model_dir: Path) -> None: + self.tokenizer, _ = load_tokenizer(model_dir=model_dir) + def tokenize(self, pairs: list[tuple[str, str]], **_: Any) -> list[Encoding]: return self.tokenizer.encode_batch(pairs) # type: ignore[union-attr] @@ -169,8 +172,11 @@ def _preprocess_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 + if not hasattr(self, "tokenizer") or self.tokenizer is None: + model_dir = getattr(self, "_model_dir", None) + if model_dir is None: + raise ValueError("Tokenizer cannot be loaded before model files are resolved.") + self._load_tokenizer(model_dir=Path(model_dir)) token_num = 0 assert self.tokenizer is not None diff --git a/fastembed/sparse/bm42.py b/fastembed/sparse/bm42.py index 2b090f749..b554ccb21 100644 --- a/fastembed/sparse/bm42.py +++ b/fastembed/sparse/bm42.py @@ -355,8 +355,6 @@ def _get_worker_class(cls) -> Type[TextEmbeddingWorker[SparseEmbedding]]: 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) diff --git a/fastembed/text/onnx_text_model.py b/fastembed/text/onnx_text_model.py index feeb70d55..e30c6bdf3 100644 --- a/fastembed/text/onnx_text_model.py +++ b/fastembed/text/onnx_text_model.py @@ -38,6 +38,9 @@ def __init__(self) -> None: self.tokenizer: Tokenizer | None = None self.special_token_to_id: dict[str, int] = {} + def _load_tokenizer(self, model_dir: Path) -> None: + self.tokenizer, self.special_token_to_id = load_tokenizer(model_dir=model_dir) + def _preprocess_onnx_input( self, onnx_input: dict[str, NumpyArray], **kwargs: Any ) -> dict[str, NumpyArray | NDArray[np.int64]]: @@ -65,7 +68,7 @@ def _load_onnx_model( device_id=device_id, extra_session_options=extra_session_options, ) - self.tokenizer, self.special_token_to_id = load_tokenizer(model_dir=model_dir) + self._load_tokenizer(model_dir=model_dir) def load_onnx_model(self) -> None: raise NotImplementedError("Subclasses must implement this method") @@ -168,8 +171,11 @@ def _embed_documents( 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 + if not hasattr(self, "tokenizer") or self.tokenizer is None: + model_dir = getattr(self, "_model_dir", None) + if model_dir is None: + raise ValueError("Tokenizer cannot be loaded before model files are resolved.") + self._load_tokenizer(model_dir=Path(model_dir)) token_num = 0 assert self.tokenizer is not None diff --git a/tests/test_late_interaction_embeddings.py b/tests/test_late_interaction_embeddings.py index ea83e76a3..0e433fce8 100644 --- a/tests/test_late_interaction_embeddings.py +++ b/tests/test_late_interaction_embeddings.py @@ -276,6 +276,8 @@ def test_lazy_load(model_name: str): assert not hasattr(model.model, "model") docs = ["hello world", "flag embedding"] + assert model.token_count(docs, is_doc=False, include_extension=True) > 0 + assert not hasattr(model.model, "model") list(model.embed(docs)) assert hasattr(model.model, "model") diff --git a/tests/test_text_cross_encoder.py b/tests/test_text_cross_encoder.py index 00499e10d..a29fe9d12 100644 --- a/tests/test_text_cross_encoder.py +++ b/tests/test_text_cross_encoder.py @@ -108,6 +108,9 @@ def test_lazy_load(model_name: str) -> None: 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."] + pairs = [(query, doc) for doc in documents] + assert model.token_count(pairs) > 0 + assert not hasattr(model.model, "model") list(model.rerank(query, documents)) assert hasattr(model.model, "model") diff --git a/tests/test_text_onnx_embeddings.py b/tests/test_text_onnx_embeddings.py index 51e476750..571ccd2eb 100644 --- a/tests/test_text_onnx_embeddings.py +++ b/tests/test_text_onnx_embeddings.py @@ -266,6 +266,8 @@ def test_lazy_load(model_name: str) -> None: model = TextEmbedding(model_name=model_name, lazy_load=True) assert not hasattr(model.model, "model") docs = ["hello world", "flag embedding"] + assert model.token_count(docs) > 0 + assert not hasattr(model.model, "model") list(model.embed(docs)) assert hasattr(model.model, "model") From c8ea84a869b56e8bd1563e09dad7f99b3df0b0ac Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Tue, 29 Sep 2026 16:09:20 +0700 Subject: [PATCH 2/8] refactor: centralize lazy tokenizer loading for token_count --- fastembed/late_interaction/colbert.py | 17 ++++++++++------- .../colmodernvbert.py | 3 +-- .../late_interaction_multimodal/colpali.py | 3 +-- .../onnx_multimodal_model.py | 12 ++++++++++++ .../rerank/cross_encoder/onnx_text_model.py | 18 ++++++++++++------ fastembed/text/onnx_text_model.py | 19 +++++++++++++------ tests/test_attention_embeddings.py | 2 ++ tests/test_late_interaction_multimodal.py | 12 ++++++++++++ tests/test_sparse_embeddings.py | 2 ++ tests/test_text_multitask_embeddings.py | 3 +++ 10 files changed, 68 insertions(+), 23 deletions(-) diff --git a/fastembed/late_interaction/colbert.py b/fastembed/late_interaction/colbert.py index 65cbe95eb..44af3bda2 100644 --- a/fastembed/late_interaction/colbert.py +++ b/fastembed/late_interaction/colbert.py @@ -1,4 +1,5 @@ import string +from pathlib import Path from typing import Any, Iterable, Sequence, Type import numpy as np @@ -104,10 +105,7 @@ def token_count( include_extension: bool = False, **kwargs: Any, ) -> int: - if not hasattr(self, "tokenizer") or self.tokenizer is None: - self._load_tokenizer(model_dir=self._model_dir) - if self.query_tokenizer is None: - self._load_query_tokenizer() + self._ensure_tokenizer() token_num = 0 texts = [texts] if isinstance(texts, str) else texts tokenizer = self.tokenizer if is_doc else self.query_tokenizer @@ -220,10 +218,15 @@ def load_onnx_model(self) -> None: device_id=self.device_id, extra_session_options=self._extra_session_options, ) - self._load_query_tokenizer() - def _load_query_tokenizer(self) -> None: - self.query_tokenizer, _ = load_tokenizer(model_dir=self._model_dir) + def _load_tokenizer(self, model_dir: Path) -> None: + """Load the document and the query tokenizers, and apply colbert's own configuration. + + Both tokenizers are always (re)loaded from disk here, so the truncation length is + derived from the on-disk value and never decremented twice. + """ + super()._load_tokenizer(model_dir=model_dir) + self.query_tokenizer, _ = load_tokenizer(model_dir=model_dir) assert self.tokenizer is not None # load_tokenizer always enables both diff --git a/fastembed/late_interaction_multimodal/colmodernvbert.py b/fastembed/late_interaction_multimodal/colmodernvbert.py index 90570dc85..8ed6b271c 100644 --- a/fastembed/late_interaction_multimodal/colmodernvbert.py +++ b/fastembed/late_interaction_multimodal/colmodernvbert.py @@ -203,8 +203,7 @@ def token_count( include_extension: bool = False, **kwargs: Any, ) -> int: - if not hasattr(self, "tokenizer") or self.tokenizer is None: - self._load_tokenizer(model_dir=self._model_dir) + self._ensure_tokenizer() token_num = 0 texts = [texts] if isinstance(texts, str) else texts assert self.tokenizer is not None diff --git a/fastembed/late_interaction_multimodal/colpali.py b/fastembed/late_interaction_multimodal/colpali.py index b993db03b..24a4399ee 100644 --- a/fastembed/late_interaction_multimodal/colpali.py +++ b/fastembed/late_interaction_multimodal/colpali.py @@ -180,8 +180,7 @@ def token_count( include_extension: bool = False, **kwargs: Any, ) -> int: - if not hasattr(self, "tokenizer") or self.tokenizer is None: - self._load_tokenizer(model_dir=self._model_dir) + self._ensure_tokenizer() token_num = 0 texts = [texts] if isinstance(texts, str) else texts assert self.tokenizer is not None diff --git a/fastembed/late_interaction_multimodal/onnx_multimodal_model.py b/fastembed/late_interaction_multimodal/onnx_multimodal_model.py index e603a450d..cfdda1793 100644 --- a/fastembed/late_interaction_multimodal/onnx_multimodal_model.py +++ b/fastembed/late_interaction_multimodal/onnx_multimodal_model.py @@ -18,6 +18,9 @@ class OnnxMultimodalModel(OnnxModel[T]): + # set by the concrete model classes in their __init__ + _model_dir: Path + ONNX_OUTPUT_NAMES: list[str] | None = None def __init__(self) -> None: @@ -82,6 +85,15 @@ def _load_onnx_model( def _load_tokenizer(self, model_dir: Path) -> None: self.tokenizer, self.special_token_to_id = load_tokenizer(model_dir=model_dir) + def _ensure_tokenizer(self) -> None: + """Load the tokenizer if it has not been loaded yet, leaving the onnx model alone. + + Tokenizer files are lightweight, so counting tokens does not have to resolve + `lazy_load` into a full onnx session. + """ + if getattr(self, "tokenizer", None) is None: + self._load_tokenizer(model_dir=self._model_dir) + def load_onnx_model(self) -> None: raise NotImplementedError("Subclasses must implement this method") diff --git a/fastembed/rerank/cross_encoder/onnx_text_model.py b/fastembed/rerank/cross_encoder/onnx_text_model.py index e513a0399..f0c46ff08 100644 --- a/fastembed/rerank/cross_encoder/onnx_text_model.py +++ b/fastembed/rerank/cross_encoder/onnx_text_model.py @@ -20,6 +20,8 @@ class OnnxCrossEncoderModel(OnnxModel[float]): ONNX_OUTPUT_NAMES: list[str] | None = None + # set by the concrete model classes in their __init__ + _model_dir: Path @classmethod def _get_worker_class(cls) -> Type["TextRerankerWorker"]: @@ -50,6 +52,15 @@ def _load_onnx_model( def _load_tokenizer(self, model_dir: Path) -> None: self.tokenizer, _ = load_tokenizer(model_dir=model_dir) + def _ensure_tokenizer(self) -> None: + """Load the tokenizer if it has not been loaded yet, leaving the onnx model alone. + + Tokenizer files are lightweight, so counting tokens does not have to resolve + `lazy_load` into a full onnx session. + """ + if getattr(self, "tokenizer", None) is None: + self._load_tokenizer(model_dir=self._model_dir) + def tokenize(self, pairs: list[tuple[str, str]], **_: Any) -> list[Encoding]: return self.tokenizer.encode_batch(pairs) # type: ignore[union-attr] @@ -172,12 +183,7 @@ def _preprocess_onnx_input( def _token_count( self, pairs: Iterable[tuple[str, str]], batch_size: int = 1024, **_: Any ) -> int: - if not hasattr(self, "tokenizer") or self.tokenizer is None: - model_dir = getattr(self, "_model_dir", None) - if model_dir is None: - raise ValueError("Tokenizer cannot be loaded before model files are resolved.") - self._load_tokenizer(model_dir=Path(model_dir)) - + self._ensure_tokenizer() token_num = 0 assert self.tokenizer is not None for batch in iter_batch(pairs, batch_size): diff --git a/fastembed/text/onnx_text_model.py b/fastembed/text/onnx_text_model.py index e30c6bdf3..f368e5125 100644 --- a/fastembed/text/onnx_text_model.py +++ b/fastembed/text/onnx_text_model.py @@ -15,6 +15,9 @@ class OnnxTextModel(OnnxModel[T]): + # set by the concrete model classes in their __init__ + _model_dir: Path + ONNX_OUTPUT_NAMES: list[str] | None = None @classmethod @@ -41,6 +44,15 @@ def __init__(self) -> None: def _load_tokenizer(self, model_dir: Path) -> None: self.tokenizer, self.special_token_to_id = load_tokenizer(model_dir=model_dir) + def _ensure_tokenizer(self) -> None: + """Load the tokenizer if it has not been loaded yet, leaving the onnx model alone. + + Tokenizer files are lightweight, so counting tokens does not have to resolve + `lazy_load` into a full onnx session. + """ + if getattr(self, "tokenizer", None) is None: + self._load_tokenizer(model_dir=self._model_dir) + def _preprocess_onnx_input( self, onnx_input: dict[str, NumpyArray], **kwargs: Any ) -> dict[str, NumpyArray | NDArray[np.int64]]: @@ -171,12 +183,7 @@ def _embed_documents( 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, "tokenizer") or self.tokenizer is None: - model_dir = getattr(self, "_model_dir", None) - if model_dir is None: - raise ValueError("Tokenizer cannot be loaded before model files are resolved.") - self._load_tokenizer(model_dir=Path(model_dir)) - + self._ensure_tokenizer() token_num = 0 assert self.tokenizer is not None texts = [texts] if isinstance(texts, str) else texts diff --git a/tests/test_attention_embeddings.py b/tests/test_attention_embeddings.py index 8f62af435..5b0ce6d2a 100644 --- a/tests/test_attention_embeddings.py +++ b/tests/test_attention_embeddings.py @@ -162,6 +162,8 @@ 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"] + assert model.token_count(docs) > 0 + assert not hasattr(model.model, "model") list(model.embed(docs)) assert hasattr(model.model, "model") diff --git a/tests/test_late_interaction_multimodal.py b/tests/test_late_interaction_multimodal.py index dc97a7250..70b571aab 100644 --- a/tests/test_late_interaction_multimodal.py +++ b/tests/test_late_interaction_multimodal.py @@ -162,6 +162,18 @@ def test_embedding_size(): assert model.embedding_size == 128 +def test_lazy_load() -> None: + model_name = "Qdrant/colmodernvbert" + model = LateInteractionMultimodalEmbedding(model_name=model_name, lazy_load=True) + assert not hasattr(model.model, "model") + + assert model.token_count(queries) > 0 + assert not hasattr(model.model, "model") + + list(model.embed_text(queries)) + assert hasattr(model.model, "model") + + def test_token_count(model_cache) -> None: model_name = "Qdrant/colmodernvbert" with model_cache(model_name) as model: diff --git a/tests/test_sparse_embeddings.py b/tests/test_sparse_embeddings.py index 9fdbb84d7..8457b65c8 100644 --- a/tests/test_sparse_embeddings.py +++ b/tests/test_sparse_embeddings.py @@ -415,6 +415,8 @@ def test_lazy_load(model_name: str) -> None: assert not hasattr(model.model, "model") docs = ["hello world", "flag embedding"] + assert model.token_count(docs) > 0 + assert not hasattr(model.model, "model") list(model.embed(docs)) assert hasattr(model.model, "model") diff --git a/tests/test_text_multitask_embeddings.py b/tests/test_text_multitask_embeddings.py index f246b489f..886a71251 100644 --- a/tests/test_text_multitask_embeddings.py +++ b/tests/test_text_multitask_embeddings.py @@ -234,6 +234,9 @@ def test_lazy_load(model_name: str): model = TextEmbedding(model_name=model_name, lazy_load=True) assert not hasattr(model.model, "model") + assert model.token_count(docs) > 0 + assert not hasattr(model.model, "model") + list(model.embed(docs)) assert hasattr(model.model, "model") From 4d2815586c2ccf8c7e2a9febe07a2dda7320694b Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Tue, 29 Sep 2026 16:35:38 +0700 Subject: [PATCH 3/8] new: use tokenizer hooks in siglip, if_splade, and bm42 --- fastembed/sparse/bm42.py | 12 ++++++++---- fastembed/sparse/if_splade.py | 13 +++---------- fastembed/text/siglip_embedding.py | 27 ++++++++++++++------------- tests/test_attention_embeddings.py | 1 + tests/test_sparse_embeddings.py | 5 +++-- 5 files changed, 29 insertions(+), 29 deletions(-) diff --git a/fastembed/sparse/bm42.py b/fastembed/sparse/bm42.py index b554ccb21..3172c1133 100644 --- a/fastembed/sparse/bm42.py +++ b/fastembed/sparse/bm42.py @@ -141,6 +141,13 @@ def __init__( if not self.lazy_load: self.load_onnx_model() + def _load_tokenizer(self, model_dir: Path) -> None: + super()._load_tokenizer(model_dir=model_dir) + # `_reconstruct_bpe` needs the special tokens, and it is also used by `query_embed`, + # which runs no inference, so they are derived here rather than in `load_onnx_model` + self.special_tokens = set(self.special_token_to_id.keys()) + self.special_tokens_ids = set(self.special_token_to_id.values()) + def load_onnx_model(self) -> None: self._load_onnx_model( model_dir=self._model_dir, @@ -154,8 +161,6 @@ def load_onnx_model(self) -> 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)) def _filter_pair_tokens(self, tokens: list[tuple[str, Any]]) -> list[tuple[str, Any]]: @@ -336,8 +341,7 @@ def query_embed(self, query: str | Iterable[str], **kwargs: Any) -> Iterable[Spa if isinstance(query, str): query = [query] - if not hasattr(self, "model") or self.model is None: - self.load_onnx_model() + self._ensure_tokenizer() for text in query: encoded = self.tokenizer.encode(text) # type: ignore[union-attr] diff --git a/fastembed/sparse/if_splade.py b/fastembed/sparse/if_splade.py index 51767e85d..9947d9221 100644 --- a/fastembed/sparse/if_splade.py +++ b/fastembed/sparse/if_splade.py @@ -6,9 +6,8 @@ from fastembed.common import OnnxProvider from fastembed.common.model_description import ModelSource, SparseModelDescription from fastembed.common.onnx_model import OnnxOutputContext -from fastembed.common.preprocessor_utils import load_tokenizer from fastembed.common.types import Device -from fastembed.common.utils import define_cache_dir, iter_batch +from fastembed.common.utils import define_cache_dir from fastembed.sparse.sparse_embedding_base import ( SparseEmbedding, SparseTextEmbeddingBase, @@ -69,13 +68,7 @@ def _post_process_onnx_output( def token_count( self, texts: str | Iterable[str], batch_size: int = 1024, **kwargs: Any ) -> int: - # unlike `OnnxTextModel._token_count`, does not require the onnx model to be loaded - token_num = 0 - texts = [texts] if isinstance(texts, str) else texts - for batch in iter_batch(texts, batch_size): - for tokens in self.tokenizer.encode_batch(batch): # type: ignore[union-attr] - token_num += sum(tokens.attention_mask) - return token_num + return self._token_count(texts, batch_size=batch_size, **kwargs) @classmethod def _list_supported_models(cls) -> list[SparseModelDescription]: @@ -151,7 +144,7 @@ def __init__( # The tokenizer and the idf table are lightweight and are required for query embedding, # which does not involve any model inference, so they are loaded eagerly, while # `lazy_load` only defers the initialization of the onnx model - self.tokenizer, self.special_token_to_id = load_tokenizer(model_dir=self._model_dir) + self._load_tokenizer(model_dir=self._model_dir) self.special_tokens_ids: set[int] = set(self.special_token_to_id.values()) self._token_id_to_idf = self._load_idf() diff --git a/fastembed/text/siglip_embedding.py b/fastembed/text/siglip_embedding.py index c56ef99f1..f1659c1df 100644 --- a/fastembed/text/siglip_embedding.py +++ b/fastembed/text/siglip_embedding.py @@ -1,3 +1,4 @@ +from pathlib import Path from typing import Any, Type from fastembed.common.model_description import DenseModelDescription, ModelSource @@ -41,19 +42,19 @@ def _get_worker_class(cls) -> Type[OnnxTextEmbeddingWorker]: def _list_supported_models(cls) -> list[DenseModelDescription]: return supported_siglip_models - def load_onnx_model(self) -> None: - super().load_onnx_model() - if self.tokenizer is not None: - truncation = self.tokenizer.truncation - padding = self.tokenizer.padding - if truncation and padding and padding.get("length") is None: - self.tokenizer.enable_padding( - direction=padding["direction"], - pad_id=padding["pad_id"], - pad_type_id=padding["pad_type_id"], - pad_token=padding["pad_token"], - length=truncation["max_length"], - ) + def _load_tokenizer(self, model_dir: Path) -> None: + super()._load_tokenizer(model_dir=model_dir) + assert self.tokenizer is not None + truncation = self.tokenizer.truncation + padding = self.tokenizer.padding + if truncation and padding and padding.get("length") is None: + self.tokenizer.enable_padding( + direction=padding["direction"], + pad_id=padding["pad_id"], + pad_type_id=padding["pad_type_id"], + pad_token=padding["pad_token"], + length=truncation["max_length"], + ) class SiglipTextEmbeddingWorker(OnnxTextEmbeddingWorker): diff --git a/tests/test_attention_embeddings.py b/tests/test_attention_embeddings.py index 5b0ce6d2a..8e539009f 100644 --- a/tests/test_attention_embeddings.py +++ b/tests/test_attention_embeddings.py @@ -169,6 +169,7 @@ def test_lazy_load(model_name: str) -> None: model = SparseTextEmbedding(model_name=model_name, lazy_load=True) list(model.query_embed(docs)) + assert not hasattr(model.model, "model") # query embedding does not require inference model = SparseTextEmbedding(model_name=model_name, lazy_load=True) list(model.passage_embed(docs)) diff --git a/tests/test_sparse_embeddings.py b/tests/test_sparse_embeddings.py index 8457b65c8..1a0e2df66 100644 --- a/tests/test_sparse_embeddings.py +++ b/tests/test_sparse_embeddings.py @@ -399,8 +399,9 @@ def test_if_splade_query_embed_is_inference_free() -> None: lazy_load=True, ) embeddings = list(model.query_embed(["hello world", "flag embedding"])) - # queries are embedded with a tokenizer and an idf lookup table only, - # the onnx model must stay unloaded + assert model.token_count(["hello world", "flag embedding"]) > 0 + # queries are embedded with a tokenizer and an idf lookup table only, and token counting + # needs the tokenizer alone, so the onnx model must stay unloaded assert not hasattr(model.model, "model") assert all(len(embedding.indices) > 0 for embedding in embeddings) From 9c516b7a625db16c49c59462f1b5720329fe3e1b Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Tue, 29 Sep 2026 19:19:20 +0700 Subject: [PATCH 4/8] fix: build post-processing state lazily for bm42, minicoil, and colbert --- fastembed/late_interaction/colbert.py | 4 ++++ fastembed/sparse/bm42.py | 11 ++++++++--- fastembed/sparse/minicoil.py | 23 ++++++++++++++++++++--- tests/test_late_interaction_multimodal.py | 5 ++--- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/fastembed/late_interaction/colbert.py b/fastembed/late_interaction/colbert.py index 44af3bda2..63aa8bf28 100644 --- a/fastembed/late_interaction/colbert.py +++ b/fastembed/late_interaction/colbert.py @@ -56,6 +56,10 @@ def _post_process_onnx_output( "input_ids and attention_mask must be provided for document post-processing" ) + # with `lazy_load` and `parallel`, inference runs in the workers and the parent + # never calls `load_onnx_model`, so `skip_list` and `pad_token_id` might not be set yet + self._ensure_tokenizer() + 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: diff --git a/fastembed/sparse/bm42.py b/fastembed/sparse/bm42.py index 3172c1133..9f99bc110 100644 --- a/fastembed/sparse/bm42.py +++ b/fastembed/sparse/bm42.py @@ -144,9 +144,12 @@ def __init__( def _load_tokenizer(self, model_dir: Path) -> None: super()._load_tokenizer(model_dir=model_dir) # `_reconstruct_bpe` needs the special tokens, and it is also used by `query_embed`, - # which runs no inference, so they are derived here rather than in `load_onnx_model` + # which runs no inference, so they are derived here rather than in `load_onnx_model`. + # The same goes for `invert_vocab`: with `lazy_load` and `parallel`, post-processing + # runs in a parent process which never loads the onnx model. self.special_tokens = set(self.special_token_to_id.keys()) self.special_tokens_ids = set(self.special_token_to_id.values()) + self.invert_vocab = {idx: token for token, idx in self.tokenizer.get_vocab().items()} # type: ignore[union-attr] def load_onnx_model(self) -> None: self._load_onnx_model( @@ -159,8 +162,6 @@ def load_onnx_model(self) -> None: extra_session_options=self._extra_session_options, ) - for token, idx in self.tokenizer.get_vocab().items(): # type: ignore[union-attr] - self.invert_vocab[idx] = token self.stopwords = set(self._load_stopwords(self._model_dir)) def _filter_pair_tokens(self, tokens: list[tuple[str, Any]]) -> list[tuple[str, Any]]: @@ -241,6 +242,10 @@ def _post_process_onnx_output( if output.input_ids is None: raise ValueError("input_ids must be provided for document post-processing") + # with `lazy_load` and `parallel`, inference runs in the workers and the parent + # never calls `load_onnx_model`, so the tokenizer might not be loaded here yet + self._ensure_tokenizer() + token_ids_batch = output.input_ids.astype(int) # attention_value shape: (batch_size, num_heads, num_tokens, num_tokens) diff --git a/fastembed/sparse/minicoil.py b/fastembed/sparse/minicoil.py index 6f29c60fd..bef8d3c47 100644 --- a/fastembed/sparse/minicoil.py +++ b/fastembed/sparse/minicoil.py @@ -159,13 +159,24 @@ def load_onnx_model(self) -> None: device_id=self.device_id, extra_session_options=self._extra_session_options, ) + # rebuilt on every call, so the vocab resolver wraps the tokenizer which has just been reloaded + self._load_post_processing_state() + def _load_tokenizer(self, model_dir: Path) -> None: + super()._load_tokenizer(model_dir=model_dir) 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.invert_vocab = {idx: token for token, idx in self.tokenizer.get_vocab().items()} self.special_tokens = set(self.special_token_to_id.keys()) self.special_tokens_ids = set(self.special_token_to_id.values()) + + def _load_post_processing_state(self) -> None: + """Load the vocab resolver, the encoder and the sparse vector converter. + + None of them needs the onnx session, so the parent process can post-process the outputs + of parallel workers without loading it. Requires the tokenizer to be loaded. + """ + assert self.tokenizer is not None + self.stopwords = set(self._load_stopwords(self._model_dir)) stemmer = SnowballStemmer(get_language_by_model_name(self.model_name)) @@ -279,6 +290,12 @@ def _post_process_onnx_output( if output.input_ids is None: raise ValueError("input_ids must be provided for document post-processing") + if self.vocab_resolver is None: + # with `lazy_load` and `parallel`, inference runs in the workers and the parent + # never calls `load_onnx_model` + self._ensure_tokenizer() + self._load_post_processing_state() + assert self.vocab_resolver is not None assert self.encoder is not None assert self.sparse_vector_converter is not None diff --git a/tests/test_late_interaction_multimodal.py b/tests/test_late_interaction_multimodal.py index 70b571aab..5237475af 100644 --- a/tests/test_late_interaction_multimodal.py +++ b/tests/test_late_interaction_multimodal.py @@ -167,12 +167,11 @@ def test_lazy_load() -> None: model = LateInteractionMultimodalEmbedding(model_name=model_name, lazy_load=True) assert not hasattr(model.model, "model") + # token counting only needs the tokenizer; embedding is not exercised here, since loading + # a second copy of the model next to the one `model_cache` keeps takes several GB assert model.token_count(queries) > 0 assert not hasattr(model.model, "model") - list(model.embed_text(queries)) - assert hasattr(model.model, "model") - def test_token_count(model_cache) -> None: model_name = "Qdrant/colmodernvbert" From a1119336d3abf2fbe48348e9a3d4b120fe66898d Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Tue, 29 Sep 2026 20:01:49 +0700 Subject: [PATCH 5/8] fix: assign colbert and minicoil loader state only once complete --- fastembed/late_interaction/colbert.py | 32 ++++++++++++++++----------- fastembed/sparse/minicoil.py | 22 ++++++++++-------- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/fastembed/late_interaction/colbert.py b/fastembed/late_interaction/colbert.py index 63aa8bf28..81ebed634 100644 --- a/fastembed/late_interaction/colbert.py +++ b/fastembed/late_interaction/colbert.py @@ -229,28 +229,34 @@ def _load_tokenizer(self, model_dir: Path) -> None: Both tokenizers are always (re)loaded from disk here, so the truncation length is derived from the on-disk value and never decremented twice. """ - super()._load_tokenizer(model_dir=model_dir) - self.query_tokenizer, _ = load_tokenizer(model_dir=model_dir) + tokenizer, special_token_to_id = load_tokenizer(model_dir=model_dir) + query_tokenizer, _ = load_tokenizer(model_dir=model_dir) - assert self.tokenizer is not None # load_tokenizer always enables both - assert self.tokenizer.padding is not None and self.tokenizer.truncation 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] + assert tokenizer.padding is not None and tokenizer.truncation is not None + mask_token_id = special_token_to_id[self.MASK_TOKEN] + pad_token_id = tokenizer.padding["pad_id"] + skip_list = { + 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( + max_length = tokenizer.truncation["max_length"] - 1 + tokenizer.enable_truncation(max_length=max_length) + query_tokenizer.enable_truncation(max_length=max_length) + query_tokenizer.enable_padding( pad_token=self.MASK_TOKEN, - pad_id=self.mask_token_id, + pad_id=mask_token_id, length=self.MIN_QUERY_LENGTH, ) + self.query_tokenizer = query_tokenizer + self.special_token_to_id = special_token_to_id + self.mask_token_id = mask_token_id + self.pad_token_id = pad_token_id + self.skip_list = skip_list + self.tokenizer = tokenizer + def embed( self, documents: str | Iterable[str], diff --git a/fastembed/sparse/minicoil.py b/fastembed/sparse/minicoil.py index bef8d3c47..05b76fba0 100644 --- a/fastembed/sparse/minicoil.py +++ b/fastembed/sparse/minicoil.py @@ -177,29 +177,33 @@ def _load_post_processing_state(self) -> None: """ assert self.tokenizer is not None - self.stopwords = set(self._load_stopwords(self._model_dir)) - + stopwords = set(self._load_stopwords(self._model_dir)) stemmer = SnowballStemmer(get_language_by_model_name(self.model_name)) - self.vocab_resolver = VocabResolver( + vocab_resolver = VocabResolver( tokenizer=VocabTokenizer(self.tokenizer), - stopwords=self.stopwords, + stopwords=stopwords, stemmer=stemmer, ) - self.vocab_resolver.load_json_vocab(str(self._model_dir / MINICOIL_VOCAB_FILE)) + 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 + encoder = Encoder(weights) - self.sparse_vector_converter = SparseVectorConverter( - stopwords=self.stopwords, + sparse_vector_converter = SparseVectorConverter( + stopwords=stopwords, stemmer=stemmer, k=self.k, b=self.b, avg_len=self.avg_len, ) + self.stopwords = stopwords + self.encoder = encoder + self.output_dim = encoder.output_dim + self.sparse_vector_converter = sparse_vector_converter + self.vocab_resolver = vocab_resolver + def token_count( self, texts: str | Iterable[str], batch_size: int = 1024, **kwargs: Any ) -> int: From 77400ef9bb2afd904a8296b7c4776a2e5baa8e0b Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Wed, 30 Sep 2026 01:54:01 +0700 Subject: [PATCH 6/8] fix: reuse already-loaded tokenizer and minicoil state in load_onnx_model --- .../late_interaction_multimodal/onnx_multimodal_model.py | 2 +- fastembed/rerank/cross_encoder/onnx_text_model.py | 2 +- fastembed/sparse/minicoil.py | 4 ++-- fastembed/text/onnx_text_model.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/fastembed/late_interaction_multimodal/onnx_multimodal_model.py b/fastembed/late_interaction_multimodal/onnx_multimodal_model.py index cfdda1793..a542a27a3 100644 --- a/fastembed/late_interaction_multimodal/onnx_multimodal_model.py +++ b/fastembed/late_interaction_multimodal/onnx_multimodal_model.py @@ -78,7 +78,7 @@ def _load_onnx_model( device_id=device_id, extra_session_options=extra_session_options, ) - self._load_tokenizer(model_dir=model_dir) + self._ensure_tokenizer() assert self.tokenizer is not None self.processor = load_preprocessor(model_dir=model_dir) diff --git a/fastembed/rerank/cross_encoder/onnx_text_model.py b/fastembed/rerank/cross_encoder/onnx_text_model.py index f0c46ff08..bd932870b 100644 --- a/fastembed/rerank/cross_encoder/onnx_text_model.py +++ b/fastembed/rerank/cross_encoder/onnx_text_model.py @@ -46,7 +46,7 @@ def _load_onnx_model( device_id=device_id, extra_session_options=extra_session_options, ) - self._load_tokenizer(model_dir=model_dir) + self._ensure_tokenizer() assert self.tokenizer is not None def _load_tokenizer(self, model_dir: Path) -> None: diff --git a/fastembed/sparse/minicoil.py b/fastembed/sparse/minicoil.py index 05b76fba0..b9ec18ca2 100644 --- a/fastembed/sparse/minicoil.py +++ b/fastembed/sparse/minicoil.py @@ -159,8 +159,8 @@ def load_onnx_model(self) -> None: device_id=self.device_id, extra_session_options=self._extra_session_options, ) - # rebuilt on every call, so the vocab resolver wraps the tokenizer which has just been reloaded - self._load_post_processing_state() + if self.vocab_resolver is None: + self._load_post_processing_state() def _load_tokenizer(self, model_dir: Path) -> None: super()._load_tokenizer(model_dir=model_dir) diff --git a/fastembed/text/onnx_text_model.py b/fastembed/text/onnx_text_model.py index f368e5125..8a678a9a4 100644 --- a/fastembed/text/onnx_text_model.py +++ b/fastembed/text/onnx_text_model.py @@ -80,7 +80,7 @@ def _load_onnx_model( device_id=device_id, extra_session_options=extra_session_options, ) - self._load_tokenizer(model_dir=model_dir) + self._ensure_tokenizer() def load_onnx_model(self) -> None: raise NotImplementedError("Subclasses must implement this method") From 500880f667b0052b6b7d735ab6f514844e4a65c2 Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Wed, 30 Sep 2026 02:07:17 +0700 Subject: [PATCH 7/8] fix: build minicoil post-processing state only where it is used --- fastembed/sparse/minicoil.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/fastembed/sparse/minicoil.py b/fastembed/sparse/minicoil.py index b9ec18ca2..109d8a15c 100644 --- a/fastembed/sparse/minicoil.py +++ b/fastembed/sparse/minicoil.py @@ -159,8 +159,6 @@ def load_onnx_model(self) -> None: device_id=self.device_id, extra_session_options=self._extra_session_options, ) - if self.vocab_resolver is None: - self._load_post_processing_state() def _load_tokenizer(self, model_dir: Path) -> None: super()._load_tokenizer(model_dir=model_dir) @@ -295,8 +293,8 @@ def _post_process_onnx_output( raise ValueError("input_ids must be provided for document post-processing") if self.vocab_resolver is None: - # with `lazy_load` and `parallel`, inference runs in the workers and the parent - # never calls `load_onnx_model` + # built on first use instead of in `load_onnx_model`: parallel workers never + # post-process, and with `lazy_load` the process that does may never load the model self._ensure_tokenizer() self._load_post_processing_state() From af4333ccd97e830d6e7293095822d948846e5b1c Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Thu, 1 Oct 2026 22:09:56 +0700 Subject: [PATCH 8/8] refactoring: declare _model_dir once in OnnxModel --- fastembed/common/onnx_model.py | 2 ++ fastembed/late_interaction_multimodal/onnx_multimodal_model.py | 3 --- fastembed/rerank/cross_encoder/onnx_text_model.py | 2 -- fastembed/text/onnx_text_model.py | 3 --- 4 files changed, 2 insertions(+), 8 deletions(-) diff --git a/fastembed/common/onnx_model.py b/fastembed/common/onnx_model.py index efde77f68..aa80dfccd 100644 --- a/fastembed/common/onnx_model.py +++ b/fastembed/common/onnx_model.py @@ -26,6 +26,8 @@ class OnnxOutputContext: class OnnxModel(Generic[T]): EXPOSED_SESSION_OPTIONS = ("enable_cpu_mem_arena",) + # set by the concrete model classes in their __init__ + _model_dir: Path @classmethod def _get_worker_class(cls) -> Type["EmbeddingWorker[T]"]: diff --git a/fastembed/late_interaction_multimodal/onnx_multimodal_model.py b/fastembed/late_interaction_multimodal/onnx_multimodal_model.py index a542a27a3..cc1b1ae0e 100644 --- a/fastembed/late_interaction_multimodal/onnx_multimodal_model.py +++ b/fastembed/late_interaction_multimodal/onnx_multimodal_model.py @@ -18,9 +18,6 @@ class OnnxMultimodalModel(OnnxModel[T]): - # set by the concrete model classes in their __init__ - _model_dir: Path - ONNX_OUTPUT_NAMES: list[str] | None = None def __init__(self) -> None: diff --git a/fastembed/rerank/cross_encoder/onnx_text_model.py b/fastembed/rerank/cross_encoder/onnx_text_model.py index bd932870b..3ed09fc3f 100644 --- a/fastembed/rerank/cross_encoder/onnx_text_model.py +++ b/fastembed/rerank/cross_encoder/onnx_text_model.py @@ -20,8 +20,6 @@ class OnnxCrossEncoderModel(OnnxModel[float]): ONNX_OUTPUT_NAMES: list[str] | None = None - # set by the concrete model classes in their __init__ - _model_dir: Path @classmethod def _get_worker_class(cls) -> Type["TextRerankerWorker"]: diff --git a/fastembed/text/onnx_text_model.py b/fastembed/text/onnx_text_model.py index 8a678a9a4..74e9ab6a9 100644 --- a/fastembed/text/onnx_text_model.py +++ b/fastembed/text/onnx_text_model.py @@ -15,9 +15,6 @@ class OnnxTextModel(OnnxModel[T]): - # set by the concrete model classes in their __init__ - _model_dir: Path - ONNX_OUTPUT_NAMES: list[str] | None = None @classmethod