Почему ANN ранжирует неточно

Пользователь спрашивает: «Python декораторы для кеширования». Векторный поиск возвращает документы с высоким косинусным сходством к запросу. Но среди первых десяти оказывается статья «Декораторы в Python: полное руководство» с разделом о кешировании на странице 7 — и статья «functools.lru_cache: полный разбор», которая целиком посвящена именно этой задаче. Первая попала в top-3 потому что имеет высокую плотность слов «декоратор» и «Python». Вторая — на 8-м месте.

Проблема фундаментальная. Bi-encoder (модель эмбеддингов) кодирует запрос и документ по отдельности, а потом сравнивает векторы. Информация о конкретном взаимодействии между словами запроса и словами документа при этом теряется: модель не «видит» оба текста одновременно.

Аналогия. Bi-encoder — как составить резюме кандидата и описание вакансии по отдельности, а потом сравнить краткие характеристики. Cross-encoder — как пригласить кандидата и работодателя в одну комнату и смотреть, как они взаимодействуют. Второй подход точнее, но дороже.

Bi-encoder vs Cross-encoder: архитектурное различие

Это ключевое различие, без понимания которого невозможно выбрать правильный подход.

Bi-encoder (ANN retrieval)
query[Encoder]q_vec[1536] cosinescore doc[Encoder]d_vec[1536]
  • Кодирует независимо: query и doc обрабатываются отдельно
  • Предвычисление: doc_vec индексируется заранее
  • Скорость: 1–5 мс на запрос (ANN по индексу)
  • Масштаб: миллионы документов без потери скорости
  • Точность: приближённая — векторы теряют контекст взаимодействия
Cross-encoder (Reranker)
[CLS] query_tokens [SEP] doc_tokens [SEP] ↓ (полное внимание между ВСЕМИ токенами) [BERT / Transformer][CLS] pooling → Linearscore ∈ [0,1]
  • Видит оба текста вместе: query и doc конкатенированы во вход
  • Cross-attention: каждый токен запроса «смотрит» на каждый токен документа
  • Скорость: 10–100 мс на одну пару (BERT forward pass)
  • Масштаб: только на pre-filtered кандидатах (50–200 doc)
  • Точность: значительно выше — улавливает тонкие смысловые совпадения

Почему cross-attention точнее

Механизм внимания в Transformer позволяет каждому токену запроса «смотреть» на каждый токен документа и наоборот. Это фундаментально меняет то, что может выучить модель:

  • Отрицание: «Python кеширование без декораторов» — bi-encoder с трудом различает это и «Python кеширование с декораторами» (похожие векторы). Cross-encoder видит слово «без» в контексте всего предложения.
  • Специфичность: «как работает functools.lru_cache» — cross-encoder замечает, что документ содержит именно этот термин в контексте «как работает», а не просто упоминает его в списке.
  • Порядок слов: «A лучше B» vs «B лучше A» — одинаковые токены, разный смысл. Bi-encoder даст схожие векторы; cross-encoder различит.

Двухэтапный pipeline: Retrieve → Rerank

Cross-encoder точен, но медленен: 50 мс на одну пару. Если применять его ко всем 100 000 документам — 5000 секунд на запрос. Решение: сначала быстро отбираем кандидатов ANN-поиском, затем пересчитываем только их.

1
Первичный поиск (Retrieve)
ANN по всему индексу или с metadata filter. Быстро, но приближённо. Намеренно берём с запасом — N = 50–200 кандидатов.
~5 мс · N = 100 doc
2
Пересчёт (Rerank)
Cross-encoder вычисляет score для каждой пары (query, candidate). Работает только с N кандидатами — не с полным индексом.
~50–100 мс · N пар
3
Финальный топ-K
Сортируем N кандидатов по новым rerank-скорам. Берём top-K для контекста LLM. Порядок может радикально измениться.
K = 5–10 doc → LLM
100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
Этап 1: Retrieve (быстрый, приближённый) Этап 2: Rerank (медленный, точный) Запрос "Python кеширование декораторы" ANN Search ~5 мс · O(log N) Полный индекс N = 100,000 документов Кандидаты N = 100 doc отобраны приближённо запрос передаётся в reranker Cross-Encoder score(query, docᵢ) × 100 ~50 мс · N forward passes Top-K результатов (K = 5–10) отсортированы по rerank-скору → контекст LLM Пример изменения порядка после reranking: ANN топ-3 (cosine): #1 [0.89] Декораторы в Python: полное руководство (700 стр.) #2 [0.87] Python метапрограммирование и декораторы #3 [0.85] functools.lru_cache — полный разбор После reranking: #1 [0.97] functools.lru_cache — полный разбор #2 [0.81] Python метапрограммирование и декораторы #3 [0.43] Декораторы в Python: полное руководство ↑ #3 стал #1 точечный ответ выше общего обзора

Что меняется после reranking

Реальный пример: запрос «как обработать ошибку подключения к Redis». ANN-поиск возвращает документы по близости к «Redis» и «ошибка» в пространстве эмбеддингов. Cross-encoder оценивает каждый документ на конкретный вопрос.

До reranking (cosine similarity)
#1
Redis: архитектура и типы данных
0.88
#2
Работа с Redis в Python: подключение, команды
0.86
#3
ConnectionError: [Errno 111] Connection refused
0.84
#4
Redis Sentinel и высокая доступность
0.82
#5
Retry-логика при ошибках сети в redis-py
0.79
После reranking (cross-encoder)
#3
Работа с Redis в Python: подключение, команды
0.67
#4
Redis: архитектура и типы данных
0.22
#5
Redis Sentinel и высокая доступность
0.14

Cross-encoder понял: вопрос про обработку ошибки, а не про Redis в целом. Документ про retry-логику (#5 у ANN) стал #1. «Архитектура Redis» (#1 у ANN) упала до #4 — она полезна для изучения, но не отвечает на вопрос «как обработать ошибку».

Модели: какую выбрать

ms-marco MiniLM (Microsoft)
cross-encoder/ms-marco-MiniLM-L-6-v2
English 22M params Local Free
Дистиллированная версия MS MARCO cross-encoder. Обучена на миллионах поисковых пар из Bing. Лучший выбор для быстрого English-ранкера: 22М параметров, ~35 мс/пара на CPU. Есть L-12 версия — точнее, но вдвое медленнее.
BGE-Reranker v2 (BAAI)
BAAI/bge-reranker-v2-m3
Multilingual 568M params Local Free
BAAI General Embedding reranker с мультиязычной поддержкой (русский, китайский, английский и др.). Серия v2-m3 — state-of-the-art среди open-source. Есть base (279M), large, и m3. Используй bge-reranker-v2-m3 для Russian RAG.
Cohere Rerank-3
rerank-multilingual-v3.0
100+ languages API Paid
Лучшее качество среди коммерческих решений, 100+ языков. Простой REST API, нет инфраструктурных затрат, платите за запросы. ~$0.002 за 1000 документов. Идеален для продакшена без GPU. Latency ~200 мс на запрос через API.
Jina Reranker v2
jinaai/jina-reranker-v2-base-multilingual
Multilingual 137M params Local Free
Компактный мультиязычный ранкер от Jina AI. 137М параметров — хороший баланс скорость/качество. Поддерживает до 8192 токенов в паре (длинные документы). Лицензия: Apache 2.0.

Производительность и качество

Тест на MS MARCO dev set (NDCG@10, более высокий = лучше). Latency — на одиночной паре на CPU (Intel i7, без GPU):

Сравнение NDCG@10 ↑ (выше — лучше)
ms-marco-MiniLM-L-6
38.8
ms-marco-MiniLM-L-12
39.9
── без reranker (только ANN) ──
31.5
bge-reranker-base
39.3
bge-reranker-large
41.5
bge-reranker-v2-m3
40.8
Cohere rerank-v3 (API)
43.7
Прирост без reranker → с reranker — примерно +7–12 пунктов NDCG@10 при N=100 кандидатах. Это значительно: поиск без reranker соответствует уровню «плохой поисковик», с reranker — близко к commercial search engines.

Реализация с sentence-transformers

bash
pip install sentence-transformers
python — CrossEncoder: базовое использование
from sentence_transformers import CrossEncoder

# Загружаем модель (скачивается при первом вызове)
# Для English: "cross-encoder/ms-marco-MiniLM-L-6-v2"
# Для мультиязычного: "BAAI/bge-reranker-v2-m3"
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")

query = "как обработать ошибку подключения к Redis"

# Кандидаты от первичного ANN-поиска
candidates = [
    "Redis: архитектура, типы данных, персистентность",
    "Работа с Redis в Python: подключение, основные команды",
    "ConnectionError при подключении к Redis: диагностика",
    "Redis Sentinel: настройка высокой доступности",
    "Retry-логика и backoff при ошибках сети в redis-py",
    "Redis Cluster: шардирование и репликация",
    "Мониторинг Redis с помощью Prometheus и Grafana",
]

# Формируем пары (query, doc) для каждого кандидата
pairs = [[query, doc] for doc in candidates]

# Predict возвращает массив скоров для каждой пары
scores = reranker.predict(pairs)

# Сортируем кандидатов по убыванию скора
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)

print(f"Query: {query}\n")
for rank, (doc, score) in enumerate(ranked, 1):
    print(f"#{rank:2d}  [{score:6.3f}]  {doc}")
output
Query: как обработать ошибку подключения к Redis

#1  [ 9.841]  Retry-логика и backoff при ошибках сети в redis-py
#2  [ 8.312]  ConnectionError при подключении к Redis: диагностика
#3  [ 3.124]  Работа с Redis в Python: подключение, основные команды
#4  [ 0.871]  Redis: архитектура, типы данных, персистентность
#5  [-1.203]  Redis Sentinel: настройка высокой доступности
#6  [-2.891]  Мониторинг Redis с помощью Prometheus и Grafana
#7  [-4.512]  Redis Cluster: шардирование и репликация
Скоры ms-marco не нормализованы. CrossEncoder возвращает логиты — числа в диапазоне от −∞ до +∞ (не от 0 до 1). Для ранжирования это не важно, но для порогового отсечения нужна нормализация: score = sigmoid(logit). В sentence-transformers: reranker.predict(pairs, activation_fct=torch.sigmoid). BGE и Jina по умолчанию возвращают значения в [0,1].
python — нормализация + пороговое отсечение
import torch
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, min_score: float = 0.1):
    """
    Reranker с нормализацией sigmoid и пороговым отсечением.

    Args:
        query:      Запрос пользователя
        candidates: Список текстов-кандидатов
        top_k:      Максимальное количество результатов
        min_score:  Минимальный порог релевантности (0–1)

    Returns:
        Список (doc, score) отсортированный по убыванию
    """
    if not candidates:
        return []

    pairs = [[query, doc] for doc in candidates]

    # activation_fct=torch.sigmoid нормализует в [0, 1]
    scores = reranker.predict(pairs, activation_fct=torch.sigmoid)

    ranked = sorted(
        zip(candidates, scores.tolist()),
        key=lambda x: x[1],
        reverse=True,
    )

    # Применяем порог и ограничение top_k
    return [(doc, score) for doc, score in ranked if score >= min_score][:top_k]


# Пример
results = rerank(
    "как обработать ошибку подключения к Redis",
    candidates,
    top_k=3,
    min_score=0.3,  # отсекаем нерелевантные (score < 0.3)
)

for doc, score in results:
    print(f"[{score:.3f}] {doc}")

Cohere Rerank API

bash
pip install cohere
python — Cohere Rerank
import cohere
import os

co = cohere.Client(os.environ["COHERE_API_KEY"])

query = "Python кеширование через декораторы"

candidates = [
    "Декораторы в Python: полное руководство по синтаксису",
    "functools.lru_cache: кеширование с LRU-стратегией",
    "Python метапрограммирование: декораторы классов",
    "Кеширование HTTP-ответов в Django",
    "Как написать собственный декоратор кеша с TTL",
    "Redis как backend для кеширования в Python",
    "asyncio и кеширование асинхронных функций",
]

response = co.rerank(
    model="rerank-multilingual-v3.0",   # или "rerank-english-v3.0"
    query=query,
    documents=candidates,
    top_n=3,                            # вернуть только топ-3
    return_documents=True,              # включить текст в ответ
)

print(f"Query: {query}\n")
for result in response.results:
    print(
        f"#{result.index + 1:2d}  "
        f"relevance={result.relevance_score:.3f}  "
        f"{result.document.text}"
    )
python — Cohere: реальный RAG-поток с overfetch
from qdrant_client import QdrantClient
from sentence_transformers import SentenceTransformer
import cohere, os

qdrant = QdrantClient(url="http://localhost:6333")
embed  = SentenceTransformer("all-MiniLM-L6-v2")
co     = cohere.Client(os.environ["COHERE_API_KEY"])


def rag_retrieve_rerank(
    query: str,
    collection: str,
    retrieve_n: int = 100,   # ANN: берём с запасом
    rerank_top_k: int = 5,   # Reranker: финальный топ для LLM
) -> list[dict]:
    """Двухэтапный retrieval: ANN overfetch → Cohere rerank."""

    # ── Этап 1: ANN-поиск с overfetch ─────────────────────────────────
    q_vec = embed.encode(query, normalize_embeddings=True).tolist()
    ann_results = qdrant.search(
        collection_name=collection,
        query_vector=q_vec,
        limit=retrieve_n,
        with_payload=True,
    )

    if not ann_results:
        return []

    # ── Этап 2: Cohere Rerank ──────────────────────────────────────────
    docs_for_rerank = [r.payload["text"] for r in ann_results]

    reranked = co.rerank(
        model="rerank-multilingual-v3.0",
        query=query,
        documents=docs_for_rerank,
        top_n=rerank_top_k,
        return_documents=True,
    )

    # Восстанавливаем полный payload по индексу кандидата
    final = []
    for r in reranked.results:
        original = ann_results[r.index]
        final.append({
            "text":            r.document.text,
            "relevance_score": r.relevance_score,
            "ann_score":       original.score,   # для сравнения / дебаггинга
            "payload":         original.payload,
        })

    return final


# Использование
results = rag_retrieve_rerank(
    query="обработка ошибок подключения Redis в Python",
    collection="knowledge_base",
    retrieve_n=100,
    rerank_top_k=5,
)

for i, doc in enumerate(results, 1):
    delta = "↑" if doc["relevance_score"] > 0.5 else "↓"
    print(
        f"#{i} {delta}  rel={doc['relevance_score']:.3f}  "
        f"ann={doc['ann_score']:.3f}  {doc['text'][:70]}"
    )

BGE-Reranker: локальный мультиязычный

BGE-Reranker — лучший выбор для русскоязычного контента без API-зависимости. Модель bge-reranker-v2-m3 обучена на данных 100+ языков и показывает сильные результаты на русском языке.

bash
pip install sentence-transformers transformers torch
python — BGE-Reranker-v2-m3
from sentence_transformers import CrossEncoder

# bge-reranker-v2-m3 поддерживает русский, китайский, английский и др.
bge_reranker = CrossEncoder(
    "BAAI/bge-reranker-v2-m3",
    max_length=512,          # max длина пары (query + doc tokens)
    device="cpu",            # или "cuda" при наличии GPU
)

query = "настройка nginx для SSL-терминации с Let's Encrypt"

candidates = [
    "Nginx: настройка HTTPS и SSL-сертификатов",
    "Let's Encrypt certbot: автоматическое получение сертификатов",
    "SSL-терминация в nginx: проксирование HTTPS на HTTP бэкенд",
    "Nginx как reverse proxy: конфигурация и балансировка",
    "Certbot и nginx: полная инструкция по настройке",
    "HTTP/2 в nginx: включение и оптимизация",
    "Настройка firewall ufw для nginx (порты 80 и 443)",
]

# BGE возвращает score в [0, 1] без дополнительной нормализации
scores = bge_reranker.predict([[query, doc] for doc in candidates])

ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
for rank, (doc, score) in enumerate(ranked, 1):
    print(f"#{rank}  [{score:.4f}]  {doc}")
python — BGE в продакшене: batching и GPU
from sentence_transformers import CrossEncoder
import numpy as np

class BGEReranker:
    """Production-ready BGE reranker с batching и GPU-поддержкой."""

    def __init__(
        self,
        model_name: str = "BAAI/bge-reranker-v2-m3",
        device: str = "cpu",
        max_length: int = 512,
        batch_size: int = 32,
    ):
        self.model = CrossEncoder(
            model_name,
            max_length=max_length,
            device=device,
        )
        self.batch_size = batch_size

    def rerank(
        self,
        query: str,
        candidates: list[str],
        top_k: int | None = None,
    ) -> list[tuple[str, float]]:
        """
        Ранжирует кандидатов, возвращает (doc, score) по убыванию.
        При top_k=None возвращает все.
        """
        if not candidates:
            return []

        pairs = [[query, doc] for doc in candidates]

        # show_progress_bar=False убирает tqdm в продакшене
        scores = self.model.predict(
            pairs,
            batch_size=self.batch_size,
            show_progress_bar=False,
        )

        ranked = sorted(
            zip(candidates, scores.tolist()),
            key=lambda x: x[1],
            reverse=True,
        )

        return ranked if top_k is None else ranked[:top_k]

    def score_pair(self, query: str, doc: str) -> float:
        """Скор одной пары (для дебаггинга или LLM-оценки)."""
        return float(self.model.predict([[query, doc]])[0])


# Использование
reranker = BGEReranker(device="cpu", batch_size=16)

results = reranker.rerank(
    query="настройка nginx SSL Let's Encrypt",
    candidates=candidates,
    top_k=3,
)

for doc, score in results:
    print(f"[{score:.4f}] {doc}")

Интеграция в полный RAG-pipeline

Собираем полный pipeline: metadata filter → hybrid ANN → rerank → LLM generation. Каждый этап отвечает за своё:

python — полный RAG pipeline с reranking
from dataclasses import dataclass
from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue, Range
from sentence_transformers import SentenceTransformer, CrossEncoder
from anthropic import Anthropic
import torch


@dataclass
class RAGConfig:
    # Retrieval
    retrieve_n: int = 100       # кандидатов от ANN (с запасом)
    rerank_top_k: int = 5       # финальных документов для LLM
    min_relevance: float = 0.3  # порог отсечения нерелевантных

    # Models
    embed_model: str = "all-MiniLM-L6-v2"
    rerank_model: str = "BAAI/bge-reranker-v2-m3"


class RAGPipeline:
    def __init__(self, qdrant_url: str, collection: str, cfg: RAGConfig):
        self.qdrant     = QdrantClient(url=qdrant_url)
        self.collection = collection
        self.cfg        = cfg

        self.embedder = SentenceTransformer(cfg.embed_model)
        self.reranker = CrossEncoder(cfg.rerank_model)
        self.llm      = Anthropic()

    def retrieve(self, query: str, filters: Filter | None = None) -> list[dict]:
        """Этап 1: ANN с overfetch."""
        q_vec = self.embedder.encode(query, normalize_embeddings=True).tolist()
        hits = self.qdrant.search(
            collection_name=self.collection,
            query_vector=q_vec,
            limit=self.cfg.retrieve_n,
            query_filter=filters,
            with_payload=True,
        )
        return [{"text": h.payload["text"], "meta": h.payload, "ann_score": h.score}
                for h in hits]

    def rerank(self, query: str, candidates: list[dict]) -> list[dict]:
        """Этап 2: Cross-encoder reranking."""
        if not candidates:
            return []

        texts = [c["text"] for c in candidates]
        pairs = [[query, t] for t in texts]

        scores = self.reranker.predict(pairs, activation_fct=torch.sigmoid)

        # Объединяем score с метаданными
        for cand, score in zip(candidates, scores):
            cand["rerank_score"] = float(score)

        # Сортируем по rerank_score, отсекаем по порогу
        ranked = sorted(candidates, key=lambda x: x["rerank_score"], reverse=True)
        filtered = [c for c in ranked if c["rerank_score"] >= self.cfg.min_relevance]
        return filtered[:self.cfg.rerank_top_k]

    def generate(self, query: str, context_docs: list[dict]) -> str:
        """Этап 3: LLM generation с контекстом."""
        context = "\n\n".join(
            f"[{i+1}] {doc['text']}" for i, doc in enumerate(context_docs)
        )
        response = self.llm.messages.create(
            model="claude-opus-4-6",
            max_tokens=1024,
            messages=[{
                "role": "user",
                "content": (
                    f"Ответь на вопрос, используя только приведённые источники.\n\n"
                    f"Источники:\n{context}\n\n"
                    f"Вопрос: {query}"
                ),
            }],
        )
        return response.content[0].text

    def query(
        self,
        question: str,
        tenant_id: str,
        user_role_level: int = 1,
    ) -> dict:
        """Полный RAG-цикл: retrieve → rerank → generate."""

        # Metadata filter: изоляция по тенанту + RBAC
        filters = Filter(must=[
            FieldCondition(key="tenant_id",    match=MatchValue(value=tenant_id)),
            FieldCondition(key="is_verified",  match=MatchValue(value=True)),
            FieldCondition(key="access_level", range=Range(lte=user_role_level)),
        ])

        # Шаг 1: ANN retrieval
        candidates = self.retrieve(question, filters=filters)

        # Шаг 2: Reranking
        ranked = self.rerank(question, candidates)

        # Шаг 3: Generation
        answer = self.generate(question, ranked)

        return {
            "answer":     answer,
            "sources":    ranked,
            "candidates": len(candidates),
            "used":       len(ranked),
        }


# ── Использование ──────────────────────────────────────────────────────
pipeline = RAGPipeline(
    qdrant_url="http://localhost:6333",
    collection="knowledge_base",
    cfg=RAGConfig(retrieve_n=100, rerank_top_k=5),
)

result = pipeline.query(
    question="как обработать ошибку ConnectionError при работе с Redis?",
    tenant_id="acme-corp",
    user_role_level=1,
)

print(f"Найдено кандидатов: {result['candidates']}")
print(f"Использовано после reranking: {result['used']}")
print(f"\nОтвет:\n{result['answer']}")
print("\nИсточники:")
for src in result["sources"]:
    print(f"  rerank={src['rerank_score']:.3f}  ann={src['ann_score']:.3f}  {src['text'][:60]}")

Когда использовать reranker

Ситуация Нужен reranker? Почему
Корпоративный Q&A-агент Да, обязательно Точность критична; латентность +100мс приемлема
RAG с небольшим чанком (≤200 токенов) Да Маленькие чанки теряют контекст — reranker восполняет
Поиск по длинным документам (≥1000 токенов) Опционально Длинные чанки сами содержат контекст; выигрыш меньше
Realtime-ответы (<200мс total) С осторожностью Используй MiniLM-L6 или Cohere с небольшим N
Разнообразный мультиязычный контент Да, bge-reranker-v2-m3 Мультиязычные эмбеддинги теряют нюансы; reranker компенсирует
Простой FAQ (10–50 документов) Нет Небольшой корпус — ANN уже точен; reranker избыточен
Batch-обработка без latency-требований Да, всегда Нет причин не использовать; только повысит качество

Шпаргалка

КонцепцияКлючевое
Bi-encoder Кодирует query и doc отдельно → cosine. Быстро, масштабируется, но теряет контекст взаимодействия.
Cross-encoder BERT видит [CLS] + query + [SEP] + doc одновременно. Полное cross-attention. Медленно, но точно.
Two-stage pipeline ANN(N=100) → Cross-encoder → top-K. ANN быстро отбирает кандидатов; reranker точно ранжирует их.
retrieve_n Кол-во кандидатов от ANN. Больше → reranker найдёт больше хорошего, но дольше. Типично 50–200.
ms-marco MiniLM English, 22M, ~35мс/пара CPU. Лучший выбор для English fast reranking.
bge-reranker-v2-m3 Multilingual (включая RU), 568M, ~90мс/пара CPU. Лучший open-source для русского.
Cohere Rerank-3 API, 100+ языков, наилучшее качество, $0.002/1k. Для продакшена без GPU-инфраструктуры.
Нормализация ms-marco Возвращает логиты (−∞…+∞). Для порогов: activation_fct=torch.sigmoid.
Порог min_relevance Отсекает нерелевантные документы из контекста LLM. Типично 0.2–0.4 (после sigmoid).
Прирост качества +7–12 пунктов NDCG@10 vs ANN без reranker. Эквивалент разницы «плохой поиск» → «хороший поиск».

Практика

  1. Сравните ANN vs reranked результаты. Возьмите любой корпус из 30–50 документов. Сделайте 5 запросов. Для каждого выведите топ-5 от чистого ANN и топ-5 после BGE-reranker. Посчитайте, для скольки запросов reranker изменил порядок топ-3. Есть ли случаи, когда reranker ухудшил результат?
  2. Подберите retrieve_n. Зафиксируйте 10 размеченных запросов (с известным «правильным» документом). Запустите pipeline с retrieve_n ∈ {10, 25, 50, 100, 200}. Измерьте Recall@retrieve_n (попал ли правильный документ в кандидаты) и итоговый MRR@5 (позиция правильного документа после reranking). Найдите минимальный retrieve_n при котором MRR@5 не падает.
  3. Сравните модели. Используя набор из задания 1, прогоните через четыре модели: ms-marco-MiniLM-L6, ms-marco-MiniLM-L12, bge-reranker-base, bge-reranker-v2-m3. Замерьте: (а) latency per query, (б) MRR@5. Постройте scatter-plot: latency vs MRR. Какая модель даёт лучший trade-off для вашего датасета?