Reranker czy sędzia-LLM na prawdziwych danych: lepsza kolejność przegrała z kolejką na GPU
We wpisie o rozdziale 4 „Generative AI Design Patterns" reranker bge oddzielił pytania spoza domeny od pozostałych na sztucznym korpusie. Ostrzegłem wtedy, że ten rozdział zniknie, gdy w bazie pojawią się fragmenty z tematów sąsiednich. Teraz powtórzyłem pomiar na prawdziwej bazie wiedzy w firmie, na stacji z RTX 5090, i rozdział zniknął. Sędzia-LLM nadal układał fragmenty lepiej niż wyspecjalizowany reranker (MRR 0,87–0,95 wobec 0,84), ale ten sam pomysł w dwóch implementacjach dał dwa różne wyniki. Gdy karta równolegle przetwarzała długie konteksty, sędzia potrzebował 9–14 s, a reranker 0,12 s. Próg odmowy na ocenie rerankera wyglądał na odporny, dopóki liczyłem go na 21 pytaniach z etykietą źródła. Na wszystkich 37 pytaniach z odpowiedzią nie oddziela pytań spoza bazy żaden model. Ten błąd złapałem dopiero przy weryfikacji tego wpisu.
To ciąg dalszy wpisu o wyszukiwaniu i zaufaniu w RAG-u. Tamten pomiar robiłem na laptopie bez GPU i na korpusie, który sam napisałem. Ten robię na danych, których nie wymyśliłem, i na sprzęcie z wpisu o kwantyzacjach Qwena na RTX 5090.
Teza
Sędzia-LLM porządkuje fragmenty lepiej, ale na współdzielonej karcie wygrywa reranker. Sędzia generuje tekst na tym samym GPU co model odpowiedzi, więc czeka w tej samej kolejce. Reranker liczy jedną ocenę na parę w jednym przejściu i kolejki prawie nie zauważa. Próg na ocenie rerankera nie jest bramką odmowy. Pytania z tematów bliskich bazie dostają wysokie oceny, a krótkie dopytania bez kontekstu rozmowy dostają niskie, więc oba rodzaje błędów zostają. Wynik bramki zależy przy tym bardziej od tego, które pytania wliczysz do testu, niż od wyboru modelu.
Co mierzyłem
Dane. 44 pytania do wewnętrznej bazy wiedzy firmy (przepisy, materiały szkoleniowe, struktura działów i kontakty). Wśród nich jest 13 pytań o fakt, 5 wyliczeń, 6 sprawdzających, 3 o zakres dat albo wersję przepisu, 10 doczepek w rodzaju „a kto tam jest kierownikiem?" i 7 pytań, na które baza nie zna odpowiedzi. Doczepki oceniam bez historii rozmowy, bo tak widzi je reranker w potoku. Każde pytanie ma do 50 fragmentów, dokładnie tych, które potok RAG dostał z wyszukiwania wektorowego. Razem to 2200 par pytanie–fragment, a fragmenty mają do 2470 znaków. Treści pytań ani fragmentów nie publikuję. Kandydatów zmierzyłem 7 października 2026, sędziego w skrypcie i warianty instrukcji Qwen3 dzień później. Tabela z wdrożenia pochodzi z osobnej ewaluacji całego systemu z 7 października.
Etykiety. Fragment jest „mocno trafny", gdy pochodzi z pliku, który zestaw podaje jako źródło odpowiedzi, i zawiera token z oczekiwanej odpowiedzi (liczbę, nazwisko). Taki fragment ma 17 pytań. Słabsza etykieta (tylko plik) obejmuje 21 pytań. Pozostałe 16 pytań z odpowiedzią, głównie doczepki i pytania sprawdzające, nie ma żadnej etykiety fragmentu.
Miary.
- Odsetek mocnych w top20, czyli jaka część mocno trafnych fragmentów trafia do dwudziestki, którą dostaje model odpowiedzi.
- MRR, czyli odwrotność pozycji pierwszego mocno trafnego fragmentu.
- Bramka odmowy, czyli ile z 7 pytań spoza bazy odrzuca próg na najwyższej ocenie, gdy odcina zero, jedno albo dwa pytania z odpowiedzią.
Kandydaci.
| typ | licencja | VRAM na RTX 5090 | |
|---|---|---|---|
BAAI/bge-reranker-v2-m3 | cross-encoder (XLM-RoBERTa) | Apache-2.0 | +2,5 GB |
Qwen/Qwen3-Reranker-0.6B | model generatywny czytany jako klasyfikator „yes/no" | Apache-2.0 | +2,8 GB |
| sędzia-LLM | Qwen3.8-27B INT4, ten sam model co do odpowiedzi | — | wspólny |
Licencje sprawdziłem w API Hugging Face (cardData.license), a wagi pobrałem z przypiętą rewizją. Wszystko działało w vLLM v0.30.0 w Dockerze. Sędzia ocenia fragmenty w skali 0–10, w 5 równoległych partiach po 10 fragmentów. Każdy fragment jest przycięty do 600 znaków, a odpowiedź to tablica liczb wymuszona schematem JSON. To wariant, który w potoku zastąpił jedno długie wywołanie i skrócił reranking z ok. 10 s do ok. 3 s.
Qwen3-Reranker wymaga w vLLM dwóch ustawień. Pierwsze przemapowuje architekturę na klasyfikator czytający logity tokenów no/yes. Drugie podaje szablon promptu, na którym model był trenowany:
docker run -d --gpus all --network host -v ~/models:/models:ro vllm/vllm-openai:v0.30.0 \
--model /models/qwen3-reranker-0.6b --runner pooling \
--hf-overrides '{"architectures":["Qwen3ForSequenceClassification"],"classifier_from_token":["no","yes"],"is_original_qwen3_reranker":true}' \
--chat-template /vllm-workspace/examples/pooling/score/template/qwen3_reranker.jinja \
--host 127.0.0.1 --port 8102 --gpu-memory-utilization 0.08 --max-model-len 2048
Bez --chat-template vLLM ostrzega, że oceny mogą być niedokładne. Tego wariantu nie mierzyłem. Szablon jest w obrazie, w katalogu z przykładami.
Kolejność: sędzia wygrywa, ale nie zawsze tak samo
| trafienie top20 | odsetek mocnych w top20 | MRR | |
|---|---|---|---|
| cosinus (bez rerankera) | 17/17 | 0,850 | 0,854 |
| sędzia-LLM, wersja w potoku | 17/17 | 0,903 | 0,950 |
| sędzia-LLM, ten sam prompt w skrypcie pomiarowym | 17/17 | 0,896 | 0,873 |
| bge-reranker-v2-m3 | 17/17 | 0,955 | 0,843 |
| Qwen3-Reranker-0.6B | 17/17 | 0,942 | 0,879 |
Wszystkie warianty umieściły mocno trafny fragment w top20 dla 17 z 17 pytań. Rerankery wpuszczają do dwudziestki więcej trafnych fragmentów niż sędzia. Sędzia częściej stawia trafny fragment na pierwszym miejscu. Różnica MRR między sędzią w potoku a bge pochodzi z trzech pytań, w których bge dał pierwszy trafny fragment na 2., 3. i 4. pozycję. Przy 17 pytaniach to rząd szumu, choć kierunek był ten sam dla obu rerankerów.
Ciekawsze są dwa wiersze sędziego. Odtworzyłem w skrypcie prompt z potoku, a 1856 z 2200 ocen wyszło identycznych. Mimo to MRR spadł z 0,950 do 0,873. Dwa przebiegi skryptu różniły się w 40 z 2200 ocen, ale dały te same miary. Szum między uruchomieniami jest więc mały wobec 344 różnic z potokiem. Najbardziej prawdopodobnie różnicę robią szczegóły implementacji: słowa w poleceniu, numeracja fragmentów w partii, pośrednik po drodze. Której z tych rzeczy, nie wyizolowałem. Wynik sędziego zależy od konkretnego promptu, a nie tylko od modelu.
To zgadza się z tym, co Lakshmanan i Hapke piszą o LLM-as-Judge. Sędzia jest pobłażliwy („like professors who give every student A's and B's"), woli tekst dobrze napisany od poprawnego, a skale 1–10 i 1–100 pogarszają spójność ocen w porównaniu z 1–5 i pytaniami binarnymi (rozdz. 6, Pattern 17). Mój sędzia oceniał w skali 0–10. Pobłażliwość było widać przy pytaniach spoza bazy: w wersji ze skryptu cztery z siedmiu dostały ocenę 10, w wersji z potoku dwa (wrócę do tego niżej).
Czas: wspólna karta zmienia ranking
Sędzia nie ma osobnej karty. Generuje na tym samym GPU co model odpowiedzi i to samo GPU obsługuje inne sesje. Zmierzyłem więc czas oceny 50 fragmentów dla 10 losowych pytań (jedno żądanie albo 5 partii) w trzech warunkach:
| warunki na karcie | sędzia-LLM p50 / p90 | bge p50 / p90 | Qwen3 p50 / p90 |
|---|---|---|---|
| bezczynna | 2,57 / 2,62 s | 0,07–0,12 / 0,08–0,12 s | 0,13 / 0,15 s |
| 4 krótkie generacje w tle | 2,62 / 2,68 s | 0,15 / 0,16 s | 0,26 / 0,29 s |
| 4 generacje z ok. 17 tys. tokenów kontekstu w tle | 9,41 / 13,69 s | 0,12 / 0,12 s | — |
Przedział dla bge na wolnej karcie obejmuje kontener testowy i docelową usługę z okienkiem 8192 tokenów. Obie wartości są poniżej progu, który mnie interesował (1 s na 50 par).
Krótkie generacje w tle sędziemu prawie nie przeszkadzają, bo vLLM łączy je w paczki. Długie konteksty to co innego. Każde takie żądanie najpierw przelicza kilkanaście tysięcy tokenów wejścia (prefill), a krótkie partie sędziego czekają za nim. Pod takim obciążeniem sędzia był 3,5–5 razy wolniejszy. Obciążenie z długim kontekstem to dla tej karty normalny stan. Ten sam model obsługuje też sesje agenta programistycznego z oknem 80 tys. tokenów.
Kleppmann i Riccomini opisują dokładnie ten mechanizm w usługach webowych. Kilka wolnych żądań blokuje szybkie, które przyszły za nimi (head-of-line blocking), a serwer tego nie widzi we własnych metrykach (Designing Data-Intensive Applications, s. 39). Gdy żądanie składa się z kilku równoległych wywołań, czeka na najwolniejsze: „It takes just one slow call to make the entire end-user request slow" (s. 41). Sędzia w 5 partiach ma ten problem w miniaturze, bo jego czas to czas najwolniejszej partii. Dlatego podaję p90, a nie średnią. Zastosowanie tego do inferencji na GPU to mój wniosek, książka dotyczy usług sieciowych.
Bramka odmowy: wynik zależy od tego, które pytania policzysz
Ranking zawsze wyłania zwycięzcę, nawet gdy żaden fragment nie odpowiada na pytanie. Bramka odmowy to próg na najlepszej ocenie: poniżej progu system odpowiada „nie wiem" bez wołania modelu.
Pierwsze liczenie: 21 pytań z etykietą
Mój skrypt ewaluacyjny liczył bramkę tylko na pytaniach z etykietą źródła, czyli na 21 z 37 pytań z odpowiedzią. Na tym podzbiorze najwyższy próg, który nie odcina żadnego pytania z odpowiedzią, dawał:
| odrzucone spoza bazy | najwyżej oceniona odrzucona odmowa → najniżej oceniona odpowiedź | |
|---|---|---|
| cosinus | 5/7 | 0,711 → 0,727 |
| sędzia-LLM, potok | 5/7 | 9 → 10 |
| Qwen3-Reranker | 5/7 | 0,988 → 0,994 |
| bge-reranker-v2-m3 | 4/7 | 0,035 → 0,372 |
Wyglądało to na mocny argument za bge. Cosinus i Qwen3 odrzucały więcej, ale z progiem wciśniętym w szczelinę szerokości 0,016 i 0,006. bge miał odstęp od 0,035 do 0,372, więc próg 0,05 wydawał się mieć zapas w obie strony. Na tej podstawie wybrałem bge i próg 0,05, i tak napisałem pierwszą wersję tego wpisu.
Drugie liczenie: wszystkie 37 pytań
Weryfikacja przed publikacją przeliczyła bramkę na wszystkich pytaniach z odpowiedzią. Ile z 7 pytań spoza bazy odrzuca próg, który odcina 0, 1 albo 2 pytania z odpowiedzią:
| 0 odciętych | 1 odcięte | 2 odcięte | trzy najniższe maksima z odpowiedzią | |
|---|---|---|---|---|
| cosinus | 1/7 | 1/7 | 1/7 | 0,669 · 0,672 · 0,672 |
| sędzia-LLM, potok | 0/7 | 4/7 | 5/7 | 0 · 9 · 10 |
| sędzia-LLM, skrypt | 0/7 | 3/7 | 3/7 | 0 · 9 · 10 |
| Qwen3-Reranker | 2/7 | 2/7 | 2/7 | 0,102 · 0,159 · 0,183 |
| bge-reranker-v2-m3 | 1/7 | 4/7 | 4/7 | 0,009 · 0,048 · 0,066 |
Szerokiego zapasu nie ma. Próg 0,05 dla bge odrzuca 4 pytania spoza bazy, ale odcina też 2 z 37 pytań z odpowiedzią: pytanie sprawdzające z oceną 0,009 i doczepkę z oceną 0,048, czyli 0,002 pod progiem. Przy jednym odciętym pytaniu z odpowiedzią bge i sędzia w potoku odrzucają po 4 z 7, więc bge nie ma tu przewagi. Pytania, które psują bramkę, to te bez etykiety: krótkie dopytania bez kontekstu rozmowy i pytania sprawdzające. Właśnie ich nie było w pierwszym liczeniu.
Moja decyzja produkcyjna się nie zmienia, bo bge wygrywa czasem. Zmienia się jej uzasadnienie: próg 0,05 to kompromis z kosztem w postaci dwóch odciętych pytań, a nie bezpieczny margines.
Chollet opisuje ten mechanizm przy zbiorze walidacyjnym. Każda decyzja podjęta na podstawie wyniku walidacji przecieka do modelu, a model po wielu takich decyzjach „performs artificially well on the validation data, because that's what you optimized it for" (Deep Learning with Python, s. 134). Gdy różne podziały danych dają bardzo różne wyniki, zbiór jest za mały, żeby cokolwiek rozstrzygać (s. 135). U mnie podziałem był wybór pytań do miary. 21 pytań dawało szeroką szczelinę, a 37 nie daje żadnej.
Rozdział z poprzedniego wpisu zniknął
Na sztucznym korpusie bge rozdzielił wszystkie 35 pytań na „z domeny" i „spoza domeny". Na prawdziwych danych dwa pytania spoza bazy dostały od wszystkich trzech modeli oceny bliskie maksimum: bge 0,95 i 0,92, Qwen3 0,99 i 1,00, sędzia w potoku 10 i 9. W jednym z nich interfejs czatu bez rerankera odpowiadał z przepisów sąsiedniej procedury, które w bazie są. To pytanie z tematu bliskiego bazie, a fragment rzeczywiście pasuje do niego tematycznie. Reranker mierzy, czy fragment dotyczy tego samego, a nie czy zawiera odpowiedź. Wysoka ocena nie jest więc jego błędem, tylko granicą tego, co mierzy.
Wcześniej opisałem już ten sam mechanizm dla progu na cosinusie. Borwankar uznaje similarity > 0.7 za „sweet spot" (Vector Databases, s. 208), a u Magdy trafne i nietrafne wyniki mają dystans 0,39, 0,40 i 0,41, więc żaden próg ich nie rozdzieli (Just Use Postgres!, s. 230–231). Próg jest własnością pary model–korpus, a nie stałą. Przeniesienie tego na oceny rerankera to mój wniosek: w książkach jest mowa o podobieństwie wektorów.
Kto więc ma odmówić? U mnie zrobił to model odpowiedzi. Lakshmanan i Hapke proponują jeszcze jedno miejsce: test „czy pytanie jest w domenie" na samym zapytaniu, zanim ruszy wyszukiwanie (rozdz. 4, Pattern 11). Tego nie testowałem.
Qwen3-Reranker: oceny nasycone, instrukcja nie pomogła
Qwen3 układał fragmenty podobnie jak bge, ale jego oceny skupiają się przy 1,0. Wszystkie 21 pytań z etykietą źródła miały maksimum co najmniej 0,994, a trzy z siedmiu pytań spoza bazy przekraczały 0,98. Taki rozkład nie zostawia miejsca na próg.
Model przyjmuje instrukcję zadania, a vLLM przekazuje ją polem instruction w żądaniu /v1/rerank. Domyślna instrukcja z szablonu jest ogólna i angielska („Given a web search query, retrieve relevant passages…"). Sprawdziłem instrukcję domenową po polsku i tę samą treść po angielsku:
| instrukcja | odsetek top20 | MRR | najniższe maksimum (21 pytań z etykietą) |
|---|---|---|---|
| domyślna | 0,942 | 0,879 | 0,994 |
| domenowa, po polsku | 0,927 | 0,817 | 0,990 |
| domenowa, po angielsku | 0,910 | 0,850 | 0,982 |
Instrukcja nie pomogła. MRR spadł o 0,03–0,06, czyli o jedno–dwa pytania, a nasycenie zostało.
Co dało wdrożenie
bge-reranker-v2-m3 zastąpił sędziego w potoku RAG i został dodany do interfejsu czatu z bazą wiedzy, w obu miejscach z progiem 0,05. W interfejsie czatu przebieg tych samych pytań dał:
| poprawne | odmowy | błędy przepełnienia kontekstu | p50 / p90 | |
|---|---|---|---|---|
| bez rerankera | 42/46 | 5/7 | 2 | 5,6 / 12,5 s |
| z rerankerem i progiem 0,05 | 43/46 | 7/7 | 0 | 5,1 / 9,0 s |
Siedem odmów nie wzięło się z samego progu. W drugiej ścieżce, w potoku RAG, sprawdziłem, że 4 pytania odrzucił próg, a 3 model odpowiedzi. W interfejsie czatu tego podziału nie rozbijałem. Próg przycina też słabe fragmenty, więc kontekst skurczył się z ok. 14 tys. do ok. 2,5 tys. tokenów i zniknęły błędy przepełnienia okna. Ten sam próg obciął jednak kontekst dwóm doczepkom, które wcześniej przechodziły, co zgadza się z drugim liczeniem bramki. Te liczby pochodzą z osobnego przebiegu ewaluacji całego systemu, robionego przy wdrożeniu (46 tur, bo doczepki liczone są osobno).
Zastrzeżenia
- Mała próba: 17 pytań z mocną etykietą, 37 z odpowiedzią i 7 spoza bazy. Różnice rzędu jednego pytania to szum, a etykiety „mocne" są automatyczne (token z odpowiedzi w treści).
- Sędzia w skrypcie to rekonstrukcja. Prompt i podział na partie wziąłem z potoku, ale implementacja jest moja. 84% ocen wyszło identycznych, a różnica w MRR pokazuje, że te 16% ma znaczenie.
- Jedna karta, jeden silnik, jedna wersja: RTX 5090, vLLM v0.30.0. Obciążenie w tle generowałem syntetycznie: 4 wątki, jeden prompt powtórzony do ok. 17 tys. tokenów z losowym początkiem, żeby ominąć cache prefiksu.
- Okno pomiaru bge pod długim obciążeniem było krótkie (10 żądań po ok. 0,12 s). Obciążenie było wtedy w fazie prefillu, czyli tej, która blokuje sędziego.
- Nie testowałem polskich rerankerów, bramki na samym pytaniu ani sędziego z grubszą skalą (1–5 albo tak/nie), którą zalecają autorzy.
Wnioski
- Na współdzielonej karcie sędzia-LLM płaci za kolejkę, a nie za model: 2,6 s na wolnej karcie, 9,4 / 13,7 s (p50/p90) obok długich kontekstów. Cross-encoder w tych samych warunkach potrzebował 0,12 s i 2,5 GB VRAM.
- Lepsze MRR sędziego zależy od implementacji promptu. Ten sam pomysł dał 0,950 i 0,873. Kto porównuje rerankery z sędzią-LLM, powinien porównywać z konkretnym promptem i podać go.
- Bramkę odmowy licz na wszystkich pytaniach z odpowiedzią, także tych bez etykiety. Na 21 pytaniach z etykietą bge miał szeroki zapas. Na 37 próg 0,05 odcina dwa pytania z odpowiedzią i nie daje przewagi nad sędzią. Pytania, których nie da się łatwo oznaczyć, czyli dopytania i pytania sprawdzające, to dokładnie te, które psują próg.
- Reranker nie zastąpi odmowy. Pytania z tematów bliskich bazie dostają wysokie oceny trafności, więc „nie wiem" musi umieć powiedzieć jeszcze ktoś: model odpowiedzi albo osobny test na samym pytaniu.
- Qwen3-Reranker-0.6B nadaje się do kolejności, ale nie do progu, bo jego oceny skupiają się przy 1,0. Instrukcja domenowa tego nie zmieniła.