За пределами схемы Top-K: BM25, гибридный поиск, переранжирование и MMR в RAG
Что-то большее, чем сходство top-K в RAG: BM25 для точных терминов, гибридный поиск с RRF, переранжирование с использованием кросс-кодера и MMR для разнообразия — всё это объединено в единую систему.
Источники: Retrieval Strategies in RAG: Beyond Basic Similarity Search
Первые системы RAG, которые создают большинство разработчиков, следуют одному принципу: встраивают документы, сохраняют их в векторной базе данных, и когда поступает вопрос, берут наиболее близкие фрагменты и передают их ЯИ. Этот подход работает, пока что-то идет не так. Пользователи начинают жаловаться, что упускаются очевидные ответы. В документе явно присутствует нужная информация, но система поиска её не находит, или лучший результат на первом месте формально соответствует вопросу, но на самом деле не является тем, что нужно пользователю. Простой поиск по сходству — хорошая отправная точка, но у него есть реальные ограничения; как только они становятся очевидными, более совершенные стратегии поиска помогают их преодолеть. Здесь рассматриваются причины неудачи, что делают гибридные методы поиска и переранжирования, а также как объединить все элементы в единую систему.
Поиск по сходству, кратко
В базовой конфигурации вопрос пользователя преобразуется в эмбеддинг — список чисел, представляющих его смысл. У каждого фрагмента в хранилище векторов тоже есть свой эмбеддинг. Система находит фрагменты, эмбеддинги которых наиболее близки к эмбеддингу вопроса (определяется по косинусовой схожести или скалярному произведению), и возвращает 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 — объяснение косинусной похожести, алгоритма BM25, гибридного метода RRF, механизма переранжирования с использованием кросс-энкодеров и алгоритма MMR с примерами кода, анализом компромиссов и подходом, основанным на запросе, для выбора подходящей техники поиска в RAG.
- Поиск и оценка в RAG: почему поиск требует отдельного этапа переранжирования — методы определения похожести векторов помогают находить кандидатов, но не могут оценить их релевантность. Рассматривается двухэтапная схема поиска и оценки с использованием методов RRF, кросс-энкодеров, моделей ColBERT и инструментов переранжирования на основе больших языковых моделей.