Почему ANN ранжирует неточно
Пользователь спрашивает: «Python декораторы для кеширования». Векторный поиск возвращает документы с высоким косинусным сходством к запросу. Но среди первых десяти оказывается статья «Декораторы в Python: полное руководство» с разделом о кешировании на странице 7 — и статья «functools.lru_cache: полный разбор», которая целиком посвящена именно этой задаче. Первая попала в top-3 потому что имеет высокую плотность слов «декоратор» и «Python». Вторая — на 8-м месте.
Проблема фундаментальная. Bi-encoder (модель эмбеддингов) кодирует запрос и документ по отдельности, а потом сравнивает векторы. Информация о конкретном взаимодействии между словами запроса и словами документа при этом теряется: модель не «видит» оба текста одновременно.
Bi-encoder vs Cross-encoder: архитектурное различие
Это ключевое различие, без понимания которого невозможно выбрать правильный подход.
- Кодирует независимо: query и doc обрабатываются отдельно
- Предвычисление: doc_vec индексируется заранее
- Скорость: 1–5 мс на запрос (ANN по индексу)
- Масштаб: миллионы документов без потери скорости
- Точность: приближённая — векторы теряют контекст взаимодействия
- Видит оба текста вместе: 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-поиском, затем пересчитываем только их.
Что меняется после reranking
Реальный пример: запрос «как обработать ошибку подключения к Redis». ANN-поиск возвращает документы по близости к «Redis» и «ошибка» в пространстве эмбеддингов. Cross-encoder оценивает каждый документ на конкретный вопрос.
Cross-encoder понял: вопрос про обработку ошибки, а не про Redis в целом. Документ про retry-логику (#5 у ANN) стал #1. «Архитектура Redis» (#1 у ANN) упала до #4 — она полезна для изучения, но не отвечает на вопрос «как обработать ошибку».
Модели: какую выбрать
cross-encoder/ms-marco-MiniLM-L-6-v2
BAAI/bge-reranker-v2-m3
bge-reranker-v2-m3 для Russian RAG.
rerank-multilingual-v3.0
jinaai/jina-reranker-v2-base-multilingual
Производительность и качество
Тест на MS MARCO dev set (NDCG@10, более высокий = лучше). Latency — на одиночной паре на CPU (Intel i7, без GPU):
Реализация с sentence-transformers
pip install sentence-transformers
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}")
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: шардирование и репликация
score = sigmoid(logit). В sentence-transformers:
reranker.predict(pairs, activation_fct=torch.sigmoid).
BGE и Jina по умолчанию возвращают значения в [0,1].
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
pip install cohere
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}"
)
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+ языков
и показывает сильные результаты на русском языке.
pip install sentence-transformers transformers torch
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}")
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. Каждый этап отвечает за своё:
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. Эквивалент разницы «плохой поиск» → «хороший поиск». |
Практика
- Сравните ANN vs reranked результаты. Возьмите любой корпус из 30–50 документов. Сделайте 5 запросов. Для каждого выведите топ-5 от чистого ANN и топ-5 после BGE-reranker. Посчитайте, для скольки запросов reranker изменил порядок топ-3. Есть ли случаи, когда reranker ухудшил результат?
- Подберите retrieve_n. Зафиксируйте 10 размеченных запросов (с известным «правильным» документом). Запустите pipeline с retrieve_n ∈ {10, 25, 50, 100, 200}. Измерьте Recall@retrieve_n (попал ли правильный документ в кандидаты) и итоговый MRR@5 (позиция правильного документа после reranking). Найдите минимальный retrieve_n при котором MRR@5 не падает.
- Сравните модели. Используя набор из задания 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 для вашего датасета?