Почему двухэтапный поиск работает
Разберитесь в компромиссе между полнотой и точностью при одноэтапном поиске и узнайте, как быстрый грубый поиск с последующим медленным, но точным переранжированием объединяет преимущества обоих подходов.
«Почему двухэтапный поиск работает» — бесплатный урок AI Engineering Academy на CoddyKit. Это урок 1 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения AI Engineering Academy, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс AI Engineering Academy содержит 4 уроков всего.
Компромисс между полнотой и точностью поиска
Каждая система поиска сталкивается с фундаментальным компромиссом: полнота показывает, сколько релевантных документов Вы нашли (не пропустили ли Вы какие-либо?), а точность показывает, насколько точны результаты в начале списка (сколько найденных документов действительно релевантны?). Одновременная максимизация обоих показателей требует больших вычислительных затрат. Быстрые поисковые модули жертвуют точностью ради полноты, а точные ранжировщики — скоростью ради качества.
Би-кодировщик и перекрёстный кодировщик: ключевое различие
Два типа моделей, лежащих в основе двухэтапного поиска, различаются тем, как они воспринимают запрос и документ. Би-кодировщик независимо кодирует запрос и каждый документ, а затем измеряет сходство между их векторами — это быстро, но независимое кодирование ограничивает качество. Перекрёстный кодировщик видит запрос и документ, объединённые в один вход, что позволяет глубоко учитывать их взаимодействие; однако его сложность по размеру набора кандидатов составляет O(n).
# 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Этап 1: быстрый предварительный поиск
На первом этапе работает быстрый поисковый модуль — обычно би-кодировщик с индексом приблизительного поиска ближайших соседей или индексом BM25, — который с высокой полнотой и умеренной точностью извлекает большой набор кандидатов (50–200 документов). Цель этого этапа — не идеальная точность, а отсутствие пропущенных релевантных документов. Мы забрасываем широкую сеть и принимаем некоторые ложноположительные результаты, зная, что второй этап их отсеет.
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')Этап 2: точное повторное ранжирование перекрёстным кодировщиком
На втором этапе берётся набор кандидатов с первого этапа, и для каждой пары (запрос, документ) заново вычисляется оценка с помощью перекрёстного кодировщика, который читает их вместе. Поскольку он обрабатывает только 50–200 кандидатов, а не весь корпус, можно позволить себе дорогостоящее совместное кодирование. Глубокое внимание перекрёстного кодировщика к объединённому входу позволяет значительно точнее оценивать истинную релевантность, чем би-кодировщику.
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')Почему это сочетание работает
Двухэтапная схема использует важную асимметрию: быстрый поиск ANN на первом этапе масштабируется до миллионов документов и выполняется за миллисекунды, тогда как точный перекрёстный кодировщик на втором этапе работает только с небольшим набором кандидатов. Вы получаете масштабируемость приблизительного поиска и точность совместной оценки. Вся цепочка обработки одновременно быстрая и очень точная — по отдельности ни один из этапов не даёт такого результата.
Профиль задержки двухэтапного поиска
В типичной двухэтапной цепочке обработки: этап 1 (векторный поиск ANN по 1 млн документов) занимает 5–20 мс; этап 2 (перекрёстный кодировщик для 100 кандидатов) занимает 100–500 мс в зависимости от длины документов и оборудования. Общий бюджет задержки составляет 150–600 мс, что приемлемо для большинства приложений. Ускорение с помощью GPU на втором этапе может сократить повторное ранжирование коротких документов до менее чем 30 мс, благодаря чему такая цепочка по задержке может конкурировать с одноэтапным поиском в приложениях, чувствительных к времени отклика.
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Выбор подходящего размера набора кандидатов
Размер набора кандидатов на первом этапе — критически важный гиперпараметр. Если он слишком мал (например, 10), релевантные документы могут быть пропущены ещё до начала повторного ранжирования. Если он слишком велик (например, 500), задержка второго этапа резко возрастает. Кривая полноты при N — количество найденных релевантных документов при разных значениях N — помогает сделать выбор. Обычно оптимальный диапазон составляет от 50 до 150 кандидатов: полнота уже близка к насыщению, а задержка остаётся приемлемой.
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}')Гибридный первый этап и перекрёстный кодировщик на втором этапе
Наиболее мощная двухэтапная конфигурация сочетает гибридный поисковый модуль (плотный поиск + BM25) на первом этапе с перекрёстным кодировщиком на втором. Гибридный поиск максимизирует полноту первого этапа, объединяя семантическое сопоставление и поиск по ключевым словам, а затем перекрёстный кодировщик точно выбирает наиболее релевантные документы из объединённого набора кандидатов. Такая конфигурация стабильно достигает передового качества поиска в тестах.
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,
)Коммерческие API для повторного ранжирования
Если Вам нужна точность перекрёстного кодировщика без самостоятельного управления моделью, облачные API повторного ранжирования предлагают как Cohere Rerank, так и Jina AI Reranker. Вы отправляете запрос и список текстов документов, а в ответ получаете оценки релевантности. Эти API используют большие модели перекрёстного кодирования (часто более 500 млн параметров), которые превосходят небольшие перекрёстные кодировщики, размещённые на собственных серверах, но требуют дополнительной задержки API (50–300 мс) и оплаты за каждый документ, обработанный повторным ранжированием.
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]}')Когда двухэтапный поиск избыточен
По сравнению с одноэтапным поиском двухэтапный поиск добавляет сложность и задержку. Он нужен не всегда. Для небольших корпусов размером менее 10 000 документов одноэтапный перекрёстный кодировщик, работающий со всем набором, может быть достаточно быстрым. Если критична задержка менее 100 мс, а прирост точности невелик, предпочтительнее может оказаться одноэтапный поиск по плотным векторам. Используйте двухэтапный поиск при большом корпусе, высоких требованиях к точности и допустимой задержке поиска 200–500 мс.
Трёхэтапный поиск для экстремального масштаба
Для корпусов, содержащих десятки миллионов документов, иногда используется трёхэтапный конвейер: на первом этапе ANN извлекает 10 000 кандидатов, на втором этапе быстрый небольшой перехресный кодировщик повторно ранжирует их до 100, а на третьем этапе большой мощный перехресный кодировщик повторно ранжирует их до 5. На каждом этапе к меньшему набору применяется более дорогая и точная модель. Эта архитектура используется в крупномасштабных поисковых системах и системах вопросов и ответов по документам.
Быстрая проверка
Проверьте, насколько Вы поняли, почему работает двухэтапный поиск, из этого урока.
Итоги урока
В этом уроке Вы узнали: би-кодировщики работают быстро, но ограничены независимым кодированием запроса и документа; перекрёстные кодировщики обеспечивают точность благодаря совместному кодированию, но работают слишком медленно для поиска по всему корпусу; а двухэтапный поиск объединяет оба подхода: быстрый первый этап с высоким полнотой, за которым следует точный второй этап с высокой точностью. На первом этапе извлекается гораздо больше кандидатов, чем требуется, чтобы не пропустить релевантные документы. Далее мы реализуем повторное ранжирование с помощью Cohere и BGE.
Часто задаваемые вопросы
Урок «Почему двухэтапный поиск работает» бесплатный?
Да — полный текст урока «Почему двухэтапный поиск работает» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс AI Engineering Academy, подпишись на CoddyKit PRO. Курс AI Engineering Academy содержит 4 уроков всего.
Чему я научусь в уроке «Почему двухэтапный поиск работает»?
Разберитесь в компромиссе между полнотой и точностью при одноэтапном поиске и узнайте, как быстрый грубый поиск с последующим медленным, но точным переранжированием объединяет преимущества обоих подх… Ты практикуешь AI Engineering Academy с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.
Нужен ли мне опыт, чтобы начать AI Engineering Academy?
Предыдущий опыт не требуется. AI Engineering Academy на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 1 из 4.
Сколько времени занимает урок «Почему двухэтапный поиск работает»?
Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.
Можно ли писать и запускать код в этом уроке AI Engineering Academy?
Да. Каждый урок AI Engineering Academy включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.
Все уроки этого курса
- Почему двухэтапный поиск работает
- Переранжирование с помощью кросс-энкодера: Cohere и BGE
- Контекстное сжатие и фильтрация релевантности
- Измерение влияния переранжирования