Visual Document Retrieval
Safetensors
sentence-transformers
colpali-engine
qwen3_5
vision-language
colbert
late-interaction
multi-vector
matryoshka
vidore
token-compression
Instructions to use tencent/EVIE-4.5B with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- sentence-transformers
How to use tencent/EVIE-4.5B with sentence-transformers:
from sentence_transformers import MultiVectorEncoder model = MultiVectorEncoder("tencent/EVIE-4.5B") queries = ["Which planet is known as the Red Planet?"] documents = [ "Venus is often called Earth's twin because of its similar size and proximity.", "Mars, known for its reddish appearance, is often referred to as the Red Planet.", "Jupiter, the largest planet in our solar system, has a prominent red spot.", ] query_embeddings = model.encode_query(queries) document_embeddings = model.encode_document(documents) similarities = model.similarity(query_embeddings, document_embeddings) print(similarities) - Notebooks
- Google Colab
- Kaggle
Download code/teacher/scripts/train.py from tencent/EVIE-4.5B: direct link, hf CLI and curl.
- Browser
- Download file 12.7 kB
-
https://huggingface.co/tencent/EVIE-4.5B/resolve/main/code/teacher/scripts/train.py
- Command line
-
hf download hf://tencent/EVIE-4.5B/code/teacher/scripts/train.py
-
curl -L -o train.py https://huggingface.co/tencent/EVIE-4.5B/resolve/main/code/teacher/scripts/train.py
12.7 kB
| #!/usr/bin/env python3 | |
| """Minimal local ColQwen3.5 LoRA trainer for explicit train-only sources.""" | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import os | |
| import time | |
| import sys | |
| from pathlib import Path | |
| _SHARED = Path(__file__).resolve().parents[2] / "shared" | |
| if str(_SHARED) not in sys.path: | |
| sys.path.insert(0, str(_SHARED)) | |
| import torch | |
| from peft import LoraConfig | |
| from torch.distributed.elastic.multiprocessing.errors import record | |
| from transformers import TrainingArguments, set_seed | |
| from data_loader import ( | |
| ALLOWED_SOURCES, | |
| _default_dataset_cache_dir, | |
| build_hardneg_dataset, | |
| build_train_dataset, | |
| ) | |
| from paths import forbid_venv_path | |
| from colpali_engine.loss.late_interaction_losses import ColbertLoss, ColbertNegativeCELoss | |
| from colpali_engine.models import ColQwen3_5, ColQwen3_5Processor | |
| from transformers.models.qwen3_5 import Qwen3_5Config | |
| from colpali_engine.trainer.colmodel_training import ColModelTraining, ColModelTrainingConfig | |
| TARGET_MODULES = ( | |
| r"(.*(model)(?!.*visual).*(down_proj|gate_proj|up_proj|k_proj|q_proj|v_proj|o_proj|" | |
| r"in_proj_qkv|in_proj_z|in_proj_b|in_proj_a|out_proj).*$)" | |
| ) | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("--base-model", default="") | |
| parser.add_argument("--data-root", default="./data") | |
| parser.add_argument("--output-dir", required=True) | |
| parser.add_argument("--sources", nargs="*", default=[], choices=ALLOWED_SOURCES, | |
| help="Optional ablation filter on the source column; empty = full corpus.") | |
| parser.add_argument("--seed", type=int, default=42) | |
| parser.add_argument("--epochs", type=float, default=1.0) | |
| parser.add_argument("--max-steps", type=int, default=-1) | |
| parser.add_argument("--max-samples-per-source", type=int, default=0) | |
| parser.add_argument("--per-device-batch-size", type=int, default=2) | |
| parser.add_argument("--grad-accum", type=int, default=1) | |
| parser.add_argument("--learning-rate", type=float, default=4.57e-5) | |
| parser.add_argument("--weight-decay", type=float, default=0.02) | |
| parser.add_argument("--warmup-ratio", type=float, default=0.08) | |
| parser.add_argument("--max-visual-tokens", type=int, default=1024) | |
| parser.add_argument("--col-dim", type=int, default=512, | |
| help="custom_text_proj output dim (4096 for EVIE-8B).") | |
| parser.add_argument("--dataloader-workers", type=int, default=2) | |
| parser.add_argument( | |
| "--dataloader-prefetch-factor", | |
| type=int, | |
| default=4, | |
| help="Batches prefetched by each DataLoader worker; ignored when workers=0.", | |
| ) | |
| parser.add_argument("--save-steps", type=int, default=500) | |
| parser.add_argument("--logging-steps", type=int, default=10) | |
| parser.add_argument("--lora-r", type=int, default=32) | |
| parser.add_argument("--lora-alpha", type=int, default=128) | |
| parser.add_argument("--lora-dropout", type=float, default=0.197) | |
| parser.add_argument("--loss-temperature", type=float, default=0.02) | |
| parser.add_argument("--hardneg-in-batch-weight", type=float, default=0.5) | |
| parser.add_argument("--resume-from-checkpoint", default="", | |
| help="Checkpoint path, or 'latest' to resume the newest output checkpoint.") | |
| parser.add_argument("--attn", choices=("flash_attention_2", "sdpa", "eager"), default="flash_attention_2") | |
| parser.add_argument("--grad-checkpointing", choices=("on", "off"), default="off") | |
| parser.add_argument( | |
| "--bidirectional-attention", | |
| choices=("on", "off"), | |
| default="on", | |
| help="on = encoder-ize full-attention layers (ColEmbed V2).", | |
| ) | |
| parser.add_argument( | |
| "--hardneg-root", | |
| default="", | |
| help="Hardneg output with queries/. corpus/ is optional; without it images load from --data-root.", | |
| ) | |
| parser.add_argument("--num-hard-negs", type=int, default=2) | |
| parser.add_argument( | |
| "--use-hardnegatives", | |
| choices=("on", "off"), | |
| default="on", | |
| help="When --hardneg-root is set: on=ColbertNegativeCELoss; off=same rows, in-batch only.", | |
| ) | |
| parser.add_argument( | |
| "--report-to", | |
| default="none", | |
| help="Comma-separated metric trackers, e.g. wandb,tensorboard.", | |
| ) | |
| parser.add_argument("--logging-dir", default=None, help="TensorBoard event output directory.") | |
| parser.add_argument("--run-name", default=None, help="Run name for the tracker (e.g. wandb).") | |
| return parser.parse_args() | |
| def resolve_pretrained(spec: str) -> str: | |
| path = Path(spec) | |
| return str(path.resolve()) if path.exists() else spec | |
| def prepare_output(path: Path, resume_requested: bool) -> None: | |
| path = forbid_venv_path(path, "output-dir") | |
| prepared_by_launcher = os.environ.get("EVIE_OUTPUT_PREPARED") == "1" | |
| if path.exists() and any(path.iterdir()) and not resume_requested and not prepared_by_launcher: | |
| raise FileExistsError(f"Refusing to overwrite a non-empty output directory: {path}") | |
| path.mkdir(parents=True, exist_ok=True) | |
| def main() -> None: | |
| args = parse_args() | |
| output_dir = Path(args.output_dir).resolve() | |
| base_model = resolve_pretrained(args.base_model) | |
| data_root = Path(args.data_root).resolve() | |
| if not data_root.is_dir(): | |
| raise FileNotFoundError(f"Data root is missing: {data_root}") | |
| if args.hardneg_root: | |
| hn = Path(args.hardneg_root).resolve() | |
| subdir = os.environ.get("HARDNEG_SUBDIR", "judged") | |
| if not (hn / subdir).is_dir(): | |
| raise FileNotFoundError(f"hardneg-root needs {subdir}/: {hn}") | |
| prepare_output(output_dir, resume_requested=bool(args.resume_from_checkpoint)) | |
| os.environ.setdefault("HF_DATASETS_CACHE", str(_default_dataset_cache_dir())) | |
| set_seed(args.seed) | |
| print("== building train-only query/image pairs ==") | |
| use_hardneg = bool(args.hardneg_root) and args.use_hardnegatives == "on" | |
| if args.hardneg_root: | |
| train_dataset = build_hardneg_dataset( | |
| hardneg_root=args.hardneg_root, | |
| data_root=data_root, | |
| num_negatives=args.num_hard_negs, | |
| max_samples=args.max_samples_per_source, | |
| use_negatives=use_hardneg, | |
| ) | |
| else: | |
| train_dataset = build_train_dataset( | |
| data_root=data_root, | |
| sources=args.sources, | |
| max_samples_per_source=args.max_samples_per_source, | |
| ) | |
| print("== loading processor and bf16 base model ==") | |
| processor = ColQwen3_5Processor.from_pretrained( | |
| base_model, | |
| max_num_visual_tokens=args.max_visual_tokens, | |
| ) | |
| # Raw Qwen3.5 has no ColBERT head. Set config.dim before build so | |
| # custom_text_proj is created at --col-dim; missing keys stay randomly | |
| # initialized. Forward L2-normalizes each token, so init scale washes out. | |
| model_config = Qwen3_5Config.from_pretrained(base_model) | |
| model_config.dim = args.col_dim | |
| model = ColQwen3_5.from_pretrained( | |
| base_model, | |
| config=model_config, | |
| torch_dtype=torch.bfloat16, | |
| attn_implementation=args.attn, | |
| ) | |
| print(f"[model] custom_text_proj = Linear(-> {args.col_dim}), fresh full-rank head") | |
| try: | |
| model.rope_deltas = None | |
| except AttributeError: | |
| pass | |
| if args.bidirectional_attention == "on": | |
| model.enable_bidirectional_attention() | |
| print("[model] bidirectional attention enabled on full-attention layers") | |
| use_gc = args.grad_checkpointing == "on" | |
| if use_gc: | |
| model.enable_input_require_grads() | |
| report_to = [item.strip() for item in args.report_to.split(",") if item.strip()] | |
| if not report_to or report_to == ["none"]: | |
| report_to = [] | |
| training_args = TrainingArguments( | |
| output_dir=str(output_dir), | |
| num_train_epochs=args.epochs, | |
| max_steps=args.max_steps, | |
| per_device_train_batch_size=args.per_device_batch_size, | |
| gradient_accumulation_steps=args.grad_accum, | |
| gradient_checkpointing=use_gc, | |
| gradient_checkpointing_kwargs={"use_reentrant": False} if use_gc else None, | |
| dataloader_num_workers=args.dataloader_workers, | |
| dataloader_pin_memory=True, | |
| dataloader_persistent_workers=args.dataloader_workers > 0, | |
| dataloader_prefetch_factor=args.dataloader_prefetch_factor if args.dataloader_workers > 0 else None, | |
| dataloader_drop_last=True, | |
| save_steps=args.save_steps, | |
| save_total_limit=2, | |
| logging_steps=args.logging_steps, | |
| learning_rate=args.learning_rate, | |
| lr_scheduler_type="cosine", | |
| warmup_ratio=args.warmup_ratio, | |
| weight_decay=args.weight_decay, | |
| bf16=True, | |
| seed=args.seed, | |
| data_seed=args.seed, | |
| ddp_find_unused_parameters=False, | |
| report_to=report_to, | |
| logging_dir=args.logging_dir, | |
| run_name=args.run_name, | |
| ) | |
| lora = LoraConfig( | |
| r=args.lora_r, | |
| lora_alpha=args.lora_alpha, | |
| lora_dropout=args.lora_dropout, | |
| init_lora_weights="gaussian", | |
| bias="none", | |
| task_type="FEATURE_EXTRACTION", | |
| target_modules=TARGET_MODULES, | |
| modules_to_save=["custom_text_proj"], # fresh head: full-rank, not LoRA | |
| ) | |
| if use_hardneg: | |
| judged = bool(getattr(train_dataset, "judged_pos", False)) | |
| all_pos = bool(getattr(train_dataset, "all_pos", False)) | |
| mode = ", judged-pos" if judged else (", all-pos" if all_pos else "") | |
| print( | |
| f"== Loss: ColbertNegativeCELoss (hard_negs={args.num_hard_negs}{mode}) ==" | |
| ) | |
| loss_func = ColbertNegativeCELoss( | |
| temperature=args.loss_temperature, | |
| normalize_scores=True, | |
| use_smooth_max=False, | |
| pos_aware_negative_filtering=True, | |
| in_batch_term_weight=args.hardneg_in_batch_weight, | |
| ) | |
| else: | |
| print("== Loss: ColbertLoss (in-batch only) ==") | |
| loss_func = ColbertLoss( | |
| temperature=args.loss_temperature, | |
| normalize_scores=True, | |
| use_smooth_max=False, | |
| ) | |
| trainer = ColModelTraining( | |
| ColModelTrainingConfig( | |
| output_dir=str(output_dir), | |
| processor=processor, | |
| model=model, | |
| train_dataset=train_dataset, | |
| eval_dataset=None, | |
| run_eval=False, | |
| loss_func=loss_func, | |
| tr_args=training_args, | |
| peft_config=lora, | |
| ) | |
| ) | |
| print("== training ==") | |
| started = time.time() | |
| resume = args.resume_from_checkpoint or None | |
| if resume == "latest": | |
| checkpoints = sorted( | |
| output_dir.glob("checkpoint-*"), | |
| key=lambda p: int(p.name.rsplit("-", 1)[-1]), | |
| ) | |
| if not checkpoints: | |
| raise FileNotFoundError(f"no checkpoint-* found under {output_dir}") | |
| resume = str(checkpoints[-1]) | |
| training_args.resume_from_checkpoint = resume | |
| trainer.train() | |
| trainer.save() | |
| world_size = int(os.environ.get("WORLD_SIZE", 1)) | |
| try: | |
| n_samples = len(train_dataset) | |
| except TypeError: | |
| n_samples = None | |
| config = vars(args) | { | |
| "base_model": str(base_model), | |
| "data_root": str(data_root), | |
| "framework": "minimal-colqwen35-lora", | |
| "hardneg_subdir": ( | |
| os.environ.get("HARDNEG_SUBDIR", "judged") if args.hardneg_root else None | |
| ), | |
| "judged_pos": bool(getattr(train_dataset, "judged_pos", False)), | |
| "all_pos": bool(getattr(train_dataset, "all_pos", False)), | |
| "world_size": world_size, | |
| "effective_batch_size": args.per_device_batch_size * args.grad_accum * world_size, | |
| "train_samples": n_samples, | |
| "train_runtime_seconds": round(time.time() - started, 1), | |
| "total_params": sum(p.numel() for p in trainer.model.parameters()), | |
| "trainable_params": sum( | |
| p.numel() for p in trainer.model.parameters() if p.requires_grad | |
| ), | |
| } | |
| if int(os.environ.get("RANK", "0")) == 0: | |
| (output_dir / "run_config.json").write_text( | |
| json.dumps(config, ensure_ascii=False, indent=2) + "\n", | |
| encoding="utf-8", | |
| ) | |
| if torch.distributed.is_available() and torch.distributed.is_initialized(): | |
| torch.distributed.barrier() | |
| print(f"== complete: {output_dir} ==") | |
| def run_with_error_recording() -> None: | |
| """Persist the original failing DDP rank's traceback for torchrun.""" | |
| try: | |
| main() | |
| finally: | |
| if torch.distributed.is_available() and torch.distributed.is_initialized(): | |
| torch.distributed.destroy_process_group() | |
| if __name__ == "__main__": | |
| run_with_error_recording() | |