Skip to navigation

Embedding Model Customization

View as Markdown

Fine-tune Nemotron 3 Embed 1B on domain retrieval data and score it against the base checkpoint with BEIR nDCG and Recall. This is the NeMo Helix mapping of the Nemotron embed recipe.

Pre-trained embeddings handle general retrieval well but underperform on specialized vocabulary, document structure, and query phrasing. Fine-tuning adapts the model to your corpus. Expect larger gains where the base model has the least prior exposure, such as legal, biomedical, and internal engineering documents, and smaller ones on corpora close to its pre-training mix.

This notebook starts from NVIDIA’s published NVDocs Stage 0 dump instead of generating Q&A pairs, so the first job it runs is hard-negative mining.

StageCommandNotes
0 SDGnemo data-designer retrieval-generateSkipped here. Run it on your own corpus with Generate retrieval training data
1 Prepnemo data-designer retrieval-prepare with enable_mining: trueWrites training.jsonl and the frozen eval_beir/ split
2 FinetuneAutomodel recipe: bi_encoderAlternate data: SPECTER or triplet JSONL
3 DeployRetriever NIM 2.2.0Base and tuned endpoints
4 Evalnemo evals retrieve-evalnDCG@k, Recall@k, Precision@k, MAP@k

Deployment precedes evaluation because retrieve-eval scores Inference Gateway providers rather than filesets, so both models have to be served first. Export is a setting on the training job rather than a stage of its own.

Dataset format

Stage 1 writes contrastive rows as JSONL:

{"query": "What is machine learning?", "pos_doc": "Machine learning is a subset of AI...", "neg_doc": ["A related but incorrect passage..."]}

neg_doc is a non-empty list of hard negatives: passages that score close to the positive but do not answer the query. Mining fills it during Stage 1 against a fileset-backed encoder, and Automodel samples four of them per query (train_n_passages: 5).

Prerequisites

  • A running NeMo Helix and a CLI context that points at it. See the Quickstart and Connect with CLI Contexts.
  • The Python SDK: pip install "nemo-helix[all]", or make bootstrap from the repo root.
  • An NGC API key for the nvcr.io NIM images.
  • GPU capacity: 4× 80 GB for the validated training job, about 40 GB for mining and evaluation. Serving the base and tuned NIMs side by side takes two GPUs.
  • A fileset-backed encoder entity, created in the next section. Models that Inference Gateway discovers from a running endpoint have fileset: null and can be neither trained nor used as the mining encoder.

1. Initialize the SDK

The SDK and the nemo CLI connect to the deployment in your current CLI context. To connect to another deployment, refer to Connect with CLI Contexts. These examples use the default workspace. If your current CLI context uses a different workspace, run nemo config set --workspace default first.

In environments without a CLI configuration file, such as Google Colab, set NHX_BASE_URL (and NHX_ACCESS_TOKEN for an authenticated deployment) before you create the client. NemoClient.from_config() reads both:

import os
os.environ["NHX_BASE_URL"] = "<YOUR_NHX_BASE_URL>"

Export your NGC API key. It becomes a Platform secret so the cluster can pull NIM images:

export NGC_API_KEY=<YOUR_NGC_API_KEY>
echo "$NGC_API_KEY" | nemo secrets create ngc-api-key --workspace default --from-file -
import json
import os
import time
import uuid
from IPython.display import clear_output
from nemo_helix_plugin.client.client import NemoClient
from nemo_helix_plugin.client.errors import ConflictError
from nemo_helix_plugin.files.storage_config import HuggingfaceStorageConfig
from nemo_helix_plugin.files.types import CreateFilesetRequest, FilesetPurpose
from nemo_helix_plugin.models.types import (
ContainerExecutorConfig,
CreateModelDeploymentConfigRequest,
CreateModelDeploymentRequest,
CreateModelEntityRequest,
Engine,
ModelDeploymentConfigModelSpec,
UpdateModelEntityRequest,
)
from nemo_helix_plugin.secrets.types import HelixSecretCreateRequest
from nemo_customizer.sdk import Customization
from nemo_data_designer_plugin.sdk.resources import DataDesignerResource
from filesets import FilesetFileSystem
from nemo_helix_plugin.files.client import FilesClient
from pydantic import SecretStr
from nemo_helix_plugin.schema import SecretRef
client = NemoClient.from_config()
customization = Customization.from_client(client)
data_designer = DataDesignerResource(client)
print(f"Platform: {client.base_url}")

2. Register the trainable base (Nemotron 3 Embed 1B)

Mining and training both read weights from a fileset, so register the Hugging Face checkpoint as a fileset and back a model entity with it.

Equivalent CLI:

nemo files filesets create nemotron-3-embed-1b --workspace default --purpose model --exist-ok \
--description "Nemotron 3 Embed 1B BF16 checkpoint" \
--storage '{"type":"huggingface","repo_id":"nvidia/Nemotron-3-Embed-1B-BF16","repo_type":"model"}'
nemo models create nemotron-3-embed-1b --workspace default --exist-ok \
--fileset default/nemotron-3-embed-1b --trust-remote-code
nemo models get nemotron-3-embed-1b --workspace default

The entity is ready once nemo models get reports a non-null fileset and a populated spec. For a gated repo, create the token first with nemo secrets create hf-token --value "$HF_TOKEN" and add "token_secret":"hf-token" to --storage.

from nemo_helix_plugin.files.storage_config import HuggingfaceStorageConfig
NGC_API_KEY = os.environ.get("NGC_API_KEY")
if not NGC_API_KEY:
raise ValueError("NGC_API_KEY is required to pull Retriever NIM images from nvcr.io")
NGC_SECRET_NAME = "ngc-api-key"
try:
client.secrets.create_secret(body=HelixSecretCreateRequest(name=NGC_SECRET_NAME, value=SecretStr(NGC_API_KEY))).data()
print(f"Created secret: {NGC_SECRET_NAME}")
except ConflictError:
print(f"Secret '{NGC_SECRET_NAME}' already exists")
HF_TOKEN = os.getenv("HF_TOKEN")
HF_REPO_ID = "nvidia/Nemotron-3-Embed-1B-BF16"
MODEL_NAME = "nemotron-3-embed-1b"
if HF_TOKEN:
try:
client.secrets.create_secret(body=HelixSecretCreateRequest(name="hf-token", value=SecretStr(HF_TOKEN))).data()
except ConflictError:
pass
storage = HuggingfaceStorageConfig(
repo_id=HF_REPO_ID,
repo_type="model",
token_secret=SecretRef("hf-token"),
)
else:
storage = HuggingfaceStorageConfig(
repo_id=HF_REPO_ID,
repo_type="model",
)
try:
client.files.create_fileset(
body=CreateFilesetRequest(
name=MODEL_NAME,
purpose=FilesetPurpose.MODEL,
description="Nemotron 3 Embed 1B BF16 checkpoint",
storage=storage,
),
).data()
except ConflictError:
print("Base model fileset already exists")
try:
base_model = client.models.create_model(
body=CreateModelEntityRequest(
name=MODEL_NAME,
fileset=f"default/{MODEL_NAME}",
trust_remote_code=True,
),
).data()
print(f"Created Model Entity: {MODEL_NAME}")
except ConflictError:
base_model = client.models.update_model(
name=MODEL_NAME,
body=UpdateModelEntityRequest(
fileset=f"default/{MODEL_NAME}",
trust_remote_code=True,
),
).data()
print(f"Updated Model Entity: {MODEL_NAME}")
print("Waiting for ModelSpec...")
spec_start = time.time()
while not getattr(base_model, "spec", None):
if time.time() - spec_start > 180:
raise TimeoutError("ModelSpec not populated")
time.sleep(3)
base_model = client.models.get_model(name=MODEL_NAME).data()
print(f"fileset={base_model.fileset} spec={base_model.spec}")
if not base_model.fileset:
raise RuntimeError("Entity has a null fileset; it cannot be trained. Recreate from the Hugging Face fileset.")

3. Stage 1: mine hard negatives from the NVDocs dump

Retrieval-Synthetic-NVDocs-v1 is NVIDIA’s published Stage 0 output, so Stage 1 reads it directly and no LLM is called. Point sdg_input at the dump, turn on mining, and name the encoder entity from the previous section.

A hard negative is a corpus passage that the current encoder ranks near the positive even though it does not answer the query. Mining embeds every training query and the whole corpus on GPU, then keeps the highest-scoring passages below min_positive_score * hard_neg_margin. Start with hard_neg_margin: 0.95 and hard_negatives_to_mine: 5; Stage 2 uses four of those candidates with train_n_passages: 5. Raise the margin toward 1.0 for harder negatives, or lower it to 0.85–0.90 to reduce false negatives. Change it only if Stage 4 metrics plateau. See the Nemotron recipe’s hard-negative mining guidance.

To generate Q&A pairs from your own corpus first, run Generate retrieval training data and pass that job’s artifacts fileset as sdg_input.

Equivalent CLI:

nemo data-designer retrieval-prepare --workspace default --spec '{
"sdg_input": "hf://nvidia/Retrieval-Synthetic-NVDocs-v1@1c0d1856f3fb595b2dda98d4b61061fa6d782d51/nv_pp_dd_sdg.json",
"enable_mining": true,
"model": "default/nemotron-3-embed-1b",
"hard_negatives_to_mine": 5,
"hard_neg_margin": 0.95,
"mining": {"query_embedding_batch_size": 64, "document_embedding_batch_size": 64}
}'
nemo jobs watch <prepare-job> --workspace default
from nemo_data_designer_plugin.jobs.retrieval_spec import RetrievalMiningOptions, RetrievalPrepareJobConfig
NVDOCS_SDG = "hf://nvidia/Retrieval-Synthetic-NVDocs-v1@1c0d1856f3fb595b2dda98d4b61061fa6d782d51/nv_pp_dd_sdg.json"
prepare_spec = RetrievalPrepareJobConfig(
sdg_input=NVDOCS_SDG,
enable_mining=True,
model=f"default/{MODEL_NAME}",
hard_negatives_to_mine=5,
hard_neg_margin=0.95,
mining=RetrievalMiningOptions(
query_embedding_batch_size=64,
document_embedding_batch_size=64,
),
)
prepare_job = data_designer.retrieval_prepare(prepare_spec, workspace="default")
print(f"Prepare job: {prepare_job.name}")
prepare_job.wait_until_done()
prepare_status = prepare_job.get_job_status()
print(f"Stage 1 status: {prepare_status}")
if prepare_status != "completed":
raise RuntimeError(f"retrieval-prepare finished with status {prepare_status}")

Stage 1 writes training.jsonl and eval_beir/ under its job result as workspace/fileset#path.

Automodel dataset.training and retrieve-eval dataset take a fileset name with no path fragment, so copy training.jsonl and eval_beir/ to the root of a dataset fileset. Stage 1 unique-keys eval_beir/qrels/test.tsv before writing it; still drop duplicate (query-id, corpus-id) rows if you are uploading an older dump, because the BEIR loader rejects them. The next cell does the copy and unique-key.

Freeze the resulting fileset and reuse it for every run. Scoring two runs on different splits measures data variance rather than model quality.

Equivalent CLI, where RESULT_PATH is the path fragment of the artifacts result’s artifact_url:

PREPARE_JOB=<prepare-job-name>
RESULT_PATH=results/<attempt-id>/artifacts
nemo jobs results list "$PREPARE_JOB" --workspace default --output json
nemo files download "job-fileset-$PREPARE_JOB" --workspace default \
--remote-path "$RESULT_PATH/training.jsonl" \
-o ./artifacts/training.jsonl
nemo files download "job-fileset-$PREPARE_JOB" --workspace default \
--remote-path "$RESULT_PATH/eval_beir" \
-o ./artifacts/
nemo files filesets create nvdocs-embed-artifacts --workspace default \
--purpose dataset --exist-ok
nemo files upload ./artifacts/ nvdocs-embed-artifacts --workspace default
nemo files list nvdocs-embed-artifacts --workspace default

Unique-key ./artifacts/eval_beir/qrels/test.tsv before the upload; the cell below does it inline.

import csv
import tempfile
from pathlib import Path
results = client.jobs.results.list(prepare_job.name, workspace="default").data
artifacts = next(row for row in results if row.name == "artifacts")
ARTIFACTS_REF = artifacts.artifact_url
print("Stage 1 artifacts:", ARTIFACTS_REF)
TRAINING_FILESET = "nvdocs-embed-artifacts"
fileset_ref, _, remote_prefix = ARTIFACTS_REF.partition("#")
job_fileset = fileset_ref.split("/", 1)[-1]
local_dir = Path(tempfile.mkdtemp(prefix="nvdocs-artifacts-"))
fs = FilesetFileSystem(client=FilesClient.from_client(client))
fs.get(
rpath=f"default/{job_fileset}/{remote_prefix}/training.jsonl",
lpath=str(local_dir / "training.jsonl"),
)
# eval_beir arrives under local_dir; a destination already named eval_beir nests as eval_beir/eval_beir
fs.get(
rpath=f"default/{job_fileset}/{remote_prefix}/eval_beir",
lpath=str(local_dir),
recursive=True,
)
if not (local_dir / "eval_beir" / "corpus.jsonl").is_file():
raise RuntimeError(f"eval_beir/corpus.jsonl missing under {local_dir}")
# the BEIR loader rejects duplicate judgments
qrels_path = local_dir / "eval_beir" / "qrels" / "test.tsv"
with qrels_path.open(encoding="utf-8", newline="") as stream:
reader = csv.DictReader(stream, delimiter="\t")
header = reader.fieldnames
if header is None:
raise RuntimeError(f"{qrels_path} is missing a header")
seen: set[tuple[str, str]] = set()
kept: list[dict[str, str]] = []
for row in reader:
key = (row["query-id"], row["corpus-id"])
if key not in seen:
seen.add(key)
kept.append(row)
with qrels_path.open("w", encoding="utf-8", newline="") as stream:
writer = csv.DictWriter(stream, fieldnames=header, delimiter="\t", lineterminator="\n")
writer.writeheader()
writer.writerows(kept)
print(f"qrels judgments: {len(kept)}")
try:
client.files.create_fileset(
body=CreateFilesetRequest(
name=TRAINING_FILESET,
purpose=FilesetPurpose.DATASET,
description="NVDocs mined retrieval artifacts",
),
).data()
except ConflictError:
pass
fs.put(
lpath=f"{local_dir}/",
rpath=f"default/{TRAINING_FILESET}/",
recursive=True,
)
TRAINING_REF = f"default/{TRAINING_FILESET}"
print("dataset.training and retrieve-eval dataset:", TRAINING_REF)

4. Stage 2: Automodel bi_encoder

model and dataset.training are independent inputs. The entity supplies the encoder weights; the fileset supplies the contrastive rows, with training.jsonl at its root. To train on an existing triplet dataset instead, build that fileset in Train on SPECTER or triplet JSONL and point dataset.training at it — SPECTER carries no eval_beir, so keep the NVDocs artifacts fileset for Stage 4.

The NVDocs artifacts fileset has no validation JSONL. schedule.validation_split: 0.01 therefore creates a deterministic 1% holdout from training.jsonl; that holdout produces validation loss and selects the best checkpoint. It does not modify or replace the frozen eval_beir/ data used in Stage 4. Validation data is optional: when a val*.jsonl, validation*.jsonl, or dev*.jsonl file is present in the training fileset, Automodel uses it instead of creating the holdout.

These values are the configuration validated on the frozen 20,909-query NVDocs split. The scores under Interpreting results are from this job.

KnobValue
training.recipebi_encoder
training.finetuning_typeall_weights, or lora_merged to merge adapters back into the checkpoint
training.retrieval.export.primaryhf
training.retrieval.do_distributed_inbatch_negativefalse
Sequence length512, the same at mine, train, and eval time
LR / min LR / warmup2.5e-5 / 1.25e-5 / 432
GBS / MBS128 / 8
Epochs3
GPUs4, tensor parallel 1
Validationvalidation_split: 0.01, val_check_interval: 0.25, checkpoint_selection: both

export.primary: hf writes the Hugging Face checkpoint to the fileset root, where Retriever NIM 2.2.0 looks for it, and keeps ONNX under alternates/onnx. Unmerged lora is rejected for bi_encoder, since an embedding NIM serves a full checkpoint and cannot load a standalone adapter.

Equivalent CLI:

nemo customization automodel.jobs submit --workspace default --name embed-nvdocs --spec '{
"model": "default/nemotron-3-embed-1b",
"dataset": {"training": "default/nvdocs-embed-artifacts"},
"training": {
"training_type": "sft",
"recipe": "bi_encoder",
"finetuning_type": "all_weights",
"execution_profile": "gpu",
"max_seq_length": 512,
"precision": "bf16",
"attn_implementation": "flash_attention_2",
"retrieval": {
"query_max_length": 512,
"passage_max_length": 512,
"train_n_passages": 5,
"do_distributed_inbatch_negative": false,
"export": {"primary": "hf"}
}
},
"schedule": {"epochs": 3, "seed": 42, "validation_split": 0.01, "val_check_interval": 0.25, "checkpoint_selection": "both"},
"batch": {"global_batch_size": 128, "micro_batch_size": 8},
"optimizer": {"learning_rate": 2.5e-5, "min_learning_rate": 1.25e-5, "warmup_steps": 432, "weight_decay": 0.01, "lr_decay_style": "cosine"},
"parallelism": {"num_gpus_per_node": 4, "tensor_parallel_size": 1},
"output": {"name": "nemotron-3-embed-1b-tuned"}
}'
nemo jobs watch embed-nvdocs --workspace default

training.recipe, training.finetuning_type, and training.retrieval.export.primary have no per-flag form; supply them through --spec or --spec-file.

from nemo_automodel_plugin.schema import (
AutomodelJobInput,
BatchSpec,
DatasetSpec,
ExportSpec,
OptimizerSpec,
OutputRequest,
ParallelismSpec,
RetrievalSpec,
ScheduleSpec,
TrainingSpec,
)
job_suffix = uuid.uuid4().hex[:4]
JOB_NAME = f"embed-nvdocs-{job_suffix}"
OUTPUT_NAME = f"nemotron-3-embed-1b-tuned-{job_suffix}"
spec = AutomodelJobInput(
model=f"default/{MODEL_NAME}",
dataset=DatasetSpec(training=TRAINING_REF),
training=TrainingSpec(
training_type="sft",
recipe="bi_encoder",
finetuning_type="all_weights",
execution_profile="gpu",
max_seq_length=512,
precision="bf16",
attn_implementation="flash_attention_2",
retrieval=RetrievalSpec(
query_max_length=512,
passage_max_length=512,
train_n_passages=5,
do_distributed_inbatch_negative=False,
export=ExportSpec(primary="hf"),
),
),
schedule=ScheduleSpec(
epochs=3,
seed=42,
validation_split=0.01,
val_check_interval=0.25,
checkpoint_selection="both",
),
batch=BatchSpec(global_batch_size=128, micro_batch_size=8),
optimizer=OptimizerSpec(
learning_rate=2.5e-5,
min_learning_rate=1.25e-5,
warmup_steps=432,
weight_decay=0.01,
lr_decay_style="cosine",
),
parallelism=ParallelismSpec(num_gpus_per_node=4, tensor_parallel_size=1),
output=OutputRequest(name=OUTPUT_NAME),
)
job = customization.automodel.jobs.create(
spec=spec.model_dump(mode="json", exclude_unset=True),
workspace="default",
name=JOB_NAME,
)
print(f"Submitted job: {JOB_NAME}")
print(f"Output model: {OUTPUT_NAME}")
import time
# Poll the job until it reaches a terminal state
while True:
status = client.jobs.get_status(name=JOB_NAME, workspace="default")
print(f"Status: {status.status}")
if status.status.lower() in ("completed", "failed", "cancelled", "error"):
break
time.sleep(30)

Interpreting contrastive loss

The automatic 1% holdout above supplies the validation loss for this NVDocs job. A healthy run drops sharply over the first 20–30% of training, then flattens toward a stable floor with validation loss tracking close behind. Validation loss rising while training loss falls is overfitting: cut epochs. Spikes or NaN mean the learning rate is too high. A curve that never moves points at the learning rate being too low or at the data itself.

There is no target loss value, since the absolute number depends on batch size, negative count, and temperature. Loss is also not retrieval accuracy: it can keep improving while nDCG stagnates when the mined negatives are too easy. Stage 4 metrics are the ground truth.

On this split, peak learning rate 2.5e-5 with floor 1.25e-5 beat 1e-5 and 2e-5 with the batch size above held fixed. If a new corpus lands on an endpoint of a small sweep, extend one step further in that direction.

5. Stage 3: deploy Retriever NIM 2.2.0

The base deployment serves the weights baked into the NIM image (model_spec: {}); the tuned deployment mounts the Automodel output entity. Both need override_config.nimLegacy: false, because NIM 2.x replaced NIM_MODEL_NAME and NIM_MODEL_PATH with their NIM_ENGINE_* equivalents and rejects the retired names. Scoring both models at once occupies two GPUs; the first image pull can take several minutes.

Equivalent CLI:

EXECUTOR='{"gpu":1,"image_name":"nvcr.io/nim/nvidia/nemotron-3-embed-1b","image_tag":"2.2.0","override_config":{"nimLegacy":false}}'
nemo inference deployment-configs create embed-base-cfg --workspace default --exist-ok \
--engine nim --model-spec '{}' --executor-config "$EXECUTOR" \
--model-entity-id default/nemotron-3-embed-1b
nemo inference deployment-configs create embed-tuned-cfg --workspace default --exist-ok \
--engine nim --executor-config "$EXECUTOR" \
--model-spec '{"model_namespace":"default","model_name":"nemotron-3-embed-1b-tuned"}' \
--model-entity-id default/nemotron-3-embed-1b-tuned
nemo inference deployments create embed-base --workspace default --config embed-base-cfg \
--exist-ok --wait --timeout 1800
nemo inference deployments create embed-tuned --workspace default --config embed-tuned-cfg \
--exist-ok --wait --timeout 1800
nemo models get nemotron-3-embed-1b --workspace default
nemo models get nemotron-3-embed-1b-tuned --workspace default

retrieve-eval only scores entities with a provider attached, so both nemo models get calls must show a non-empty model_providers list before Stage 4. Training exported Hugging Face weights to the fileset root; had it exported ONNX as primary, the tuned executor would also need "additional_envs":{"NIM_ENGINE_MODEL_PATH":"alternates/hf"}.

NIM_IMAGE = "nvcr.io/nim/nvidia/nemotron-3-embed-1b"
NIM_TAG = "2.2.0"
deploy_suffix = uuid.uuid4().hex[:4]
BASE_CFG = f"embed-base-cfg-{deploy_suffix}"
TUNED_CFG = f"embed-tuned-cfg-{deploy_suffix}"
BASE_DEP = f"embed-base-{deploy_suffix}"
TUNED_DEP = f"embed-tuned-{deploy_suffix}"
executor = ContainerExecutorConfig(
gpu=1,
image_name=NIM_IMAGE,
image_tag=NIM_TAG,
override_config={"nimLegacy": False},
)
base_cfg = client.models.create_deployment_config(
body=CreateModelDeploymentConfigRequest(
name=BASE_CFG,
engine=Engine.NIM,
model_spec=ModelDeploymentConfigModelSpec(),
executor_config=executor,
model_entity_id=f"default/{MODEL_NAME}",
),
).data()
tuned_cfg = client.models.create_deployment_config(
body=CreateModelDeploymentConfigRequest(
name=TUNED_CFG,
engine=Engine.NIM,
model_spec=ModelDeploymentConfigModelSpec(
model_namespace="default",
model_name=OUTPUT_NAME,
),
executor_config=executor,
model_entity_id=f"default/{OUTPUT_NAME}",
),
).data()
base_deployment = client.models.create_deployment(
body=CreateModelDeploymentRequest(name=BASE_DEP, config=base_cfg.name),
).data()
tuned_deployment = client.models.create_deployment(
body=CreateModelDeploymentRequest(name=TUNED_DEP, config=tuned_cfg.name),
).data()
print(base_deployment.name, tuned_deployment.name)
for deployment in (BASE_DEP, TUNED_DEP):
if not client.models.wait_for_deployment_status(deployment, "READY", workspace="default", timeout=2700):
raise RuntimeError(f"{deployment} did not reach READY")
for entity in (MODEL_NAME, OUTPUT_NAME):
providers = client.models.get_model(name=entity).data().model_providers
print(entity, "providers:", providers)
if not providers:
raise RuntimeError(f"{entity} has no model_providers; retrieve-eval cannot score it")

6. Serving sanity (/v1/embeddings)

One request confirms the endpoint answers and returns 2048 dimensions. Pass input_type as query or document to match how the text will be used at retrieval time.

Equivalent CLI:

nemo inference gateway provider post v1/embeddings embed-tuned --workspace default \
--body '{"model":"default/nemotron-3-embed-1b-tuned","input":["What does NVLink connect?"],"input_type":"query"}'
from typing import Any, cast
from nemo_helix_plugin.inference_gateway.client import InferenceGatewayClient
from nemo_helix_plugin.inference_gateway.types import JsonBody
resp = InferenceGatewayClient.from_client(client).provider_post(
name=TUNED_DEP,
workspace="default",
trailing_uri="v1/embeddings",
body=JsonBody(
{
"model": f"default/{OUTPUT_NAME}",
"input": ["What does NVLink connect?"],
"input_type": "query",
}
),
).data()
resp_body = cast(dict[str, Any], resp)
print("dim", len(resp_body["data"][0]["embedding"]))

7. Stage 4: retrieve-eval on the frozen eval_beir split

retrieve-eval embeds the corpus with each model, ranks the queries, and reports nDCG@k, Recall@k, Precision@k, and MAP@k for the target and the baseline. Pass the same artifacts fileset every run.

Equivalent CLI:

nemo evals retrieve-eval --workspace default --spec '{
"dataset": "default/nvdocs-embed-artifacts",
"target": "default/nemotron-3-embed-1b-tuned",
"baseline": "default/nemotron-3-embed-1b",
"k": [1, 5, 10, 100]
}'
nemo jobs watch <eval-job> --workspace default
nemo jobs results list <eval-job> --workspace default --output json

target, baseline, and k have no per-flag form; supply them through --spec or --spec-file.

import shutil
import subprocess
eval_spec = {
"dataset": TRAINING_REF,
"target": f"default/{OUTPUT_NAME}",
"baseline": f"default/{MODEL_NAME}",
"k": [1, 5, 10, 100],
}
cmd = [
shutil.which("nemo") or "nemo",
"evaluator",
"retrieve-eval",
"--workspace",
"default",
"--spec",
json.dumps(eval_spec),
]
proc = subprocess.run(cmd, check=False, capture_output=True, text=True)
if proc.returncode != 0:
raise RuntimeError(f"retrieve-eval failed:\n{proc.stderr}")
eval_job_name = json.loads(proc.stdout[proc.stdout.index("{") :])["name"]
print("retrieve-eval job:", eval_job_name)
while True:
status = client.jobs.get_status(name=eval_job_name, workspace="default")
print("eval status:", status.status)
if status.status in ("completed", "failed", "cancelled", "error"):
break
time.sleep(30)
if status.status != "completed":
raise RuntimeError(f"retrieve-eval finished with status: {status.status}")
for row in client.jobs.results.list(eval_job_name, workspace="default").data:
print(json.dumps(row.model_dump(), default=str, indent=2)[:4000])

Interpreting results

retrieve-eval scores the baseline and the target in one job, and the gap between those two is the result. Absolute values shift with the eval split, corpus size, domain, and mining depth, so read a published score as a reference point rather than a number to hit.

These are reference scores for a tuned Nemotron 3 Embed 1B on a 20,909-query NVDocs eval_beir split:

Base: Nemotron 3 Embed 1B (untuned)
- nDCG@10: 0.5671
- Recall@10: 0.6375
- Recall@100: 0.8238
- MAP@10: 0.4787
Target: 3 epochs, all_weights, distributed in-batch negatives off, 4 GPUs, global batch 128, micro batch 8, learning rate 2.5e-5, min learning rate 1.25e-5, warmup 432
- nDCG@10: 0.6318
- Recall@10: 0.7124
- Recall@100: 0.8958
- MAP@10: 0.5413
Uplift
- nDCG@10: +0.0647 (+11.4%)
- Recall@10: +0.0748 (+11.7%)
- Recall@100: +0.0720 (+8.7%)
- MAP@10: +0.0627 (+13.1%)

These scores are from the job above on that frozen split. Validation loss was lowest at the final step, so the best and last checkpoints match.

Hyperparameters

Learning rate is the most sensitive parameter, followed by epochs, warmup, and batch size.

ParameterAPI fieldValue
Learning rateoptimizer.learning_rate2.5e-5
Min learning rateoptimizer.min_learning_rate1.25e-5
Warmupoptimizer.warmup_steps432
Decayoptimizer.lr_decay_stylecosine
Weight decayoptimizer.weight_decay0.01
Epochsschedule.epochs3
Global batchbatch.global_batch_size128
Micro batchbatch.micro_batch_size8
Distributed in-batch negativestraining.retrieval.do_distributed_inbatch_negativefalse
GPUsparallelism.num_gpus_per_node4, tensor parallel 1
Sequence lengthtraining.max_seq_length, training.retrieval.query_max_length, training.retrieval.passage_max_length512
Passages per querytraining.retrieval.train_n_passages5, one positive and four mined negatives

Batch size sets how many gradient steps an epoch takes rather than how many negatives each query sees, so smaller datasets benefit from a smaller global batch. When you scale data, adding documents helps more than generating more queries per document: new documents bring new vocabulary and retrieval patterns, while extra queries cover the same passage from another angle.

Troubleshooting

  • neg_doc must contain at least 1 document: Stage 1 ran without mining. Re-run retrieval-prepare with enable_mining: true and a fileset-backed model.
  • CUDA out of memory during mining: lower mining.query_embedding_batch_size and mining.document_embedding_batch_size.
  • Empty train split: the corpus is too small. Generating your own data needs 50 documents at minimum and 500 or more for good coverage.
  • retrieve-eval reports no model_providers: the NIM is not serving the entity yet. Wait for READY and for the gateway to attach the provider.

Clean up

Each deployment holds a GPU. Release them once evaluation finishes; the configs can stay for the next run.

nemo inference deployments delete embed-base --workspace default
nemo inference deployments delete embed-tuned --workspace default

Next steps