What fine-tuning changes
RAG does not change the model. It changes the response, by putting retrieved context, rules or tools in front of whatever model you are serving, fine-tuned or not. Fine-tuning changes the model. You continue training it on examples of your own, and its behaviour moves toward the terminology, the output format and the notion of a correct answer that your domain uses. The two are routinely used together.
It is usually applied to a smaller open model such as Llama, Qwen or Mistral, where the goal is to get that model good enough at one narrow task that you stop routing the task to a larger one.
This is part two. It starts from the tables that RAG on Your Lakehouse builds, so read that first. It parses a directory of contract PDFs into
contract_chunks(Hudi on Lance base files, holding the text and its embeddings) and loads the clause labels describing those contracts intocontract_annotations(Hudi on Parquet). Everything below starts from those two tables.
The kinds of fine-tuning
Two independent questions. First, what you change:
| Full fine-tune | every weight is updated; needs the most GPU memory and the most data |
| LoRA / QLoRA | small adapter matrices are trained and the base weights stay frozen; cheap enough to iterate on, and what most teams reach for |
Second, what you feed it, which is what determines the shape of your dataset:
| Objective | Each example is | training_type |
|---|---|---|
| Supervised fine-tuning (SFT) | a prompt and the response you want back | sft |
| Preference tuning (DPO) | a prompt, one preferred and one rejected response | preference |
| Continued pretraining | raw text, no prompts | text |
The exporter below writes all three. This guide builds an SFT dataset, the most common of them.
Where you run it
Open-source trainers on your own hardware. TRL with PEFT, Axolotl, LLaMA-Factory and Unsloth all run a LoRA or full fine-tune locally. You own the GPUs and the training loop, and the trainer reads the dataset off disk or from the Hugging Face Hub.
A managed platform. Together and Fireworks take a dataset you upload and run the job for you. Fireworks accepts JSONL in the OpenAI chat shape, with a messages array and an optional weight that scales the loss. Together accepts JSONL or pre-tokenized Parquet carrying input_ids and attention_mask. Neither reads a Hugging Face dataset id, so you upload a file and the job refers to it by id. Baseten works differently again: you supply a training script and a config, it provisions the GPUs, and your script loads the data itself.
Producing the examples stays with you in all of these cases, and the people who know which rows mean what are the ones who have to shape them. The useful part is that the shapes on offer are a small set: chat records as JSONL, or pre-tokenized columns. The dataset is therefore the portable artifact. Build it once and it travels to whichever of those you train on.
What you’ll build
An SFT dataset assembled from the two tables above, validated against the trainer’s schema and exported as JSONL or pre-tokenized Parquet, with its token count known before anything is transferred.
contract_chunks (Hudi · Lance) × contract_annotations (Hudi · Parquet)
│ locate each expert-labelled clause inside the chunk that contains it
▼
training rows messages: [system, user → question + context, assistant → answer]
│ format("training_dataset")
▼
provider file JSONL, or pre-tokenized Parquet + a token manifest
│ upload
▼
training run on Together, or on a trainer of your own
The previous guide read 510 CUAD contract PDFs — real commercial contracts with lawyer-assigned clause labels, from Hugging Face — into those tables, along with the 6,591 clause labels that lawyers assigned them across 41 clause types.
Everything below runs on Quanton.
Setup
quanton_llm_training ships in the image, so there is nothing to install:
import quanton_llm_training
quanton_llm_training.register(spark)
Two options pull in extra packages. outputFormat=tokenized needs transformers, for the tokenizer and chat template. upload=true needs the target platform’s SDK, which today means the together package. For Fireworks you write the JSONL and upload it with firectl or the REST API, and for Baseten you point your training script at the exported file.
Step 1 — Build the training rows in SQL
Curation is a query. A CUAD label is a clause type (Non-Compete, Anti-Assignment) plus the span of contract text a lawyer highlighted as being that clause. The chunks are the same contracts as parsed text. Locating the span inside the chunk that contains it gives a grounded triple, a question, the context to answer it from, and an answer a lawyer wrote rather than one a model invented:
norm = lambda c: F.lower(F.regexp_replace(c, r"\s+", " "))
spans = (annotations
.select("file_name", "clause_type", F.explode("clause_spans").alias("span"))
.where(F.length("span") > 40))
positives = (
spans.withColumn("span_key", F.substring(norm(F.col("span")), 1, 120))
.join(chunks.withColumn("chunk_norm", norm(F.col("chunk"))), "file_name")
.where(F.expr("instr(chunk_norm, span_key) > 0"))
.groupBy("file_name", "clause_type", "span")
.agg(F.first("chunk").alias("chunk"))
)
Three parts of that query are worth calling out. Whitespace is normalised on both sides because the label text and the chunk text came out of the PDF through different tools. The join key is a 120-character prefix rather than the whole span, since long spans cross chunk boundaries and would otherwise be dropped. The length filter and the groupBy remove accidental matches from very short spans and keep one chunk per contract, clause type and span.
A second query shapes each surviving row into an SFT record:
train = positives.select(
F.array(
F.struct(F.lit("system").alias("role"),
F.lit("You are a contract analyst. Answer strictly from the excerpt; "
"quote the contract language that supports your answer.").alias("content")),
F.struct(F.lit("user").alias("role"),
F.concat(F.lit('Does the following contract excerpt contain a "'),
F.col("clause_type"),
F.lit('" clause? If yes, quote the relevant language.\n\nExcerpt:\n'),
F.col("chunk")).alias("content")),
F.struct(F.lit("assistant").alias("role"),
F.concat(F.lit("Yes. This excerpt contains a "), F.col("clause_type"),
F.lit(' clause: "'), F.substring(F.col("span"), 1, 800),
F.lit('"')).alias("content")),
).alias("messages")
)
A join, a filter and an array of structs. No dedicated tooling in the middle.
One thing this dataset gets wrong, to keep the example short. Every row is a positive, so every answer starts Yes. This excerpt contains. Train on that and the model learns to say yes rather than to check. A usable dataset needs roughly as many negatives, chunks from the same contracts where that clause type is absent and the answer is no, which is an anti-join over the same two tables.
Step 2 — Export with validation
(train.write.format("training_dataset")
.option("provider", "together")
.option("training_type", "sft")
.option("rowIdColumn", "chunk_id")
.save("s3://bucket/exports/contract-analyst-v1"))
Rows are validated on the executors as the part files are written: roles against {system, user, assistant, tool}, no empty content, and at least one assistant turn. A row that fails stops the export job, and rowIdColumn names the column to quote in the error so it points at a record you can go and open.
The check runs here rather than at upload time because a platform’s endpoint reports the first failure and stops, which costs one round trip per bad row on a file you have already transferred.
The required columns per shape:
training_type | Required columns |
|---|---|
sft | messages array<struct<role,content>>, or prompt + completion |
preference | input, preferred_output, non_preferred_output |
text | text |
Only those columns reach the file. chunk_id above is read for error messages, not written, so to trace a training row back to the contract that produced it, keep the curated rows as a table of their own and export from that.
Step 3 — Count the tokens before uploading
outputFormat=tokenized runs the model’s tokenizer and chat template over each record and writes Parquet with input_ids, attention_mask and labels, which is Together’s documented Parquet contract:
(train.limit(1000).write.format("training_dataset")
.option("provider", "together")
.option("training_type", "sft")
.option("outputFormat", "tokenized")
.option("tokenizer", "Qwen/Qwen2.5-0.5B-Instruct")
.save(path))
Every export writes a _manifest.json beside the part files. On this path it carries the token counts. From the CUAD run above:
{
"output_format": "tokenized",
"training_type": "sft",
"total_rows": 1000,
"tokenizer": "Qwen/Qwen2.5-0.5B-Instruct",
"total_tokens": 552944,
"truncated_rows": 0
}
Platforms bill on tokens, so total_tokens is the size of the run you are about to start, and you get it before transferring anything: 553 tokens per example here, which puts the full 5,000-row version of this dataset at roughly 2.8M tokens per epoch. That is the number to price, or to trim the dataset against, while it still costs nothing to change your mind.
truncated_rows counts records longer than maxSeqLen. It reads zero above because that option was not set, so nothing was truncating. Set maxSeqLen to the sequence length your run will actually use and the number starts meaning something: a truncated example is one whose assistant turn may have been cut off, so you would be paying to train on a half-finished answer.
Two properties of the tokenized path worth knowing before choosing it.
labels mirror input_ids, which is full-sequence loss. The model trains on the prompt as well as the answer. Together’s Parquet contract does allow -100 in labels to mask a position out of the loss, but emitting that needs chat templates with generation markers and is a tracked follow-up. If assistant-only masking matters for your run, use jsonl and let the platform handle it.
prompt + completion rows are joined with an explicit separator before tokenizing. Concatenating the two strings directly fuses the boundary into a single token, so "...worth?" followed by "About $5" tokenizes with ?About as one unit and the model never sees where the prompt ends.
Step 4 — Upload
.option("upload", "true") # key read from $TOGETHER_API_KEY
The part files are merged and pushed from the driver in commit(), and the manifest gains a provider_file_id. The API key is read only from an environment variable, never from an option, so it cannot end up in a query plan or a log.
The merge streams rather than reading files whole: JSONL in fixed-size blocks, and Parquet a row group at a time through a single writer, because Parquet files each carry their own footer and cannot be byte-concatenated. The merged file still lands on driver-local disk, so a multi-GB export needs that much free space there. For those, write to object storage and upload out of band.
Uploading is the one part that is platform-specific, and only one adapter is written so far:
| platform | exporter support | how the platform takes the dataset |
|---|---|---|
together | jsonl and tokenized, uploaded in commit() | a file you upload, referenced by file id |
fireworks | no adapter yet; write the JSONL and upload it yourself | JSONL via the UI, firectl or the REST API |
baseten | no adapter yet; point your training script at the output | your training script loads it on Baseten’s GPUs |
The gaps in the middle column are ours, not theirs. All three ingest data perfectly well, and the exported file is valid for each. provider must be together today because the other two raise NotImplementedError rather than silently producing something untested.
No adapter starts a training run. The export ends at a validated file and a file id, and kicking off the job is a separate call you make, so an export cannot start a billable run by itself:
client.fine_tuning.create(
training_file=manifest["upload"]["provider_file_id"],
model="meta-llama/Meta-Llama-3.1-8B-Instruct",
lora=True,
)
Where to go next
The negative examples are what this dataset needs first: rows drawn from the same contracts where the clause type is absent and the answer is no. That is the query in Step 1 with the containment test inverted.
After that, because the export is derived from tables rather than hand-maintained, the operational questions become ordinary ones. Persist the curated rows as their own Hudi table, keyed on the chunk they came from, and you can diff two exports to see what changed between v1 and v2, trace a suspect model answer back to the contract that taught it, and re-run the query when the source documents change.
Serving the tuned model is its own stack: vLLM if you host it, or the platform’s endpoint if you don’t.
Related
- RAG on Your Lakehouse — required reading for this guide. It builds
contract_chunksandcontract_annotationsfrom a directory of PDFs and a CSV of labels.