Skip to main content

bge_m3_embedding_server/embedder/
model_files.rs

1// Copyright (c) 2026 J. Patrick Fulton
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! `HuggingFace` Hub download + cache-layout helpers for the BGE-M3 model files.
16
17use std::path::{Path, PathBuf};
18
19use anyhow::Result;
20use tracing::info;
21
22use crate::config::ModelVariant;
23
24const REPO_ID: &str = "BAAI/bge-m3";
25/// Pinned HF commit — prevents silent model updates and provides supply-chain
26/// integrity for the ONNX weights and tokenizer. Update this hash intentionally
27/// after verifying a new revision produces equivalent embeddings.
28const REPO_REVISION: &str = "5617a9f61b028005a4858fdac845db406aefb181";
29
30const XENOVA_REPO_ID: &str = "Xenova/bge-m3";
31/// Pinned HF commit for the Xenova/bge-m3 FP16 (~1.08 GB) and INT8 (~568 MB) models.
32/// Update intentionally after verifying equivalent embedding quality vs FP32.
33const XENOVA_REPO_REVISION: &str = "4de13258303883538bd53b696b452bf8099f0858";
34
35/// Paths to the ONNX model and tokenizer files resolved from the hf-hub cache.
36pub(super) struct ModelFiles {
37    /// Path to the ONNX model file (variant-specific).
38    pub onnx_path: PathBuf,
39    /// Path to the `tokenizer.json` file.
40    pub tokenizer_path: PathBuf,
41}
42
43/// Returns `true` when the primary ONNX model file already exists in the
44/// hf-hub snapshot cache, meaning `repo.get()` will return immediately
45/// without fetching from the network.
46///
47/// hf-hub 0.5.x layout when constructed with `ApiBuilder::with_cache_dir(p)`:
48/// `{p}/models--{owner}--{name}/snapshots/{revision}/{filename}`
49///
50/// Note: this differs from Python `huggingface_hub`, which appends a `hub/`
51/// segment when `HF_HOME` is set. The Rust crate treats `with_cache_dir`
52/// as `HF_HUB_CACHE` directly — no `hub/` subdirectory is added.
53fn is_model_cached(cache_dir: &Path, repo_id: &str, revision: &str, onnx_filename: &str) -> bool {
54    let repo_dir = format!("models--{}", repo_id.replace('/', "--"));
55    cache_dir
56        .join(repo_dir)
57        .join("snapshots")
58        .join(revision)
59        .join(onnx_filename)
60        .exists()
61}
62
63/// Downloads (or retrieves from the local hf-hub snapshot cache) the ONNX model
64/// and tokenizer files for the given model variant.
65///
66/// `show_progress` enables hf-hub's download progress bar; pass `true` only for
67/// the leader worker (worker 0) so progress is shown exactly once.
68pub(super) fn download_model_files(
69    cache_dir: &Path,
70    show_progress: bool,
71    variant: ModelVariant,
72) -> Result<ModelFiles> {
73    // Fail fast if the cache directory is structurally invalid (e.g. a path
74    // component is a regular file or a non-directory device, the parent is
75    // read-only, or the operator pointed `BGE_M3_CACHE_DIR` at something we
76    // can never write to). `create_dir_all` is idempotent on an already-valid
77    // directory, so this is a no-op on a healthy production setup. The check
78    // is cheap and runs before any network syscall.
79    //
80    // Without this check, `hf_hub::ApiBuilder` defers cache validation until
81    // mid-download — after a `metadata()` HTTP round-trip that has *no*
82    // default ureq timeout. On a runner with a misconfigured cache dir AND
83    // unreliable IPv6 connectivity (notably GitHub Actions), the connect
84    // call to huggingface.co blocks indefinitely instead of letting the
85    // doomed mkdir surface as the actual cause. That hang reaches all the
86    // way up to `EmbedPool::spawn`'s init task, which never sees a ready
87    // signal — manifesting as the spawn-tests timeout on CI.
88    std::fs::create_dir_all(cache_dir).map_err(|e| {
89        anyhow::anyhow!(
90            "Cannot create or access model cache directory {}: {e}",
91            cache_dir.display()
92        )
93    })?;
94
95    let (repo_id, repo_revision) = match variant {
96        ModelVariant::Fp32 => (REPO_ID, REPO_REVISION),
97        ModelVariant::Fp16 | ModelVariant::Int8 => (XENOVA_REPO_ID, XENOVA_REPO_REVISION),
98    };
99
100    // Check the hf-hub snapshot directory for the primary ONNX file before
101    // touching the network.  This lets us log a clear "from cache" message
102    // rather than silence while hf-hub resolves files.
103    let onnx_filename = match variant {
104        ModelVariant::Fp32 => "onnx/model.onnx",
105        ModelVariant::Fp16 => "onnx/model_fp16.onnx",
106        ModelVariant::Int8 => "onnx/model_int8.onnx",
107    };
108    let cached = is_model_cached(cache_dir, repo_id, repo_revision, onnx_filename);
109    if cached {
110        info!(
111            repo_id,
112            revision = repo_revision,
113            model_variant = %variant,
114            "Model files found in local cache — no download needed"
115        );
116    } else {
117        info!(
118            repo_id,
119            revision = repo_revision,
120            model_variant = %variant,
121            "Model files not in local cache — downloading from HuggingFace Hub"
122        );
123    }
124
125    let api = hf_hub::api::sync::ApiBuilder::new()
126        .with_cache_dir(cache_dir.to_path_buf())
127        .with_progress(show_progress)
128        .build()
129        .map_err(|e| anyhow::anyhow!("Failed to build hf-hub API: {e}"))?;
130
131    let repo = api.repo(hf_hub::Repo::with_revision(
132        repo_id.to_string(),
133        hf_hub::RepoType::Model,
134        repo_revision.to_string(),
135    ));
136
137    let onnx_path = match variant {
138        ModelVariant::Fp32 => {
139            let path = repo
140                .get("onnx/model.onnx")
141                .map_err(|e| anyhow::anyhow!("Failed to get onnx/model.onnx: {e}"))?;
142            repo.get("onnx/model.onnx_data")
143                .map_err(|e| anyhow::anyhow!("Failed to get onnx/model.onnx_data: {e}"))?;
144            repo.get("onnx/Constant_7_attr__value")
145                .map_err(|e| anyhow::anyhow!("Failed to get onnx/Constant_7_attr__value: {e}"))?;
146            path
147        }
148        ModelVariant::Fp16 => repo
149            .get("onnx/model_fp16.onnx")
150            .map_err(|e| anyhow::anyhow!("Failed to get onnx/model_fp16.onnx: {e}"))?,
151        ModelVariant::Int8 => repo
152            .get("onnx/model_int8.onnx")
153            .map_err(|e| anyhow::anyhow!("Failed to get onnx/model_int8.onnx: {e}"))?,
154    };
155
156    let tokenizer_path = repo
157        .get("tokenizer.json")
158        .map_err(|e| anyhow::anyhow!("Failed to get tokenizer.json: {e}"))?;
159
160    Ok(ModelFiles {
161        onnx_path,
162        tokenizer_path,
163    })
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::config::ModelVariant;
170
171    /// `download_model_files` must fail synchronously — without ever issuing a
172    /// network request — when the cache directory is structurally impossible
173    /// to create. `/dev/null/impossible` is the canonical bad-cache fixture
174    /// used by `EmbedPool::spawn` tests: `/dev/null` is a character device on
175    /// every Unix, so `mkdir /dev/null/impossible` reliably returns ENOTDIR.
176    ///
177    /// Regression guard: before the upfront `create_dir_all` validation,
178    /// hf-hub's lazy cache layout meant this code path executed a metadata
179    /// HTTP call to huggingface.co first and only attempted the doomed mkdir
180    /// inside the download flow. On runners with no IPv6 connectivity and
181    /// ureq's default `None` connect timeout, that call blocked indefinitely
182    /// and the leader-failure spawn tests timed out on CI.
183    #[cfg(unix)]
184    #[test]
185    fn download_model_files_fails_fast_on_unwritable_cache_dir() {
186        let bad = Path::new("/dev/null/impossible");
187        let started = std::time::Instant::now();
188        let result = download_model_files(bad, false, ModelVariant::Fp32);
189        let elapsed = started.elapsed();
190        let Err(err) = result else {
191            panic!("expected Err for an unwritable cache dir, got Ok");
192        };
193        assert!(
194            elapsed < std::time::Duration::from_secs(2),
195            "validation should fail without a network round-trip; took {elapsed:?}"
196        );
197        let msg = err.to_string();
198        assert!(
199            msg.contains("cache directory"),
200            "error should mention the cache directory; got: {msg}"
201        );
202    }
203}