Loader Architecture¶
Overview¶
The loader streams pre-embedded parquet data from S3 or HuggingFace into vector stores (Qdrant, etc.). It's designed for bulk loading terabyte-scale datasets with minimal memory usage.
Three CLI tools operate at different scales:
| Command | What it does | When to use |
|---|---|---|
nova load |
Single-machine loader | Dev, small datasets, single VM |
nova load-dist |
Fan out across SkyPilot spot instances | Large datasets (100GB+) |
Module Structure¶
supernova/loader/
├── datasource/
│ ├── base.py # DataReader ABC — DuckDB streaming, batch iteration
│ ├── s3.py # S3DataReader — httpfs, AWS creds, file_list support
│ └── huggingface.py # HuggingFaceDataReader — hf:// protocol
├── vectorstore/
│ ├── base.py # VectorStore ABC — upsert, indexing lifecycle
│ └── qdrant.py # QdrantVectorStore — async client, deferred indexing
└── runner.py # Async orchestrator — prefetch, slice, concurrent upsert
Data Flow¶
Single machine (nova load)¶
1. DuckDB fetchmany(prefetch_size) # large read from S3/HF, one I/O op
2. Slice into upsert batches # in-memory, no network
3. asyncio tasks with semaphore # concurrent writes to vector store
4. Repeat until exhausted
Distributed (nova load-dist)¶
Master (your laptop):
1. boto3 list_objects → discover parquet files
2. Round-robin assign files to N shards
3. Generate per-shard YAML configs (paper trail in ~/.nova/runs/<run_id>/)
4. Create Qdrant collection + defer indexing
5. sky jobs launch --async × N (env vars injected, not written to disk)
6. Poll sky jobs queue until all complete
7. Enable indexing → wait for HNSW build → report
Workers (SkyPilot spot instances):
- Run nova load --no-manage-indexing
- Read only their assigned parquet files (file_list)
- Upsert to shared Qdrant collection
- No indexing lifecycle — master handles that
DataReader¶
Base class for all parquet data sources. Handles DuckDB connection, SQL generation, batch streaming, and column/payload mapping.
Key concepts¶
id_expression — DuckDB SQL expression that yields the point id per row. Defaults to the bare column name row_id (works if your parquets carry a row_id column). Any DuckDB expression that returns UBIGINT or a UUID string is valid:
row_id— use a pre-baked id columnhash(text)— content-deduplicated idsuuid()— random per-row UUIDsvf_point_id(filename, file_row_number)— recommended for supernova-produced corpora; matchesnova brute-forceandnova generate-queriesso recall ground truth aligns
The base reader (supernova/loader/datasource/base.py) registers three macros at connection time:
vf_uuid_from_hex(h)— formats a 32-char hex string as a canonical UUID.make_point_id(source_file, source_row)—vf_uuid_from_hex(md5(source_file || ':' || source_row)). Mirrorssupernova.utils.make_point_idexactly.vf_point_id(fname, rnum)—make_point_id(substr(fname, prefix_len + 1), rnum), whereprefix_lenis the URI-prefix length each subclass declares via_root_uri_prefix().
If the id_expression references filename or file_row_number, the base reader auto-injects read_parquet(..., filename=true, file_row_number=true) so those virtual columns are available. Use file_row_number, not ROW_NUMBER() OVER (PARTITION BY filename) — file_row_number is the physical row index and is stable under DuckDB's parallel parquet scan; window-function row numbers reflect scan order and can produce different IDs from the brute-force side. There's an explicit regression test in tests/test_loader_id_expression.py that documents both behaviours.
vectors (top-level config) — declares one or more named vectors. Passed to both the reader (which knows the parquet column to read) and the vector store (which knows how to configure the collection):
vectors:
dense:
type: dense
column: dense_embedding
distance: cosine # cosine | dot | euclid | manhattan
sparse:
type: sparse
column: sparse_embedding
colbert:
type: multivector
column: multivector_embedding
distance: cosine
comparator: max_sim
Each entry's key becomes the vector name in Qdrant. Records emitted by read_batches carry vectors: {name: value} rather than a single embedding.
payload_fields — controls what goes into the vector store payload:
payload_fields:
abstract: text # parquet "text" col → stored as "abstract"
source: source # parquet "source" col → stored as "source"
metadata: payload # JSON string columns that parse to a dict are unpacked
Default: {} (no payload).
source_sql — the DuckDB FROM clause. Defaults to '<glob_path>' but subclasses override it with read_parquet([...]) when file_list is set. This is how distributed workers read only their assigned files.
_root_uri_prefix() — the URI prefix the base class strips from DuckDB's filename column to recover the bare key passed into make_point_id. Each subclass declares this once (f"s3://{bucket}/" for S3, f"hf://datasets/{repo_id}/" for HF) and the base class registers vf_point_id automatically. Both this loader and the brute-force / generate-queries pipelines must agree on the bare-key form, otherwise IDs won't match — see supernova/destinations.py:bare_key_for_uri.
S3DataReader¶
- Glob path:
s3://{bucket}/{prefix}/**/*.parquet - Configures DuckDB httpfs with
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_SESSION_TOKEN,AWS_REGION - Supports
file_list: list[str]for explicit file assignment (used by distributed dispatch)
HuggingFaceDataReader¶
- Glob path:
hf://datasets/{repo_id}/**/*.parquet - Streams directly via DuckDB's native HF protocol (no local download)
- Optional
subdirto scope to a subfolder - Requires
HF_TOKENenv var for authenticated access
Note: the embed-side storage backend now writes to hf://buckets/... (HF Storage Buckets), but the loader still reads from hf://datasets/... because DuckDB's httpfs extension only supports the dataset/space form of hf:// ("DuckDB only supports querying datasets or spaces"). Legacy corpora already in dataset repos load fine; new corpora written to buckets cannot be loaded into Qdrant via nova load until DuckDB adds bucket support upstream.
ID space anchoring¶
Point IDs are md5(bare_key + ":" + row_index) as a UUID. The same bare_key form is computed by three places that must agree: the loader's vf_point_id macro (when writing to Qdrant), generate-queries (when stamping __source_file__ on sampled rows), and brute-force (when emitting hit IDs). If any of those drifts, recall@k breaks silently — payloads still match but UUIDs don't.
The bare key is anchored at the top-level container: the S3 bucket, or the HF dataset repo. So:
s3://bucket/prefix/path/file.parquet→ bare keyprefix/path/file.parquethf://datasets/ns/repo/data/path/file.parquet→ bare keydata/path/file.parquet
Two consequences fall out of this anchor choice:
Stable across scope within a container. Loading just s3://b/fineweb/cc-2025-26/... and loading the wider s3://b/fineweb/... produce the same IDs for the same physical rows. You can do incremental or partial loads, then later widen the scope, without invalidating earlier ground-truth or fragmenting the ID space.
Reset across containers. Migrating S3 bucket A → S3 bucket B, or S3 → HF, changes the anchor and therefore the IDs. The recall ground-truth (queries_*.parquet, brute_force_*.parquet) must be regenerated on the new side; you cannot reuse a Qdrant collection across container migrations.
This is a deliberate trade-off, not an oversight. There is no unforced way to make a hash function span containers — they're literally different ID universes — without introducing an external "logical dataset name" registry that someone has to set correctly per run. The current scheme prioritises the workflow that's actually common (scoped loads within one bucket/repo) over the one that's rare (cross-backend migrations). The seam where this is implemented is DataReader._root_uri_prefix() and supernova.destinations.bare_key_for_uri() — both must strip the same prefix.
VectorStore¶
Lifecycle methods¶
| Method | Purpose | Called by |
|---|---|---|
ensure_collection(dimensions) |
Create collection if it doesn't exist (dimensions: dict[name, int] for dense+multivector) | Master or single-machine loader |
defer_indexing() |
Set indexing_threshold=0 for fast bulk writes | Master or single-machine loader |
upsert_batch(points) |
Insert points into collection | Every worker |
enable_indexing() |
Restore threshold, trigger HNSW build | Master or single-machine loader |
wait_for_indexing() |
Poll until collection status is GREEN | Master or single-machine loader |
close() |
Clean up connections | Everyone |
Deferred indexing¶
The key optimization for bulk loading. Without it, every upsert triggers incremental HNSW graph updates (expensive). With deferred indexing:
defer_indexing()→ Qdrant stores vectors flat, no graph construction- Blast data in as fast as possible (parallel upserts)
enable_indexing()→ Qdrant builds HNSW in one efficient batch passwait_for_indexing()→ block until complete
This is dramatically faster for bulk loads.
QdrantVectorStore¶
- Uses
qdrant-clientAsyncQdrantClient defer_indexing()setsindexing_threshold=0viaupdate_collectionenable_indexing()setsindexing_threshold=20000(Qdrant default)wait_for_indexing()pollsget_collectionevery 5s until status is GREEN- Supports scalar (INT8) and binary quantization via
params
Runner¶
run_loader() is the core async function. Key parameters:
| Parameter | Default | Description |
|---|---|---|
batch_size |
1000 | Points per upsert call |
prefetch_size |
batch_size * 10 | Rows per DuckDB fetch (reduces remote I/O) |
concurrency |
8 | Max parallel upsert tasks (semaphore) |
manage_indexing |
True | If False, skip collection creation and indexing lifecycle |
Prefetch strategy¶
DuckDB reads prefetch_size rows per fetch — one remote I/O operation that reads full parquet row groups. Those rows are sliced into batch_size upsert batches locally (no network). This minimizes S3/HF round trips for large datasets.
prefetch_size=100,000 → DuckDB reads 100k rows (one S3 request)
batch_size=1,000 → sliced into 100 upsert batches (local)
concurrency=8 → 8 upserts running in parallel
Configuration Reference¶
nova load and nova load-dist consume the same config file from configs/loader/. The dispatch: and resources: blocks are read by nova load-dist only and ignored by the single-machine loader.
vectors: # required, at least one entry
dense:
type: dense # dense | sparse | multivector
column: dense_embedding
distance: cosine # dense/multivector: cosine | dot | euclid | manhattan
# multivector only:
# comparator: max_sim
datasource:
type: s3 # s3 | huggingface
# S3 options
bucket: my-bucket
prefix: my-dataset
# HuggingFace options
repo_id: org/dataset
subdir: en # optional subfolder
# Common options
id_expression: "vf_point_id(filename, file_row_number)" # DuckDB SQL; default: "row_id"
payload_fields: # optional payload composition
text: text
file_list: # optional explicit file list (used by dispatch workers)
- s3://bucket/file1.parquet
- s3://bucket/file2.parquet
vectorstore:
type: qdrant
url: ${QDRANT_URL}
api_key: ${QDRANT_API_KEY}
collection_name: my-collection
loader:
batch_size: 1000 # default
prefetch_size: 10000 # default: batch_size * 10
concurrency: 8 # default
# Optional, only consumed by `nova load-dist`:
dispatch:
num_shards: 10 # number of parallel SkyPilot workers
run_name: my-run # optional, defaults to config filename
resources: # SkyPilot VM spec
cpus: 8
memory: 32
cloud: aws
use_spot: true
Adding a new component¶
See Extending supernova for concrete walkthroughs of:
- Adding a raw input source (e.g. Common Crawl)
- Adding a new corpus backend (e.g. GCS — covers
Destination,StorageBackend, andDataReadertogether) - Adding a new vector store (e.g. Weaviate)