How to Merge LoRA Adapters into a Base Model and Build a Tuned .cact Binary File
The Needle repository provides a three-step workflow: train a LoRA adapter with finetune_local, merge it into base weights using build_main and merge_lora, then export to a compressed .cact binary with write_export.
The Needle project implements a lightweight, JAX-based inference engine that supports efficient fine-tuning through Low-Rank Adaptation (LoRA). This approach lets you adapt large pretrained models without modifying the original checkpoint, then bake those adaptations into a single portable binary. Below you'll find the complete technical walkthrough derived from the cactus-compute/needle source code.
What is LoRA in the Needle Framework?
LoRA (Low-Rank Adaptation) reduces fine-tuning costs by learning small "A" and "B" matrices for specific weight groups instead of updating full parameter tensors. In Needle, these adapters target attention projections—q_proj, k_proj, v_proj, gate_proj, and out_proj—with configurable rank and scaling.
The architecture lives in needle/model/architecture.py, while the training and merging logic resides in needle/model/finetune.py.
Step 1: Train a LoRA Adapter with finetune_local
The finetune_local function in needle/model/finetune.py handles the entire training pipeline. It loads the base checkpoint from checkpoints/needle2.pkl, initializes LoRA matrices for the target projections, and runs a JAX training loop against your JSONL dataset.
Key implementation details from lines 67–84 and 94–104:
- Adapter creation logic builds A and B matrices with shape determined by
--lora_rank - The
--lora_alphaparameter controls the scaling factor applied during forward passes - Output serializes to a pickle file at your specified
--checkpoint_dir
Here's a complete training script that mirrors the needle finetune CLI command:
from needle.model.finetune import finetune_local, argparse
parser = argparse.ArgumentParser()
parser.add_argument("--jsonl_path", required=True)
parser.add_argument("--checkpoint_dir", default="ckpt")
parser.add_argument("--lora_rank", type=int, default=8)
parser.add_argument("--lora_alpha", type=float, default=16)
parser.add_argument("--epochs", type=int, default=3)
parser.add_argument("--batch_size", type=int, default=32)
args = parser.parse_args([
"--jsonl_path", "needle_data.jsonl",
"--checkpoint_dir", "ckpt",
"--lora_rank", "8",
"--lora_alpha", "16",
"--epochs", "3",
"--batch_size", "32",
])
finetune_local(args)
This produces ckpt/needle_lora.pkl containing the trained adapter weights.
Step 2: Merge the Adapter with build_main and merge_lora
Once you have an adapter, the build_main function (lines 12–34 of finetune.py) orchestrates the merge operation. It reconstructs LoRA parameters from the pickle file, applies merge_lora to combine low-rank updates with base weights, then prepares the unified parameter set for export.
The merge process (line 18):
- Loads base checkpoint via
load_checkpointfromneedle/model/run.py - Deserializes adapter with
pickle.load(lines 12–18) - Applies
merge_lorato compute:W_merged = W_base + (alpha / rank) * B @ A
from needle.model.finetune import build_main, argparse
parser = argparse.ArgumentParser()
parser.add_argument("--checkpoint", default="checkpoints/needle2.pkl")
parser.add_argument("--lora", required=True)
parser.add_argument("--out", default="needle_finetuned.cact")
parser.add_argument("--bits", default="4")
args = parser.parse_args([
"--checkpoint", "checkpoints/needle2.pkl",
"--lora", "ckpt/needle_lora.pkl",
"--out", "needle_finetuned.cact",
"--bits", "4",
])
build_main(args)
The merged weights now contain the fine-tuned knowledge directly in the parameter tensors—no adapter inference overhead remains.
Step 3: Export to .cact Format with write_export
The final phase invokes write_export from needle/model/export.py (called at lines 25–31 of build_main). This serializes:
- Merged model parameters (optionally quantized)
- Architecture configuration
- Tokenizer vocabulary and encoding tables
- KV-window metadata for efficient caching
The default --bits 4 applies 4-bit quantization, substantially reducing file size while preserving inference quality. Lines 32–34 of build_main report the resulting tensor count and binary size.
Loading the finished binary requires no additional files:
from needle import Needle
model = Needle(weights="needle_finetuned.cact", tools=[...])
result = model("What are the top-3 most recent issues in the repo?")
print(result)
The Needle class automatically restores tokenizer and KV-cache configuration from the binary header.
Key Source Files You Should Know
| File | Purpose | Critical Functions |
|---|---|---|
needle/model/finetune.py |
LoRA training and merging | finetune_local, build_main, merge_lora |
needle/model/run.py |
Checkpoint loading | load_checkpoint |
needle/model/export.py |
Binary serialization | write_export |
needle/cli.py |
Command-line interface | Exposes needle finetune, needle build |
needle/model/architecture.py |
Model definition | SimpleAttentionNetwork |
needle/model/tokenizer.py |
Text encoding | Tokenizer class |
How to Choose LoRA Hyperparameters
The finetune_local signature exposes two critical parameters:
--lora_rank(default 8): Controls the inner dimension of A and B matrices. Higher ranks capture more complex adaptations but increase adapter size and training time.--lora_alpha(default 16): Sets the scaling factor applied to LoRA outputs. The effective scale isalpha / rank, so the default yields 2x scaling.
For small datasets or narrow task adaptations, ranks of 4–8 typically suffice. Cross-task generalization may require 16–32.
Summary
- Train adapters with
finetune_localinneedle/model/finetune.pyto create serialized.pklfiles without touching base weights - Merge adapters via
build_main, which callsmerge_lorato bake low-rank updates into the original parameter tensors - Export binaries through
write_exportinneedle/model/export.pyto produce self-contained.cactfiles with optional 4-bit quantization - Load directly into the
Needleclass for inference—no separate tokenizer or configuration files required
Frequently Asked Questions
What is the difference between a .pkl adapter and a .cact binary?
A .pkl file contains only the small LoRA matrices (A and B tensors) plus metadata—it cannot run inference alone and must be combined with a base checkpoint. A .cact file is a complete, standalone artifact with merged weights, tokenizer, and inference configuration ready for immediate use.
Does merging LoRA adapters change the base checkpoint?
No. The load_checkpoint function in needle/model/run.py loads base weights into memory; merge_lora operates on these in-memory tensors. The original checkpoints/needle2.pkl remains unmodified. You can merge multiple adapters sequentially or maintain separate .cact variants from one base model.
How much does quantization affect model quality?
The default 4-bit quantization in write_export uses symmetric rounding optimized for transformer attention weights. For most fine-tuning scenarios on reasoning tasks, quality degradation is minimal (<2% on needle's evaluation suite). You can specify --bits 8 or --bits 16 for higher precision at larger file sizes.
Can I merge multiple LoRA adapters into one .cact file?
The current build_main implementation accepts a single --lora path. To compose multiple adapters, train a single adapter on combined data, or sequentially merge adapters by calling build_main with the output of a previous merge as the new --checkpoint. The pipeline is designed for single-adapter workflows for simplicity.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →