Skip to content

Commit 21b9df8

Browse files
committed
Add local embed checkpoint routing
1 parent 5197cc6 commit 21b9df8

7 files changed

Lines changed: 221 additions & 26 deletions

File tree

nemo_retriever/src/nemo_retriever/models/__init__.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,18 @@ def _resolve_local_embed_arch(model_arch: str | None) -> bool:
9090
return arch == "vl"
9191

9292

93+
def resolve_embed_model_use_vl(model_name: str | None, *, model_arch: str | None = None) -> bool:
94+
"""Return whether *model_name* should use the VL embedder path.
95+
96+
Registered Hub IDs use the existing VL model allow-list. Local checkpoint
97+
directories do not have a stable Hub ID to match against, so they must
98+
declare their architecture via *model_arch* or ``NRL_LOCAL_EMBED_ARCH``.
99+
"""
100+
if _is_local_checkpoint_dir(model_name):
101+
return _resolve_local_embed_arch(model_arch)
102+
return is_vl_embed_model(model_name)
103+
104+
93105
def create_local_embedder(
94106
model_name: str | None = None,
95107
*,
@@ -135,13 +147,7 @@ def create_local_embedder(
135147
raise ValueError(f"backend must be 'vllm' or 'hf', got {backend!r}")
136148
model_id = resolve_embed_model(model_name)
137149

138-
# Registered Hub ids select VL vs text by the id allow-list (unchanged). A
139-
# local checkpoint dir is not in the allow-list, so it must declare its
140-
# architecture explicitly (fail-loud rather than guess).
141-
if _is_local_checkpoint_dir(model_name):
142-
use_vl = _resolve_local_embed_arch(model_arch)
143-
else:
144-
use_vl = is_vl_embed_model(model_name)
150+
use_vl = resolve_embed_model_use_vl(model_name, model_arch=model_arch)
145151

146152
if use_vl:
147153
if b == "hf":

nemo_retriever/src/nemo_retriever/models/hf_model_registry.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,14 @@
4747
}
4848

4949

50-
def get_hf_revision(model_id: str, *, strict: bool = True) -> str | None:
50+
def _is_local_model_dir(model_id: str) -> bool:
51+
"""Return whether *model_id* is a local HF model directory."""
52+
return (
53+
bool(model_id) and os.path.isdir(str(model_id)) and os.path.isfile(os.path.join(str(model_id), "config.json"))
54+
)
55+
56+
57+
def get_hf_revision(model_id: str, *, strict: bool = True, allow_local_path: bool = False) -> str | None:
5158
"""Return the pinned commit SHA for *model_id*.
5259
5360
Parameters
@@ -59,16 +66,15 @@ def get_hf_revision(model_id: str, *, strict: bool = True) -> str | None:
5966
no pinned revision. When ``False``, log a warning and return
6067
``None`` so that ``from_pretrained`` falls back to the ``main``
6168
branch.
69+
allow_local_path:
70+
When ``True``, a local model directory containing ``config.json`` is
71+
allowed to return ``None`` because it has no Hub commit SHA to pin.
6272
"""
6373
revision = HF_MODEL_REVISIONS.get(model_id)
6474
if revision is not None:
6575
return revision
6676

67-
# A local filesystem checkpoint has no Hub commit to pin, so the revision
68-
# gate does not apply: load the on-disk files as-is. This is scoped to
69-
# directories only -- unregistered *Hub* ids still hit the strict gate
70-
# below, preserving the supply-chain pin for remote models.
71-
if model_id and os.path.isdir(str(model_id)):
77+
if allow_local_path and _is_local_model_dir(model_id):
7278
return None
7379

7480
msg = (

nemo_retriever/src/nemo_retriever/models/inference/processor.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,9 @@ def maybe_inject_local_hf_embedder(task_config: Dict[str, Any], transform_config
9191
return
9292

9393
from nemo_retriever.model import (
94-
_is_local_checkpoint_dir,
95-
_resolve_local_embed_arch,
9694
create_local_embedder,
97-
is_vl_embed_model,
9895
resolve_embed_model,
96+
resolve_embed_model_use_vl,
9997
)
10098

10199
embed_model = resolve_embed_model(
@@ -124,12 +122,7 @@ def maybe_inject_local_hf_embedder(task_config: Dict[str, Any], transform_config
124122

125123
prefix = f"{transform_config.input_type}: " if getattr(transform_config, "input_type", None) else ""
126124

127-
# A local checkpoint dir declares vl/text explicitly (same resolver as the
128-
# factory); registered ids fall back to the id allow-list.
129-
if _is_local_checkpoint_dir(embed_model):
130-
use_vl = _resolve_local_embed_arch(model_arch)
131-
else:
132-
use_vl = is_vl_embed_model(embed_model)
125+
use_vl = resolve_embed_model_use_vl(embed_model, model_arch=model_arch)
133126

134127
if use_vl:
135128

nemo_retriever/src/nemo_retriever/models/local/llama_nemotron_embed_1b_v2_embedder.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def _ensure_loaded(self) -> None:
6868
max_model_len = int(self.max_length) if int(self.max_length) > 0 else None
6969
self._llm = create_vllm_llm(
7070
str(model_id),
71-
revision=get_hf_revision(model_id),
71+
revision=get_hf_revision(model_id, allow_local_path=True),
7272
dimensions=self.dimensions,
7373
gpu_memory_utilization=self.gpu_memory_utilization,
7474
enforce_eager=self.enforce_eager,

nemo_retriever/src/nemo_retriever/models/local/llama_nemotron_embed_1b_v2_hf_embedder.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def _ensure_loaded(self) -> None:
5656
model_id = self.model_id or _DEFAULT_EMBED_MODEL
5757
dev = torch.device(self.device or ("cuda" if torch.cuda.is_available() else "cpu"))
5858
hf_cache_dir = configure_global_hf_cache_base(self.hf_cache_dir)
59-
_revision = get_hf_revision(model_id)
59+
_revision = get_hf_revision(model_id, allow_local_path=True)
6060
self._tokenizer = AutoTokenizer.from_pretrained(
6161
model_id,
6262
revision=_revision,

nemo_retriever/src/nemo_retriever/models/local/llama_nemotron_embed_vl_1b_v2_embedder.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def _ensure_loaded(self) -> None:
7070
# device_map when requesting it. Fall back to sdpa/eager on CPU or
7171
# when flash-attn is not installed.
7272
use_gpu = dev.type == "cuda"
73-
_revision = get_hf_revision(model_id)
73+
_revision = get_hf_revision(model_id, allow_local_path=True)
7474
for attn_impl in ("flash_attention_2", "sdpa", "eager"):
7575
try:
7676
kwargs: dict[str, Any] = {
@@ -234,7 +234,7 @@ def _ensure_loaded(self) -> None:
234234
model_id = self.model_id or "nvidia/llama-nemotron-embed-vl-1b-v2"
235235
self._llm = create_vllm_llm(
236236
str(model_id),
237-
revision=get_hf_revision(model_id),
237+
revision=get_hf_revision(model_id, allow_local_path=True),
238238
gpu_memory_utilization=self.gpu_memory_utilization,
239239
enforce_eager=self.enforce_eager,
240240
limit_mm_per_prompt={"image": 1},
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2024-25, NVIDIA CORPORATION & AFFILIATES.
2+
# All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
"""Tests for dropping in a local embedding checkpoint directory.
6+
7+
Two mechanisms are exercised, both scoped to on-disk checkpoints only so that
8+
registered Hub ids keep their existing behavior:
9+
10+
- The revision pin is bypassed for local model directories only when the
11+
caller explicitly opts into local-path loading.
12+
- ``create_local_embedder`` routes a local dir to the VL or text embedder based
13+
on an *explicit* ``vl``/``text`` declaration (arg or ``NRL_LOCAL_EMBED_ARCH``),
14+
failing loudly rather than inferring.
15+
"""
16+
17+
import sys
18+
from types import ModuleType
19+
from unittest.mock import MagicMock
20+
21+
import pytest
22+
23+
from nemo_retriever.models import (
24+
LOCAL_EMBED_ARCH_ENV,
25+
create_local_embedder,
26+
create_local_query_embedder,
27+
resolve_embed_model_use_vl,
28+
)
29+
from nemo_retriever.models.hf_model_registry import get_hf_revision
30+
31+
# ---------------------------------------------------------------------------
32+
# Lock: get_hf_revision is bypassed for local dirs, unchanged for Hub ids
33+
# ---------------------------------------------------------------------------
34+
35+
36+
def test_local_dir_requires_explicit_revision_pin_bypass(tmp_path):
37+
(tmp_path / "config.json").write_text("{}", encoding="utf-8")
38+
39+
with pytest.raises(ValueError, match="No pinned HuggingFace revision"):
40+
get_hf_revision(str(tmp_path))
41+
42+
assert get_hf_revision(str(tmp_path), allow_local_path=True) is None
43+
assert get_hf_revision(str(tmp_path), strict=True, allow_local_path=True) is None
44+
45+
46+
def test_local_dir_without_model_config_does_not_bypass_revision_pin(tmp_path):
47+
with pytest.raises(ValueError, match="No pinned HuggingFace revision"):
48+
get_hf_revision(str(tmp_path), allow_local_path=True)
49+
50+
51+
def test_registered_hub_id_still_pinned():
52+
assert get_hf_revision("nvidia/llama-nemotron-embed-1b-v2") == "b4caa8456edd360b3b4e938d94ed4398dd437fad"
53+
54+
55+
def test_unregistered_hub_id_still_raises():
56+
with pytest.raises(ValueError, match="No pinned HuggingFace revision"):
57+
get_hf_revision("some-org/not-registered")
58+
59+
60+
def test_unregistered_hub_id_non_strict_returns_none():
61+
assert get_hf_revision("some-org/not-registered", strict=False) is None
62+
63+
64+
# ---------------------------------------------------------------------------
65+
# Routing: local dir -> VL or text embedder by explicit declaration
66+
# ---------------------------------------------------------------------------
67+
68+
69+
def test_resolver_routes_registered_vl_id_without_arch(_patch_embedders):
70+
assert resolve_embed_model_use_vl("nvidia/llama-nemotron-embed-vl-1b-v2") is True
71+
72+
73+
def test_resolver_routes_registered_text_id_without_arch(_patch_embedders):
74+
assert resolve_embed_model_use_vl("nvidia/llama-nemotron-embed-1b-v2") is False
75+
76+
77+
def test_resolver_routes_local_dir_from_arch(tmp_path, _patch_embedders):
78+
assert resolve_embed_model_use_vl(str(tmp_path), model_arch="vl") is True
79+
assert resolve_embed_model_use_vl(str(tmp_path), model_arch="text") is False
80+
81+
82+
@pytest.fixture(autouse=True)
83+
def _patch_embedders(monkeypatch):
84+
"""Stub the four embedder classes so no real model is loaded.
85+
86+
Mirrors test_create_local_embedder.py: the ``model.local`` package exposes
87+
classes lazily, so inject fake submodules directly into ``sys.modules``.
88+
"""
89+
fake_text_vllm = MagicMock(name="LlamaNemotronEmbed1BV2Embedder")
90+
fake_text_hf = MagicMock(name="LlamaNemotronEmbed1BV2HFEmbedder")
91+
fake_vl_hf = MagicMock(name="LlamaNemotronEmbedVL1BV2Embedder")
92+
fake_vl_vllm = MagicMock(name="LlamaNemotronEmbedVL1BV2VLLMEmbedder")
93+
94+
text_mod = ModuleType("nemo_retriever.models.local.llama_nemotron_embed_1b_v2_embedder")
95+
text_mod.LlamaNemotronEmbed1BV2Embedder = fake_text_vllm
96+
97+
text_hf_mod = ModuleType("nemo_retriever.models.local.llama_nemotron_embed_1b_v2_hf_embedder")
98+
text_hf_mod.LlamaNemotronEmbed1BV2HFEmbedder = fake_text_hf
99+
100+
vl_mod = ModuleType("nemo_retriever.models.local.llama_nemotron_embed_vl_1b_v2_embedder")
101+
vl_mod.LlamaNemotronEmbedVL1BV2Embedder = fake_vl_hf
102+
vl_mod.LlamaNemotronEmbedVL1BV2VLLMEmbedder = fake_vl_vllm
103+
104+
monkeypatch.setitem(sys.modules, "nemo_retriever.models.local.llama_nemotron_embed_1b_v2_embedder", text_mod)
105+
monkeypatch.setitem(sys.modules, "nemo_retriever.models.local.llama_nemotron_embed_1b_v2_hf_embedder", text_hf_mod)
106+
monkeypatch.setitem(sys.modules, "nemo_retriever.models.local.llama_nemotron_embed_vl_1b_v2_embedder", vl_mod)
107+
monkeypatch.delenv(LOCAL_EMBED_ARCH_ENV, raising=False)
108+
109+
yield fake_text_vllm, fake_text_hf, fake_vl_hf, fake_vl_vllm
110+
111+
112+
def test_local_dir_arch_vl_routes_to_vl_vllm(tmp_path, _patch_embedders):
113+
_, _, _, fake_vl_vllm = _patch_embedders
114+
result = create_local_embedder(str(tmp_path), model_arch="vl") # default backend vllm
115+
fake_vl_vllm.assert_called_once()
116+
assert fake_vl_vllm.call_args.kwargs["model_id"] == str(tmp_path)
117+
assert result is fake_vl_vllm.return_value
118+
119+
120+
def test_local_dir_arch_vl_hf_routes_to_vl_hf(tmp_path, _patch_embedders):
121+
_, _, fake_vl_hf, _ = _patch_embedders
122+
result = create_local_embedder(str(tmp_path), backend="hf", model_arch="vl")
123+
fake_vl_hf.assert_called_once()
124+
assert result is fake_vl_hf.return_value
125+
126+
127+
def test_local_dir_arch_text_hf_routes_to_text_hf(tmp_path, _patch_embedders):
128+
_, fake_text_hf, _, _ = _patch_embedders
129+
result = create_local_embedder(str(tmp_path), backend="hf", model_arch="text")
130+
fake_text_hf.assert_called_once()
131+
assert fake_text_hf.call_args.kwargs["model_id"] == str(tmp_path)
132+
assert result is fake_text_hf.return_value
133+
134+
135+
def test_local_dir_arch_text_vllm_routes_to_text_vllm(tmp_path, _patch_embedders):
136+
fake_text_vllm, _, _, _ = _patch_embedders
137+
result = create_local_embedder(str(tmp_path), model_arch="text")
138+
fake_text_vllm.assert_called_once()
139+
assert result is fake_text_vllm.return_value
140+
141+
142+
def test_local_dir_without_arch_fails_loud(tmp_path, _patch_embedders):
143+
with pytest.raises(ValueError, match=LOCAL_EMBED_ARCH_ENV):
144+
create_local_embedder(str(tmp_path), backend="hf")
145+
146+
147+
def test_local_dir_invalid_arch_fails_loud(tmp_path, _patch_embedders):
148+
with pytest.raises(ValueError, match=LOCAL_EMBED_ARCH_ENV):
149+
create_local_embedder(str(tmp_path), backend="hf", model_arch="multimodal")
150+
151+
152+
def test_local_dir_arch_from_env(tmp_path, monkeypatch, _patch_embedders):
153+
_, _, fake_vl_hf, _ = _patch_embedders
154+
monkeypatch.setenv(LOCAL_EMBED_ARCH_ENV, "vl")
155+
result = create_local_embedder(str(tmp_path), backend="hf")
156+
fake_vl_hf.assert_called_once()
157+
assert result is fake_vl_hf.return_value
158+
159+
160+
def test_explicit_arg_overrides_env(tmp_path, monkeypatch, _patch_embedders):
161+
_, fake_text_hf, fake_vl_hf, _ = _patch_embedders
162+
monkeypatch.setenv(LOCAL_EMBED_ARCH_ENV, "vl")
163+
create_local_embedder(str(tmp_path), backend="hf", model_arch="text")
164+
fake_text_hf.assert_called_once()
165+
fake_vl_hf.assert_not_called()
166+
167+
168+
def test_query_embedder_forwards_arch_for_local_dir(tmp_path, _patch_embedders):
169+
_, _, fake_vl_hf, _ = _patch_embedders
170+
result = create_local_query_embedder(str(tmp_path), backend="hf", model_arch="vl")
171+
fake_vl_hf.assert_called_once()
172+
assert result is fake_vl_hf.return_value
173+
174+
175+
def test_query_embedder_local_dir_without_arch_fails_loud(tmp_path, _patch_embedders):
176+
with pytest.raises(ValueError, match=LOCAL_EMBED_ARCH_ENV):
177+
create_local_query_embedder(str(tmp_path), backend="hf")
178+
179+
180+
# ---------------------------------------------------------------------------
181+
# Registered Hub ids: routing is unchanged (no arch needed)
182+
# ---------------------------------------------------------------------------
183+
184+
185+
def test_registered_id_ignores_arch_and_uses_allowlist(_patch_embedders):
186+
_, _, _, fake_vl_vllm = _patch_embedders
187+
# The VL default id routes to VL regardless of any arch hint.
188+
result = create_local_embedder("nvidia/llama-nemotron-embed-vl-1b-v2")
189+
fake_vl_vllm.assert_called_once()
190+
assert result is fake_vl_vllm.return_value

0 commit comments

Comments
 (0)