BM25 kontra embeddingi: mała ławka, która pokazuje, że semantyka bywa krucha
Embeddingi rozumieją znaczenie, więc zawsze wygrywają z wyszukiwaniem po słowach? Zbudowałem ławkę pomiarową, która pokazuje czarno na białym, że to nieprawda — i kiedy semantyka pada.
Powszechny mit brzmi: “embeddingi rozumieją znaczenie, więc zawsze biją wyszukiwanie po słowach”. Zbudowałem małą ławkę pomiarową, która stawia BM25 (słowa) przeciw embeddingom (znaczenie) na jednym korpusie — i na zapytaniu o rzadki kod WIS embeddingi spudłowały całkowicie, podczas gdy BM25 trafił od razu w punkt.
O co chodzi
To nie jest produkt — to ławka porównawcza. Bierze dwa sposoby szukania, jeden wspólny korpus i 9 pytań testowych, a potem dla każdego pytania wystawia werdykt: kto trafił lepiej — BM25, embeddingi, czy remis.
Dwie metody, które zwykle stawia się przeciwko sobie:
- BM25 — wyszukiwanie leksykalne, po słowach. Świetne na rzadki, konkretny token: kod, symbol, nazwę pola.
- Embeddingi — wyszukiwanie semantyczne, po znaczeniu. Tekst zamieniany na wektory, więc parafraza dalej trafia w sedno.
Cały kod to czysty Python (biblioteka standardowa), zero zależności — bez numpy, bez SDK. Embeddingi liczone są lokalnie przez LM Studio (przez urllib), więc dane nie wychodzą z maszyny i koszt to 0 PLN. Korpus jest syntetyczny i bez danych osobowych: 12 krótkich dokumentów o tematyce księgowej (JPK_V7, KSeF, VAT, ZUS, CIT, korekty faktur, WIS, stawki), pociętych na 23 fragmenty. Zbiór testowy to 9 zapytań — 5 z twardymi terminami (“exact”) i 4 parafrazy — każde z oczekiwanym plikiem docelowym.
Żeby porównanie było uczciwe, oba retrievale tną korpus identycznie: tym samym modułem chunkującym, dzielącym po nagłówkach z limitem 50 linii / 450 słów i z provenance (plik + zakres linii). Jak nie zrównasz wejścia, mierzysz przypadek, nie metodę.
Pod maską
BM25 to własna implementacja (k1=1.5, b=0.75) z jednym uproszczeniem pod polską fleksję: rozszerzaniem terminów po wspólnym prefiksie minimum 5 znaków. To świadomie nie jest pełny stemmer — tania heurystyka, jawnie oznaczona w kodzie jako uproszczenie.
Embeddingi liczy lokalny model nomic-embed-text-v1.5 (768 wymiarów). Podobieństwo kosinusowe napisałem od zera, czystym Pythonem, bez numpy. Jest tu pułapka, którą łatwo przegapić: nomic wymaga prefiksów zadania — dokumenty embedować trzeba z przedrostkiem search_document: , a zapytania z search_query: . Bez tego jakość retrievalu spada; w kodzie jest to obsłużone i opisane jako “gotcha”.
Wektory są cache’owane na dysku z manifestem opartym na hashu treści fragmentu — każda zmiana korpusu (granic albo treści) unieważnia cache i wymusza ponowne policzenie. Jest też tryb offline (EMBED_MOCK=1): deterministyczne pseudo-wektory z hasha SHA1, jawnie oznaczone jako NIE-semantyczne, tylko do przetestowania pipeline’u bez LM Studio — a raport w tym trybie dostaje wyraźne ostrzeżenie, żeby nie brać go za realny wynik.
Harness mierzy dla każdego pytania rank oczekiwanego dokumentu w top-k obu metod i wystawia werdykt:
pytanie ──┬──> BM25 (po słowach) ──> rank oczekiwanego dokumentu
└──> embeddingi (po sensie) ──> rank oczekiwanego dokumentu
│
└──> werdykt: BM25 / embeddingi / remis / oba pudłują
Co z tego wynika
Realny przebieg (top-k=3, nomic-embed-text-v1.5) na 9 zapytaniach: 6 remisów, 1 wygrana BM25, 2 wygrane embeddingów. Czyli najczęściej obie metody radzą sobie tak samo — ciekawie robi się na skrajach.
Kiedy embeddingi padają. Zapytanie “WIS stawka podatku dla towaru lub usługi”: embeddingi nie wciągnęły właściwego dokumentu nawet do top-3 (rank = None), a BM25 dał go na 1. miejscu. Mechanizm jest jawnie zdiagnozowany: rzadki kod o wysokim IDF (WIS) tonie w gęstym klastrze kilku dokumentów o stawkach. Embeddingi rozkładają podobieństwo po całym klastrze i gubią target — BM25 wyłapuje rzadki token bez mrugnięcia.
Kiedy embeddingi wygrywają. Parafraza, która nie dzieli słów kluczowych z dokumentem: “pismo zmieniające wartość transakcji gdy klient oddał produkt” → dokument o fakturze korygującej. Tu BM25 ma rank None (brak wspólnych słów = brak trafienia), a embeddingi łapią sens i dają dokument w top-3.
To jest cała pointa tej ławki: embeddingi są mocne, ale kruche i zależne od sformułowania. Semantyka opłaca się tam, gdzie użytkownicy mówią różnym językiem — a nie tam, gdzie liczy się twardy, unikalny kod. Wniosek na produkcję jest więc nie “doklej embeddingi”, tylko: hybryda (słowa + znaczenie) + zestaw pytań testowych + pomiar na realnym korpusie. Ławka uczy dokładnie tego osądu — kiedy semantyka jest warta zachodu, a kiedy BM25 wystarczy.
Jest jeszcze sygnał skalowania zapisany wprost w kodzie: kosinus liczę brute-force po wszystkich wektorach, co przy setkach fragmentów jest banalnie szybkie. Realna baza wektorowa z indeksem ANN (FAISS / Chroma / sqlite-vec) zaczyna się opłacać od kilkudziesięciu tysięcy wektorów w górę, a przy setkach tysięcy staje się praktycznie konieczna — i tu interfejs jest tak wydzielony, że podmiana składnicy nie rusza reszty. To rekomendacja projektowa, nie zmierzony benchmark.
Dowód
Kod jest publiczny, licencja MIT: github.com/martin0ne/rag-lexical-vs-semantic. Nie powstał “na czuja” — repo ma 19 testów (unittest) pokrywających chunking, BM25, embeddingi (unit + indeks + smoke test na żywo), korpus i sam harness; test live jest automatycznie pomijany, gdy LM Studio jest offline. Korpus nie zawiera danych osobowych, commity idą z adresu noreply, a .gitignore wycina cache i śmieci.
Odpalisz to w dwóch trybach: EMBED_MOCK=1 python3 compare.py działa offline (ale to NIE-semantyczny atrapowy wynik), a realną tezę zobaczysz dopiero z włączonym LM Studio.
Ta ławka to soczewka, nie produkt. Jeśli chcesz zobaczyć, jak ten wniosek — “łącz słowa i znaczenie” — wygląda już wdrożony w działającym asystencie z cytatami plik:linia, opisałem cały build osobno: jak zbudowałem wyszukiwarkę po dokumentach, która nie zmyśla. A jeśli interesuje cię, czemu w ogóle warto uziemiać model w źródle: czemu chatbot zmyśla i co RAG ma z tym wspólnego.
Powiązane artykuły.
Agent dokumentowy, który sam planuje, czego szukać i co czytać (ReAct)
Zwykły RAG szuka raz i odpowiada. agent-flow działa w pętli myśl→narzędzie→obserwacja: sam decyduje, czego szukać i co przeczytać dalej, a każde twierdzenie w raporcie ma cytat plik:linia. Plus bramka akceptacji dla człowieka.
Co dane mówią o adopcji AI: Polska vs UE — pojedynek dwóch urzędów w SQL
Polska wdraża AI szybko, ale od tak niskiej bazy, że dystans do UE rośnie. Ręcznie pisany SQL uzgadnia ten sam wskaźnik między GUS a Eurostatem — JOIN, reconciliation, funkcje okna.
Zbudowałem wyszukiwarkę po dokumentach, która nie zmyśla — oto jak
RAG, który zamiast wymyślać odpowiedzi, pokazuje plik i linię, z której je wziął. Jak działa hybryda BM25 + embeddingi + RRF i dlaczego pojedyncza metoda zawodzi.