Embedding Model Customization

View as Markdown

Run in Google Colab

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 Platform 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 evaluator 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 Platform at NMP_BASE_URL, default http://localhost:8080. See the Quickstart.
  • The Python SDK: pip install "nemo-platform[all]", or make bootstrap from the repo root.
  • An NGC API key for the nvcr.io NIM images.
  • GPU capacity: 80 GB for training, 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

export NMP_BASE_URL=<YOUR_NMP_BASE_URL>
export NGC_API_KEY=<YOUR_NGC_API_KEY>

The nemo CLI reads NMP_BASE_URL too, and --base-url overrides it per command. The NGC key becomes a Platform secret so the cluster can pull NIM images:

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_platform import NeMoPlatform, ConflictError
NMP_BASE_URL = os.environ.get("NMP_BASE_URL", "http://localhost:8080")
client = NeMoPlatform(base_url=NMP_BASE_URL, workspace="default")
print(f"Platform: {NMP_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_platform.types.files import HuggingfaceStorageConfigParam
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(name=NGC_SECRET_NAME, workspace="default", value=NGC_API_KEY)
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"
storage_kwargs = {"type": "huggingface", "repo_id": HF_REPO_ID, "repo_type": "model"}
if HF_TOKEN:
try:
client.secrets.create(name="hf-token", workspace="default", value=HF_TOKEN)
except ConflictError:
pass
storage_kwargs["token_secret"] = "hf-token"
storage = HuggingfaceStorageConfigParam(**storage_kwargs)
try:
client.files.filesets.create(
workspace="default",
name=MODEL_NAME,
purpose="model",
description="Nemotron 3 Embed 1B BF16 checkpoint",
storage=storage,
)
except ConflictError:
print("Base model fileset already exists")
try:
base_model = client.models.create(
workspace="default",
name=MODEL_NAME,
fileset=f"default/{MODEL_NAME}",
trust_remote_code=True,
)
print(f"Created Model Entity: {MODEL_NAME}")
except ConflictError:
base_model = client.models.update(
workspace="default",
name=MODEL_NAME,
fileset=f"default/{MODEL_NAME}",
trust_remote_code=True,
)
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.retrieve(workspace="default", name=MODEL_NAME)
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 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={
"query_embedding_batch_size": 64,
"document_embedding_batch_size": 64,
},
)
prepare_job = client.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, addressed as workspace/fileset#path. In 0.6, Automodel dataset.training and retrieve-eval dataset take a fileset name without a path fragment, so the next cell copies both artifacts to the root of a fileset of its own. Release 0.7 resolves workspace/fileset#path directly and the copy is no longer needed.

The copy also drops duplicate (query-id, corpus-id) rows from eval_beir/qrels/test.tsv, which the BEIR loader rejects.

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

Deduplicate ./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-"))
client.files.download(
remote_path=f"{remote_prefix}/training.jsonl",
local_path=str(local_dir / "training.jsonl"),
fileset=job_fileset,
workspace="default",
)
# eval_beir arrives under local_dir; a destination already named eval_beir nests as eval_beir/eval_beir
client.files.download(
remote_path=f"{remote_prefix}/eval_beir",
local_path=str(local_dir),
fileset=job_fileset,
workspace="default",
)
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
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.filesets.create(
workspace="default",
name=TRAINING_FILESET,
purpose="dataset",
description="NVDocs mined retrieval artifacts",
)
except ConflictError:
pass
client.files.upload(
local_path=f"{local_dir}/",
remote_path="",
fileset=TRAINING_FILESET,
workspace="default",
)
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.

These values match the Nemotron embed recipe, except for epochs: the recipe’s 3 is calibrated for its example dataset, and NVDocs-scale corpora train for 1–2.

KnobValue
training.recipebi_encoder
training.finetuning_typeall_weights, or lora_merged to merge adapters back into the checkpoint
training.retrieval.export.primaryhf
Sequence length512, the same at mine, train, and eval time
LR / warmup / GBS / MBS1e-5 / 5 / 128 / 4
Epochs1

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",
"max_seq_length": 512,
"retrieval": {
"query_max_length": 512,
"passage_max_length": 512,
"train_n_passages": 5,
"export": {"primary": "hf"}
}
},
"schedule": {"epochs": 1},
"batch": {"global_batch_size": 128, "micro_batch_size": 4},
"optimizer": {"learning_rate": 1e-5, "warmup_steps": 5, "weight_decay": 0.01},
"parallelism": {"num_gpus_per_node": 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
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={"training": TRAINING_REF},
training={
"training_type": "sft",
"recipe": "bi_encoder",
"finetuning_type": "all_weights",
"max_seq_length": 512,
"retrieval": {
"query_max_length": 512,
"passage_max_length": 512,
"train_n_passages": 5,
"export": {"primary": "hf"},
},
},
schedule={"epochs": 1},
batch={"global_batch_size": 128, "micro_batch_size": 4},
optimizer={"learning_rate": 1e-5, "warmup_steps": 5, "weight_decay": 0.01},
parallelism={"num_gpus_per_node": 1},
output={"name": OUTPUT_NAME},
)
job = client.customization.automodel.jobs.create(spec=spec, workspace="default", name=JOB_NAME)
print(f"Submitted {job.job.name}")
print(f"Output model: {OUTPUT_NAME}")
while True:
status = client.jobs.get_status(name=job.job.name, workspace="default")
clear_output(wait=True)
print(status.model_dump_json(indent=2))
if status.status in ("completed", "failed", "cancelled", "error"):
break
time.sleep(30)
if status.status != "completed":
raise RuntimeError(f"Training finished with status: {status.status}")

Interpreting contrastive loss

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.

When iterating, sweep the learning rate at 5e-6, 1e-5, and 2e-5 with everything else fixed. If the best result lands on an endpoint, 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 = {
"gpu": 1,
"image_name": NIM_IMAGE,
"image_tag": NIM_TAG,
"override_config": {"nimLegacy": False},
}
base_cfg = client.inference.deployment_configs.create(
workspace="default",
name=BASE_CFG,
engine="nim",
model_spec={},
executor_config=executor,
model_entity_id=f"default/{MODEL_NAME}",
)
tuned_cfg = client.inference.deployment_configs.create(
workspace="default",
name=TUNED_CFG,
engine="nim",
model_spec={"model_namespace": "default", "model_name": OUTPUT_NAME},
executor_config=executor,
model_entity_id=f"default/{OUTPUT_NAME}",
)
base_deployment = client.inference.deployments.create(
workspace="default", name=BASE_DEP, config=base_cfg.name
)
tuned_deployment = client.inference.deployments.create(
workspace="default", name=TUNED_DEP, config=tuned_cfg.name
)
print(base_deployment.name, tuned_deployment.name)
for deployment in (BASE_DEP, TUNED_DEP):
if not client.models.wait_for_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.retrieve(workspace="default", name=entity).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"}'
resp = client.inference.gateway.provider.post(
"v1/embeddings",
name=TUNED_DEP,
workspace="default",
body={
"model": f"default/{OUTPUT_NAME}",
"input": ["What does NVLink connect?"],
"input_type": "query",
},
)
print("dim", len(resp["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 evaluator retrieve-eval submit --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",
"submit",
"--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 submit 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

An example result after finetuning for 3 epochs on NVDocs SDG dataset and evaluating on 20,909-query eval_beir split:

Model: base
- nDCG@10: 0.56713
- Recall@10: 0.63753
- Precision@10: 0.13192
Model: fine-tuned
- nDCG@10: 0.61696 (+9%)
- Recall@10: 0.69999 (+10%)
- Precision@10: 0.14807 (+12%)

Compare the fine-tuned model against the base checkpoint in the same evaluation. The eval split, corpus size, domain, and mining depth all move the scale.

Hyperparameters

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

ParameterAPI fieldValue
Learning rateoptimizer.learning_rate1e-5, swept at half and double
Warmupoptimizer.warmup_steps5, or 5–10% of total steps
Weight decayoptimizer.weight_decay0.01
Epochsschedule.epochs1 for 10,000 rows or more, 2 for a few thousand
Global batchbatch.global_batch_size128
Micro batchbatch.micro_batch_size4
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