Omrankning med cross-encoders
Kunna lägga till en reranker och mäta precision@k före och efter.
Förkunskaper
- EHybridsökning och RRFkrävs
Intuition
En bi-encoder kodar fråga och dokument var för sig och jämför vektorerna. Det gör det möjligt att förkoda hela indexet — men modellen ser aldrig fråga och dokument tillsammans.
En cross-encoder matar in dem ihop och ger ett relevanspoäng. Den ser hur orden i frågan förhåller sig till orden i dokumentet, och blir därför betydligt mer träffsäker.
| Bi-encoder | Cross-encoder | |
|---|---|---|
| Indata | fråga och dokument separat | ihop, i samma sekvens |
| Kan förkoda index | ja | nej |
| Anrop per fråga | 1 | ett per dokument |
| Kvalitet | god | bättre |
| 1 M dokument | ~10 ms | flera timmar |
Lösningen är tvåstegshämtning: låt bi-encodern hämta 50–100 kandidater brett och billigt, och låt cross-encodern ranka om dem. Du får den bättre modellens kvalitet till en kostnad som skalar med 50, inte med en miljon.
Formellt
Arkitekturen i praktiken:
fråga → BM25 + vektorsök → RRF → 50 kandidater → cross-encoder → topp 5 → LLM
(~10 ms) (~100 ms)
Vad det är värt. Typiska förbättringar av nDCG@10 vid omrankning av topp-50 ligger på 10–20 % relativt. Den avgörande effekten är dock en annan: precisionen i topp-3 stiger kraftigt, och det är de dokumenten som faktiskt hamnar i LLM-promptens kontext.
Tre familjer av rerankers:
| Typ | Hur | Kostnad |
|---|---|---|
| Cross-encoder (BGE, mxbai, Cohere Rerank) | ett anrop per par | medel |
| ColBERT (sen interaktion) | tokenvis likhet, förkodningsbar | låg, men större index |
| LLM som domare | be en modell rangordna | hög |
ColBERT är en intressant mellanväg: den behåller en vektor per token och beräknar maxlikhet per frågetoken. Det ger nästan cross-encoderkvalitet med förkodning — till priset av ett tio till hundra gånger större index.
Hur många kandidater ska rankas om? Det är en avvägning:
| Kandidater | Recall från steg 1 | Latens |
|---|---|---|
| 20 | kan missa rätt dokument | låg |
| 50 | oftast rätt balans | ~100 ms |
| 200 | nästan alltid med | flera hundra ms |
Grundregeln: en reranker kan bara förbättra det som steg 1 hämtade. Är recall@50 bara 0,7 kan ingen omrankning i världen komma över det — då är det hämtningen som ska förbättras, inte rankningen.
Mät alltid båda stegen separat:
| Mått | Berättar |
|---|---|
| recall@50 (steg 1) | taket för hela systemet |
| nDCG@10 före omrankning | utgångsläget |
| nDCG@10 efter | vinsten |
| precision@3 | vad som faktiskt hamnar i prompten |
| Latens p50 och p95 | kostnaden |
| Andel frågor som blev sämre | regressioner |
Den sista raden gäller här som vid hybridsökning: ett förbättrat medelvärde kan dölja att en femtedel av frågorna försämrats.
Kod
import time
import numpy as np
from sentence_transformers import CrossEncoder, SentenceTransformer
bi = SentenceTransformer("intfloat/multilingual-e5-base")
cross = CrossEncoder("BAAI/bge-reranker-v2-m3", max_length=512)
def hamta(fraga, index, k=50):
q = bi.encode(f"query: {fraga}", normalize_embeddings=True)
return index.search(q, k) # [(dok_id, text, poang), ...]
def ranka_om(fraga, kandidater, topp=5):
par = [(fraga, k["text"]) for k in kandidater]
poang = cross.predict(par, batch_size=32)
ordning = np.argsort(-poang)
return [kandidater[i] | {"reranker_poang": float(poang[i])} for i in ordning[:topp]]
def tva_stegs(fraga, index, hamta_k=50, topp=5):
return ranka_om(fraga, hamta(fraga, index, hamta_k), topp)
# Mät båda stegen separat — och regressionerna
def utvardera(facit, index, hamta_k=50):
tak, fore, efter, samre = 0, 0, 0, 0
latens = []
for f in facit:
kand = hamta(f["fraga"], index, hamta_k)
ids = [k["id"] for k in kand]
tak += int(f["ratt"] in ids) # taket: recall@hamta_k
fore += int(f["ratt"] in ids[:3])
t0 = time.perf_counter()
omrankad = [k["id"] for k in ranka_om(f["fraga"], kand, topp=3)]
latens.append((time.perf_counter() - t0) * 1000)
traff_efter = f["ratt"] in omrankad
efter += int(traff_efter)
samre += int((f["ratt"] in ids[:3]) and not traff_efter)
n = len(facit)
latens.sort()
return {
f"recall@{hamta_k} (taket)": round(tak / n, 3),
"precision@3 före": round(fore / n, 3),
"precision@3 efter": round(efter / n, 3),
"regressioner": round(samre / n, 3),
"reranker_p50_ms": round(latens[n // 2], 1),
"reranker_p95_ms": round(latens[int(0.95 * n) - 1], 1),
}
# Hur många kandidater lönar sig? Kör kurvan på egen data.
def svep_kandidater(facit, index, kandidater=(10, 20, 50, 100, 200)):
for k in kandidater:
r = utvardera(facit, index, hamta_k=k)
print(f" k={k:>3}: tak {r[f'recall@{k} (taket)']:.3f} "
f"p@3 {r['precision@3 efter']:.3f} p95 {r['reranker_p95_ms']:>6.1f} ms")
Den första raden i utvärderingen är den viktigaste. Ligger recall@50 på 0,70 är 0,70 taket för hela systemet — rerankern kan aldrig hitta något som steg 1 inte hämtade. Är den siffran låg är det hämtningen som ska förbättras först.
Behärskning innebär
- Förklarar skillnaden mellan bi- och cross-encoder
- Sätter upp tvåstegshämtning
- Mäter effekten och kostnaden
Logga in för att göra övningarna och bygga upp din behärskning.
Källor
- arXiv — ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT — arXiv (öppen åtkomst; licens per artikel)
- Sentence-Transformers — dokumentation (Apache-2.0) — Apache-2.0
- Manning, Raghavan & Schütze — Introduction to Information Retrieval — fri att läsa online (författarnas utgåva)