fastembed-gpu 0.8.1__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/NOTICE +2 -2
  2. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/PKG-INFO +3 -3
  3. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/README.md +2 -2
  4. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/common/model_management.py +258 -42
  5. fastembed_gpu-0.9.0/fastembed/common/onnx_external_data.py +91 -0
  6. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/common/onnx_model.py +28 -4
  7. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/common/preprocessor_utils.py +12 -4
  8. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/common/utils.py +29 -11
  9. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/image/onnx_embedding.py +3 -2
  10. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/image/onnx_image_model.py +2 -0
  11. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/image/transform/functional.py +9 -5
  12. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/image/transform/operators.py +31 -5
  13. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/late_interaction/colbert.py +39 -16
  14. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/late_interaction/late_interaction_text_embedding.py +7 -1
  15. fastembed_gpu-0.9.0/fastembed/late_interaction/lateon.py +128 -0
  16. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/late_interaction/token_embeddings.py +1 -1
  17. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/late_interaction_multimodal/colmodernvbert.py +51 -46
  18. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/late_interaction_multimodal/colpali.py +3 -3
  19. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/late_interaction_multimodal/late_interaction_multimodal_embedding.py +11 -2
  20. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/late_interaction_multimodal/onnx_multimodal_model.py +15 -1
  21. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/parallel_processor.py +32 -3
  22. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/postprocess/muvera.py +23 -9
  23. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/rerank/cross_encoder/onnx_text_cross_encoder.py +19 -1
  24. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/rerank/cross_encoder/onnx_text_model.py +16 -4
  25. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/sparse/bm25.py +65 -14
  26. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/sparse/bm42.py +16 -8
  27. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/sparse/if_splade.py +4 -10
  28. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/sparse/minicoil.py +31 -11
  29. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/sparse/sparse_embedding_base.py +1 -1
  30. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/sparse/sparse_text_embedding.py +1 -1
  31. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/sparse/splade_pp.py +2 -1
  32. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/text/builtin_sentence_embedding.py +15 -0
  33. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/text/custom_text_embedding.py +23 -2
  34. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/text/multitask_embedding.py +1 -1
  35. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/text/onnx_embedding.py +32 -9
  36. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/text/onnx_text_model.py +16 -4
  37. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/text/pooled_embedding.py +7 -7
  38. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/text/pooled_normalized_embedding.py +22 -6
  39. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/text/siglip_embedding.py +14 -13
  40. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/text/text_embedding.py +2 -0
  41. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/pyproject.toml +1 -1
  42. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/LICENSE +0 -0
  43. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/__init__.py +0 -0
  44. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/common/__init__.py +0 -0
  45. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/common/model_description.py +0 -0
  46. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/common/types.py +0 -0
  47. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/embedding.py +0 -0
  48. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/image/__init__.py +0 -0
  49. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/image/image_embedding.py +0 -0
  50. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/image/image_embedding_base.py +0 -0
  51. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/image/normalized_embedding.py +0 -0
  52. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/image/siglip_embedding.py +0 -0
  53. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/late_interaction/__init__.py +0 -0
  54. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/late_interaction/jina_colbert.py +0 -0
  55. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/late_interaction/late_interaction_embedding_base.py +0 -0
  56. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/late_interaction_multimodal/__init__.py +0 -0
  57. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/late_interaction_multimodal/late_interaction_multimodal_embedding_base.py +0 -0
  58. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/postprocess/__init__.py +0 -0
  59. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/py.typed +0 -0
  60. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/rerank/cross_encoder/__init__.py +0 -0
  61. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/rerank/cross_encoder/custom_text_cross_encoder.py +0 -0
  62. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/rerank/cross_encoder/text_cross_encoder.py +0 -0
  63. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/rerank/cross_encoder/text_cross_encoder_base.py +0 -0
  64. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/sparse/__init__.py +0 -0
  65. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/sparse/utils/minicoil_encoder.py +0 -0
  66. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/sparse/utils/sparse_vectors_converter.py +0 -0
  67. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/sparse/utils/tokenizer.py +0 -0
  68. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/sparse/utils/vocab_resolver.py +0 -0
  69. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/text/__init__.py +0 -0
  70. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/text/clip_embedding.py +0 -0
  71. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/text/last_token_normalized_embedding.py +0 -0
  72. {fastembed_gpu-0.8.1 → fastembed_gpu-0.9.0}/fastembed/text/text_embedding_base.py +0 -0
@@ -12,8 +12,8 @@ This distribution includes the following Jina AI models, each with its respectiv
12
12
 
13
13
  These models are developed by Jina (https://jina.ai/) and are subject to Jina AI's licensing terms.
14
14
 
15
- This distribution includes the following Google models, each with its respective license:
16
- - vidore/colpali-v1.3
15
+ This distribution includes the following Gemma-based models, each with its respective license:
16
+ - Qdrant/colpali-v1.3-fp16
17
17
  - License: gemma
18
18
  - google/embeddinggemma-300m
19
19
  - License: gemma
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastembed-gpu
3
- Version: 0.8.1
3
+ Version: 0.9.0
4
4
  Summary: Fast, light, accurate library built for retrieval embedding generation
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -211,10 +211,10 @@ query = "What is Qdrant?"
211
211
  model = LateInteractionMultimodalEmbedding(model_name="Qdrant/colpali-v1.3-fp16")
212
212
  doc_images_embeddings = list(model.embed_image(doc_images))
213
213
  # shape (2, 1030, 128)
214
- # [array([[-0.03353882, -0.02090454, ..., -0.15576172, -0.07678223]], dtype=float32)]
214
+ # [array([[-0.03354, -0.0209, ..., -0.1558, -0.0768]], dtype=float16)]
215
215
  query_embedding = model.embed_text(query)
216
216
  # shape (1, 20, 128)
217
- # [array([[-0.00218201, 0.14758301, ..., -0.02207947, 0.16833496]], dtype=float32)]
217
+ # [array([[-0.002182, 0.1476, ..., -0.02208, 0.1683]], dtype=float16)]
218
218
  ```
219
219
 
220
220
  ### 🔄 Rerankers
@@ -169,10 +169,10 @@ query = "What is Qdrant?"
169
169
  model = LateInteractionMultimodalEmbedding(model_name="Qdrant/colpali-v1.3-fp16")
170
170
  doc_images_embeddings = list(model.embed_image(doc_images))
171
171
  # shape (2, 1030, 128)
172
- # [array([[-0.03353882, -0.02090454, ..., -0.15576172, -0.07678223]], dtype=float32)]
172
+ # [array([[-0.03354, -0.0209, ..., -0.1558, -0.0768]], dtype=float16)]
173
173
  query_embedding = model.embed_text(query)
174
174
  # shape (1, 20, 128)
175
- # [array([[-0.00218201, 0.14758301, ..., -0.02207947, 0.16833496]], dtype=float32)]
175
+ # [array([[-0.002182, 0.1476, ..., -0.02208, 0.1683]], dtype=float16)]
176
176
  ```
177
177
 
178
178
  ### 🔄 Rerankers
@@ -5,15 +5,18 @@ import json
5
5
  import shutil
6
6
  import tarfile
7
7
  import tempfile
8
+ import warnings
8
9
  import contextlib
9
10
  from copy import deepcopy
10
11
  from pathlib import Path, PureWindowsPath
11
12
  from typing import Any, TypeVar, Generic
12
13
 
13
14
  import requests
14
- from huggingface_hub import snapshot_download, model_info, list_repo_tree
15
+ from huggingface_hub import constants, snapshot_download, model_info, list_repo_tree
16
+ from huggingface_hub.file_download import repo_folder_name
15
17
  from huggingface_hub.hf_api import RepoFile
16
18
  from huggingface_hub.utils import (
19
+ HFValidationError,
17
20
  RepositoryNotFoundError,
18
21
  disable_progress_bars,
19
22
  enable_progress_bars,
@@ -21,12 +24,44 @@ from huggingface_hub.utils import (
21
24
  from loguru import logger
22
25
  from tqdm import tqdm
23
26
  from fastembed.common.model_description import BaseModelDescription
27
+ from fastembed.common.onnx_external_data import link_external_data
24
28
 
25
29
  T = TypeVar("T", bound=BaseModelDescription)
26
30
 
27
31
  _DOWNLOAD_CHUNK_SIZE = 256 * 1024
28
32
 
29
33
 
34
+ def _hf_transport_errors() -> tuple[type[Exception], ...]:
35
+ """Network errors of huggingface_hub's HTTP library that aren't OSError.
36
+
37
+ A refused connection, a DNS failure or a timeout raises an OSError in huggingface_hub 0.x,
38
+ which is built on requests, but a TransportError in 1.x (httpx) and 2.x (httpx2).
39
+ """
40
+ try:
41
+ # huggingface_hub>=1.30 re-exports whichever of httpx and httpx2 it's built on.
42
+ from huggingface_hub.utils import httpx
43
+ except ImportError:
44
+ try:
45
+ import httpx # huggingface_hub 1.0 to 1.29
46
+ except ImportError: # huggingface_hub 0.x
47
+ return ()
48
+ return (httpx.TransportError,)
49
+
50
+
51
+ # Errors from an HF download that download_model handles by falling back to url and retrying.
52
+ _HF_DOWNLOAD_ERRORS = (OSError, RepositoryNotFoundError, ValueError) + _hf_transport_errors()
53
+
54
+
55
+ class CorruptedCacheError(ValueError):
56
+ """A cached model file is present but does not match the size recorded for it.
57
+
58
+ Distinct from the download failures above so that callers can tell "this snapshot is
59
+ unusable, look elsewhere" apart from "the hub could not be reached": only the former
60
+ is worth re-fetching a file the hub's existence-only cache check would otherwise keep.
61
+ Subclasses ValueError to stay inside _HF_DOWNLOAD_ERRORS for callers that don't care.
62
+ """
63
+
64
+
30
65
  class ModelManagement(Generic[T]):
31
66
  METADATA_FILE = "files_metadata.json"
32
67
 
@@ -135,6 +170,41 @@ class ModelManagement(Generic[T]):
135
170
  file.write(chunk)
136
171
  return output_path
137
172
 
173
+ @classmethod
174
+ def _find_legacy_cased_source(cls, cache_dir: str, hf_source_repo: str) -> str | None:
175
+ """Looks for a cached snapshot of the same repo spelled with a different casing.
176
+
177
+ Built-in sources used to be lowercase and were canonicalized once it turned out that
178
+ relying on the hub's normalizing redirect breaks proxies. Both the hub and fastembed
179
+ derive the cache directory from the source verbatim, so on a case-sensitive filesystem
180
+ an offline load would otherwise miss a model an older version had already cached.
181
+
182
+ Args:
183
+ cache_dir (str): The path to the cache directory.
184
+ hf_source_repo (str): Name of the model on HuggingFace Hub.
185
+
186
+ Returns:
187
+ str | None: The differently cased source found in the cache, None if there is none.
188
+ """
189
+ separator = constants.REPO_ID_SEPARATOR
190
+ try:
191
+ expected = repo_folder_name(repo_id=hf_source_repo, repo_type="model")
192
+ entries = list(Path(cache_dir).iterdir())
193
+ except (HFValidationError, OSError):
194
+ return None
195
+
196
+ # the repo id sits at the tail of the folder name, with every "/" replaced
197
+ offset = len(expected) - len(hf_source_repo.replace("/", separator))
198
+
199
+ for entry in entries:
200
+ if entry.name == expected or entry.name.lower() != expected.lower():
201
+ continue
202
+ if not entry.is_dir():
203
+ continue
204
+ # the hub forbids the separator inside a repo id, so it only marks the split
205
+ return entry.name[offset:].replace(separator, "/")
206
+ return None
207
+
138
208
  @classmethod
139
209
  def download_files_from_huggingface(
140
210
  cls,
@@ -147,7 +217,7 @@ class ModelManagement(Generic[T]):
147
217
  """
148
218
  Downloads a model from HuggingFace Hub.
149
219
  Args:
150
- hf_source_repo (str): Name of the model on HuggingFace Hub, e.g. "qdrant/all-MiniLM-L6-v2-onnx".
220
+ hf_source_repo (str): Name of the model on HuggingFace Hub, e.g. "Qdrant/all-MiniLM-L6-v2-onnx".
151
221
  cache_dir (Optional[str]): The path to the cache directory.
152
222
  extra_patterns (list[str]): extra patterns to allow in the snapshot download, typically
153
223
  includes the required model files.
@@ -156,6 +226,15 @@ class ModelManagement(Generic[T]):
156
226
  Path: The path to the model directory.
157
227
  """
158
228
 
229
+ def _repo_relative_path(relative_path: Path) -> str:
230
+ # Cached files are stored under `snapshots/<revision>/<repo_path>`. Strip that
231
+ # prefix so the remainder matches a repo file's path, which may itself be nested
232
+ # (e.g. `onnx/model.onnx`).
233
+ parts = relative_path.parts
234
+ if len(parts) > 2 and parts[0] == "snapshots":
235
+ return "/".join(parts[2:])
236
+ return relative_path.as_posix()
237
+
159
238
  def _verify_files_from_metadata(
160
239
  model_dir: Path, stored_metadata: dict[str, Any], repo_files: list[RepoFile]
161
240
  ) -> bool:
@@ -167,7 +246,8 @@ class ModelManagement(Generic[T]):
167
246
  return False
168
247
 
169
248
  if repo_files: # online verification
170
- file_info = next((f for f in repo_files if f.path == file_path.name), None)
249
+ repo_path = _repo_relative_path(Path(rel_path))
250
+ file_info = next((f for f in repo_files if f.path == repo_path), None)
171
251
  if (
172
252
  not file_info
173
253
  or file_info.size != meta["size"]
@@ -184,15 +264,19 @@ class ModelManagement(Generic[T]):
184
264
  return False
185
265
 
186
266
  def _collect_file_metadata(
187
- model_dir: Path, repo_files: list[RepoFile]
267
+ model_dir: Path, revision: str, repo_files: list[RepoFile]
188
268
  ) -> dict[str, dict[str, int | str]]:
189
269
  meta: dict[str, dict[str, int | str]] = {}
190
270
  file_info_map = {f.path: f for f in repo_files}
191
- for file_path in model_dir.rglob("*"):
271
+ # Only look at the downloaded revision's folder. The cache may also hold folders of
272
+ # older revisions, whose files differ from the ones on the hub now, so checking
273
+ # them would report a good download as corrupted.
274
+ for file_path in (model_dir / "snapshots" / revision).rglob("*"):
192
275
  if file_path.is_file() and file_path.name != cls.METADATA_FILE:
193
- repo_file = file_info_map.get(file_path.name)
276
+ relative_path = file_path.relative_to(model_dir)
277
+ repo_file = file_info_map.get(_repo_relative_path(relative_path))
194
278
  if repo_file:
195
- meta[str(file_path.relative_to(model_dir))] = {
279
+ meta[str(relative_path)] = {
196
280
  "size": repo_file.size,
197
281
  "blob_id": repo_file.blob_id,
198
282
  }
@@ -216,31 +300,96 @@ class ModelManagement(Generic[T]):
216
300
 
217
301
  allow_patterns.extend(extra_patterns)
218
302
 
219
- snapshot_dir = Path(cache_dir) / f"models--{hf_source_repo.replace('/', '--')}"
303
+ snapshot_dir = Path(cache_dir) / repo_folder_name(
304
+ repo_id=hf_source_repo, repo_type="model"
305
+ )
220
306
  metadata_file = snapshot_dir / cls.METADATA_FILE
221
307
 
222
308
  if local_files_only:
223
309
  disable_progress_bars()
224
- if metadata_file.exists():
225
- metadata = json.loads(metadata_file.read_text())
226
- verified = _verify_files_from_metadata(snapshot_dir, metadata, repo_files=[])
227
- if not verified:
228
- logger.warning(
229
- "Local file sizes do not match the metadata."
230
- ) # do not raise, still make an attempt to load the model
231
- result = snapshot_download(
232
- repo_id=hf_source_repo,
233
- allow_patterns=allow_patterns,
234
- cache_dir=cache_dir,
235
- local_files_only=local_files_only,
236
- **kwargs,
237
- )
238
- return result
310
+ # a canonical directory can exist yet hold no usable snapshot, e.g. when an
311
+ # earlier download was interrupted, so fall back on failure rather than on
312
+ # the directory being absent
313
+ sources = [hf_source_repo]
314
+ legacy_source = cls._find_legacy_cased_source(cache_dir, hf_source_repo)
315
+ if legacy_source is not None:
316
+ sources.append(legacy_source)
317
+
318
+ # a corrupt candidate is remembered rather than raised on the spot: the next
319
+ # candidate may still be loadable, and if none is, this is the error worth
320
+ # reporting, since it is the only one download_model can act on.
321
+ corruption: CorruptedCacheError | None = None
322
+
323
+ for index, source in enumerate(sources):
324
+ last_source = index == len(sources) - 1
325
+ snapshot_dir = Path(cache_dir) / repo_folder_name(
326
+ repo_id=source, repo_type="model"
327
+ )
328
+ metadata_file = snapshot_dir / cls.METADATA_FILE
329
+ if metadata_file.exists():
330
+ metadata = json.loads(metadata_file.read_text())
331
+ if not _verify_files_from_metadata(snapshot_dir, metadata, repo_files=[]):
332
+ # A size mismatch on one of this model's own files (its weights, or
333
+ # whatever else its description lists) means loading would later fail
334
+ # with a cryptic error, so the snapshot is not offered at all. A
335
+ # mismatch confined to a file nothing asked for is left to best-effort
336
+ # loading, preserving the previous behavior.
337
+ model_files = set(extra_patterns)
338
+ model_file_corrupt = any(
339
+ _repo_relative_path(Path(rel_path)) in model_files
340
+ and (snapshot_dir / rel_path).is_file()
341
+ and (snapshot_dir / rel_path).stat().st_size != meta["size"]
342
+ for rel_path, meta in metadata.items()
343
+ )
344
+ if model_file_corrupt:
345
+ corruption = corruption or CorruptedCacheError(
346
+ f"Cached model files for {source} in {cache_dir} do not match "
347
+ "the stored metadata; the cache appears corrupted."
348
+ )
349
+ if last_source:
350
+ raise corruption
351
+ logger.info(
352
+ f"{source} is corrupted in {cache_dir}, loading "
353
+ f"{sources[index + 1]}, cached from the same repo by an older "
354
+ "fastembed version."
355
+ )
356
+ continue
357
+ logger.warning("Local file sizes do not match the metadata.")
358
+ try:
359
+ # a legacy source is only ever resolved against the cache: sending it
360
+ # to the hub would ask for the very redirect this casing avoids
361
+ return snapshot_download(
362
+ repo_id=source,
363
+ allow_patterns=allow_patterns,
364
+ cache_dir=cache_dir,
365
+ local_files_only=True,
366
+ **kwargs,
367
+ )
368
+ except _HF_DOWNLOAD_ERRORS:
369
+ if last_source:
370
+ # an earlier candidate was corrupt and this one is merely absent:
371
+ # report the corruption, the only failure a re-download can fix
372
+ if corruption is not None:
373
+ raise corruption
374
+ raise
375
+ logger.info(
376
+ f"{source} is not usable in {cache_dir}, loading "
377
+ f"{sources[index + 1]}, cached from the same repo by an older "
378
+ "fastembed version."
379
+ )
239
380
 
240
- repo_revision = model_info(hf_source_repo).sha
241
- repo_tree = list(list_repo_tree(hf_source_repo, revision=repo_revision, repo_type="model"))
381
+ # hub sends this request with no timeout unless given one, so an endpoint that accepts
382
+ # the connection but never answers would block here for good, before download_model can
383
+ # retry or fall back to another source. list_repo_tree and snapshot_download's own
384
+ # metadata requests can't be given one, but a silent endpoint now fails here first.
385
+ repo_revision = model_info(hf_source_repo, timeout=constants.HF_HUB_ETAG_TIMEOUT).sha
386
+ repo_tree = list(
387
+ list_repo_tree(
388
+ hf_source_repo, revision=repo_revision, repo_type="model", recursive=True
389
+ )
390
+ )
242
391
 
243
- allowed_extensions = {".json", ".onnx", ".txt"}
392
+ allowed_extensions = {Path(pattern).suffix for pattern in allow_patterns}
244
393
  repo_files = (
245
394
  [
246
395
  f
@@ -253,9 +402,18 @@ class ModelManagement(Generic[T]):
253
402
 
254
403
  verified_metadata = False
255
404
 
256
- if snapshot_dir.exists() and metadata_file.exists():
405
+ # a forced download re-fetches every file, so show progress and re-collect the metadata
406
+ if not kwargs.get("force_download") and snapshot_dir.exists() and metadata_file.exists():
257
407
  metadata = json.loads(metadata_file.read_text())
258
- verified_metadata = _verify_files_from_metadata(snapshot_dir, metadata, repo_files)
408
+ # metadata lacking a requested file, e.g. a new model_file, can't vouch for it
409
+ requested_files = {f.path for f in repo_files if f.path in allow_patterns}
410
+ stored_files = {_repo_relative_path(Path(rel_path)) for rel_path in metadata}
411
+ # empty metadata lists no files, so it can't vouch for the cached ones
412
+ verified_metadata = (
413
+ bool(metadata)
414
+ and requested_files.issubset(stored_files)
415
+ and _verify_files_from_metadata(snapshot_dir, metadata, repo_files)
416
+ )
259
417
 
260
418
  if verified_metadata:
261
419
  disable_progress_bars()
@@ -272,7 +430,10 @@ class ModelManagement(Generic[T]):
272
430
  not verified_metadata
273
431
  ): # metadata is not up-to-date, update it and check whether the files have been
274
432
  # downloaded correctly
275
- metadata = _collect_file_metadata(snapshot_dir, repo_files)
433
+ # result is <cache_dir>/.../snapshots/<revision>. Only the revision is taken from
434
+ # it: the hub resolves cache_dir, so the rest of the path may not match ours.
435
+ downloaded_revision = Path(result).name
436
+ metadata = _collect_file_metadata(snapshot_dir, downloaded_revision, repo_files)
276
437
 
277
438
  download_successful = _verify_files_from_metadata(
278
439
  snapshot_dir, metadata, repo_files=[]
@@ -282,7 +443,10 @@ class ModelManagement(Generic[T]):
282
443
  "Files have been corrupted during downloading process. "
283
444
  "Please check your internet connection and try again."
284
445
  )
285
- _save_file_metadata(snapshot_dir, metadata)
446
+ # Empty means no file was checked, e.g. the files went to a local_dir instead of
447
+ # the cache. Saving it would make later downloads skip this check.
448
+ if metadata:
449
+ _save_file_metadata(snapshot_dir, metadata)
286
450
 
287
451
  return result
288
452
 
@@ -374,6 +538,20 @@ class ModelManagement(Generic[T]):
374
538
 
375
539
  # check if the model_dir and the model files are both present for macOS
376
540
  if model_dir.exists() and len(list(model_dir.glob("*"))) > 0:
541
+ if deprecated_tar_struct and not source_url:
542
+ # No built-in model is served from the bucket anymore, only copies of it remain.
543
+ # A model with a url of its own (a custom one) got its copy from there instead.
544
+ # stacklevel points at the caller of TextEmbedding(...) via download_model.
545
+ warnings.warn(
546
+ f"Loading {model_name} from {model_dir}, a copy downloaded from Google Cloud "
547
+ "Storage by an older fastembed version. Support for such copies is deprecated "
548
+ "and will be removed in a future release. To switch to Hugging Face, load the "
549
+ "model once while huggingface.co (or an `HF_ENDPOINT` mirror) is reachable, "
550
+ "without `local_files_only=True` or `HF_HUB_OFFLINE=1`, then delete "
551
+ f"{model_dir}.",
552
+ FutureWarning,
553
+ stacklevel=5,
554
+ )
377
555
  return model_dir
378
556
 
379
557
  if local_files_only:
@@ -425,6 +603,19 @@ class ModelManagement(Generic[T]):
425
603
 
426
604
  return model_dir
427
605
 
606
+ @staticmethod
607
+ def _link_onnx_external_data(model: BaseModelDescription, model_dir: Path) -> Path:
608
+ """Links the external data of `model` right after its download, see link_external_data.
609
+
610
+ Loading the model links it as well, but linking it here also covers lazy_load=True, e.g.
611
+ in a Docker build step, where links made by a later step would copy the files into a new
612
+ image layer. A failure, e.g. in a read-only cache, is reported when the model is loaded.
613
+ """
614
+ if model.model_file.endswith(".onnx"):
615
+ with contextlib.suppress(OSError):
616
+ link_external_data(model_dir, model.model_file, model.additional_files)
617
+ return model_dir
618
+
428
619
  @classmethod
429
620
  def download_model(cls, model: T, cache_dir: str, retries: int = 3, **kwargs: Any) -> Path:
430
621
  """
@@ -440,8 +631,7 @@ class ModelManagement(Generic[T]):
440
631
  "description": "Base English model, v1.5",
441
632
  "size_in_GB": 0.44,
442
633
  "sources": {
443
- "url": "https://storage.googleapis.com/qdrant-fastembed/fast-bge-base-en-v1.5.tar.gz",
444
- "hf": "qdrant/bge-base-en-v1.5-onnx-q",
634
+ "hf": "Qdrant/bge-base-en-v1.5-onnx-Q",
445
635
  }
446
636
  }
447
637
  ```
@@ -466,6 +656,12 @@ class ModelManagement(Generic[T]):
466
656
  extra_patterns = [model.model_file]
467
657
  extra_patterns.extend(model.additional_files)
468
658
 
659
+ # Only a cached file that is present but does not match its recorded size needs
660
+ # force_download: the hub's cache check is existence-only, so a plain snapshot_download
661
+ # would keep the truncated blob. Every other probe failure (a missing file, an absent
662
+ # cache) is served by an ordinary download, which re-uses what is already on disk.
663
+ force_download = False
664
+
469
665
  if hf_source:
470
666
  try:
471
667
  cache_kwargs = deepcopy(kwargs)
@@ -481,7 +677,9 @@ class ModelManagement(Generic[T]):
481
677
  if (resolved_path / model.model_file).exists() and all(
482
678
  (resolved_path / file).exists() for file in extra_patterns
483
679
  ):
484
- return resolved_path
680
+ return cls._link_onnx_external_data(model, resolved_path)
681
+ except CorruptedCacheError:
682
+ force_download = True
485
683
  except Exception:
486
684
  pass
487
685
  finally:
@@ -493,28 +691,35 @@ class ModelManagement(Generic[T]):
493
691
 
494
692
  if hf_source and not local_files_only:
495
693
  # we have already tried loading with `local_files_only=True` via hf and we failed
694
+
695
+ # Merged into kwargs so a caller-supplied force_download can't raise a
696
+ # duplicate keyword error. Cleared straight away: force_download deletes the
697
+ # partially fetched `.incomplete` file, so leaving it on would make every
698
+ # retry restart a large download from zero instead of resuming it.
699
+ attempt_kwargs = {**kwargs, "force_download": True} if force_download else kwargs
700
+ force_download = False
496
701
  try:
497
- return Path(
702
+ model_dir = Path(
498
703
  cls.download_files_from_huggingface(
499
704
  hf_source,
500
705
  cache_dir=cache_dir,
501
706
  extra_patterns=extra_patterns,
502
- **kwargs,
707
+ **attempt_kwargs,
503
708
  )
504
709
  )
505
- except (EnvironmentError, RepositoryNotFoundError, ValueError) as e:
506
- if not local_files_only:
507
- logger.error(
508
- f"Could not download model from HuggingFace: {e} "
509
- "Falling back to other sources."
510
- )
710
+ return cls._link_onnx_external_data(model, model_dir)
711
+ except _HF_DOWNLOAD_ERRORS as e:
712
+ logger.error(
713
+ f"Could not download model from HuggingFace: {e} "
714
+ "Falling back to other sources."
715
+ )
511
716
  finally:
512
717
  enable_progress_bars()
513
718
  if url_source or local_files_only:
514
719
  try:
515
720
  return cls.retrieve_model_gcs(
516
721
  model.model,
517
- str(url_source),
722
+ url_source or "",
518
723
  str(cache_dir),
519
724
  deprecated_tar_struct=model.sources.deprecated_tar_struct,
520
725
  local_files_only=local_files_only,
@@ -522,6 +727,17 @@ class ModelManagement(Generic[T]):
522
727
  except Exception:
523
728
  if not local_files_only:
524
729
  logger.error(f"Could not download model from url: {url_source}")
730
+ elif model.sources.deprecated_tar_struct:
731
+ # Nothing is downloaded from the bucket anymore, but an old copy may still be cached.
732
+ legacy_dir = Path(cache_dir) / f"fast-{model.model.split('/')[-1]}"
733
+ if legacy_dir.is_dir() and any(legacy_dir.iterdir()):
734
+ return cls.retrieve_model_gcs(
735
+ model.model,
736
+ "",
737
+ str(cache_dir),
738
+ deprecated_tar_struct=True,
739
+ local_files_only=True,
740
+ )
525
741
 
526
742
  if local_files_only:
527
743
  logger.error("Could not find model in cache_dir")
@@ -0,0 +1,91 @@
1
+ """Loading ONNX models with external data from a huggingface_hub cache.
2
+
3
+ onnxruntime>=1.24 refuses external data that, once symlinks are resolved, is located outside of
4
+ the directory of the model file, and of the directory the model file resolves to (a fallback of
5
+ 1.24.2, pyproject.toml excludes 1.24.0 and 1.24.1). Since huggingface_hub 1.32, a snapshot is made
6
+ of symlinks into a blob store shared by the whole cache and sharded by hash, where a model and its
7
+ data may resolve into different directories. Then the model and its data are hardlinked into
8
+ `ONNX_SNAPSHOTS_DIR` of the repo cache, laid out as in the snapshot, which takes no extra space.
9
+ """
10
+
11
+ import contextlib
12
+ import os
13
+ import shutil
14
+ from pathlib import Path
15
+
16
+ # Next to `snapshots` in the cache of a huggingface_hub repo: `onnx_snapshots/<revision>/...`
17
+ ONNX_SNAPSHOTS_DIR = "onnx_snapshots"
18
+
19
+
20
+ def link_external_data(model_dir: Path, model_file: str, additional_files: list[str]) -> Path:
21
+ """Returns a path of `model_file` from which onnxruntime can load its external data.
22
+
23
+ Args:
24
+ model_dir (Path): The directory with the model files, e.g. a huggingface_hub snapshot.
25
+ model_file (str): The path of the ONNX file, relative to `model_dir`.
26
+ additional_files (list[str]): Other files of the model, relative to `model_dir`,
27
+ among which its external data.
28
+
29
+ Returns:
30
+ Path: The ONNX file to load, `model_dir / model_file` unless it had to be linked.
31
+
32
+ Raises:
33
+ OSError: If the files had to be linked, but couldn't be, e.g. in a read-only cache or
34
+ on a filesystem without hardlinks.
35
+ """
36
+ model_path = model_dir / model_file
37
+ snapshots_dir = model_dir.parent
38
+ repo_dir = snapshots_dir.parent
39
+ if snapshots_dir.name != "snapshots" or not repo_dir.name.startswith("models--"):
40
+ return model_path # not a huggingface_hub cache, there is no place of ours to link into
41
+ if not model_path.exists():
42
+ return model_path # onnxruntime reports it more clearly than a failed hardlink would
43
+
44
+ # external data is located relative to the model file, so it can't be anywhere else
45
+ data_paths = [
46
+ path
47
+ for path in (model_dir / file for file in additional_files)
48
+ if path.parent.is_relative_to(model_path.parent) and path.exists()
49
+ ]
50
+ # onnxruntime accepts data within these, as in the caches of older huggingface_hub versions
51
+ real_model_dirs = (
52
+ os.path.realpath(model_path.parent),
53
+ os.path.dirname(os.path.realpath(model_path)),
54
+ )
55
+ if all(
56
+ any(Path(os.path.realpath(path)).is_relative_to(d) for d in real_model_dirs)
57
+ for path in data_paths
58
+ ):
59
+ return model_path
60
+
61
+ links_dir = repo_dir / ONNX_SNAPSHOTS_DIR
62
+ for path in (model_path, *data_paths):
63
+ _link_file(path, links_dir / model_dir.name / path.relative_to(model_dir))
64
+
65
+ # the links keep the blobs on disk, so drop those of revisions deleted from the cache
66
+ for revision_dir in links_dir.iterdir():
67
+ if not (snapshots_dir / revision_dir.name).is_dir():
68
+ shutil.rmtree(revision_dir, ignore_errors=True)
69
+
70
+ return links_dir / model_dir.name / model_file
71
+
72
+
73
+ def _link_file(source: Path, link: Path) -> None:
74
+ """Makes `link` a hardlink to the file `source` resolves to, unless it already is one."""
75
+ target = os.path.realpath(source)
76
+ try:
77
+ if os.path.samefile(link, target):
78
+ return
79
+ # a link to a blob downloaded again since, or a copy of the cache without its hardlinks
80
+ link.unlink()
81
+ except FileNotFoundError:
82
+ pass
83
+ except OSError:
84
+ # e.g. such a copy on a read-only filesystem, which works all the same
85
+ if os.path.getsize(link) != os.path.getsize(target):
86
+ raise
87
+ return
88
+ link.parent.mkdir(parents=True, exist_ok=True)
89
+ # os.link is atomic, so if the link exists, another process loading the model just made it
90
+ with contextlib.suppress(FileExistsError):
91
+ os.link(target, link)
@@ -6,9 +6,11 @@ from typing import Any, Generic, Iterable, Sequence, Type, TypeVar
6
6
  import numpy as np
7
7
  import onnxruntime as ort
8
8
 
9
+ from loguru import logger
9
10
  from numpy.typing import NDArray
10
11
  from tokenizers import Tokenizer
11
12
 
13
+ from fastembed.common.onnx_external_data import ONNX_SNAPSHOTS_DIR, link_external_data
12
14
  from fastembed.common.types import OnnxProvider, NumpyArray, Device
13
15
  from fastembed.parallel_processor import Worker
14
16
 
@@ -26,6 +28,8 @@ class OnnxOutputContext:
26
28
 
27
29
  class OnnxModel(Generic[T]):
28
30
  EXPOSED_SESSION_OPTIONS = ("enable_cpu_mem_arena",)
31
+ # set by the concrete model classes in their __init__
32
+ _model_dir: Path
29
33
 
30
34
  @classmethod
31
35
  def _get_worker_class(cls) -> Type["EmbeddingWorker[T]"]:
@@ -76,8 +80,8 @@ class OnnxModel(Generic[T]):
76
80
  cuda: bool | Device = Device.AUTO,
77
81
  device_id: int | None = None,
78
82
  extra_session_options: dict[str, Any] | None = None,
83
+ additional_files: list[str] | None = None,
79
84
  ) -> None:
80
- model_path = model_dir / model_file
81
85
  # List of Execution Providers: https://onnxruntime.ai/docs/execution-providers
82
86
  available_providers = ort.get_available_providers()
83
87
  cuda_available = "CUDAExecutionProvider" in available_providers
@@ -122,9 +126,29 @@ class OnnxModel(Generic[T]):
122
126
  if extra_session_options is not None:
123
127
  self.add_extra_session_options(so, extra_session_options)
124
128
 
125
- self.model = ort.InferenceSession(
126
- str(model_path), providers=onnx_providers, sess_options=so
127
- )
129
+ model_path = model_dir / model_file
130
+ link_error: OSError | None = None
131
+ try:
132
+ model_path = link_external_data(model_dir, model_file, additional_files or [])
133
+ except OSError as e:
134
+ # e.g. a read-only cache, which onnxruntime<1.24 loads from all the same
135
+ link_error = e
136
+
137
+ try:
138
+ self.model = ort.InferenceSession(
139
+ str(model_path), providers=onnx_providers, sess_options=so
140
+ )
141
+ except Exception:
142
+ if link_error is not None:
143
+ logger.warning(
144
+ f"Could not link the files of {model_path} into {ONNX_SNAPSHOTS_DIR}: "
145
+ f"{link_error}. onnxruntime>=1.24 refuses external data that resolves "
146
+ "outside of the model directory, as in a huggingface_hub>=1.32 cache. "
147
+ "Download the model with fastembed into a writable cache_dir on a "
148
+ "filesystem with hardlinks, or delete it from the cache and download it "
149
+ "again with HF_HUB_DISABLE_SHARED_BLOBS=1."
150
+ )
151
+ raise
128
152
  if "CUDAExecutionProvider" in requested_provider_names:
129
153
  assert self.model is not None
130
154
  current_providers = self.model.get_providers()