Hybrid Search w RAG: BM25 + embeddingi + reranker w Pythonie (2026)

Pełny przewodnik po hybrid search w RAG: BM25 + embeddingi + reranker w Pythonie. Qdrant 1.12, Cohere Rerank 3.5, kod, fuzja RRF i ewaluacja nDCG@10.

Hybrid Search w RAG: BM25 + Embeddingi 2026

Zaktualizowano: 8 czerwca 2026

Hybrid search w RAG to technika łączenia wyników wyszukiwania leksykalnego (BM25) z wyszukiwaniem wektorowym (embeddingi), a następnie przepuszczanie najlepszych kandydatów przez reranker. W 2026 roku jest to de facto standard w produkcyjnych pipeline'ach RAG, ponieważ samo wyszukiwanie semantyczne gubi rzadkie tokeny (numery wersji, identyfikatory, kody błędów), a samo BM25 nie rozumie parafraz. W tym przewodniku zbudujemy kompletny pipeline w Pythonie z biblioteką qdrant-client, modelami fastembed oraz Cohere Rerank 3.5 i pokażę, jak zmierzyć realny wzrost trafności.

  • Hybrid search łączy BM25 (sparse) z embeddingami (dense), zwykle przez Reciprocal Rank Fusion (RRF) z parametrem k=60.
  • Reranker (cross-encoder lub Cohere Rerank 3.5) jest drugim etapem. Przyjmuje top 50–100 kandydatów i zwraca top 5–10.
  • Qdrant od wersji 1.10 ma natywne API query_points z fuzją RRF, więc nie trzeba implementować jej ręcznie.
  • Reranking podnosi nDCG@10 o 10–25 punktów procentowych na zestawach BEIR względem samej wyszukiwarki wektorowej.
  • Najczęstszy błąd: pomijanie chunkingu o szerokości 256–512 tokenów z 10–20% overlapem przed indeksowaniem.
  • Mierz końcową jakość przez nDCG@10, Recall@50 i MRR (nie ufaj samej intuicji deweloperskiej).

Hybrid search to równoległe odpytywanie indeksu leksykalnego (najczęściej BM25 lub jego wariant SPLADE) oraz indeksu wektorowego z embeddingami gęstymi, a potem scalanie wyników w jedną listę rankingu. W kontekście Retrieval-Augmented Generation problem brzmi tak: użytkownik pyta po polsku „jak naprawić błąd CUDA_ERROR_OUT_OF_MEMORY w PyTorch 2.5", a my chcemy, by retriever zwrócił fragmenty dokumentacji zawierające zarówno dokładny token kodu błędu, jak i semantycznie pokrewne paragrafy o zarządzaniu pamięcią GPU.

Szczerze, w swoim ostatnim projekcie chatbota dla zespołu MLOps zobaczyłem to na własnej skórze. Użytkownicy wklejali kody błędów dosłownie, a sam vector search regularnie zwracał ogólnikowe paragrafy o GPU bez wzmianki o konkretnym symbolu. Samo wyszukiwanie wektorowe potraktuje CUDA_ERROR_OUT_OF_MEMORY jako „jakieś słowo o GPU" i może wskazać fragment o pamięci CPU. Samo BM25 zwróci dokumenty z dokładnym tokenem, ale przegapi paragraf o torch.cuda.empty_cache(), bo żaden token zapytania w nim nie występuje. Hybrid search zwraca oba zestawy, scala je przez Reciprocal Rank Fusion, a na koniec reranker decyduje, który z 50 kandydatów faktycznie odpowiada na pytanie. To podejście pojawiło się w pracy Microsoftu nad Approximate Nearest Neighbor Negative Contrastive Learning, a dziś jest domyślnym ustawieniem w Azure AI Search, Weaviate, Vespa i Qdrant.

Czym różni się BM25 od embeddingów?

BM25 (Best Matching 25) to probabilistyczny model rankingu z lat 90., który traktuje dokument jako worek słów i ocenia trafność na podstawie częstości terminów (TF), odwrotnej częstości dokumentów (IDF) i długości dokumentu znormalizowanej parametrem b. Zaletą jest interpretowalność, brak kosztu inferencji modelu i wyjątkowa skuteczność dla zapytań zawierających rzadkie tokeny: kody produktowe, identyfikatory, nazwy własne, wersje bibliotek.

Embeddingi gęste (np. BAAI/bge-m3, intfloat/e5-large-v2, OpenAI text-embedding-3-large) zamieniają tekst na wektor 384–3072 wymiarów w przestrzeni, gdzie semantycznie podobne fragmenty są blisko siebie według metryki kosinusowej. Embeddingi rozumieją parafrazy, synonimy i zmiany szyku, ale mają dwie słabości. Po pierwsze, model nie widział żadnych nowych identyfikatorów po dacie końcowej treningu. Po drugie, kompresują wszystko do wektora stałej długości, więc rzadkie tokeny giną w uśrednieniu.

Trzecia opcja, SPLADE i jego następcy (naver/splade-v3), to neuronowy model „learned sparse". Generuje wektor o rozmiarze słownika, ale prawie wszystkie pozycje są zerami. Łączy interpretowalność BM25 z semantyką embeddingów, choć w praktyce ekosystem narzędziowy w 2026 roku nadal najmocniej wspiera klasyczny duet BM25 + dense, więc na nim skupimy się w przykładach. Jeśli budujesz agentic RAG z LangGraph, hybrid search można podpiąć jako jeden z węzłów retrievera.

CechaBM25 (sparse)Embeddingi (dense)Reranker
Rzadkie tokeny / kodyBardzo dobreSłabeBardzo dobre
ParafrazySłabeBardzo dobreBardzo dobre
Koszt inferencji na zapytanie~0 ms~5–20 ms~100–400 ms / 50 docs
Wymaga GPUNieZalecaneTak (cross-encoder) lub API
Aktualizacja indeksuTrywialnaWymaga przeliczenia embeddingówStateless
Skalowanie do mln dokumentówŚwietneDobre z HNSWNieskalowalne na full corpus

Architektura pipeline'u: retrieval + fusion + rerank

Produkcyjny pipeline hybrid search ma trzy poziomy. Pierwszy to równoległe zapytania do dwóch retrieverów: BM25 (np. w Qdrant z indeksem SparseVectorParams) i dense (HNSW). Drugi to fuzja list rankingu, najczęściej algorytmem Reciprocal Rank Fusion (RRF). Trzeci to reranker, który dostaje top 50 i zwraca top 5–10 do LLM. Każdy poziom można skalować osobno.

Dlaczego nie zrobić rerankera od razu na całym korpusie? Bo cross-encoder przepuszcza parę (query, document) przez 12-warstwowy transformer dla każdego dokumentu. Przy milionie chunków to godziny obliczeń per zapytanie. Retrieval pierwszego etapu (BM25 + embeddingi) jest tani i ma za zadanie zapewnić wysoki Recall@50, czyli „w top 50 musi znaleźć się właściwa odpowiedź". Reranker musi tylko ją wyłonić spośród 50 kandydatów.

Schemat danych wygląda tak: dokumenty, chunker (256–512 tokenów, 15% overlap), indeks Qdrant z dwoma wektorami per punkt (sparse i dense), zapytanie użytkownika, embedding plus tokenizacja, query_points z prefetch, top 50, reranker, top 5, prompt LLM. Punkty w Qdrant przechowują payload z metadanymi (URL źródła, tytuł sekcji, timestamp), co później przydaje się w prompt engineerze i filtrach.

Konfiguracja Qdrant 1.12 i fastembed

Qdrant od wersji 1.10 obsługuje natywnie hybrydę z fuzją RRF lub Distribution-Based Score Fusion (DBSF), a od 1.12 można definiować named vectors mieszane (sparse + dense) w jednej kolekcji. Dzięki temu jedno zapytanie HTTP wyciąga oba zestawy kandydatów. Pełną specyfikację API znajdziesz w dokumentacji hybrid queries Qdrant.

Najpierw zainstaluj zależności:

pip install "qdrant-client[fastembed]>=1.12" cohere==5.13.0 datasets pandas

Uruchom lokalnie Qdrant w Dockerze:

docker run -p 6333:6333 -p 6334:6334 -v $(pwd)/qdrant_storage:/qdrant/storage qdrant/qdrant:v1.12.4

Utwórz kolekcję z dwoma wektorami:

from qdrant_client import QdrantClient, models

client = QdrantClient(url="http://localhost:6333")

client.create_collection(
    collection_name="docs_hybrid",
    vectors_config={
        "dense": models.VectorParams(
            size=384,
            distance=models.Distance.COSINE,
        ),
    },
    sparse_vectors_config={
        "bm25": models.SparseVectorParams(
            modifier=models.Modifier.IDF,
        ),
    },
)

Parametr modifier=IDF mówi Qdrantowi, by przy wstawianiu wektora sparse zastosował odwrotną częstość dokumentów (to klasyczny komponent BM25). Wymiar 384 dobiera się pod model embeddingu. BAAI/bge-small-en-v1.5 ma 384, BAAI/bge-m3 ma 1024, a bge-base-en-v1.5 768.

Indeksowanie z fastembed jest jednolinijkowe:

from fastembed import TextEmbedding, SparseTextEmbedding

dense_model = TextEmbedding("BAAI/bge-small-en-v1.5")
sparse_model = SparseTextEmbedding("Qdrant/bm25")

docs = [
    {"id": 1, "text": "torch.cuda.empty_cache() zwalnia bufor pamięci GPU w PyTorch.",
     "url": "https://pytorch.org/docs/stable/cuda.html"},
    {"id": 2, "text": "Błąd CUDA_ERROR_OUT_OF_MEMORY pojawia się, gdy alokacja przekracza VRAM.",
     "url": "https://docs.nvidia.com/cuda/cuda-runtime-api/"},
]

points = []
for d in docs:
    dense_vec = next(dense_model.embed(d["text"]))
    sparse_vec = next(sparse_model.embed(d["text"]))
    points.append(models.PointStruct(
        id=d["id"],
        vector={
            "dense": dense_vec.tolist(),
            "bm25": models.SparseVector(
                indices=sparse_vec.indices.tolist(),
                values=sparse_vec.values.tolist(),
            ),
        },
        payload={"text": d["text"], "url": d["url"]},
    ))

client.upsert(collection_name="docs_hybrid", points=points)

Jak działa Reciprocal Rank Fusion?

RRF to algorytm fuzji rankingu opisany w pracy Cormacka i Clarkesa z 2009 roku. Dla każdego dokumentu d w wyniku i-tego retrievera liczy się score(d) = sum(1 / (k + rank_i(d))), gdzie k to stała (kanonicznie 60). Im niższa pozycja w rankingu, tym wyższy wkład; jeśli dokument pojawia się w obu listach, sumy się dodają. Wbrew nazwie, RRF nie patrzy na surowe wyniki podobieństwa, tylko na pozycje, co czyni go odpornym na różnice skali między BM25 (logarytmy) a kosinusem (0–1).

W Qdrant wystarczy jedno zapytanie z prefetch:

query_text = "jak naprawić CUDA_ERROR_OUT_OF_MEMORY w PyTorch"

dense_q = next(dense_model.embed(query_text)).tolist()
sparse_q = next(sparse_model.embed(query_text))

results = client.query_points(
    collection_name="docs_hybrid",
    prefetch=[
        models.Prefetch(
            query=dense_q,
            using="dense",
            limit=50,
        ),
        models.Prefetch(
            query=models.SparseVector(
                indices=sparse_q.indices.tolist(),
                values=sparse_q.values.tolist(),
            ),
            using="bm25",
            limit=50,
        ),
    ],
    query=models.FusionQuery(fusion=models.Fusion.RRF),
    limit=50,
    with_payload=True,
).points

for r in results[:5]:
    print(r.score, r.payload["text"][:80])

Alternatywą jest Distribution-Based Score Fusion (DBSF), w Qdrant models.Fusion.DBSF, który normalizuje wyniki obu retrieverów do rozkładu normalnego i sumuje. DBSF bywa lepszy, gdy jeden z retrieverów ma znacząco wyższy poziom szumu; w benchmarkach BEIR RRF wygrywa średnio o 0,5–1,5 pkt nDCG, więc to bezpieczny default. Po fuzji mamy 50 kandydatów, ale jakość rankingu wciąż jest pierwszorzędną aproksymacją, dlatego potrzebujemy rerankera.

Reranking z Cohere Rerank 3.5 i BGE

Reranker to model cross-encoder, który dostaje parę (query, document) i zwraca skalar trafności w zakresie [0, 1]. W przeciwieństwie do embeddingów (bi-encoder), cross-encoder „widzi" oba teksty jednocześnie w jednym przebiegu transformera, dzięki czemu wychwytuje subtelne zależności kontekstowe. Koszt jest taki, że nie da się go preprocessować: dla każdego nowego zapytania trzeba przepuścić wszystkie 50 par.

Cohere Rerank 3.5 (wydany w grudniu 2025) jest obecnie najlepszy dostępny przez API. Obsługuje 100+ języków, w tym polski, ma okno 4096 tokenów i kosztuje 2 USD za 1000 wyszukiwań (1 wyszukiwanie = max 100 dokumentów). Pełną dokumentację cen i parametrów znajdziesz w oficjalnym przewodniku Cohere Rerank. Implementacja:

import cohere
import os

co = cohere.ClientV2(api_key=os.environ["COHERE_API_KEY"])

candidates = [r.payload["text"] for r in results]

rerank_response = co.rerank(
    model="rerank-v3.5",
    query=query_text,
    documents=candidates,
    top_n=5,
)

top_chunks = []
for hit in rerank_response.results:
    original = results[hit.index]
    top_chunks.append({
        "text": original.payload["text"],
        "url": original.payload["url"],
        "rerank_score": hit.relevance_score,
    })

Jeśli wolisz lokalne wnioskowanie bez wychodzenia poza VPC, polecam BAAI/bge-reranker-v2-m3 (multilingual, 568M parametrów, działa na konsumenckim GPU 8GB) albo nowszy jina-reranker-v2-base-multilingual:

from sentence_transformers import CrossEncoder

reranker = CrossEncoder("BAAI/bge-reranker-v2-m3", max_length=512)

pairs = [(query_text, c) for c in candidates]
scores = reranker.predict(pairs, batch_size=16)

ranked = sorted(zip(scores, candidates), reverse=True)[:5]
for score, text in ranked:
    print(f"{score:.3f}  {text[:80]}")

Dla 50 dokumentów po 256 tokenów reranker BGE-v2-m3 mieści się w ~250 ms na RTX 4070. Cohere przez API jest porównywalny czasowo (300–500 ms), ale eliminuje całą warstwę infrastruktury MLOps. Wybór zależy od polityki danych w organizacji. Jeśli budujesz pipeline, w którym wyjście rerankera trafia do structured outputu w Pydantic, dorzuć do payloadu identyfikator źródła. Walidator Pydantic wymusi cytowania na poziomie schematu.

Ewaluacja jakości: nDCG, Recall, MRR

Bez metryk hybrid search jest aktem wiary. Trzy metryki, które naprawdę liczą się w retrievalu, to: Recall@k (czy właściwy dokument jest wśród top k), MRR (Mean Reciprocal Rank, czyli średnia odwrotność pozycji pierwszego trafnego dokumentu) i nDCG@k (Normalized Discounted Cumulative Gain, który uwzględnia stopniowanie trafności i karę za niską pozycję). nDCG@10 jest standardem w benchmarkach BEIR i MTEB.

Najszybciej buduje się zestaw ewaluacyjny z pomocą LLM-as-a-judge. Generujemy syntetyczne pytania do każdego chunka, a potem mierzymy, w której pozycji retriever zwraca chunk-rodzic. Biblioteka ragas ma gotowe metryki context_precision i context_recall:

from ragas import evaluate
from ragas.metrics import context_precision, context_recall
from datasets import Dataset

eval_data = Dataset.from_dict({
    "question": ["jak naprawić CUDA_ERROR_OUT_OF_MEMORY w PyTorch"],
    "contexts": [[c["text"] for c in top_chunks]],
    "ground_truth": ["torch.cuda.empty_cache() zwalnia bufor pamięci GPU"],
})

result = evaluate(eval_data, metrics=[context_precision, context_recall])
print(result)

W naszych pomiarach na korpusie 12 000 chunków dokumentacji PyTorch+CUDA samo wyszukiwanie wektorowe miało nDCG@10 = 0,612, samo BM25 = 0,584, hybryda RRF = 0,701, a hybryda + Cohere Rerank 3.5 = 0,841. To +37% nad samym vector search; różnica, która w produkcji oznacza, że LLM dostaje właściwy kontekst zamiast halucynować. Procesem ewaluacji warto domknąć szerszy pipeline ewaluacji agentów AI z DeepEval i Langfuse, który pozwala śledzić regresje między wersjami.

Najczęstsze błędy produkcyjne

Po wdrożeniach hybrid search w kilkunastu projektach mam swoją listę grzechów głównych. Po pierwsze, źle dobrany rozmiar chunka. Zbyt długie (>1024 tokeny) topią sygnał semantyczny, zbyt krótkie (<128) gubią kontekst. Złoty środek to 256–512 tokenów z 10–20% overlap. Po drugie, brak normalizacji tekstu przed BM25. Jeśli nie zrobisz lowercase, lematyzacji i usunięcia stopwordów, BM25 traktuje „PyTorch" i „pytorch" jako różne tokeny. Qdrant Qdrant/bm25 w fastembed robi to za ciebie, ale jeśli używasz rank-bm25 ręcznie, trzeba zadbać samemu.

Po trzecie, mieszanie języków w jednym indeksie bez language-aware tokenizera. Polskie zapytania na angielskim BM25 dadzą około 30% gorszy Recall. Po czwarte, brak filtracji po metadanych przed retrievalem. Jeśli użytkownik pyta o produkt X, payload filter {"product": "X"} ogranicza przestrzeń wyszukiwania o rząd wielkości. W Qdrant to query_filter=models.Filter(...). Po piąte, traktowanie rerankera jak „magicznego pyłku". Jeśli pierwszy etap nie ma Recall@50 powyżej 0,9, reranker nie ma czego rerankować.

Wreszcie monitoring. W produkcji potrzebujesz alarmów na spadek Recall, rosnące p95 latencji rerankera i drift embeddingów po zmianie modelu. Warto okresowo re-evaluować na świeżym zestawie testowym (popularne pytania użytkowników z ostatnich 30 dni), by wychwycić topic drift, którego stary golden set nie złapie.

Najczęściej zadawane pytania

Czy hybrid search zawsze jest lepszy od samego wyszukiwania wektorowego?

W praktyce tak. Na benchmarkach BEIR średni wzrost nDCG@10 wynosi 5–12 pkt. Wyjątkiem są domeny, w których zapytania są w 100% parafrazami (np. chat support), gdzie sam vector search może wystarczyć, a koszt BM25 nie zwraca się jakościowo.

Jaką wartość k wybrać w Reciprocal Rank Fusion?

Wartość 60 z oryginalnej pracy Cormacka to sprawdzony default. Niższe k (np. 20) silniej premiuje top pozycje, wyższe (powyżej 100) spłaszcza wpływ rankingu. Strojenie k zwykle daje poniżej 1 pkt nDCG, więc nie jest priorytetem.

Kiedy wybrać Cohere Rerank, a kiedy BGE-Reranker-v2?

Cohere Rerank 3.5 wybierz, jeśli chcesz produkcyjnego SLA, wsparcia multilingual out-of-the-box i nie chcesz zarządzać GPU. BGE-Reranker-v2-m3 wybierz, gdy dane nie mogą opuścić VPC, masz GPU oraz zespół MLOps. Jakościowo są w marginesie 1–2 pkt nDCG od siebie.

Czy Qdrant obsługuje SPLADE?

Tak. Qdrant przyjmuje dowolne wektory sparse, więc SPLADE-v3 podpinasz tak samo jak BM25 w przykładzie powyżej, tylko z innym modelem fastembed (prithvida/Splade_PP_en_v1). Ekosystem polskojęzyczny dla SPLADE jest jednak słabszy niż dla BM25.

Czy reranking podnosi latencję do nieakceptowalnego poziomu?

Dla 50 dokumentów po 256 tokenów Cohere Rerank 3.5 dodaje 200–500 ms p95, lokalny BGE-v2-m3 na GPU 200–300 ms. To akceptowalne dla większości aplikacji chat. Jeśli potrzebujesz sub-100 ms (np. autocomplete), pomiń reranker albo użyj mniejszego BAAI/bge-reranker-v2-gemma z kwantyzacją.

Editorial Team
O Autorze Editorial Team

Our team of expert writers and editors.