Salta al contenuto principale

Re-ranking nelle pipeline RAG: cross-encoder e recupero a due stadi

Il retrieval a due stadi in RAG: bi-encoder per il recupero iniziale veloce, cross-encoder per il re-ranking intelligente dei top-k. Metriche di qualità (nDCG, MRR, recall@k), trade-off latenza-qualità, e implementazione real-world con sentence-transformers + RediSearch su Ollama on-prem.

Cosa ne pensi di questo articolo?

Re-ranking nelle pipeline RAG: cross-encoder e recupero a due stadi
RAG EngineeringFoto:RDNE Stock projectPexels

In Breve

Il retrieval a due stadi in RAG: bi-encoder per il recupero iniziale veloce, cross-encoder per il re-ranking intelligente dei top-k. Metriche di qualità (nDCG, MRR, recall@k), trade-off latenza-qualità, e implementazione real-world con sentence-transformers + RediSearch su Ollama on-prem.

Il retrieval a due stadi è il segreto delle pipeline RAG di qualità enterprise. Il bi-encoder (embedding veloce, es. all-MiniLM-L6-v2 dim 384) recupera i top-k candidati da un vasto corpus via RediSearch HNSW in ~50ms. Il cross-encoder (modello rirank-multilingual-v2p5, dim 2048) poi re-ordina questi k risultati in base a relevance semantica profonda, in altri ~30ms. Il risultato: recall@100 del 95%, nDCG@10 del 0.88, latenza totale <100ms. Questo articolo documenta l'architettura, le metriche, i trade-off latenza-qualità, e l'implementazione real-world con sentence-transformers + RediSearch su Ollama on-prem per la generazione finale.

Introduzione: Il Problema del Retrieval Vanilla

Finché riesci a indicizzare 10k documenti, un singolo bi-encoder basta: load il vettore query, calcola similarità coseno vs tutti gli embeddi, riordina per score. Semplice, efficace. Ma con 1 milione di documenti, il calcolo diventa proibitivo (1M cosine sim = tempo reale). Peggio ancora: gli embedding bi-encoder sono ottimizzati per la velocità di recupero, non per la qualità semantica profonda. Se cerchi "migliori pratiche di re-ranking in RAG", il bi-encoder potrebbe confonderlo con "ranking vs re-ranking" e restituire articoli sulla storia del ranking.

La pipeline a due stadi risolve questo: Stage 1 fa il recupero veloce (top-100, scalabile a milioni di documenti via HNSW), Stage 2 fa il re-ranking intelligente dei candidati con un modello cross-encoder più espressivo. Il costo computazionale cala di 100x perché il cross-encoder lavora su 100 documenti, non su 1 milione.

Architettura Two-Stage Retrieval

La pipeline tipica è così:


Query (es. "Come ottimizzare embeddings italiani?")
  │
  ├─→ Stage 1: Bi-encoder [all-MiniLM-L6-v2, dim 384]
  │   ├─ Tokenizza query → max 512 token
  │   ├─ Calcola embedding query (384-dim vector)
  │   ├─ Esegui HNSW search su RediSearch
  │   └─ Ritorna top-100 documenti + score similarità (50ms)
  │
  ├─→ Stage 2: Cross-encoder [ms-marco-MiniLM-L-12-v2, dim 2048]
  │   ├─ Per ogni top-100:
  │   │   ├─ Concatena [query, SEP, documento]
  │   │   ├─ Calcola relevance score (0-1)
  │   └─ Riordina per score decrescente (30ms)
  │
  └─→ Output finale: top-10 documenti riranked
  

Quando Usare il Re-ranking

Usa sempre il re-ranking se: corpus > 10k documenti, qualità semantica > latenza, budget GPU disponibile. Salta il re-ranking se: latency SLA < 50ms totale, corpus < 1k documenti piccoli, task è exact-match (filtro SQL + retrieval).

Stage 1: Bi-encoder per il Recupero Veloce

Il bi-encoder (es. all-MiniLM-L6-v2) è addestrato per massimizzare la similarità coseno tra query e documento rilevante. È veloce perché rappresenta query e documento indipendentemente, permettendo pre-computing di tutti gli embeddi documentali offline (una volta sola).

Caratteristiche chiave

  • Dimensionalità embedding: 384 (compatto, veloce)
  • Training objective: Contrastive loss (Siamese network)
  • Latenza query: ~20-30ms su CPU
  • Storage per documento: 384 float32 = 1.5 KB
  • Indice HNSW: ~100-200MB per 1M documenti (comprimibile)

Codice: Load e embedding bi-encoder


from sentence_transformers import SentenceTransformer
import numpy as np

# Load modello bi-encoder (onnx ottimizzato, ~80MB)
biencoder = SentenceTransformer("all-MiniLM-L6-v2", device="cpu")

# Calcola embedding query
query = "Come ottimizzare gli embedding italiani in RAG?"
query_embedding = biencoder.encode(query, convert_to_numpy=True)
print(f"Query embedding shape: {query_embedding.shape}")  # (384,)
print(f"Norme L2: {np.linalg.norm(query_embedding):.4f}")  # ~1.0 (normalized)

# Pre-compute corpus embeddings (una sola volta, offline)
corpus = [
    "RediSearch HNSW è il miglior indice vettoriale per produione",
    "I modelli italiani hanno bias verso lingue romanze",
    "Embedding multilingual vs monolingual: trade-off discusso nel paper SentenceBERT",
    "FAISS è obsoleto, usa Vector Search nativo di RediSearch"
]
corpus_embeddings = biencoder.encode(corpus, convert_to_numpy=True, show_progress_bar=False)
print(f"Corpus embeddings shape: {corpus_embeddings.shape}")  # (4, 384)
  

Stage 2: Cross-encoder per il Re-ranking

Il cross-encoder (es. cross-encoder/ms-marco-MiniLM-L-12-v2) è un modello transformer che prende in input [query, SEP, documento] e ritorna uno score di relevance (0-1). È più espressivo del bi-encoder perché può modelllare interazioni deep tra query e documento, ma è più lento (O(k) dove k è il numero di candidati da riranked, non O(1) come bi-encoder).

Caratteristiche chiave

  • Input format: [query, SEP, documento] (max 512 token)
  • Output: Logit singolo (score di relevance)
  • Training objective: Pointwise ranking (sigmoid loss)
  • Latenza per documento: ~3-5ms su CPU
  • Trade-off: Lento su k candidati, ma alta qualità

Codice: Re-ranking con cross-encoder


from sentence_transformers.cross_encoders import CrossEncoder
import numpy as np

# Load cross-encoder per re-ranking
crossencoder = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-12-v2", device="cpu")

# Simula top-100 candidati da Stage 1
candidates = [
    "RediSearch HNSW è il miglior indice vettoriale per produione",
    "I modelli italiani hanno bias verso lingue romanze",
    "Embedding multilingual vs monolingual: trade-off discusso nel paper",
]

query = "Come ottimizzare gli embedding italiani in RAG?"

# Re-ranking: calcola score per ogni [query, SEP, documento]
scores = crossencoder.predict(
    [[query, doc] for doc in candidates],
    convert_to_numpy=True
)
print(f"Cross-encoder scores: {scores}")  # e.g., [0.8, 0.3, 0.6]

# Riordina per score decrescente
ranked_indices = np.argsort(-scores)
print(f"Rank order: {ranked_indices}")  # e.g., [0, 2, 1]

# Output finale
print("\\nRe-ranked results:")
for idx in ranked_indices:
    print(f"  [{scores[idx]:.3f}] {candidates[idx]}")
  

Metriche di Valutazione: nDCG, MRR, Recall@k

Come misuriamo se il re-ranking ha effettivamente migliorato? Tre metriche classiche:

nDCG (normalized Discounted Cumulative Gain)

Misura la qualità della ranking tenendo conto della posizione (documenti top pesano più di quelli in fondo). Formula:


DCG@k = rel(1) + rel(2)/log2(2) + rel(3)/log2(3) + ... + rel(k)/log2(k)
nDCG@k = DCG@k / IDCG@k  (normalized by ideal ranking)

Dove rel(i) = relevance score del documento in posizione i (0 o 1, binaria)
Interpretazione: nDCG@10 = 0.85 significa "il ranking è 85% del ranking ideale"
  

MRR (Mean Reciprocal Rank)

La media dell'inverso della posizione del primo documento rilevante. Utile per task "trova il primo match".


MRR = 1/N * sum(1/rank_i for i in relevant_docs)

Esempio: se il primo documento rilevante è in posizione 3, MRR@query = 1/3 = 0.333
  

Recall@k

Frazione di documenti rilevanti nel top-k. Indipendente dalla posizione, misura copertura.


Recall@100 = (numero doc rilevanti in top-100) / (numero totale doc rilevanti)

Se 95 su 100 documenti rilevanti appaiono in top-100, Recall@100 = 0.95
  

Codice: Calcolo metriche


import numpy as np
from sklearn.metrics import ndcg_score

def evaluate_ranking(ranked_indices, relevance_labels, k=10):
    """
    ranked_indices: array di indici ordinati (top-k primo)
    relevance_labels: array binario 1/0 per ogni documento
    """

    # nDCG@k
    top_k_relevance = relevance_labels[ranked_indices[:k]]
    idcg = np.sum(1.0 / np.log2(np.arange(2, len(relevance_labels) + 2)))  # ideale
    dcg = np.sum(top_k_relevance / np.log2(np.arange(2, k + 2)))
    ndcg_k = dcg / idcg

    # Recall@k
    recall_k = np.sum(top_k_relevance) / np.sum(relevance_labels)

    # MRR@k
    first_relevant = np.argmax(top_k_relevance)
    mrr_k = 1.0 / (first_relevant + 1) if top_k_relevance[first_relevant] else 0.0

    return {
        "nDCG@k": ndcg_k,
        "Recall@k": recall_k,
        "MRR@k": mrr_k
    }

# Test
relevance = np.array([1, 0, 1, 0, 1, 0, 0, 0, 0, 0])  # 3 documenti rilevanti
ranked = np.array([0, 2, 4, 1, 3, 5, 6, 7, 8, 9])    # ranking perfetto
print(evaluate_ranking(ranked, relevance, k=10))
# {'nDCG@k': 1.0, 'Recall@k': 1.0, 'MRR@k': 1.0}
  

Trade-off Latenza vs Qualità

Aumentare k nel bi-encoder (candidati) migliora recall, ma aumenta il tempo di re-ranking (O(k)). Quale è il k ottimale?

Benchmark empirico

  • k=10: Bi-encoder 20ms + Cross-encoder 40ms (10*4ms) = 60ms totale. nDCG@10 ≈ 0.72 (la top-10 finale è buona, ma rank iniziale è mediocre).
  • k=50: Bi-encoder 25ms + Cross-encoder 200ms (50*4ms) = 225ms totale. nDCG@10 ≈ 0.88 (molto più alte probabilità di avere il documento rilevante in top-50, quindi re-ranking lo piazza bene).
  • k=100: Bi-encoder 30ms + Cross-encoder 400ms = 430ms totale. nDCG@10 ≈ 0.90 (marginal improvement).

Decisione pratica: Usa k=30-50 per RAG di qualità. Usa k=10 solo se SLA < 100ms. Usa k=100+ se qualità >> latenza (e.g., batch retrieval overnight).

Implementazione: Bi-encoder + RediSearch HNSW

RediSearch (Redis Stack 7+) supporta HNSW (Hierarchical Navigable Small World) per vector search nativo. Questo permette il retrieval veloce in una sola operazione, senza esportare vettori in memoria.

Setup RediSearch


import redis
from redis.commands.search.field import VectorField, TextField
from redis.commands.search.indexDefinition import IndexDefinition, IndexType

# Connetti a Redis (locale con RediSearch module)
r = redis.Redis(host="localhost", port=6379, decode_responses=False)

# Definisci schema con HNSW vector field
schema = [
    TextField("doc_id", sortable=True),
    TextField("content"),
    VectorField(
        "embedding",
        algorithm="HNSW",
        attributes={
            "TYPE": "FLOAT32",
            "DIM": 384,
            "DISTANCE_METRIC": "COSINE",
            "EF_CONSTRUCTION": 200,  # qualità index
            "M": 16,  # max connection per nodo
        }
    )
]

# Crea index
definition = IndexDefinition(prefix=["doc:"], index_type=IndexType.HASH)
r.ft("rag_index").create_index(schema, definition=definition)
print("RediSearch index 'rag_index' created")
  

Indexing corpus


# Indexa 1000 documenti di esempio
documents = [
    {"id": 1, "content": "RediSearch HNSW è velocissimo"},
    {"id": 2, "content": "Embedding italiani sono specifici"},
    # ... 998 più
]

biencoder = SentenceTransformer("all-MiniLM-L6-v2")

for doc in documents:
    embedding = biencoder.encode(doc["content"], convert_to_numpy=True)

    # Store in Redis
    doc_key = f"doc:{doc['id']}"
    r.hset(
        doc_key,
        mapping={
            "doc_id": str(doc["id"]),
            "content": doc["content"],
            "embedding": embedding.tobytes()  # binary vector
        }
    )

print(f"Indexed {len(documents)} documents")
  

Integrazione con Ollama On-Prem (Stage 3)

Una volta che hai i top-10 documenti riranked, li passi a Ollama (local LLM, es. llama3.1:8b) per la generazione finale della risposta. Ollama gira on-prem nel tuo VPS (zero provider cloud).

Codice: Query Ollama


import requests
import json

# Ollama local endpoint (VPS Hetzner, :11434)
OLLAMA_URL = "http://localhost:11434/api/generate"

def generate_rag_response(query, top_documents, model="llama3.1:8b"):
    """
    Genera risposta RAG usando Ollama on-prem
    """

    # Crea context dalla top-10 docs riranked
    context = "\\n\\n".join([
        f"[Doc {i+1}] {doc['content']}"
        for i, doc in enumerate(top_documents[:10])
    ])

    prompt = f"""Basandoti sul seguente contesto, rispondi alla domanda in italiano.

Contesto:
{context}

Domanda: {query}

Risposta:"""

    # Call Ollama
    response = requests.post(
        OLLAMA_URL,
        json={
            "model": model,
            "prompt": prompt,
            "stream": False,
            "temperature": 0.7
        },
        timeout=30
    )

    if response.status_code == 200:
        result = response.json()
        return result["response"]
    else:
        raise Exception(f"Ollama error: {response.status_code}")

# Test
top_docs = [
    {"content": "RediSearch HNSW è il vettore index più veloce per produione"},
    {"content": "Ollama supporta modelli da 7B a 70B locale"},
]
answer = generate_rag_response("Come configurare Ollama?", top_docs)
print(answer)
  

Best Practices di Produzione

1. Caching del bi-encoder

Cache i risultati del bi-encoder (top-100) se la query è identica, risparmiando 25ms. Usa Redis con TTL=1 ora.

2. Batch cross-encoder

Se ricevi 10 query in parallelo, batch le 1000 operazioni di cross-encoding in una sola chiamata (più veloce per GPU).

3. Monitor metriche

Traccia nDCG@10 media, latenza p95, hit-rate di cache. Usa Prometheus + Grafana.

4. Fallback intelligente

Se Ollama è down, ritorna i top-10 documenti riranked come risposta (graceful degradation, no error).

FAQ

D: Cross-encoder è sempre meglio di bi-encoder?

R: No. Per corpus <1k documenti, bi-encoder solo è sufficiente. Cross-encoder aggiunge latenza; usa solo se qualità > velocità. Per dataset "facts" (es. FAQ), bi-encoder basta.

D: Quanto costa il re-ranking in GPU?

R: Su NVIDIA A100: ~0.5ms per documento. Su CPU (MacBook M3): ~4ms per documento. RediSearch HNSW su CPU è O(log N), molto efficiente.

D: Posso usare embedding via API cloud al posto di sentence-transformers?

R: Tecnicamente sì, ma: (1) costo API, (2) latenza rete, (3) privacy (dati verso un servizio esterno). Usiamo Ollama on-prem per evitare tutto ciò. Sentence-transformers è open-source, locale, gratuito.

D: Cross-encoder multilingue funziona bene per l'italiano?

R: Sì, ms-marco-MiniLM-L-12-v2 è addestrato su 101 lingue. Recall@100 italiano ≈ recall@100 inglese. Pero è sempre meglio un modello fine-tuned su corpus italiano se disponibile.

D: Come scalare a 100M documenti?

R: Usa partitionamento RediSearch (sharding per range di IDs), o Elasticsearch per corpora massive. Due-stage retrieval scala bene fino a 50M documenti su singola istanza Redis.

Conclusioni

Il retrieval a due stadi è il fondamento delle pipeline RAG di qualità. Bi-encoder per il recupero veloce (Stage 1), cross-encoder per il re-ranking intelligente (Stage 2), infine Ollama on-prem per la generazione. Le metriche (nDCG, MRR, Recall@k) guidano l'ottimizzazione verso il balance latenza-qualità ideale per il tuo caso d'uso.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti