0Pricing
AI Engineering Academy · Lekcja

Reciprocal Rank Fusion do łączenia wyników

Zaimplementuj Reciprocal Rank Fusion, aby łączyć posortowane listy wyników z wyszukiwarek gęstych i rzadkich bez konieczności normalizowania nieporównywalnych wyników podobieństwa.

Reciprocal Rank Fusion do łączenia wyników to bezpłatna lekcja AI Engineering Academy na CoddyKit. To lekcja 3 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.

Problem niezgodności wyników

Retrievery gęste generują wyniki podobieństwa cosinusowego od -1 do 1, natomiast BM25 generuje nieograniczone dodatnie wyniki zależne od rozmiaru korpusu i częstości terminów. Nie można bezpośrednio dodawać tych liczb — wynik BM25 równy 3.7 i podobieństwo cosinusowe równe 0.85 oznaczają zupełnie różne rzeczy. Prosta normalizacja wyników (dzielenie przez maksymalny wynik) jest zawodna, ponieważ dokumenty odstające zniekształcają skalę. Potrzebujemy metody niezależnej od bezwzględnych wartości wyników.

Podstawowa idea RRF

Reciprocal Rank Fusion (RRF) omija problem niezgodności wyników, zamieniając wyniki każdego retrievera na pozycje w rankingu i łącząc te rangi zamiast surowych wyników. Dokument sklasyfikowany na pierwszym miejscu otrzymuje wysoki wkład RRF, dokument na dziesiątym miejscu — znacznie niższy, a wynik końcowy jest sumą wkładów RRF ze wszystkich retrieverów. Wzór wygląda następująco: RRF(d) = sum(1 / (k + rank_i(d))), gdzie k jest stałą wygładzającą (zwykle 60).

# RRF formula
# For each retriever i, document d receives:
#   contribution = 1 / (k + rank_i(d))
# Final RRF score = sum of contributions from all retrievers
# k = 60 is the standard constant from the original 2009 paper

# Example:
# Document A: rank 1 in BM25, rank 4 in dense
#   RRF(A) = 1/(60+1) + 1/(60+4) = 0.01639 + 0.01563 = 0.03202
# Document B: rank 2 in BM25, rank 2 in dense
#   RRF(B) = 1/(60+2) + 1/(60+2) = 0.01613 + 0.01613 = 0.03226
# Document B scores slightly higher due to consistent top-2 ranking

Implementacja RRF od podstaw

Implementacja jest zaskakująco prosta. Dla każdego retrievera należy przejść po jego uporządkowanej liście wyników i akumulować wyniki RRF w słowniku, którego kluczami są identyfikatory dokumentów. Dokumenty pojawiające się w wielu retrieverach kumulują wkłady z każdego z nich. Na koniec należy posortować dokumenty malejąco według łącznego wyniku RRF, aby uzyskać połączony ranking.

from collections import defaultdict

def reciprocal_rank_fusion(
    result_lists: list[list[str]],
    k: int = 60,
) -> list[tuple[str, float]]:
    '''Merge multiple ranked result lists using RRF.
    result_lists: each inner list is a ranked list of document IDs
    Returns: sorted list of (doc_id, rrf_score) tuples
    '''
    rrf_scores = defaultdict(float)

    for ranked_list in result_lists:
        for rank, doc_id in enumerate(ranked_list, start=1):
            rrf_scores[doc_id] += 1.0 / (k + rank)

    sorted_results = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
    return sorted_results

Hybrydowe wyszukiwanie end-to-end z RRF

W praktyce uruchamia się oba retrievery, zbiera uporządkowane listy identyfikatorów dokumentów, przekazuje je do RRF, a następnie wyszukuje identyfikatory dokumentów z najwyższymi pozycjami w magazynie dokumentów. Warto zauważyć, że normalizacja wyników nie jest wymagana — znaczenie ma wyłącznie kolejność wyników każdego retrievera. Dzięki temu RRF jest wyjątkowo odporny na zmiany rozkładów i rozmiaru korpusu.

def hybrid_search_rrf(
    query: str,
    bm25_index,
    dense_retriever,
    documents: dict,  # id -> text
    top_k: int = 5,
) -> list[dict]:
    # Get ranked lists from each retriever
    bm25_ids = bm25_index.search(query, top_k=20)  # over-retrieve then fuse
    dense_ids = dense_retriever.search(query, top_k=20)

    # Run RRF
    fused = reciprocal_rank_fusion([bm25_ids, dense_ids])

    # Return top-K with scores
    results = []
    for doc_id, score in fused[:top_k]:
        results.append({
            'id': doc_id,
            'text': documents[doc_id],
            'rrf_score': round(score, 6),
        })
    return results

Dlaczego przed fuzją należy pobierać więcej wyników

Warto zauważyć wzorzec polegający na pobraniu 20 kandydatów z każdego retrievera, a następnie wybraniu 5 najlepszych po fuzji. Ta strategia nadmiarowego pobierania jest ważna, ponieważ dokument zajmujący 10. miejsce w BM25, ale 1. miejsce w retrieverze gęstym, powinien awansować na połączonej liście. Jeśli z każdego retrievera pobierze się tylko 5 wyników, takie dokumenty zostaną pominięte. Powszechną praktyką jest pobieranie top_k * 4 wyników z każdego retrievera przed fuzją, a następnie zwracanie top_k wyników końcowych.

Stała wygładzająca K

Stała k we wzorze RRF określa, jak duża waga jest przypisywana dokumentom z najwyższymi i niższymi rangami. Mała wartość k wzmacnia różnicę między rangami (ranga 1 jest znacznie lepsza niż ranga 2), natomiast duża wartość k spłaszcza rozkład (wszystkie rangi mają w przybliżeniu takie samo znaczenie). W oryginalnej publikacji użyto k=60, a badania empiryczne pokazują, że wartości od 30 do 100 zapewniają podobną skuteczność. k=60 to solidna wartość domyślna.

# Visualize effect of k on contribution weights
for k in [10, 60, 100]:
    weights = [1.0 / (k + rank) for rank in range(1, 11)]
    print(f'k={k}: rank1={weights[0]:.4f}, rank5={weights[4]:.4f}, rank10={weights[9]:.4f}')

# k=10:  rank1=0.0909, rank5=0.0667, rank10=0.0500  (steep)
# k=60:  rank1=0.0164, rank5=0.0154, rank10=0.0143  (flat)
# k=100: rank1=0.0099, rank5=0.0095, rank10=0.0091  (very flat)

Obsługa dokumentów nieobecnych w jednym z retrieverów

RRF z łatwością obsługuje brakujące dokumenty: dokument, który pojawia się tylko w jednym retrieverze, nadal otrzymuje wkład RRF tego retrievera. Po prostu nie otrzymuje drugiego wkładu. Oznacza to, że dokumenty, co do których zgadzają się oba retrievery, naturalnie uzyskają wyższe wyniki niż dokumenty znalezione tylko przez jeden z nich. Ten sygnał zgodności jest dokładnie tym, czego potrzebuje system hybrydowy.

# Example showing consensus effect
example_docs = {
    'doc_A': [1, 3],   # ranks in [bm25, dense]
    'doc_B': [2, 2],   # both retrievers like it
    'doc_C': [3, None],  # only in BM25
    'doc_D': [None, 1],  # only in dense
}

k = 60
for doc, ranks in example_docs.items():
    score = sum(1/(k + r) for r in ranks if r is not None)
    print(f'{doc}: RRF = {score:.5f}')
# doc_B ranks highest because both retrievers agree
# doc_D (rank 1 in dense only) may outscore doc_A (1,3) despite lower consensus

RRF z więcej niż dwoma retrieverami

RRF skaluje się naturalnie do trzech lub większej liczby retrieverów. Można połączyć BM25, retriever gęsty oparty na embeddingach oraz wytrenowany retriever rzadki (taki jak SPLADE), który jawnie optymalizuje dopasowywanie słów kluczowych w wyuczonej przestrzeni rzadkiej. Każdy dodatkowy retriever dodaje kolejny wkład RRF dla dokumentu, a dokumenty zajmujące wysokie pozycje we wszystkich retrieverach wygrywają z wyraźną przewagą.

# Three-way hybrid: BM25 + dense + SPLADE
bm25_ids = ['doc_B', 'doc_A', 'doc_C', 'doc_D']
dense_ids = ['doc_D', 'doc_A', 'doc_B', 'doc_E']
splade_ids = ['doc_B', 'doc_D', 'doc_A', 'doc_F']

fused = reciprocal_rank_fusion([bm25_ids, dense_ids, splade_ids])
print('Fused ranking:')
for doc_id, score in fused:
    print(f'  {doc_id}: {score:.5f}')
# doc_A and doc_B likely dominate because they appear in all three lists

Ważone RRF dla asymetrycznych retrieverów

Standardowe RRF traktuje wszystkie retrievery jednakowo, ale w praktyce jeden retriever może być bardziej niezawodny od drugiego w konkretnej dziedzinie. Ważone RRF mnoży wkład każdego retrievera przez wagę przed zsumowaniem. Waga 1.5 dla retrievera gęstego i 0.5 dla BM25 zwiększa znaczenie dopasowania semantycznego, natomiast równe wagi 1.0 traktują je symetrycznie. Wagi należy dostroić na zbiorze walidacyjnym.

def weighted_rrf(
    result_lists: list[list[str]],
    weights: list[float],
    k: int = 60,
) -> list[tuple[str, float]]:
    from collections import defaultdict
    scores = defaultdict(float)
    for ranked_list, weight in zip(result_lists, weights):
        for rank, doc_id in enumerate(ranked_list, start=1):
            scores[doc_id] += weight * (1.0 / (k + rank))
    return sorted(scores.items(), key=lambda x: x[1], reverse=True)

# Emphasize dense retrieval (0.7) over BM25 (0.3)
fused = weighted_rrf([bm25_ids, dense_ids], weights=[0.3, 0.7])

RRF a normalizacja wyników: dlaczego RRF wygrywa

Alternatywy dla RRF obejmują normalizację min–max (skalowanie wyników każdego retrievera do zakresu [0,1]) oraz normalizację z-score. Obie metody są wrażliwe na dokumenty odstające, które zawyżają maksymalny wynik i sprowadzają pozostałe wyniki niemal do zera. RRF jest odporny na wartości odstające, ponieważ wykorzystuje wyłącznie pozycje w rankingu, a nie surowe wyniki. Wyniki badań empirycznych konsekwentnie pokazują, że RRF przewyższa metody łączenia oparte na normalizacji.

Implementacja RRF za pomocą LangChain

Klasa EnsembleRetriever biblioteki LangChain implementuje łączenie RRF wewnętrznie. Przyjmuje listę retrieverów i opcjonalne wagi, uruchamia je równolegle, a następnie zwraca wyniki połączone za pomocą RRF. Dzięki temu mogą Państwo integrować wyszukiwanie hybrydowe z łańcuchami LCEL przy minimalnej ilości kodu pomocniczego oraz wymieniać poszczególne retrievery bez zmiany logiki łączenia.

from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings

# Dense retriever
vectorstore = FAISS.from_documents(langchain_docs, OpenAIEmbeddings())
vector_retriever = vectorstore.as_retriever(search_kwargs={'k': 20})

# Sparse retriever
bm25_retriever = BM25Retriever.from_documents(langchain_docs, k=20)

# Hybrid ensemble using RRF internally
ensemble = EnsembleRetriever(
    retrievers=[bm25_retriever, vector_retriever],
    weights=[0.4, 0.6],
)
results = ensemble.invoke('hybrid search reciprocal rank fusion')

Szybkie sprawdzenie

Proszę sprawdzić zrozumienie reciprocal rank fusion na podstawie tej lekcji.

Podsumowanie lekcji

W tej lekcji poznali Państwo następujące zagadnienia: RRF łączy uporządkowane listy bez normalizowania nieporównywalnych wyników, wzór 1/(k + rank) nadaje każdemu dokumentowi wkład z każdego retrievera, a dokumenty zajmujące wysokie pozycje w wielu retrieverach otrzymują najwyższe połączone wyniki. Stała wygładzająca k=60 jest solidną wartością domyślną. Klasa EnsembleRetriever biblioteki LangChain implementuje RRF natywnie. W następnej części skonfigurujemy wyszukiwanie hybrydowe bezpośrednio w Pinecone i pgvector.

Często zadawane pytania

Czy lekcja „Reciprocal Rank Fusion do łączenia wyników” jest bezpłatna?

Tak — pełny tekst „Reciprocal Rank Fusion do łączenia wyników” 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 „Reciprocal Rank Fusion do łączenia wyników”?

Zaimplementuj Reciprocal Rank Fusion, aby łączyć posortowane listy wyników z wyszukiwarek gęstych i rzadkich bez konieczności normalizowania nieporównywalnych wyników podobieństwa. Ć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 3 z 4.

Ile czasu zajmuje lekcja „Reciprocal Rank Fusion do łączenia wyników”?

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. Wyszukiwanie gęste a rzadkie: kompromisy
  2. Implementacja wyszukiwania słów kluczowych BM25
  3. Reciprocal Rank Fusion do łączenia wyników
  4. Wyszukiwanie hybrydowe w Pinecone i pgvector
← Powrót do AI Engineering Academy