Головна / Статті

Понад просту схожість Top-K: BM25, гібридний пошук, переранжування та MMR у RAG

Поза межами подібності top-K у RAG: BM25 для точних термінів, гібридний пошук із RRF, переранжування за допомогою крос-енкодера та MMR для різноманітності — усе це об’єднано в єдиний потік обробки.

Видавець: REACTAPP.TOP7 хв читання1698 слів

Джерела: Retrieval Strategies in RAG: Beyond Basic Similarity Search

Перша система RAG, яку створюють більшість розробників, дотримується одного підходу: вбудовує документи, зберігає їх у векторній базі даних, а коли надходить запит, бере найближчі фрагменти та передає їх моделі LLM. Цей підхід працює, поки щось не йде не так. Користувачі починають скаржитися, що пропускають очевидні відповіді. У документі чітко міститься інформація, проте система пошуку її не знаходить, або найкращий результат на перший погляд схожий на запит, але насправді не є тим, що потрібно користувачеві. Простий пошук за схожістю — це гарна відправна точка з реальними обмеженнями, і як тільки вони стають очевидними, кращі стратегії пошуку допомагають їх усунути. Ось причини невдачі, що роблять гібридні методи пошуку та переранжування, та як об’єднати всі елементи в єдину систему.

Пошук за схожістю, коротко

У базовій конфігурації запит користувача перетворюється на ембеддинг — список чисел, які представляють його значення. Кожен фрагмент у сховищі векторів також має свій ембеддинг. Система знаходить фрагменти, ембеддинги яких знаходяться найближче до ембеддингу запиту (вимірюється за допомогою косинусової схожості або скалярного добутку), та повертає K найкращих результатів.

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

Добре підходить для загального семантичного пошуку. Запит про те, як відбувається фотосинтез, відобразить фрагменти про те, як рослини перетворюють сонячне світло на енергію, навіть якщо формулювання відрізняється. Але у нього є слабкі місця.

Де він не справляється

Не враховуються точні ключові слова. Семантичний пошук фокусується на значенні, а не на буквальних словах, що зазвичай є перевагою. Однак коли хтось шукає конкретний термін, наприклад назву продукту, абревіатуру чи ім’я особи, ембеддинг може не збігатися достатньо точно. Наприклад, якщо користувач запитує про архітектуру моделі BERT, існує фрагмент про те, що BERT використовує двонапрямкові трансформери, проте більш загальний фрагмент про архітектури мовних моделей може мати вищий ранг, оскільки має ширшу семантичну охопленість.

Високий бал не означає найкращої відповіді. Показник схожості вимірює ступінь спорідненості двох текстів, а не їхню корисність для відповіді на запитання один про одного. Фрагмент, у якому сказано, що системи RAG зручні для відповідей на запитання, дуже схожий на запитання „Що таке RAG?“, тоді як фрагмент із справжнім визначенням є кориснішим.

Топові результати також можуть бути зайвими. Справжні документи повторюються у різних фрагментах, тому три найкращі результати можуть говорити майже про одне й те саме, витрачаючи ресурси вікна контексту на дубльований матеріал.

BM25 для пошуку ключових слів

BM25 — це класичний алгоритм пошуку інформації, який використовується у традиційних пошукових системах. Він зіставляє документи за точними ключовими словами, присвоюючи кожному з них вагу залежно від того, наскільки воно рідкісне в усьому корпусі, і не використовує жодних ембеддингів, лише сирий текст. Пошук за фразою „FAISS index“ повертає фрагменти, які буквально містять обидва слова, причому рідкісне слово має більшу вагу, ніж поширене.

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])

Він ефективний при використанні конкретних назв продуктів, імен людей чи технічних термінів, коротких точних запитів, а також там, де точність формулювання важливіша за сенс. Він має труднощі, коли користувач формулює запити інакше, ніж у документі (за допомогою синонімів чи парафраз), при довгих розмовних запитах чи коли сенс має більше значення, ніж буквальні слова.

Гібридний пошук

Гібридний пошук одночасно використовує семантичний та алгоритм BM25. Семантичний пошук обробляє сенс та синоніми, а BM25 — точні ключові слова, тож разом вони покривають ситуації, які кожен з них міг би пропустити. Дві списки результатів об’єднуються за допомогою техніки Reciprocal Rank Fusion (RRF), яка поєднує списки з рангуванням в один, не вимагаючи, щоб їхні оцінки були на одній шкалі.

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]}")

Параметр weights визначає, наскільки кожен з засобів пошуку впливає на результати. Незначне надання переваги семантичному пошуку підходить у більшості випадків, проте правильний баланс залежить від ваших даних. Виберіть гібридний підхід, якщо документи містять технічні терміни, назви продуктів чи власні імена, якщо користувачі поєднують конверсаційні запитання з запитаннями у форматі ключових слів, або якщо ви хочете використовувати збіг ключових слів як запасний механізм після семантичного розуміння.

Переоцінка рейтингів

Процес переоцінки рейтингів відбувається у два етапи. Спочатку здійснюється широкий пошук, під час якого з використанням засобу пошуку отримуються 20–30 найкращих результатів — це дешево та швидко. Потім ці кандидати проходять через спеціалізовану модель переоцінки, яка оцінює кожен з них за те, наскільки добре він відповідає на запитання, і залишається лише 3–5 найкращих результатів.

Причина полягає у тому, що векторна схожість є швидким приблизним показником релевантності, тоді як процес переранжування є повільнішим, але значно точнішим. Спочатку звужувати результати за допомогою схожості, а потім переранжувати, дозволяє досягти як швидкості, так і точності.

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]}")

Крос-енкодер-реранкер читає запит та відповідний фрагмент разом як пару та визначає, наскільки цей конкретний фрагмент є релевантним для цього конкретного запиту. Метод векторної схожості аналізує кожен текст окремо та ніколи не враховує їхній прямий зв’язок. Використовуйте реранкінг тоді, коли важлива точність та помилкові відповіді мають серйозні наслідки, коли ви виконуєте завдання з відповідей на запитання на основі конкретних документів, або коли якість відповіді безпосередньо впливає на користувачів.

Максимальна маржинальна релевантність

Метод MMR бореться з надмірністю інформації. Замість того, щоб просто брати найбільш близькі за змістом фрагменти, він обирає такі, які є водночас релевантними та різноманітними, щоб одна й та сама інформація не повторювалася кілька разів.

# 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
)

Параметр lambda_mult визначає баланс між релевантністю та різноманітністю. Значення 1.0 означає сутою релевантність, ідентичну звичайному пошуку за схожістю. Значення 0.0 означає сутою різноманітність, коли фрагменти мають бути якомога більш відмінними один від одного. Значення 0.5 збалансовує ці два підходи та зазвичай є хорошим початковим налаштуванням. Метод MMR є ефективним, коли документи є повторюваними, коли потрібно охопити кілька аспектів теми, або коли вікно контексту занадто обмежене для дублікатних фрагментів.

Повний процес

У поєднанні гібридний метод пошуку охоплює широкий діапазон можливостей, переранжування вибирає найбільш релевантні фрагменти, а повна ланцюгова структура RAG генерує відповідь.

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)

Якість отримання інформації виявляється значно кращою, ніж при використанні лише базового пошуку за схожістю.

Вибір стратегії

Короткий посібник з прийняття рішень прив’язує ситуації до відповідних стратегій:

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

Не намагайтеся створити надмірно складну систему вже з першого дня. Почніть з базового пошуку за схожістю, додайте гібридний пошук, коли помітите пропуски ключових слів, та додайте функцію переранжування, коли необхідно підвищити якість відповідей. Кожне доповнення призводить до збільшення складності, затримки та витрат на обробку даних. Для продакшн-систем типу RAG стандартом є використання гібридного пошуку разом із функцією переранжування, причому складність має з’являтися лише тоді, коли це необхідно через конкретну проблему.

Одна практична порада щодо переранжування: для цього потрібно виконати команду pip install sentence-transformers, а модель BAAI/bge-reranker-base автоматично завантажується під час першого використання об’ємом близько 1 ГБ. Для продакшн-систем API для переранжування від Cohere чи Jina AI дозволяють уникнути необхідності локальної обробки моделі.

Пов’язана література

  • Поза межами пошуку за схожістю: фільтрація метаданих для RAG за допомогою S3 Vectors — звідки беруться значення фільтрів, коли варто використовувати класифікатор, та як встановлювати фільтрувані та нефільтрувані метадані в Amazon S3 Vectors.