Dostępny na nowe rolePiotr Czerwiński

Blog · 20 maja 2026 · 10 min czytania

Podobieństwo cosinusowe to nie prawdopodobieństwo: strojenie progów w pgvector przy prawdziwych dopasowaniach

pgvector · embeddings · RAG · PostgreSQL · AI

TL;DR: Budując silnik dopasowań AI dla mojego pobocznego serwisu z ofertami pracy na Postgresie i pgvector, przekonałem się, że wyniki podobieństwa cosinusowego nie są prawdopodobieństwami, a próg, który działa dla jednego modelu embeddingów, dla innego po cichu nie zwraca niczego. Przy text-embedding-3-small idealne, trafne tematycznie dopasowanie ląduje w okolicach 0,45 do 0,55, a nie 0,9. Przeniosłem próg 0,78 ze starszej konfiguracji, każde zapytanie wracało puste, a funkcja wyglądała na działającą, bo LLM elegancko obsługiwał przypadek bez wyników. Kiedy zrozumiałem problem, rozpisałem realne opcje: skalibrowany próg globalny, progi zależne od kontekstu, zwykłe top-K bez progu, etap rerankingu i wyszukiwanie hybrydowe. Skończyło się na najmniej efektownej: skalibrowanym progu jako podłodze szumu pod twardym limitem top-K, podpartym malutkim zbiorem testowym prawdziwych par zapytanie i dopasowanie. W tym artykule opisuję problem, opcje i dokładnie to, dlaczego wybrałem właśnie tak.

Błąd, który zwracał 200 OK

Funkcja była czatem z retrievalem (RAG): zamień pytanie użytkownika na embedding, znajdź w Postgresie przez pgvector najbardziej podobne fragmenty, przekaż je LLM jako kontekst, odpowiedz z cytowaniami. Typowy pierwszy RAG. Funkcja wyszukująca przyjmowała próg podobieństwa, a ja ustawiłem go na 0,78, bo taką wartość widziałem w popularnym szablonie startowym.

Każde żądanie kończyło się sukcesem. Endpoint zwracał 200, odpowiedź się streamowała, ton był przyjazny. Tyle że odpowiedzi były bezużyteczne: każda była wariacją na temat "dokumentacja tego nie obejmuje, zapytaj człowieka", a dokładnie to prompt systemowy kazał mówić modelowi, gdy nie dostanie kontekstu. Retrieval zwracał zero fragmentów dla każdego zapytania, także dla pytań, które miały w korpusie dopasowanie słowo w słowo, a nic w całym stosie nie traktowało tego jako błędu. Pusty zbiór wyników to poprawny zbiór wyników. Źle skalibrowany próg nie wywala aplikacji, tylko obniża jakość, a jeśli nie przetestujesz zapytania, o którym wiesz, że powinno trafić, nie zauważysz tego.

Źródłem był błędny model myślowy: odczytywałem 0,78 jako "w 78% trafne", więc brzmiało to jak rozsądna poprzeczka. Podobieństwo cosinusowe tak nie działa. To kąt między dwoma wektorami, a to, gdzie lądują typowe wartości, zależy wyłącznie od geometrii modelu, który je wytworzył. text-embedding-ada-002, starsza generacja, upycha embeddingi w wąskim stożku: trafne tematycznie dopasowania dostają 0,78 do 0,85, a nawet niepowiązane teksty często przekraczają 0,7, więc tam 0,78 to rozsądna granica. text-embedding-3-small rozrzuca wektory znacznie szerzej, więc każdy wynik bezwzględny jest niższy. Gdy obniżyłem próg do zera i przyjrzałem się surowym liczbom dla mojego korpusu (około 70 fragmentów po 150 do 400 słów), struktura była wyraźna: naprawdę właściwy fragment dla pytania na temat dostawał około 0,47, powiązane, ale drugorzędne fragmenty lądowały w przedziale 0,30 do 0,40, a niepowiązany szum zostawał poniżej mniej więcej 0,20. Odziedziczony próg 0,78 leżał powyżej najlepszego możliwego wyniku, jaki model kiedykolwiek by zwrócił. Najszybciej zobaczysz to sam tak:

select
  left(content, 60) as preview,
  1 - (embedding <=> $1) as similarity
from chunks
order by embedding <=> $1
limit 20;

(W pgvector <=> to odległość cosinusowa, więc podobieństwo to jeden minus ta wartość. Typ indeksu, HNSW czy IVFFlat, wpływa na opóźnienie i kompletność wyszukiwania, a nie na same wartości podobieństwa.)

Konkretny problem wyglądał więc tak: retrieval musi niezawodnie wyciągać fragment z wynikiem 0,47, zwykle zachowywać przydatne pasmo 0,30 do 0,40, wykluczać szum poniżej 0,20, nigdy po cichu nie zwracać niczego dla pytania, na które da się odpowiedzieć, i nie zapychać promptu śmieciami, które przy każdym żądaniu kosztują tokeny.

Opcje na stole

Gdy już rozumiałem błąd, poprawka wcale nie była oczywista, bo tę klasę problemów rozwiązuje kilka standardowych podejść. Oto te, które poważnie rozważałem.

Opcja 1: skalibrowany próg globalny

Zostaje projekt z jednym progiem, ale liczbę wyprowadzam z własnych danych, a nie z szablonu: puszczam zapytania testowe z progiem ustawionym na zero, szukam luki między wynikami trafnymi a szumem i umieszczam granicę w tej luce. Zalety: banalna implementacja (zmienia się jedna stała), zero dodatkowego opóźnienia, zero dodatkowego kosztu, łatwo o tym rozumować, a do tego zostaje możliwość zwrócenia pustego zbioru, gdy nic nie jest trafne. Wady: kalibracja to stan na jedną chwilę, a nie prawo natury. Jest ważna tylko dla bieżącego modelu embeddingów, strategii dzielenia na fragmenty i kształtu korpusu. Zmień którąkolwiek z tych rzeczy, a liczba po cichu się zdezaktualizuje, czyli dokładnie to, co właśnie przeżyłem. Do tego stosuje tę samą poprzeczkę do każdego zapytania, choć krótkie zapytania z samych słów kluczowych i pełne pytania w języku naturalnym zajmują różne części zakresu wyników.

Opcja 2: progi zależne od kontekstu

Różne granice dla różnych sytuacji: osobna dla każdej kolekcji dokumentów, osobna dla każdego typu zapytania, a nawet wyliczana dla każdego zapytania z kształtu jego własnych wyników. Zalety: jednoznacznie większa precyzja. Niejednorodny korpus z długimi i krótkimi fragmentami naprawdę daje różne pasma wyników w różnych sekcjach, a ten projekt potrafi to uwzględnić. Wady: każdy próg to kolejna liczba, która może się po cichu zdezaktualizować, a teraz jest ich N. Skalibrowanie jednej granicy zajęło mi zestaw testowy i jedno popołudnie. Utrzymanie całej macierzy wymaga narzędzi, których nie miałem, i korpusu na tyle dużego, żeby rzetelnie zmierzyć każdy kontekst, a 70 fragmentów takim korpusem nie jest. Wyrafinowanie, które wyprzedza Twoją zdolność do jego oceny, to tylko bardziej wymyślny sposób na pomyłkę.

Opcja 3: top-K w ogóle bez progu

Całkiem wyrzucam próg i zawsze biorę K najbliższych fragmentów. Zalety: nigdy nie zwróci pustego zbioru, więc ciche odmawianie odpowiedzi jest strukturalnie niemożliwe, a do tego jest odporne na zmianę modelu, bo ranking przetrwa nawet wtedy, gdy wyniki bezwzględne zmienią skalę. Wady: nigdy nie zwróci pustego zbioru. Przy pytaniu nie na temat, na które korpus naprawdę nie zna odpowiedzi, top-K posłusznie dostarczy pięć najmniej nietrafnych fragmentów, a LLM dostaje wtedy wiarygodnie wyglądający kontekst do pytania, na które powinien odmówić odpowiedzi. To zamienia mój widoczny błąd (nadmiarowe odmowy) w gorszy, niewidoczny (pewne siebie odpowiedzi oparte na szumie). Poza tym przy każdym żądaniu płacisz za K fragmentów w tokenach promptu, trafnych czy nie.

Opcja 4: szeroki retrieval, potem reranking

Tanio wyciągam z pgvector hojne top-K, a potem reranker typu cross-encoder ocenia każdego kandydata względem zapytania i zostawia kilka najlepszych. Zalety: reranker ocenia faktyczną trafność tekstu względem zapytania, a nie surową geometrię wektorów, więc precyzja realnie rośnie, a wyniki rerankera dużo lepiej dają się porównywać między zapytaniami niż podobieństwo cosinusowe. Wady: drugie wywołanie modelu na gorącej ścieżce, czyli dodatkowe opóźnienie, dodatkowy koszt każdego żądania i kolejny dostawca albo model hostowany u siebie do utrzymania. Do tego nie znika pytanie o kalibrację, tylko się przesuwa: nadal potrzebujesz granicy na wyniku rerankera, żeby zdecydować, kiedy nie zwracać niczego.

Opcja 5: wyszukiwanie hybrydowe

Łączę podobieństwo wektorowe z klasycznym wyszukiwaniem pełnotekstowym (Postgres ma je wbudowane) i scalam oba rankingi, zwykle metodą reciprocal rank fusion. Zalety: wyszukiwanie leksykalne łapie dokładnie to, z czym embeddingi sobie nie radzą: identyfikatory, nazwy, rzadkie terminy, skróty. Przy dopasowaniach pełnych nazw własnych to często największy pojedynczy skok jakości. Wady: dwie ścieżki zapytań do utrzymania i strojenia, krok scalania z własnymi parametrami i znowu brak ucieczki od głównego pytania: kiedy scalone wyniki są zbyt słabe, żeby ich użyć.

Co wybrałem i dlaczego

Wybrałem opcję 1 z doczepionym kawałkiem opcji 3: skalibrowany próg jako podłogę szumu pod twardym limitem top-K. Retrieval zwraca najwyżej pięć fragmentów z wynikiem powyżej 0,3. Jedynym zadaniem progu jest trzymanie fragmentów poniżej poziomu szumu z dala od dostępnych miejsc i zachowanie możliwości zwrócenia pustego wyniku. Koszt ogranicza limit, a nie próg. Żeby stępić największą wadę opcji 1, czyli dezaktualizację kalibracji, trzymam zapytania testowe z oczekiwanymi dopasowaniami jako malutki zbiór testowy w repozytorium i uruchamiam je ponownie przy każdej zmianie modelu, sposobu dzielenia na fragmenty albo korpusu. To skrypt na pięć linijek, który zamienia "ostatnio retrieval działa jakoś gorzej" w diff, który da się przeczytać.

Rozumowanie, wprost. Mój korpus był mały i jednojęzyczny, a zmierzony rozkład miał szeroką, czystą lukę między trafnymi wynikami a szumem. Główna zaleta progów zależnych od kontekstu i rerankingu, czyli dokładniejsze rozróżnianie, rozwiązywałaby więc problem, którego jeszcze nie potrafiłem wykryć, za cenę złożoności i opóźnienia, którą zapłaciłbym na pewno. Czyste top-K przegrało na wymaganiu odmowy: w czacie, który cytuje źródła, "nie wiem" to funkcja, a top-K strukturalnie nie umie tego powiedzieć. Wyszukiwanie hybrydowe było najmocniejszym kandydatem i po nie sięgnę najpierw, gdy korpus urośnie i pojawią się zapytania pełne identyfikatorów. Na starcie je pominąłem, bo moje pary testowe pokazywały, że czysty retrieval wektorowy już trafia we właściwy fragment, a nie miałem ani jednego przypadku, który wyszukiwanie leksykalne by naprawiło. Dokładanie maszynerii bez przypadku, który by ją uzasadniał, to sposób na to, żeby system zrobił się wolny, zanim zrobi się dobry.

W wybranym projekcie liczy się jeden szczegół: umieść granicę nisko w luce. Koszty błędów są niesymetryczne. Próg odrobinę za niski podaje LLM jeden dodatkowy marginalny fragment, który model świetnie umie zignorować i który kosztuje kilka tokenów. Próg odrobinę za wysoki nie podaje mu niczego, a wtedy model albo odmawia, albo halucynuje z pewnością siebie odpowiedzi opartej na źródłach. Przechylaj się w stronę kompletności, a koszt niech trzyma w ryzach limit.

Wnioski

  • Wyniki podobieństwa zależą od modelu i nie są prawdopodobieństwami. 0,47 było idealnym dopasowaniem dla jednego modelu, a 0,78 leżało powyżej sufitu drugiego. Nigdy nie przenoś progu między modelami embeddingów i nigdy nie czytaj wyniku jako procentu trafności.
  • Pusty retrieval zawodzi po cichu. 200 OK plus uprzejma odpowiedź zastępcza wyglądają dokładnie jak działający system. Testuj zapytaniami, które muszą trafić, i obserwuj odsetek odpowiedzi z pustym kontekstem.
  • Wypisz opcje, zanim zaczniesz łatać liczbę. Próg globalny, progi zależne od kontekstu, top-K, reranking, wyszukiwanie hybrydowe: każde z nich zawodzi inaczej. Wybieraj według tego, na który błąd najmniej Cię stać, a nie według tego, który projekt brzmi najbardziej zaawansowanie.
  • Zachowaj możliwość zwrócenia niczego. Przy odpowiedziach z cytowaniami odmowa to funkcja. Czyste top-K ją usuwa i zamienia widoczne nadmiarowe odmowy na niewidoczne, pewne siebie bzdury.
  • Kalibruj, patrząc na dane, i zachowuj dowody. Uruchom z progiem ustawionym na zero, umieść granicę w luce dla własnego korpusu i zachowaj pary zapytanie i dopasowanie jako test regresji na dzień, w którym zmieni się model albo sposób dzielenia na fragmenty.
  • Przechylaj się w stronę kompletności, a koszt kontroluj limitem. Dodatkowy marginalny fragment kosztuje tokeny. Brakujący fragment kosztuje poprawność. Próg jako podłoga szumu, top-K jako budżet.