AI Engineering Academy · Lekcja

Dlaczego wyszukiwanie dwuetapowe działa

Poznaj kompromis między pełnością a precyzją w wyszukiwaniu jednoetapowym oraz dowiedz się, jak szybka wyszukiwarka zgrubna, po której następuje wolniejszy, lecz dokładny re-ranker, łączy zalety obu podejść.

Lekcja 1 z 413 kroki

Dlaczego wyszukiwanie dwuetapowe działa to bezpłatna lekcja AI Engineering Academy na CoddyKit. To lekcja 1 z 4. Możesz przeczytać całą lekcję poniżej za darmo — a potem ćwiczyć ją interaktywnie w przeglądarce z wbudowanym edytorem kodu i tutorem AI dostępnym 24/7. To część ścieżki edukacyjnej AI Engineering Academy, a Twój postęp synchronizuje się między webem a aplikacją CoddyKit. Kurs AI Engineering Academy zawiera 4 lekcji w sumie.

Kompromis między recall a precision w wyszukiwaniu

Każdy system wyszukiwania musi uwzględniać fundamentalny kompromis: recall mierzy, ile istotnych dokumentów udało się znaleźć (czy któregoś nie pominięto?), natomiast precision mierzy trafność najlepszych wyników (ile spośród pobranych dokumentów jest rzeczywiście istotnych?). Jednoczesna maksymalizacja obu tych wartości jest kosztowna obliczeniowo. Szybkie retrievery poświęcają precision na rzecz recall, a precyzyjne rankery poświęcają szybkość na rzecz trafności.

Bi-encoder a cross-encoder: kluczowa różnica

Dwa typy modeli leżących u podstaw wyszukiwania dwuetapowego różnią się sposobem przetwarzania zapytania i dokumentu. Bi-encoder koduje zapytanie i każdy dokument niezależnie, a następnie mierzy podobieństwo między ich wektorami — jest szybki, ale ogranicza go niezależne kodowanie. Cross-encoder otrzymuje zapytanie i dokument połączone w ramach jednego wejścia, co umożliwia ich głęboką interakcję — jest bardzo dokładny, ale ma złożoność O(n) względem zbioru kandydatów.

# Bi-encoder: compute query embedding ONCE, compare to all doc embeddings
# O(1) query encoding + O(n) dot products via ANN index = fast
query_vec = embed(query)  # done once
results = vector_index.search(query_vec, top_k=100)  # fast ANN search

# Cross-encoder: re-scores (query, doc) pairs jointly
# O(k) forward passes for k candidate documents = slow but accurate
for doc in results[:100]:
    score = cross_encoder.score(query, doc.text)  # joint scoring

Etap 1: szybkie wyszukiwanie zgrubne

Pierwszy etap to szybki retriever — zazwyczaj bi-encoder z indeksem approximate nearest neighbor albo indeksem BM25 — który pobiera duży zbiór kandydatów (50–200 dokumentów), zapewniając wysoki recall, ale umiarkowany precision. Celem nie jest dokładność, lecz niepominięcie istotnych dokumentów. Zarzucamy szeroką sieć i akceptujemy pewną liczbę wyników fałszywie dodatnich, wiedząc, że drugi etap je odfiltruje.

from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings

# Stage 1: retrieve 100 candidates (high recall, modest precision)
vectorstore = FAISS.from_documents(documents, OpenAIEmbeddings())
coarse_retriever = vectorstore.as_retriever(
    search_kwargs={'k': 100}  # large candidate set
)

candidates = coarse_retriever.invoke(query)
print(f'Stage 1: retrieved {len(candidates)} candidate documents')

Etap 2: dokładne ponowne szeregowanie za pomocą cross-encodera

Drugi etap pobiera zbiór kandydatów z etapu pierwszego i ponownie ocenia każdą parę (zapytanie, dokument) za pomocą cross-encodera, który odczytuje oba elementy razem. Ponieważ przetwarza tylko 50–200 kandydatów, a nie cały korpus, może sobie pozwolić na kosztowne wspólne kodowanie. Głęboka uwaga cross-encodera względem połączonego wejścia pozwala znacznie dokładniej oszacować rzeczywistą istotność niż w przypadku bi-encodera.

from sentence_transformers import CrossEncoder

reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')

def rerank(query: str, candidates: list[str], top_k: int = 5) -> list[str]:
    # Score each (query, document) pair jointly
    pairs = [[query, doc] for doc in candidates]
    scores = reranker.predict(pairs)

    # Sort by score descending
    ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
    return [doc for doc, _ in ranked[:top_k]]

candidate_texts = [doc.page_content for doc in candidates]
final_docs = rerank(query, candidate_texts, top_k=5)
print(f'Stage 2: selected top {len(final_docs)} documents after re-ranking')

Dlaczego to połączenie działa

Projekt dwuetapowy wykorzystuje kluczową asymetrię: szybkie wyszukiwanie ANN w pierwszym etapie skaluje się do milionów dokumentów i trwa milisekundy, podczas gdy dokładny cross-encoder w drugim etapie działa tylko na niewielkim zbiorze kandydatów. Otrzymują Państwo skalowalność wyszukiwania przybliżonego oraz dokładność precyzyjnego wspólnego oceniania. Cały potok jest jednocześnie szybki i bardzo dokładny — czego żaden z etapów nie zapewnia samodzielnie.

Charakterystyka opóźnień wyszukiwania dwuetapowego

W typowym potoku dwuetapowym: etap 1 (wektorowe wyszukiwanie ANN wśród 1 mln dokumentów) trwa 5–20 ms, a etap 2 (cross-encoder dla 100 kandydatów) trwa 100–500 ms, zależnie od długości dokumentów i sprzętu. Całkowity budżet opóźnienia wynosi 150–600 ms, co jest akceptowalne w większości aplikacji. Akceleracja GPU w etapie 2 może skrócić ponowne szeregowanie do poniżej 30 ms dla krótkich dokumentów, dzięki czemu potok może konkurować pod względem opóźnień z wyszukiwaniem jednoetapowym w aplikacjach wrażliwych na czas odpowiedzi.

import time

def two_stage_search(query, coarse_retriever, reranker, top_k=5):
    t0 = time.perf_counter()

    candidates = coarse_retriever.invoke(query)        # stage 1
    t1 = time.perf_counter()

    candidate_texts = [c.page_content for c in candidates]
    final_docs = rerank(query, candidate_texts, top_k)  # stage 2
    t2 = time.perf_counter()

    print(f'Stage 1 (retrieval): {(t1-t0)*1000:.1f}ms')
    print(f'Stage 2 (re-ranking): {(t2-t1)*1000:.1f}ms')
    print(f'Total: {(t2-t0)*1000:.1f}ms')
    return final_docs

Wybór odpowiedniego rozmiaru zbioru kandydatów

Rozmiar zbioru kandydatów w pierwszym etapie jest kluczowym hiperparametrem. Jeśli jest zbyt mały (na przykład 10), istotne dokumenty mogą zostać pominięte, zanim w ogóle rozpocznie się ponowne szeregowanie. Jeśli jest zbyt duży (na przykład 500), opóźnienie etapu 2 gwałtownie wzrasta. Przy wyborze pomaga krzywa recall at N — pokazująca, ile istotnych dokumentów zostaje znalezionych dla różnych wartości N. Typowe optymalne wartości mieszczą się między 50 a 150 kandydatami, gdzie recall jest bliski nasycenia, a opóźnienie pozostaje możliwe do zaakceptowania.

def recall_at_n(coarse_retriever, test_queries, golden_relevant, n_values):
    for n in n_values:
        recalls = []
        for query, relevant in zip(test_queries, golden_relevant):
            # Temporarily set k to n
            coarse_retriever.search_kwargs['k'] = n
            results = coarse_retriever.invoke(query)
            retrieved_ids = {r.metadata.get('id') for r in results}
            relevant_found = len(set(relevant) & retrieved_ids)
            recalls.append(relevant_found / len(relevant))
        avg = sum(recalls) / len(recalls)
        print(f'N={n}: recall={avg:.3f}')

Pierwszy etap hybrydowy + drugi etap z cross-encoderem

Najpotężniejsza konfiguracja dwuetapowa łączy hybrydowy retriever (dense + BM25) w pierwszym etapie z cross-encoderem w drugim etapie. Wyszukiwanie hybrydowe maksymalizuje recall pierwszego etapu, łącząc dopasowanie semantyczne ze słowami kluczowymi, a cross-encoder następnie dokładnie wybiera najbardziej istotne dokumenty ze wspólnego zbioru kandydatów. Taka konfiguracja konsekwentnie osiąga najnowocześniejszą jakość wyszukiwania w testach porównawczych.

from langchain.retrievers import EnsembleRetriever

# Stage 1: hybrid retrieval for maximum recall
hybrid_retriever = EnsembleRetriever(
    retrievers=[bm25_retriever, vector_retriever],
    weights=[0.4, 0.6],
)

# Stage 2: cross-encoder re-ranking for high precision
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain_community.cross_encoders import HuggingFaceCrossEncoder

cross_encoder_model = HuggingFaceCrossEncoder(model_name='cross-encoder/ms-marco-MiniLM-L-6-v2')
compressor = CrossEncoderReranker(model=cross_encoder_model, top_n=5)

from langchain.retrievers import ContextualCompressionRetriever
two_stage = ContextualCompressionRetriever(
    base_compressor=compressor,
    base_retriever=hybrid_retriever,
)

Komercyjne API do ponownego szeregowania

Jeśli chcą Państwo uzyskać dokładność cross-encodera bez zarządzania własnym modelem, zarówno Cohere Rerank, jak i Jina AI Reranker oferują hostowane w chmurze API do ponownego szeregowania. Wysyłają Państwo zapytanie i listę tekstów dokumentów, a w odpowiedzi otrzymują wyniki istotności. Te interfejsy API korzystają z dużych modeli cross-encoder (często mających ponad 500 mln parametrów), które przewyższają małe cross-encodery hostowane samodzielnie, ale wiążą się z dodatkowym opóźnieniem API (50–300 ms) i opłatą za każdy ponownie uszeregowany dokument.

import cohere

co = cohere.Client('YOUR_API_KEY')

def cohere_rerank(query: str, documents: list[str], top_k: int = 5):
    response = co.rerank(
        model='rerank-english-v3.0',
        query=query,
        documents=documents,
        top_n=top_k,
    )
    return [
        {'text': documents[r.index], 'score': r.relevance_score}
        for r in response.results
    ]

final = cohere_rerank(query, candidate_texts, top_k=5)
for doc in final:
    print(f'Score {doc["score"]:.3f}: {doc["text"][:80]}')

Kiedy wyszukiwanie dwuetapowe jest przesadą

W porównaniu z wyszukiwaniem jednoetapowym wyszukiwanie dwuetapowe zwiększa złożoność i opóźnienie. Nie zawsze jest konieczne. W przypadku małych korpusów, liczących mniej niż 10 000 dokumentów, pojedynczy cross-encoder działający na całym zbiorze może być wystarczająco szybki. W aplikacjach, w których kluczowe jest opóźnienie poniżej 100 ms, a zyski z dokładności są niewielkie, lepsze może być jednoetapowe wyszukiwanie gęste. Wyszukiwanie dwuetapowe warto stosować przy dużym korpusie, wysokich wymaganiach dotyczących dokładności i akceptowalnym opóźnieniu wyszukiwania wynoszącym 200–500 ms.

Wyszukiwanie trzyetapowe na ekstremalną skalę

W przypadku korpusów obejmujących dziesiątki milionów dokumentów czasami stosuje się potok trzystopniowy: pierwszy etap pobiera za pomocą ANN 10 000 kandydatów, drugi ponownie szereguje ich do 100 za pomocą szybkiego, małego cross-encodera, a trzeci ponownie szereguje ich do 5 za pomocą dużego, wydajnego cross-encodera. Każdy etap stosuje droższy i dokładniejszy model do mniejszego zbioru danych. Ta architektura jest używana przez wyszukiwarki działające na dużą skalę oraz systemy pytań i odpowiedzi dotyczących dokumentów.

Szybkie sprawdzenie

Sprawdź, czy rozumiesz, dlaczego wyszukiwanie dwuetapowe działa.

Podsumowanie lekcji

W tej lekcji dowiedziałeś się, że: bi-encodery są szybkie, ale ograniczają się do niezależnego kodowania zapytania i dokumentu; cross-encodery są dokładne dzięki wspólnemu kodowaniu, ale zbyt wolne do przeszukiwania całego korpusu; natomiast wyszukiwanie dwuetapowe łączy obie metody: szybki pierwszy etap zapewniający wysoki recall, a następnie dokładny drugi etap zapewniający wysoką precyzję. Pierwszy etap pobiera znacznie więcej kandydatów, niż jest potrzebnych, aby uniknąć pominięcia istotnych dokumentów. W następnej części zaimplementujemy ponowne szeregowanie za pomocą cross-encodera z użyciem Cohere i BGE.

Bezpłatny start

Ucz się Python dzięki korepetycjom AI — za darmo

Pisz i uruchamiaj kod w przeglądarce, otrzymuj natychmiastową pomoc od korepetytora AI dostępnego 24/7 i kontynuuj naukę w sieci lub w aplikacji.

Kursy
30
Lekcje
120

Często zadawane pytania

Czy lekcja „Dlaczego wyszukiwanie dwuetapowe działa” jest bezpłatna?

Tak — pełny tekst „Dlaczego wyszukiwanie dwuetapowe działa” jest dostępny za darmo tutaj w sieci. Aby ćwiczyć ją interaktywnie (wbudowany edytor kodu i tutor AI dostępny 24/7) i odblokować resztę kursu AI Engineering Academy, przejdź na CoddyKit PRO. Kurs AI Engineering Academy zawiera 4 lekcji w sumie.

Co nauczysz się w „Dlaczego wyszukiwanie dwuetapowe działa”?

Poznaj kompromis między pełnością a precyzją w wyszukiwaniu jednoetapowym oraz dowiedz się, jak szybka wyszukiwarka zgrubna, po której następuje wolniejszy, lecz dokładny re-ranker, łączy zalety obu… Ćwiczysz AI Engineering Academy z praktycznym kodem, który uruchamiasz bezpośrednio w przeglądarce, a tutor AI dostępny 24/7 odpowiada na Twoje pytania podczas pracy nad lekcją.

Czy potrzebuję doświadczenia, aby zacząć AI Engineering Academy?

Nie wymagamy żadnego doświadczenia. AI Engineering Academy w CoddyKit jest strukturyzowany dla początkujących i zaawansowanych użytkowników, więc możesz zacząć tutaj lub od początku i uczyć się w swoim tempie. To lekcja 1 z 4.

Ile czasu zajmuje lekcja „Dlaczego wyszukiwanie dwuetapowe działa”?

Większość lekcji CoddyKit trwa około 5–10 minut. Każda lekcja to mały, interaktywny krok, dzięki czemu robisz systematyczne postępy i zawsze wracasz dokładnie do tego samego miejsca — na webie i w aplikacji.

Czy mogę pisać i uruchamiać kod w tej lekcji AI Engineering Academy?

Tak. Każda lekcja AI Engineering Academy zawiera wbudowany edytor kodu, więc piszesz i uruchamiasz prawdziwy kod bezpośrednio w przeglądarce i od razu otrzymujesz sprzężenie zwrotne od AI — bez konfiguracji na komputerze.

Wszystkie lekcje w tym kursie

  1. Dlaczego wyszukiwanie dwuetapowe działa
  2. Re-ranking za pomocą cross-encodera z Cohere i BGE
  3. Kompresja kontekstowa i filtrowanie trafności
  4. Pomiar wpływu re-rankingu
← Powrót do AI Engineering Academy