Guide: a retriever for a low-resource language¶
Goal: semantic search in a language with little or no training data β no queryβpassage pairs, maybe no parallel text at all.
The route: manufacture the training pairs (translate β mine hard negatives) β contrastive training β benchmark against e5/LaBSE. This is the exact pipeline the synthetic data module was built for.
You need: a GPU, a multilingual base encoder (yours, or any HF encoder), an English (or other high-resource) corpus in your domain, and a small hand-labeled retrieval set for evaluation β even 50β100 queries with judged passages is enough to rank models.
1. Manufacture parallel pairs¶
No data in the target language? Translate your high-resource corpus with an open MT model. MADLAD-400 covers 400+ languages β far better at genuinely low-resource ones than any general LLM β and is Apache-licensed, so the output is commercially clean.
from foundry import load_translator, synthesize_parallel
tr = load_translator("google/madlad400-3b-mt", device="auto", dtype="bfloat16")
pairs = synthesize_parallel(
english_sentences, tr,
target_langs=["yo"], # ISO codes: "yo", "sw", "ha", ...
batch_size=32, max_new_tokens=128,
)
# β [{"anchor": english, "positive": yoruba_translation}, ...]
Cross-lingual anchor/positive pairs are exactly what aligns a multilingual embedding space: the model learns to place a sentence and its translation at the same point.
Already have pairs?
Skip to step 2. And if your language is high-resource enough that an LLM writes it well, synthesize_pairs / generate_hard_negatives can generate queryβpassage pairs directly.
2. Mine hard negatives¶
In-batch negatives (step 3) are random β easy to tell apart. Retrieval quality comes from hard negatives: passages that look right but aren't. For low-resource languages, don't ask an LLM β mine them with the encoder itself:
from transformers import AutoModel, AutoTokenizer
from foundry import mine_hard_negatives
model = AutoModel.from_pretrained("./my-base") # or any multilingual encoder
tok = AutoTokenizer.from_pretrained("./my-base")
pairs = mine_hard_negatives(pairs, model, tok, device="cuda",
batch_size=64, skip_top=1)
# each pair gains a "negative": the most confusable *other* positive
skip_top=1 skips the single highest-scoring candidate β it's often a near-duplicate of the true answer, and training against near-duplicates teaches the model the wrong lesson.
3. Contrastive training¶
InfoNCE with in-batch negatives β the e5/bge/LaBSE recipe. Every other positive in the batch is a free negative, and the mined "negative" fields join the candidate pool automatically:
from foundry import ContrastiveTrainer, ContrastiveConfig
trainer = ContrastiveTrainer(model, tok, ContrastiveConfig(
batch_size=64, # in-batch negatives = batch_size β 1: bigger is better
temperature=0.05,
epochs=1, lr_scheduler="cosine", warmup_steps=100,
device="cuda", torch_dtype="bfloat16",
))
result = trainer.train(pairs)
print(f"loss: {result['losses'][0]:.3f} -> {result['losses'][-1]:.3f}")
model.save_pretrained("./my-retriever")
tok.save_pretrained("./my-retriever")
If you hit CUDA OOM, cut max_length before you cut batch_size β batch size is where the training signal comes from.
4. Benchmark it¶
Score against the strong multilingual baselines on your evaluation set. Each model is encoded with its own tokenizer, pooling, and prefixes (e5's query:/passage: prefixes are applied automatically):
from foundry import compare_retrievers, print_retrieval_comparison
# queries: list[str] Β· corpus: list[str] Β· qrels[i]: set of corpus indices relevant to query i
results = compare_retrievers(
{"My Retriever": "./my-retriever",
"e5-base": "intfloat/multilingual-e5-base",
"LaBSE": "sentence-transformers/LaBSE"},
queries, corpus, qrels, k=10, device="cuda",
)
print_retrieval_comparison(results, k=10)
model ndcg@10 recall@10 params(M)
βββββββββββββββββββββββββββββββββββββββββββββββββ
My Retriever 0.8305 0.9040 30.0
e5-base 0.8412 0.9120 278.0
LaBSE 0.7980 0.8760 470.8
The baselines saw little or none of your language during their training β a small model trained on synthesized in-language pairs closing the gap to e5 (at a tenth of the size) is a typical outcome, and beating the baselines outright is realistic when the domain is narrow.
5. Serve it¶
./my-retriever is a standard HF directory. At query time, embed with the same pooling you trained with:
from foundry import encode_texts
corpus_emb = encode_texts(model, tok, corpus, pool="mean", device="cuda") # index once
q_emb = encode_texts(model, tok, ["ibeere kan"], pool="mean", device="cuda")
scores = q_emb @ corpus_emb.T # cosine (normalised)
Where to go from here¶
- Iterate on data, not knobs: more/cleaner synthetic pairs move nDCG more than any hyperparameter. Synthetic data β
- Distil a bigger retriever into yours first, then contrastive-tune:
EmbeddingDistillTrainer - Every metric and helper used above: Retrieval evaluation β