Embedder Config Reference¶
Every nova embed / nova embed-dist run is driven by a YAML config. This page documents every key, its default, and when you'd change it.
Example config (configs/embedder/example.yaml):
source:
type: huggingface
dataset_name: corag/kilt-corpus
split: train
text_template: "Title: {title}\n\nContents: {contents}"
dense_embedder:
type: sentence_transformer
model: intfloat/multilingual-e5-large
trust_remote_code: true
batch_size: 64
dtype: bfloat16
max_tokens: 256
truncate: true
pipeline:
chunk_size: 10000
num_workers: 1
flush_threshold: 50000
storage:
type: s3
bucket: qdrant--vectorforge
prefix: corag--kilt-corpus/embed-multilingual-e5-large
output_dir: /tmp/supernova
source:¶
Where to read rows from.
| Key | Default | Notes |
|---|---|---|
type |
required | huggingface (file-level sharded parquet reader). huggingface_parquet is accepted as a legacy alias for the same source. |
dataset_name |
required | HF hub ID, e.g. corag/kilt-corpus. |
config |
None |
HF dataset config name (for multi-config datasets). |
split |
train |
HF split. |
text_template |
None |
str.format(**row) template, e.g. "Title: {title}\n\n{contents}". Use this OR text_field, not both. |
text_field |
None |
Single column name to use as text. Use when no template formatting is needed. |
exclude_columns |
[] |
Columns to drop from the output parquet. |
total_rows_override |
None |
Manual total-row count to use instead of the auto-detected one. Useful when the dataset's metadata reports a partial row count (e.g., HF's parquet-conversion sampling). Get the real total from the dataset's HF page ("Estimated number of rows") and paste it here. |
path_filter |
auto-match by split |
Glob (or regex:<expr>, or list of either) over parquet paths. Useful for repos that mix splits or configs at different paths. |
metadata_workers |
4 |
Parallelism for the per-file footer fetches at init. Kept low by default to avoid bursting HF resolver rate limits when many ranks start simultaneously. |
prefetch |
false |
Download each parquet to local disk before streaming. Eliminates per-batch HTTP range requests at the cost of a one-time sequential download — strongly recommended for multi-rank runs. |
prefetch_dir |
/tmp/supernova_parquet |
Local directory for prefetched files. |
How --num-jobs slices the dataset¶
When nova embed is invoked with --num-jobs N (which is what nova embed-dist does for each worker), the N ranks divide the full dataset evenly:
rank_offset = rank * ceil(dataset_total / num_jobs)
rank_limit = min(ceil(dataset_total / num_jobs), dataset_total - rank_offset)
Worker logs will print a line like:
which makes the slicing auditable at a glance.
How it works¶
The source lists the dataset's parquet files on the Hub, reads each file's parquet footer for row counts, and maps (offset, limit) to a contiguous file range — yielding rows by reading those files directly. Init cost: one HF list-repo call + parallel footer reads (~3-10s for ~1000 files). Per-rank streaming cost: O(1) jump to the right file, then sequential read.
For multi-rank runs, set prefetch: true so each worker downloads its assigned files to local disk once instead of issuing per-batch HTTP range requests.
dense_embedder:¶
How to produce dense embeddings.
Common keys¶
| Key | Default | Notes |
|---|---|---|
type |
required | sentence_transformer or openai. |
model |
required | HF model ID (ST) or model name (OpenAI). |
batch_size |
32 (ST) |
Records per forward pass. Bigger = better GPU utilization up to a memory ceiling. |
dtype |
float32 |
float32, float16, or bfloat16. Use bfloat16 on modern GPUs (A10G+) for 2x memory savings with no quality loss. |
sentence_transformer-specific¶
| Key | Default | Notes |
|---|---|---|
trust_remote_code |
false |
Required for some HF models (e.g. GTE, Alibaba-NLP). |
device |
auto-detect | cuda, mps, cpu. Leave unset unless debugging. |
max_tokens |
model default | Override the model's max_seq_length. Lower = faster, less semantic coverage. |
truncate |
false |
If true, long docs get truncated at max_tokens (one embedding per doc). If false, long docs get split into pieces and each piece is embedded separately (multiple records per source row). |
Truncate vs. split behavior¶
truncate: false(default): a 2000-token doc withmax_tokens=512becomes 4 records, each with its own 512-token embedding. Preserves full semantic coverage. Longer runtime.truncate: true: same doc becomes 1 record with an embedding of just the first 512 tokens. Faster, loses content past the cutoff. Good for retrieval on short-context models.
multivector_embedder:¶
Multi-vector embeddings (ColBERT-style): each input text produces N vectors of D floats,
where N varies with input length. Stored in parquet as list<list<float32>>.
| Key | Default | Notes |
|---|---|---|
type |
required | bge_m3 (currently the only option). |
model |
BAAI/bge-m3 |
HF model ID. Only models with a ColBERT-style multi-vector head are supported. |
batch_size |
32 |
Records per forward pass. |
dtype |
float32 |
float32 or float16. bfloat16 is not supported by the FlagEmbedding path. |
max_tokens |
model native (8192 for bge-m3) | Lower = faster. |
truncate |
false |
Same contract as the sentence_transformer embedder. true = chop long docs at max_tokens, emit one record. false = split long docs into max_tokens-sized pieces, emit one multivector record per piece. |
device |
auto-detect | cuda / mps / cpu. |
Output column name is controlled by pipeline.multivector_embedding_column (default multivector_embedding).
Multi-vector embedders run as an additional forward pass on top of any dense/sparse
embedders. If you set all three (dense_embedder, sparse_embedder, multivector_embedder),
the engine runs dense+sparse via the hybrid path (one pass) and multi-vector separately (a
second pass). Multi-vector does not fuse with the hybrid path today.
Truncate vs. split behavior (multivector)¶
Matches the dense sentence_transformer semantics:
truncate: false(default): a 2000-token doc withmax_tokens=512becomes 4 multivector records, each covering a distinct 512-token slice of the original. Useful when you want passage-level multivector coverage of long docs.truncate: true: same doc becomes 1 multivector record covering only the first 512 tokens. Rest of the document is dropped. Faster, simpler indexing downstream.
multivector_embedder.pooling: — derive a dense vector too¶
You can opt in to pooling the multi-vector output down to a single dense vector — no extra forward pass. Useful when you want a standard dense column and multi-vector from the same model without paying for two separate embedders.
Nested under multivector_embedder because it only applies in that context. Cannot be
combined with a separate top-level dense_embedder (both would produce a dense column).
multivector_embedder:
type: bge_m3
model: BAAI/bge-m3
batch_size: 64
dtype: bfloat16
max_tokens: 768
truncate: true
pooling:
type: mean # mean | max | cls | last
pooled_column_name: dense_embedding
normalize: true # L2-normalize after pooling. default: true.
| Key | Default | Notes |
|---|---|---|
type |
required | mean, max, cls (first token), or last (last token). mean is the common retrieval choice. |
pooled_column_name |
dense_embedding |
Name of the dense column written to parquet. Overrides pipeline.dense_embedding_column when pooling is configured. |
normalize |
true |
L2-normalize each pooled vector so cosine similarity on the output is meaningful. |
Why pool?¶
bge-m3 (and other models with a colbert-style head) produces N vectors per text. For some downstream systems you want a single vector per doc (standard dense retrieval, smaller index, simpler reranking). Rather than running a separate dense model, you can pool the multi-vector output and get a single vector "for free." Mean-pooling of L2-normalized token vectors followed by L2-normalization is the standard recipe and matches how most dense models are actually trained.
Resulting parquet¶
When pooling is enabled, your parquet gets both columns:
multivector_embedding: list<list<float32>> # N vectors per row
dense_embedding: list<float32> # pooled, N → 1 vector
Downstream code reading only dense_embedding works unchanged.
pipeline:¶
How the in-process pipeline is shaped. These are the main memory/throughput tuning knobs.
| Key | Default | Notes |
|---|---|---|
chunk_size |
10000 |
Rows per chunker batch. Each chunk is a unit of work handed to a GPU worker. |
num_workers |
8 |
In-process async GPU workers per job. For single-GPU instances (g5.xlarge), set to 1 — additional workers compete for the same GPU and provide ~0% throughput gain. |
flush_threshold |
100000 |
Records accumulated in the ResultBuffer before flushing to a parquet file. Also determines parquet size. |
max_text_length |
None |
Character-level truncation applied before tokenization. Useful for bounding pathological outliers (docs with 100K+ chars). |
dense_embedding_column |
dense_embedding |
Output column name for dense vectors. |
sparse_embedding_column |
sparse_embedding |
Output column name for sparse vectors. |
multivector_embedding_column |
multivector_embedding |
Output column name for multi-vector embeddings. |
rendered_text_column |
text |
Column name for the template-rendered string that gets sent to the embedder. Default shadows the source's raw text field (if it has one). Set to something else (e.g. rendered_text) to keep both — the raw field passes through under its original name. |
shard_by_rank |
false |
Put each rank's output in its own subdir instead of a flat layout. Default: rank42_batch_00000000.parquet. With true: rank42/batch_00000000.parquet. Useful when producing thousands of parquets — avoids one enormous flat S3 directory. |
Memory math¶
Rough per-job memory footprint during steady state:
work_queue = num_workers × 2 chunks × chunk_size × avg_row_bytes
result_queue = unbounded (!) — can accumulate during slow flushes
buffer = flush_threshold × (row_bytes + dim × 4)
model = num_workers × model_params × bytes_per_param
pyarrow spike = ~2× buffer size during write_batch
On a 16GB g5.xlarge, for short text (e.g. Amazon reviews, ~100 tokens):
- chunk_size: 100000, flush_threshold: 100000, num_workers: 2 is fine (~4-6 GB peak).
For long text (KILT articles, FineWiki, arxiv, etc.):
- Use chunk_size: 10000, flush_threshold: 50000, num_workers: 1 to stay under 16GB.
- Or step up to g5.2xlarge (32GB RAM) and keep larger values.
Output file shape¶
With num_jobs=N and flush_threshold=F:
- Rows per job =
total_rows / N - Parquets per job =
ceil(rows_per_job / F) - Total parquets =
N × parquets_per_job
For 1B rows, num_jobs=50, flush_threshold=1_000_000: 50 workers × 20 parquets each = 1,000 files of 1M rows. Each job's slice must be ≥ flush_threshold or you get one undersized parquet per job (the drain at end-of-pipeline flushes whatever remained).
Parquet filenames are prefixed by rank in distributed mode: rank042_batch_00000000.parquet. Manifest is rank042__manifest.json. Single-job runs drop the prefix.
With shard_by_rank: true, each rank writes into its own subdir: rank042/batch_00000000.parquet + rank042/_manifest.json. Recommended when you'd otherwise have thousands of parquets in a single S3 prefix — 50 ranks × 130 files each reads much nicer than 6500 siblings.
Rendered text vs. raw source text¶
The text column in output parquets holds the rendered template (what was actually sent to the embedder), not the raw source field. If your source has a column named text (as HuggingFaceFW/finewiki does), it's shadowed by the rendered output at write time.
To keep both in the parquet, rename the rendered column:
pipeline:
rendered_text_column: rendered_text # rendered template lands here
# `text` column now passes through from the source unshadowed
Template rendering itself is unaffected — text_template: "Title: {title}\nText: {text}" still uses the original HF field names. The rename only applies at parquet write time.
storage:¶
Where to write parquets + manifests.
| Key | Default | Notes |
|---|---|---|
type |
s3 |
s3, hf, or local. |
bucket |
required (s3) | S3 bucket name. |
prefix |
required (s3), "" (hf) |
S3 key prefix, or sub-path inside an HF bucket. |
bucket_id |
required (hf) | HF Storage Bucket id, namespace/bucket-name. Writes land at hf://buckets/{bucket_id}/.... |
token |
None |
HF access token; falls back to $HF_TOKEN if unset. |
private |
true |
For type: hf. |
output_dir |
/tmp/supernova |
Local scratch dir where parquets are written before upload. Auto-cleaned after each flush. |
The output_dir is also the final destination for type: local.
Distributed resources (cli/run_embed_distributed.py:DEFAULT_RESOURCES)¶
These aren't in the YAML — they're set in code and shared across all nova embed-dist runs. Override via a config's resources: block if you need something different for a specific job.
| Key | Default | Notes |
|---|---|---|
accelerators |
A10G:1 |
GPU spec: <TYPE>:<COUNT>. |
cloud |
aws |
|
use_spot |
True |
Override per-run with --on-demand. |
disk_size |
150 |
GB. DLAMI images need ≥100GB. |
image_id |
AWS DLAMI per region | See below. |
any_of |
[us-east-1, us-west-2, us-east-2] |
Regions SkyPilot can provision in. Picks whichever has capacity first. |
AMI choice¶
We pin the AWS Deep Learning AMI (Amazon Linux 2023) explicitly because SkyPilot's default AMI ships with NVIDIA driver 535 (CUDA 12.2), which doesn't support the latest torch wheels (built for CUDA 13.0). The DLAMI has driver 580+ and supports modern torch directly.
Refresh the DLAMI IDs when they age out:
for r in us-east-1 us-west-2 us-east-2; do
aws ec2 describe-images --region $r --owners amazon \
--filters 'Name=name,Values=Deep Learning OSS Nvidia Driver AMI GPU PyTorch * (Amazon Linux 2023) *' \
'Name=architecture,Values=x86_64' \
--query 'sort_by(Images, &CreationDate) | [-1].ImageId' --output text
done
Pool shape (pool.yaml generated by nova embed-dist)¶
| Key | Default | Notes |
|---|---|---|
pool.min_workers |
max_workers (burst, default; 0 with --ramp) |
Floor. Workers won't scale below this. |
pool.max_workers |
num_jobs |
Ceiling. SkyPilot autoscales up to here as jobs queue. |
Burst is the default because SkyPilot's autoscaler ramps one replica per ~3-minute cycle — far too slow for known-sized batch runs. Pass --ramp if you actually want gradual provisioning.
Environment variables forwarded to workers¶
These get forwarded from your local shell when nova embed-dist creates the pool:
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_SESSION_TOKENAWS_REGION,AWS_DEFAULT_REGIONHF_TOKENOPENAI_API_KEY
Also set automatically by SkyPilot on each pool job:
SKYPILOT_JOB_RANK— 0-indexed rank among--num-jobs(read bynova embedfor slicing)SKYPILOT_NUM_JOBS— total job count
Tuning cheatsheet¶
| Symptom | First thing to try |
|---|---|
| Worker OOM on long-text dataset | chunk_size: 10000, num_workers: 1, flush_threshold: 50000 |
| Worker OOM on short text | num_workers: 1 (the second worker doubles model memory for ~0% speedup) |
| Pool scales up slowly | Burst is the default; check the plan output. Only --ramp opts out. |
| Only a few workers come up | Check quota (spot vs on-demand), drop use_spot, request quota bump |
| Pipeline slower than predicted | Check dtype (should be bfloat16 on GPU), check you're actually on GPU (look for Loading ... on cuda in logs) |
| High-rank jobs take forever to start | If using prefetch: true, each worker downloads its slice up front — that's expected. Without prefetch, check that footer-fetching at init isn't being rate-limited (lower metadata_workers). |
| Many small parquets | Increase flush_threshold. Each job must still have ≥flush_threshold rows (else one small parquet per job). |