Un retriever poate obține rezultate acceptabile pe întrebări ușoare și totuși să aleagă documentul greșit când două abstracte folosesc aproape aceiași termeni. În acest laborator vom reproduce tocmai această situație: modelul caută dovezi pentru afirmații științifice, extrage documentele nerelevante pe care le consideră foarte apropiate și învață să le îndepărteze de răspunsurile relevante.
La final vei avea un model Sentence Transformers salvat local, un fișier JSONL cu hard negatives și un raport care compară modelul inițial cu cel antrenat prin MRR@10, Recall@10 și nDCG@10. O îmbunătățire nu este presupusă: scriptul o măsoară pe setul de test și raportează explicit dacă nDCG@10 a crescut sau nu.
Fixăm experimentul la SciFact și rulăm totul pe CPU
Laboratorul folosește Debian 12, Python 3.11 și CPU. Sunt suficiente patru nuclee, 8 GB RAM și aproximativ 4 GB liberi. Durata orientativă este de 30–60 de minute, în principal pentru descărcarea modelului, calcularea embeddingurilor și antrenarea unei epoci.
Modelul inițial este sentence-transformers/all-MiniLM-L6-v2, fixat la revizia c9745ed1d9f207416be6d2e6f8de32d1f16199bf. Modelul produce vectori cu 384 de dimensiuni, iar cardul său indică licența Apache 2.0.
Datasetul SciFact conține 5.183 de abstracte științifice. Varianta BEIR utilizată de ir_datasets oferă 809 interogări de antrenare și 300 de interogări de test. Cardul datasetului original AllenAI indică licența CC BY-NC 2.0; laboratorul este potrivit pentru învățare și evaluare, nu pentru reutilizarea automată a datelor într-un produs comercial.
Verifică mediul înainte de instalare:
cat /etc/os-release
python3 --version
lscpu | grep -E 'Model name|^CPU(s)'
free -h
df -h .
Un exemplu de rezultat compatibil este:
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
Python 3.11.x
CPU(s): 4 sau mai multe
Mem: cel puțin 8 GiB
Avail: cel puțin 4 GiB
Versiunea minoră a sistemului, modelul procesorului și spațiul disponibil vor diferi. Punct de control: dacă python3 --version nu începe cu 3.11, creează laboratorul pe Debian 12 cu Python 3.11 înainte de a continua.
Instalează pachetele într-un mediu virtual:
mkdir -p "$HOME/rag-hard-negatives"
cd "$HOME/rag-hard-negatives"
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
Harta proiectului va fi:
rag-hard-negatives/
├── requirements.txt versiuni fixate pentru laborator
├── experiment.py încărcare, mining, antrenare și evaluare
└── artifacts/
├── hard_negatives.jsonl tripletele query–positive–negative
├── baseline_metrics.json scorurile modelului inițial
├── results.json comparația finală
└── retriever-scifact/ modelul Sentence Transformers antrenat
Pasul 1 — Blocăm bibliotecile folosite pentru retrieval și antrenare
Versiunile fixe reduc diferențele produse de schimbarea API-urilor Sentence Transformers, Transformers și ir_datasets.
Fișierul $HOME/rag-hard-negatives/requirements.txt:
torch==2.8.0
sentence-transformers==5.1.1
transformers==4.56.2
datasets==4.1.1
accelerate==1.10.1
ir-datasets==0.5.11
Instalează și verifică mediul:
cd "$HOME/rag-hard-negatives"
source .venv/bin/activate
python -m pip install -r requirements.txt
python -m pip check
python -c "import torch, sentence_transformers, ir_datasets; print(torch.__version__, sentence_transformers.__version__, ir_datasets.__version__)"
pip check trebuie să afișeze:
No broken requirements found.
A doua comandă trebuie să includă versiunile 2.8.0, 5.1.1 și 0.5.11. Punct de control: dacă pip check raportează un conflict, recreează .venv; nu actualiza individual o bibliotecă din acest mediu.
Pasul 2 — Scriem evaluatorul și minerul într-un singur program
Scriptul păstrează complet separat setul de test. Hard negatives sunt căutate numai pentru interogările de antrenare: pentru fiecare query selectează primul document returnat de model care nu apare între documentele relevante din qrels.
„Nerelevant” înseamnă aici „nemarcat ca relevant în qrels”. Judecățile de relevanță pot fi incomplete, deci fișierul rezultat trebuie inspectat înainte de folosirea pe date proprii. Similaritatea semantică ridicată nu demonstrează singură că un document este un negativ corect.
Fișierul $HOME/rag-hard-negatives/experiment.py:
import argparse
import json
import math
import random
from collections import defaultdict
from pathlib import Path
import ir_datasets
import numpy as np
import torch
from datasets import Dataset
from sentence_transformers import (
SentenceTransformer,
SentenceTransformerTrainer,
SentenceTransformerTrainingArguments,
losses,
)
from transformers import set_seed
SEED = 47
MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
MODEL_REVISION = "c9745ed1d9f207416be6d2e6f8de32d1f16199bf"
ROOT = Path(__file__).resolve().parent
ARTIFACTS = ROOT / "artifacts"
NEGATIVES_FILE = ARTIFACTS / "hard_negatives.jsonl"
BASELINE_FILE = ARTIFACTS / "baseline_metrics.json"
RESULTS_FILE = ARTIFACTS / "results.json"
MODEL_DIR = ARTIFACTS / "retriever-scifact"
def configure() -> None:
random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)
torch.set_num_threads(4)
set_seed(SEED)
ARTIFACTS.mkdir(parents=True, exist_ok=True)
def load_splits():
corpus_ds = ir_datasets.load("beir/scifact")
train_ds = ir_datasets.load("beir/scifact/train")
test_ds = ir_datasets.load("beir/scifact/test")
docs = {
str(doc.doc_id): f"{doc.title}n{doc.text}".strip()
for doc in corpus_ds.docs_iter()
}
train_queries = {
str(query.query_id): query.text for query in train_ds.queries_iter()
}
test_queries = {
str(query.query_id): query.text for query in test_ds.queries_iter()
}
train_qrels = defaultdict(dict)
for qrel in train_ds.qrels_iter():
if qrel.relevance > 0:
train_qrels[str(qrel.query_id)][str(qrel.doc_id)] = int(qrel.relevance)
test_qrels = defaultdict(dict)
for qrel in test_ds.qrels_iter():
if qrel.relevance > 0:
test_qrels[str(qrel.query_id)][str(qrel.doc_id)] = int(qrel.relevance)
if len(docs) != 5183:
raise RuntimeError(
f"Corpul SciFact trebuia să aibă 5183 documente, dar are {len(docs)}."
)
if not train_qrels or not test_qrels:
raise RuntimeError("Qrels lipsesc din splitul de antrenare sau de test.")
return docs, train_queries, test_queries, train_qrels, test_qrels
def load_baseline() -> SentenceTransformer:
return SentenceTransformer(
MODEL_NAME,
revision=MODEL_REVISION,
device="cpu",
)
def encode_corpus(model, docs):
doc_ids = sorted(docs)
embeddings = model.encode(
[docs[doc_id] for doc_id in doc_ids],
batch_size=64,
convert_to_numpy=True,
normalize_embeddings=True,
show_progress_bar=True,
)
return doc_ids, embeddings
def evaluate(model, docs, queries, qrels):
doc_ids, doc_embeddings = encode_corpus(model, docs)
query_ids = sorted(qid for qid in qrels if qid in queries)
query_embeddings = model.encode(
[queries[qid] for qid in query_ids],
batch_size=64,
convert_to_numpy=True,
normalize_embeddings=True,
show_progress_bar=True,
)
reciprocal_ranks = []
recalls = []
ndcgs = []
for qid, query_embedding in zip(query_ids, query_embeddings):
scores = doc_embeddings @ query_embedding
top_indices = np.argsort(-scores)[:10]
ranked_ids = [doc_ids[index] for index in top_indices]
relevant = qrels[qid]
hit_ranks = [
rank
for rank, doc_id in enumerate(ranked_ids, start=1)
if doc_id in relevant
]
reciprocal_ranks.append(1.0 / hit_ranks[0] if hit_ranks else 0.0)
recalls.append(
sum(doc_id in relevant for doc_id in ranked_ids) / len(relevant)
)
dcg = sum(
(2 ** relevant.get(doc_id, 0) - 1) / math.log2(rank + 1)
for rank, doc_id in enumerate(ranked_ids, start=1)
)
ideal = sorted(relevant.values(), reverse=True)[:10]
idcg = sum(
(2 ** relevance - 1) / math.log2(rank + 1)
for rank, relevance in enumerate(ideal, start=1)
)
ndcgs.append(dcg / idcg if idcg else 0.0)
return {
"queries": len(query_ids),
"mrr_at_10": float(np.mean(reciprocal_ranks)),
"recall_at_10": float(np.mean(recalls)),
"ndcg_at_10": float(np.mean(ndcgs)),
}
def inspect_data() -> None:
docs, train_queries, test_queries, train_qrels, test_qrels = load_splits()
summary = {
"documents": len(docs),
"train_queries": len(train_queries),
"test_queries": len(test_queries),
"train_queries_with_qrels": len(train_qrels),
"test_queries_with_qrels": len(test_qrels),
}
print(json.dumps(summary, indent=2, ensure_ascii=False))
def mine() -> None:
docs, train_queries, test_queries, train_qrels, test_qrels = load_splits()
model = load_baseline()
baseline = evaluate(model, docs, test_queries, test_qrels)
BASELINE_FILE.write_text(
json.dumps(baseline, indent=2, ensure_ascii=False),
encoding="utf-8",
)
doc_ids, doc_embeddings = encode_corpus(model, docs)
query_ids = sorted(qid for qid in train_qrels if qid in train_queries)
query_embeddings = model.encode(
[train_queries[qid] for qid in query_ids],
batch_size=64,
convert_to_numpy=True,
normalize_embeddings=True,
show_progress_bar=True,
)
rows = []
for qid, query_embedding in zip(query_ids, query_embeddings):
relevant_ids = set(train_qrels[qid])
positive_id = sorted(relevant_ids)[0]
scores = doc_embeddings @ query_embedding
candidates = np.argsort(-scores)[:50]
negative_id = next(
(doc_ids[index] for index in candidates if doc_ids[index] not in relevant_ids),
None,
)
if negative_id is None:
continue
negative_index = doc_ids.index(negative_id)
positive_index = doc_ids.index(positive_id)
rows.append(
{
"query_id": qid,
"positive_id": positive_id,
"negative_id": negative_id,
"anchor": train_queries[qid],
"positive": docs[positive_id],
"negative": docs[negative_id],
"positive_score": float(scores[positive_index]),
"negative_score": float(scores[negative_index]),
}
)
if not rows:
raise RuntimeError("Nu am extras niciun hard negative.")
with NEGATIVES_FILE.open("w", encoding="utf-8") as handle:
for row in rows:
handle.write(json.dumps(row, ensure_ascii=False) + "n")
print(json.dumps({"hard_negatives": len(rows)}, indent=2))
def train() -> None:
if not NEGATIVES_FILE.exists():
raise FileNotFoundError(
f"Lipsește {NEGATIVES_FILE}. Rulează mai întâi etapa mine."
)
rows = [
json.loads(line)
for line in NEGATIVES_FILE.read_text(encoding="utf-8").splitlines()
if line.strip()
]
training_data = Dataset.from_list(
[
{
"anchor": row["anchor"],
"positive": row["positive"],
"negative": row["negative"],
}
for row in rows
]
)
model = load_baseline()
loss = losses.MultipleNegativesRankingLoss(model)
arguments = SentenceTransformerTrainingArguments(
output_dir=str(ARTIFACTS / "checkpoints"),
num_train_epochs=1,
per_device_train_batch_size=16,
learning_rate=2e-5,
warmup_ratio=0.1,
fp16=False,
bf16=False,
use_cpu=True,
seed=SEED,
data_seed=SEED,
save_strategy="no",
logging_steps=20,
report_to="none",
)
trainer = SentenceTransformerTrainer(
model=model,
args=arguments,
train_dataset=training_data,
loss=loss,
)
trainer.train()
model.save_pretrained(str(MODEL_DIR))
print(json.dumps({"saved_model": str(MODEL_DIR)}, indent=2))
def evaluate_trained() -> None:
if not MODEL_DIR.exists() or not BASELINE_FILE.exists():
raise FileNotFoundError(
"Lipsește modelul antrenat sau baseline_metrics.json."
)
docs, _, test_queries, _, test_qrels = load_splits()
model = SentenceTransformer(str(MODEL_DIR), device="cpu")
baseline = json.loads(BASELINE_FILE.read_text(encoding="utf-8"))
trained = evaluate(model, docs, test_queries, test_qrels)
results = {
"baseline": baseline,
"hard_negative_model": trained,
"delta": {
metric: trained[metric] - baseline[metric]
for metric in ("mrr_at_10", "recall_at_10", "ndcg_at_10")
},
"test_ndcg_improved": trained["ndcg_at_10"] > baseline["ndcg_at_10"],
}
RESULTS_FILE.write_text(
json.dumps(results, indent=2, ensure_ascii=False),
encoding="utf-8",
)
print(json.dumps(results, indent=2, ensure_ascii=False))
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"stage",
choices=("inspect", "mine", "train", "evaluate"),
)
args = parser.parse_args()
configure()
actions = {
"inspect": inspect_data,
"mine": mine,
"train": train,
"evaluate": evaluate_trained,
}
actions[args.stage]()
if __name__ == "__main__":
main()
MultipleNegativesRankingLoss primește tripletele anchor, positive, negative. Pentru fiecare query, documentul relevant trebuie să primească un scor mai mare decât negativul explicit și decât exemplele din restul batch-ului.
Punct de control: rulează verificarea de sintaxă înainte de descărcări:
python -m py_compile experiment.py
Lipsa oricărui mesaj înseamnă că fișierul a trecut verificarea de sintaxă.
Pasul 3 — Confirmăm spliturile înainte de a vedea setul de test
Acest pas verifică identificatorii ir_datasets, dimensiunea corpusului și existența qrels. Nu antrenează modelul și nu generează predicții.
cd "$HOME/rag-hard-negatives"
source .venv/bin/activate
python experiment.py inspect
Rezultatul trebuie să respecte această structură:
{
"documents": 5183,
"train_queries": 809,
"test_queries": 300,
"train_queries_with_qrels": 809,
"test_queries_with_qrels": 300
}
Numărul interogărilor cu qrels este validat din datele descărcate, nu introdus în logica de antrenare. Punct de control: mesajul KeyError: 'beir/scifact/test' indică o instalare diferită de versiunea fixată; verifică python -m pip show ir-datasets și recreează mediul cu requirements.txt.
Pasul 4 — Extragem confuziile modelului fără contaminarea testului
Etapa mine calculează mai întâi baseline-ul pe test, apoi caută negativele exclusiv în qrels de antrenare. Fiecare negativ este unul dintre primele 50 de documente ale modelului, dar nu este marcat relevant pentru query-ul respectiv.
python experiment.py mine
wc -l artifacts/hard_negatives.jsonl
python -c "import json; r=json.loads(open('artifacts/hard_negatives.jsonl', encoding='utf-8').readline()); print({k:r[k] for k in ('query_id','positive_id','negative_id','positive_score','negative_score')})"
Vei vedea numărul de triplete și un prim rând asemănător ca structură:
{
"hard_negatives": 809
}
809 artifacts/hard_negatives.jsonl
{'query_id': '...', 'positive_id': '...', 'negative_id': '...', 'positive_score': 0...., 'negative_score': 0....}
ID-urile și scorurile nu trebuie copiate dintr-un exemplu extern: ele sunt produse de modelul fixat și de datele descărcate cu verificările ir_datasets.
Inspectează manual mai multe rânduri înainte de a folosi metoda pe documentele firmei. Un document nemarcat în qrels poate fi relevant în realitate. Același control este necesar și când benchmarkul intern ar putea fi contaminat cu date întâlnite anterior de model; procesul poate fi completat cu un test separat de benchmark contamination.
Punct de control: dacă hard_negatives este zero, nu continua. Mesajul Nu am extras niciun hard negative arată că datele, qrels sau ordonarea rezultatelor nu corespund laboratorului.
Pasul 5 — Antrenăm retrieverul pe confuziile sale cele mai apropiate
Antrenarea rulează o singură epocă, cu batch 16 și learning rate 2e-5. CPU-ul este impus prin use_cpu=True, astfel încât rezultatul să nu depindă de activarea automată a FP16 sau BF16 pe altă mașină.
python experiment.py train
test -f artifacts/retriever-scifact/config_sentence_transformers.json
find artifacts/retriever-scifact -maxdepth 1 -type f -printf '%fn' | sort
În jurnal vor apărea valori loss, iar la final:
{
"saved_model": ".../rag-hard-negatives/artifacts/retriever-scifact"
}
Fișierul config_sentence_transformers.json și greutățile modelului trebuie să existe. Loss-ul de antrenare arată dacă optimizatorul rulează, dar nu dovedește că retrieval-ul s-a îmbunătățit.
Acest retriever poate fi ulterior conectat la o decizie de retrieval adaptiv, în care sistemul caută doar când întrebarea o cere. Cele două evaluări trebuie păstrate separat: hard-negative mining testează ordonarea documentelor, nu decizia de a porni căutarea.
Punct de control: dacă apare ValueError: Column names don't match the expected dataset format, verifică să existe exact coloanele anchor, positive și negative în construcția Dataset.from_list.
Pasul 6 — Decidem pe test dacă ordonarea chiar s-a îmbunătățit
Acum modelul antrenat este evaluat pe aceleași 300 de interogări de test ca baseline-ul. Scriptul nu acceptă automat modelul nou; păstrează scorurile și diferențele în artifacts/results.json.
python experiment.py evaluate
python -m json.tool artifacts/results.json
Fișierul are forma:
{
"baseline": {
"queries": 300,
"mrr_at_10": 0....,
"recall_at_10": 0....,
"ndcg_at_10": 0....
},
"hard_negative_model": {
"queries": 300,
"mrr_at_10": 0....,
"recall_at_10": 0....,
"ndcg_at_10": 0....
},
"delta": {
"mrr_at_10": ...,
"recall_at_10": ...,
"ndcg_at_10": ...
},
"test_ndcg_improved": true sau false
}
MRR@10 măsoară poziția primului document relevant. Recall@10 arată cât din setul relevant ajunge în primele zece rezultate. nDCG@10 recompensează plasarea documentelor relevante mai sus și poate folosi grade de relevanță.
Acceptă modelul numai dacă metrica importantă pentru aplicația ta se îmbunătățește fără o regresie inacceptabilă a celorlalte. test_ndcg_improved: false este un rezultat valid: negativele pot fi etichetate incomplet, batch-ul poate fi prea mic sau o singură epocă poate să nu transfere îmbunătățirea către test.
Dacă aplicația folosește semantic caching, schimbarea modelului invalidează reprezentările vechi; urmează o strategie de invalidare după schimbarea documentelor sau a encoderului, nu amesteca embeddinguri produse de modele diferite.
Punct de control: raportul este valid numai dacă ambele secțiuni au queries: 300, toate metricile sunt între 0 și 1, iar delta este calculată din modelul antrenat minus baseline.
Mesajele care indică unde s-a rupt experimentul
KeyError: 'beir/scifact/test' înseamnă că identificatorul datasetului nu există în versiunea instalată. Confirmă mediul:
python -m pip show ir-datasets
python -m pip install --force-reinstall -r requirements.txt
Corpul SciFact trebuia să aibă 5183 documente oprește testul când descărcarea sau definiția datasetului nu corespunde celei documentate. Șterge numai cache-ul SciFact afectat conform căii afișate de ir_datasets; nu elimina întregul cache dacă include alte benchmarkuri.
OSError: sentence-transformers/all-MiniLM-L6-v2 is not a local folder apare când modelul nu poate fi descărcat sau revizia nu este accesibilă. Verifică accesul HTTPS către Hugging Face și păstrează revizia din script; înlocuirea ei cu main schimbă experimentul.
Nu am extras niciun hard negative indică absența candidaților eligibili în primele 50 de rezultate. Verifică întâi qrels și numărul documentelor cu etapa inspect, nu mări arbitrar fereastra.
ValueError: Column names don't match the expected dataset format arată că setul pentru trainer nu mai conține tripletele complete. Reia fișierul original și verifică:
grep -n '"anchor"|"positive"|"negative"' experiment.py
Lipsește modelul antrenat sau baseline_metrics.json apare dacă evaluarea a fost pornită înaintea etapelor mine și train. Rulează-le în această ordine:
python experiment.py mine
python experiment.py train
python experiment.py evaluate
Ștergem modelul de laborator fără să atingem cache-urile comune
Artefactele locale pot fi eliminate independent de modelul și datasetul păstrate în cache:
cd "$HOME/rag-hard-negatives"
rm -rf artifacts
deactivate
rm -rf .venv
Pentru eliminarea întregului laborator:
cd "$HOME"
rm -rf "$HOME/rag-hard-negatives"
Aceste comenzi șterg definitiv fișierele indicate. Nu le rula din alt director și nu modifica ținta cu wildcarduri.
Bifele care confirmă un test RAG complet
- ☐ Corpusul încărcat conține 5.183 de documente SciFact.
- ☐ Mining-ul folosește numai interogările și qrels de antrenare.
- ☐ Setul de test nu furnizează exemple trainerului.
- ☐ Fiecare rând JSONL conține query, document relevant și hard negative.
- ☐ Negativele selectate au fost verificate prin eșantionare manuală.
- ☐ Modelul antrenat este salvat în
artifacts/retriever-scifact/. - ☐ Baseline-ul și modelul nou sunt evaluate pe aceleași 300 de query-uri.
- ☐ MRR@10, Recall@10 și nDCG@10 sunt toate între 0 și 1.
- ☐ Decizia de păstrare a modelului se bazează pe
results.json, nu pe training loss.
Următorul test util este să adaugi un set intern, etichetat de două persoane, cu documente aproape identice din domeniul aplicației. După aceea, un reranker CrossEncoder poate fi evaluat numai pe primii candidați ai retrieverului, iar un Semantic Router poate trimite separat cererile către RAG, modelul rapid sau operatorul uman.
Întrebări frecvente
De ce nu este suficient training loss-ul?
Loss-ul măsoară optimizarea pe tripletele de antrenare. Numai comparația MRR@10, Recall@10 și nDCG@10 pe setul separat de test arată dacă ordonarea se transferă la interogări nevăzute.
Un document fără qrel relevant este sigur negativ?
Nu. În acest laborator este tratat drept candidat negativ, dar judecățile pot fi incomplete. De aceea, tripletele trebuie inspectate manual înainte de antrenarea pe date proprii.
De ce rulăm modelul exclusiv pe CPU?
CPU-ul evită diferențele introduse de activarea FP16 sau BF16 pe alte configurații. Scriptul fixează seed-ul și impune use_cpu=True pentru un laborator mai ușor de reprodus.
Ce înseamnă test_ndcg_improved: false?
Înseamnă că modelul antrenat nu a depășit baseline-ul la nDCG@10 pe cele 300 de query-uri de test. Rezultatul nu trebuie ascuns și modelul nou nu trebuie promovat automat.

















































