Strona główna / Artykuły

Poza podobieństwem Top-K: BM25, hybrydowy wyszukiwacz, ponowne rankowanie i MMR w RAG

Poza podobieństwem top-K w RAG: BM25 dla dokładnych terminów, wyszukiwanie hybrydowe z RRF, ponowne sortowanie za pomocą cross-encodera oraz MMR dla różnorodności, połączone w jeden pipeline.

Wydawca: REACTAPP.TOP7 min czytania1698 słów

Źródła: Retrieval Strategies in RAG: Beyond Basic Similarity Search

Pierwsze systemy RAG, które budują większość programistów, opierają się na jednym schemacie: wgruje się dokumenty do bazy danych wektorowej, a gdy pojawia się pytanie, pobiera się najbliższe fragmenty i przekazuje je modelowi LLM. To działa, dopóki tak nie przestaje. Użytkownicy zaczynają skarżyć się, że pomijane są oczywiste odpowiedzi. Dokument wyraźnie zawiera potrzebne informacje, ale mechanizm wyszukiwania ich nie ujawnia, albo najbardziej odpowiedni wynik na pierwszy rzut oka przypomina pytanie, choć w rzeczywistości nie odpowiada potrzebom użytkownika. Prosta wyszukiwarka podobieństwa stanowi dobry punkt wyjścia, ale ma swoje ograniczenia, a gdy one stają się oczywiste, lepsze strategie wyszukiwania mogą je rozwiązać. Poniżej przedstawiono przyczyny niepowodzeń, co robią wyszukiwarki hybrydowe oraz metody ponownego rankingu, oraz jak połączyć te elementy w jedną całość.

Wyszukiwarka podobieństwa – krótko

W prostym ustawieniu pytanie użytkownika przekształca się w embedding, czyli listę liczb reprezentujących jego znaczenie. Każdy element w magazynie wektorowym również ma taki embedding. System znajduje elementy, których embeddingi są najbliższe embeddingowi pytania, mierzonego za pomocą podobieństwa kosinowego lub iloczynu skalarnego, i zwraca najlepsze K z nich.

# Basic similarity search — what most RAG systems do
results = vector_store.similarity_search(query, k=3)

Dobrze radzi sobie z ogólnym dopasowywaniem semantycznym. Pytanie o to, jak działa fotosynteza, spowoduje wyświetlenie fragmentów dotyczących przekształcania światła słonecznego w energię przez rośliny, nawet jeśli sformułowanie jest inne. Ma jednak swoje słabe strony.

Gdzie zawodzi

Pomijane są dokładne słowa kluczowe. Szukanie semantyczne bierze pod uwagę znaczenie, a nie dosłowne słowa, co zazwyczaj jest zaletą. Gdy jednak ktoś szuka konkretnego terminu, takiego jak nazwa produktu, akronim lub osoba, embedding może nie odpowiadać wystarczająco dokładnie. Załóżmy, że użytkownik pyta o architekturę modelu BERT. Istnieje fragment stwierdzający, że BERT wykorzystuje transformery dwukierunkowe, ale szerszy fragment na temat architektur modeli językowych może mieć wyższą pozycję, ponieważ ma szerszy zakres semantyczny.

Najlepsze wyniki mogą być również zbędne. Prawdziwe dokumenty powtarzają się w różnych fragmentach, więc trzy najlepsze wyniki mogą mówić w zasadzie to samo, marnując przestrzeń kontekstową na duplikatowe treści.

BM25 do wyszukiwania słów kluczowych

BM25 to klasyczny algorytm wyszukiwania informacji, podobny do tego używanego w tradycyjnych silnikach wyszukiwania. Porównuje dokumenty na podstawie dokładnych słów kluczowych, przypisując każdemu z nich wagę zależną od jego rzadkości w całym korpusie, i nie wykorzystuje żadnych embeddingów, jedynie surowy tekst. Wyszukiwanie frazy „FAISS index” zwraca fragmenty, które dosłownie zawierają oba słowa, przy czym to rzadsze ma większą wagę niż to częstsze.

from langchain_community.retrievers import BM25Retriever
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
# Load and split documents
loader = TextLoader("knowledge_base.txt")
documents = loader.load()
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
chunks = splitter.split_documents(documents)
# Create BM25 retriever
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 3
# Search
results = bm25_retriever.invoke("FAISS vector index")
for doc in results:
    print(doc.page_content[:200])

Wyróżnia się konkretnymi nazwami produktów, osobami lub terminami technicznymi, krótkimi i precyzyjnymi zapytaniami, a także tam, gdzie dokładne sformułowanie ma większe znaczenie niż sens. Ma trudności, gdy użytkownik formułuje pytania inaczej niż w dokumencie (użycie synonimów, parafraz), przy długich, rozmownych zapytaniach oraz gdy sens przeważa nad dosłownym brzmieniem słów.

Poszukiwanie hybrydowe

Poszukiwanie hybrydowe realizuje jednocześnie wyszukiwanie semantyczne i BM25. Wyszukiwanie semantyczne zajmuje się znaczeniem i synonimami, natomiast BM25 – dokładnymi słowami kluczowymi, dzięki czemu razem pokrywają przypadki, które pojedyncze metody mogłyby przeoczyć. Dwie listy wyników są łączone za pomocą techniki Reciprocal Rank Fusion (RRF), która łączy listy uporządkowane w jedną, bez konieczności, by ich oceny miały wspólną skalę.

from langchain_community.retrievers import BM25Retriever
from langchain_google_genai import GoogleGenerativeAIEmbeddings
from langchain_community.vectorstores import FAISS
from langchain.retrievers import EnsembleRetriever
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader
#Load and split
loader = TextLoader("knowledge_base.txt")
documents = loader.load()
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
chunks = splitter.split_documents(documents)
#Retriever 1 — Semantic (FAISS)
embeddings = GoogleGenerativeAIEmbeddings(
    model="models/embedding-001",
    google_api_key="YOUR_KEY"
)
vector_store = FAISS.from_documents(chunks, embeddings)
semantic_retriever = vector_store.as_retriever(search_kwargs={"k": 5})
#Retriever 2 — Keyword (BM25)
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 5
#Combine both — Hybrid Search
hybrid_retriever = EnsembleRetriever(
    retrievers=[semantic_retriever, bm25_retriever],
    weights=[0.6, 0.4]  # 60% semantic, 40% keyword
)
#Search
results = hybrid_retriever.invoke("What is FAISS and how does it store vectors?")
print(f"Retrieved {len(results)} chunks")
for i, doc in enumerate(results):
    print(f"\nChunk {i+1}: {doc.page_content[:150]}")

Parametr weights określa, w jakim stopniu każdy z mechanizmów wyszukiwania przyczynia się do wyników. Lekkie skupienie się na wyszukiwaniu semantycznym nadaje się w większości przypadków, choć optymalna równowaga zależy od Twoich danych. Wybierz opcję hybrydową, gdy dokumenty zawierają terminy techniczne, nazwy produktów lub nazwy własne, gdy użytkownicy łączą pytania konwersacyjne z pytaniami opartymi na słowach kluczowych, lub gdy chcesz mieć możliwość dopasowania słów kluczowych jako dodatkową ochronę poza zrozumieniem semantycznym.

Ponowna klasyfikacja

Ponowna klasyfikacja odbywa się w dwóch etapach. Najpierw przeprowadza się szerokie wyszukiwanie, pobierając 20 lub 30 najważniejszych fragmentów za pomocą mechanizmu wyszukiwania – co jest tanie i szybkie. Następnie te kandydatury przechodzą przez specjalny model do ponownej klasyfikacji, który ocenia każdą z nich pod kątem tego, jak dobrze odpowiada na pytanie, i zachowuje tylko 3 do 5 najlepszych wyników.

Uzasadnienie polega na tym, że podobieństwo wektorowe jest szybką aproksymacją istotności, natomiast ponowna klasyfikacja jest wolniejsza, ale znacznie dokładniejsza. Najpierw ograniczenie wyników na podstawie podobieństwa, a następnie ponowna klasyfikacja, zapewnia zarówno szybkość, jak i precyzję.

from langchain_community.cross_encoders import HuggingFaceCrossEncoder
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain.retrievers import ContextualCompressionRetriever
#Base retriever — fetch top 20 candidates
base_retriever = vector_store.as_retriever(search_kwargs={"k": 20})
#Reranker model
reranker_model = HuggingFaceCrossEncoder(
    model_name="BAAI/bge-reranker-base"
)
#Reranker compressor — keeps only top 3 after reranking
reranker = CrossEncoderReranker(model=reranker_model, top_n=3)
#Combine base retriever + reranker
reranking_retriever = ContextualCompressionRetriever(
    base_compressor=reranker,
    base_retriever=base_retriever
)
#Search — fetches 20, reranks, returns top 3
results = reranking_retriever.invoke("How does FAISS perform similarity search?")
print(f"Final chunks after reranking: {len(results)}")
for i, doc in enumerate(results):
    print(f"\nTop {i+1}: {doc.page_content[:200]}")

Kodownik krzyżowy do ponownego rankowania czyta pytanie wraz z określonym fragmentem tekstu jako parę i ocenia, na ile ten konkretny fragment jest istotny dla tego konkretnego pytania. Podobieństwo wektorowe analizuje każdy tekst oddzielnie i nigdy bezpośrednio nie bierze pod uwagę ich wzajemnego związku. Należy zastosować metodę ponownego rankowania, gdy ważna jest precyzja, a błędne odpowiedzi są kosztowne, gdy odpowiada się na pytania na podstawie konkretnych dokumentów, lub w każdym przypadku, gdy jakość odpowiedzi bezpośrednio wpływa na użytkowników.

Maksymalna marginalna istotność

MMR eliminuje redundancję. Zamiast po prostu wybierać najbliższe fragmenty, wybiera te, które są zarówno istotne, jak i zróżnicowane, dzięki czemu ta sama informacja nie jest podawana kilka razy.

# Standard similarity search — might return redundant chunks
standard_results = vector_store.similarity_search(query, k=3)
# MMR search — returns relevant AND diverse chunks
mmr_results = vector_store.max_marginal_relevance_search(
    query,
    k=3,           # final number of chunks to return
    fetch_k=20,    # candidates to consider before selecting diverse ones
    lambda_mult=0.5  # 0 = max diversity, 1 = max relevance
)

Parametr lambda_mult określa kompromis pomiędzy powiązaniem a różnorodnością. Wartość 1,0 oznacza czyste powiązanie, identyczne z zwykłą wyszukiwarką podobieństwa. Wartość 0,0 oznacza czystą różnorodność, przy której fragmenty są jak najbardziej od siebie różne. Wartość 0,5 równoważy te dwa aspekty i zazwyczaj stanowi dobry początkowy ustawienie. MMR jest korzystny, gdy dokumenty są powtarzalne, gdy chce się objąć kilka aspektów danego tematu lub gdy okno kontekstowe jest zbyt małe na umieszczenie duplikatowych fragmentów.

Pełny proces

W połączeniu hybrydowa wyszukiwarka rozszerza zakres poszukiwań, ponowne sortowanie wybiera najbardziej istotne fragmenty, a kompletny łańcuch RAG generuje odpowiedź.

from langchain_google_genai import ChatGoogleGenerativeAI, GoogleGenerativeAIEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_community.retrievers import BM25Retriever
from langchain_community.cross_encoders import HuggingFaceCrossEncoder
from langchain.retrievers import EnsembleRetriever, ContextualCompressionRetriever
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader
# Setup
llm = ChatGoogleGenerativeAI(model="gemini-1.5-flash", google_api_key="YOUR_KEY")
embeddings = GoogleGenerativeAIEmbeddings(model="models/embedding-001", google_api_key="YOUR_KEY")
# Load and split documents
loader = TextLoader("your_knowledge_base.txt")
documents = loader.load()
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
chunks = splitter.split_documents(documents)
# Build retrievers
vector_store = FAISS.from_documents(chunks, embeddings)
semantic_retriever = vector_store.as_retriever(search_kwargs={"k": 10})
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 10
# Hybrid retriever
hybrid_retriever = EnsembleRetriever(
    retrievers=[semantic_retriever, bm25_retriever],
    weights=[0.6, 0.4]
)
# Add reranking on top
reranker = CrossEncoderReranker(
    model=HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-base"),
    top_n=3
)
final_retriever = ContextualCompressionRetriever(
    base_compressor=reranker,
    base_retriever=hybrid_retriever
)
# RAG prompt
prompt = ChatPromptTemplate.from_messages([
    ("system", """Answer the question using only the context below.
    If the answer is not in the context, say "I don't have that information."

    Context: {context}"""),
    ("human", "{question}")
])
def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)
# Complete chain
rag_chain = (
    {"context": final_retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)
# Use it
answer = rag_chain.invoke("Your question here")
print(answer)

Jakość wydobywania informacji okazuje się wyraźnie lepsza niż przy użyciu samego podstawowego wyszukiwania podobieństw.

Wybór strategii

Krótki przewodnik decyzyjny łączy sytuacje z odpowiednimi strategiami:

Just starting out / simple use case?
    → Basic similarity search is fine
Documents have specific technical terms or product names?
    → Add BM25 → use Hybrid Search
Answer quality is critical, wrong answers are costly?
    → Add Reranking on top of any retriever
Documents have lots of repeated content?
    → Use MMR instead of standard similarity search
Production system with high quality requirements?
    → Hybrid Search + Reranking together

Nie projektuj zbyt skomplikowanie od samego początku. Zacznij od podstawowego wyszukiwania podobieństw, dodaj wyszukiwanie hybrydowe, gdy zauważysz pominięcie kluczowych słów, a funkcję ponownego rankowania – gdy konieczna będzie lepsza jakość odpowiedzi. Każde dodatkowe rozwiązanie wiąże się z wzrostem złożoności, opóźnień i kosztów obliczeniowych. W przypadku systemów RAG produkcyjnych standardem jest wykorzystanie wyszukiwania hybrydowego w połączeniu z ponownym rankowaniem, a złożoność powinna pojawić się tylko wtedy, gdy wymaga tego konkretny problem.

Jedna praktyczna uwaga dotycząca ponownego rankowania: wymaga ono wykonyania polecenia pip install sentence-transformers, a model BAAI/bge-reranker-base jest automatycznie pobierany przy pierwszym użyciu – jego rozmiar wynosi około 1 GB. W celach produkcyjnych API do ponownego rankowania dostępne u Cohere lub Jina AI pozwalają uniknąć konieczności uruchamiania modelu lokalnie.

Literatura pokrewna

  • Poza wyszukiwaniem podobieństwa: filtrowanie metadanych dla RAG z użyciem S3 Vectors — Skąd pochodzą wartości filtrów, kiedy warto użyć klasyfikatora oraz jak ustawić metadane nadające się do filtrowania i te, które nie nadają się do filtrowania w Amazon S3 Vectors.