From 32a5a260089a06b7817baa2dbdeae48e498a2af8 Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Fri, 25 Sep 2026 13:48:35 +0700 Subject: [PATCH 1/4] fix: don't depend on the hub's case-normalizing redirect for model sources --- fastembed/common/model_management.py | 4 ++-- fastembed/late_interaction/token_embeddings.py | 2 +- fastembed/sparse/sparse_text_embedding.py | 2 +- fastembed/text/onnx_embedding.py | 10 +++++----- fastembed/text/pooled_embedding.py | 6 +++--- fastembed/text/pooled_normalized_embedding.py | 8 ++++---- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/fastembed/common/model_management.py b/fastembed/common/model_management.py index 5974535e5..837926424 100644 --- a/fastembed/common/model_management.py +++ b/fastembed/common/model_management.py @@ -169,7 +169,7 @@ def download_files_from_huggingface( """ 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". + 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 (list[str]): extra patterns to allow in the snapshot download, typically includes the required model files. @@ -480,7 +480,7 @@ def download_model(cls, model: T, cache_dir: str, retries: int = 3, **kwargs: An "description": "Base English model, v1.5", "size_in_GB": 0.44, "sources": { - "hf": "qdrant/bge-base-en-v1.5-onnx-q", + "hf": "Qdrant/bge-base-en-v1.5-onnx-Q", } } ``` diff --git a/fastembed/late_interaction/token_embeddings.py b/fastembed/late_interaction/token_embeddings.py index 55d3cd94e..f94109d67 100644 --- a/fastembed/late_interaction/token_embeddings.py +++ b/fastembed/late_interaction/token_embeddings.py @@ -19,7 +19,7 @@ " 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"), + sources=ModelSource(hf="Xenova/jina-embeddings-v2-small-en"), model_file="onnx/model.onnx", ), ] diff --git a/fastembed/sparse/sparse_text_embedding.py b/fastembed/sparse/sparse_text_embedding.py index 273e08f0a..a604d2d3f 100644 --- a/fastembed/sparse/sparse_text_embedding.py +++ b/fastembed/sparse/sparse_text_embedding.py @@ -43,7 +43,7 @@ def list_supported_models(cls) -> list[dict[str, Any]]: "license": "apache-2.0", "size_in_GB": 0.532, "sources": { - "hf": "qdrant/SPLADE_PP_en_v1", + "hf": "Qdrant/Splade_PP_en_v1", }, } ] diff --git a/fastembed/text/onnx_embedding.py b/fastembed/text/onnx_embedding.py index 6d14e8459..0f6cec9fc 100644 --- a/fastembed/text/onnx_embedding.py +++ b/fastembed/text/onnx_embedding.py @@ -47,7 +47,7 @@ ), license="mit", size_in_GB=1.20, - sources=ModelSource(hf="qdrant/bge-large-en-v1.5-onnx"), + sources=ModelSource(hf="Qdrant/bge-large-en-v1.5-onnx"), model_file="model.onnx", ), DenseModelDescription( @@ -113,7 +113,7 @@ ), license="apache-2.0", size_in_GB=0.09, - sources=ModelSource(hf="snowflake/snowflake-arctic-embed-xs"), + sources=ModelSource(hf="Snowflake/snowflake-arctic-embed-xs"), model_file="onnx/model.onnx", ), DenseModelDescription( @@ -125,7 +125,7 @@ ), license="apache-2.0", size_in_GB=0.13, - sources=ModelSource(hf="snowflake/snowflake-arctic-embed-s"), + sources=ModelSource(hf="Snowflake/snowflake-arctic-embed-s"), model_file="onnx/model.onnx", ), DenseModelDescription( @@ -149,7 +149,7 @@ ), license="apache-2.0", size_in_GB=0.54, - sources=ModelSource(hf="snowflake/snowflake-arctic-embed-m-long"), + sources=ModelSource(hf="Snowflake/snowflake-arctic-embed-m-long"), model_file="onnx/model.onnx", ), DenseModelDescription( @@ -161,7 +161,7 @@ ), license="apache-2.0", size_in_GB=1.02, - sources=ModelSource(hf="snowflake/snowflake-arctic-embed-l"), + sources=ModelSource(hf="Snowflake/snowflake-arctic-embed-l"), model_file="onnx/model.onnx", ), DenseModelDescription( diff --git a/fastembed/text/pooled_embedding.py b/fastembed/text/pooled_embedding.py index a99c7c42b..ba9a26a21 100644 --- a/fastembed/text/pooled_embedding.py +++ b/fastembed/text/pooled_embedding.py @@ -55,7 +55,7 @@ ), license="apache-2.0", size_in_GB=0.22, - sources=ModelSource(hf="qdrant/paraphrase-multilingual-MiniLM-L12-v2-onnx-Q"), + sources=ModelSource(hf="Qdrant/paraphrase-multilingual-MiniLM-L12-v2-onnx-Q"), model_file="model_optimized.onnx", ), DenseModelDescription( @@ -67,7 +67,7 @@ ), license="apache-2.0", size_in_GB=1.00, - sources=ModelSource(hf="xenova/paraphrase-multilingual-mpnet-base-v2"), + sources=ModelSource(hf="Xenova/paraphrase-multilingual-mpnet-base-v2"), model_file="onnx/model.onnx", ), DenseModelDescription( @@ -80,7 +80,7 @@ license="mit", size_in_GB=2.24, sources=ModelSource( - hf="qdrant/multilingual-e5-large-onnx", + hf="Qdrant/multilingual-e5-large-onnx", _deprecated_tar_struct=True, ), model_file="model.onnx", diff --git a/fastembed/text/pooled_normalized_embedding.py b/fastembed/text/pooled_normalized_embedding.py index 637096023..b107b05c2 100644 --- a/fastembed/text/pooled_normalized_embedding.py +++ b/fastembed/text/pooled_normalized_embedding.py @@ -19,7 +19,7 @@ license="apache-2.0", size_in_GB=0.09, sources=ModelSource( - hf="qdrant/all-MiniLM-L6-v2-onnx", + hf="Qdrant/all-MiniLM-L6-v2-onnx", _deprecated_tar_struct=True, ), model_file="model.onnx", @@ -33,7 +33,7 @@ ), license="apache-2.0", size_in_GB=0.52, - sources=ModelSource(hf="xenova/jina-embeddings-v2-base-en"), + sources=ModelSource(hf="Xenova/jina-embeddings-v2-base-en"), model_file="onnx/model.onnx", ), DenseModelDescription( @@ -45,7 +45,7 @@ ), license="apache-2.0", size_in_GB=0.12, - sources=ModelSource(hf="xenova/jina-embeddings-v2-small-en"), + sources=ModelSource(hf="Xenova/jina-embeddings-v2-small-en"), model_file="onnx/model.onnx", ), DenseModelDescription( @@ -117,7 +117,7 @@ ), license="mit", size_in_GB=1.20, - sources=ModelSource(hf="qdrant/gte-large-onnx"), + sources=ModelSource(hf="Qdrant/gte-large-onnx"), model_file="model.onnx", ), ] From 84de2f8080b8206a5f31d8685d50a09e4d0c420a Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Fri, 25 Sep 2026 14:19:12 +0700 Subject: [PATCH 2/4] fix: fall back to a differently cased cache entry when loading offline --- fastembed/common/model_management.py | 47 ++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/fastembed/common/model_management.py b/fastembed/common/model_management.py index 837926424..fb1cc46ae 100644 --- a/fastembed/common/model_management.py +++ b/fastembed/common/model_management.py @@ -157,6 +157,43 @@ def download_file_from_gcs(cls, url: str, output_path: str, show_progress: bool file.write(chunk) return output_path + @classmethod + def _find_legacy_cased_source(cls, cache_dir: str, hf_source_repo: str) -> str | None: + """Looks for a cached snapshot of the same repo spelled with a different casing. + + Built-in sources used to be lowercase and were canonicalized once it turned out that + relying on the hub's normalizing redirect breaks proxies. Both the hub and fastembed + derive the cache directory from the source verbatim, so on a case-sensitive filesystem + an offline load would otherwise miss a model an older version had already cached. + + Args: + cache_dir (str): The path to the cache directory. + hf_source_repo (str): Name of the model on HuggingFace Hub. + + Returns: + str | None: The differently cased source found in the cache, None if there is none. + """ + org, _, name = hf_source_repo.partition("/") + if not name: + return None + + expected = f"models--{org}--{name}" + try: + entries = list(Path(cache_dir).iterdir()) + except OSError: + return None + + for entry in entries: + if entry.name == expected or entry.name.lower() != expected.lower(): + continue + if not entry.is_dir(): + continue + # casing never changes length, so org and name keep their offsets + org_at = len("models--") + name_at = org_at + len(org) + len("--") + return f"{entry.name[org_at : org_at + len(org)]}/{entry.name[name_at:]}" + return None + @classmethod def download_files_from_huggingface( cls, @@ -243,6 +280,16 @@ def _save_file_metadata(model_dir: Path, meta: dict[str, dict[str, int | str]]) if local_files_only: disable_progress_bars() + if not snapshot_dir.exists(): + legacy_source = cls._find_legacy_cased_source(cache_dir, hf_source_repo) + if legacy_source is not None: + logger.info( + f"{hf_source_repo} is not in {cache_dir}, loading {legacy_source}, " + "cached from the same repo by an older fastembed version." + ) + hf_source_repo = legacy_source + snapshot_dir = Path(cache_dir) / f"models--{hf_source_repo.replace('/', '--')}" + metadata_file = snapshot_dir / cls.METADATA_FILE if metadata_file.exists(): metadata = json.loads(metadata_file.read_text()) verified = _verify_files_from_metadata(snapshot_dir, metadata, repo_files=[]) From 6ae39adcfe6566f73ba3685af75084f6031b25e2 Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Fri, 25 Sep 2026 17:33:53 +0700 Subject: [PATCH 3/4] fix: simplify --- fastembed/common/model_management.py | 81 ++++++++++++++++------------ 1 file changed, 47 insertions(+), 34 deletions(-) diff --git a/fastembed/common/model_management.py b/fastembed/common/model_management.py index fb1cc46ae..a2ee6bb81 100644 --- a/fastembed/common/model_management.py +++ b/fastembed/common/model_management.py @@ -13,8 +13,10 @@ import requests from huggingface_hub import constants, snapshot_download, model_info, list_repo_tree +from huggingface_hub.file_download import repo_folder_name from huggingface_hub.hf_api import RepoFile from huggingface_hub.utils import ( + HFValidationError, RepositoryNotFoundError, disable_progress_bars, enable_progress_bars, @@ -173,25 +175,23 @@ def _find_legacy_cased_source(cls, cache_dir: str, hf_source_repo: str) -> str | Returns: str | None: The differently cased source found in the cache, None if there is none. """ - org, _, name = hf_source_repo.partition("/") - if not name: - return None - - expected = f"models--{org}--{name}" + separator = constants.REPO_ID_SEPARATOR try: + expected = repo_folder_name(repo_id=hf_source_repo, repo_type="model") entries = list(Path(cache_dir).iterdir()) - except OSError: + except (HFValidationError, OSError): return None + # the repo id sits at the tail of the folder name, with every "/" replaced + offset = len(expected) - len(hf_source_repo.replace("/", separator)) + for entry in entries: if entry.name == expected or entry.name.lower() != expected.lower(): continue if not entry.is_dir(): continue - # casing never changes length, so org and name keep their offsets - org_at = len("models--") - name_at = org_at + len(org) + len("--") - return f"{entry.name[org_at : org_at + len(org)]}/{entry.name[name_at:]}" + # the hub forbids the separator inside a repo id, so it only marks the split + return entry.name[offset:].replace(separator, "/") return None @classmethod @@ -275,36 +275,49 @@ def _save_file_metadata(model_dir: Path, meta: dict[str, dict[str, int | str]]) allow_patterns.extend(extra_patterns) - snapshot_dir = Path(cache_dir) / f"models--{hf_source_repo.replace('/', '--')}" + snapshot_dir = Path(cache_dir) / repo_folder_name( + repo_id=hf_source_repo, repo_type="model" + ) metadata_file = snapshot_dir / cls.METADATA_FILE if local_files_only: disable_progress_bars() - if not snapshot_dir.exists(): - legacy_source = cls._find_legacy_cased_source(cache_dir, hf_source_repo) - if legacy_source is not None: + # a canonical directory can exist yet hold no usable snapshot, e.g. when an + # earlier download was interrupted, so fall back on failure rather than on + # the directory being absent + sources = [hf_source_repo] + legacy_source = cls._find_legacy_cased_source(cache_dir, hf_source_repo) + if legacy_source is not None: + sources.append(legacy_source) + + for index, source in enumerate(sources): + snapshot_dir = Path(cache_dir) / repo_folder_name( + repo_id=source, repo_type="model" + ) + metadata_file = snapshot_dir / cls.METADATA_FILE + 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 + try: + return snapshot_download( + repo_id=source, + allow_patterns=allow_patterns, + cache_dir=cache_dir, + local_files_only=local_files_only, + **kwargs, + ) + except _HF_DOWNLOAD_ERRORS: + if index == len(sources) - 1: + raise logger.info( - f"{hf_source_repo} is not in {cache_dir}, loading {legacy_source}, " - "cached from the same repo by an older fastembed version." + f"{source} is not usable in {cache_dir}, loading " + f"{sources[index + 1]}, cached from the same repo by an older " + "fastembed version." ) - hf_source_repo = legacy_source - snapshot_dir = Path(cache_dir) / f"models--{hf_source_repo.replace('/', '--')}" - metadata_file = snapshot_dir / cls.METADATA_FILE - 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 # hub sends this request with no timeout unless given one, so an endpoint that accepts # the connection but never answers would block here for good, before download_model can From 5c227750168d7f651ce8ae4db0c22a4fd19b62a7 Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Fri, 25 Sep 2026 18:16:13 +0700 Subject: [PATCH 4/4] chore --- fastembed/common/model_management.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fastembed/common/model_management.py b/fastembed/common/model_management.py index a2ee6bb81..6c85dffe7 100644 --- a/fastembed/common/model_management.py +++ b/fastembed/common/model_management.py @@ -303,11 +303,13 @@ def _save_file_metadata(model_dir: Path, meta: dict[str, dict[str, int | str]]) "Local file sizes do not match the metadata." ) # do not raise, still make an attempt to load the model try: + # a legacy source is only ever resolved against the cache: sending it + # to the hub would ask for the very redirect this casing avoids return snapshot_download( repo_id=source, allow_patterns=allow_patterns, cache_dir=cache_dir, - local_files_only=local_files_only, + local_files_only=True, **kwargs, ) except _HF_DOWNLOAD_ERRORS: