В предыдущих сериях: Qdrant (1/N), эмбеддинги (2/N), нарезка на чанки (3/N), гибридный поиск (4/N). Нужный чанк стабильно залетает в top-15. Но порядок внутри этих 15 результатов определяет, какой чанк окажется в top-1. А top-1 – это то, что видит пользователь (или LLM при генерации ответа).
Этот пост о том, как заставить систему вчитываться в результаты. Переранжирование: дешёвый двухступенчатый каскад, который не меняет recall, но переставляет результаты так, чтобы лучший оказывался первым.
Проблема: RRF не читает документы
Гибридный поиск (пост 4/N) работает так:
- Dense search (векторный поиск; mxbai-embed-large, 1024d) – находит семантически похожие чанки
- BM25 – караулит точные совпадения
- RRF (Reciprocal Rank Fusion) – сливает два списка по рангам
RRF формула: score = 0.7 / (60 + rank_dense) + 0.3 / (60 + rank_sparse)
По сути, это слепое голосование. BM25 ставит чанк вторым, dense – пятым, итоговый балл – взвешенная сумма обратных рангов. Ни один из “голосующих” не читал документ вместе с запросом. Они кодировали запрос и документы независимо друг от друга.
Конкретный пример. Запрос: “XSS vulnerability in NORA UI”. В top-15 после RRF:
- Позиция 1: случайный файл с json-содержимым (score 0.011)
- Позиция 4: описание pipeline fix/521-ui-xss-install-cmd (score 0.010)
Разница в скорах – на уровне шума (0.001). RRF выкинул JSON на первое место только потому, что dense search давеча дал ему ранг чуть выше. Но инженеру очевидно, что pipeline-фикс – стопроцентный ответ, а JSON – мусор.
Bi-encoder vs Cross-encoder
Bi-encoder (то, что уже есть)
Query → [Encoder] → vec_q ─┐
├─ cosine(vec_q, vec_d) → score
Doc → [Encoder] → vec_d ─┘
Запрос и документ кодируются отдельно. Можно предвычислить векторы для всего корпуса, сложить в индекс и искать за O(log N). Быстро: embedding + поиск ~70ms на сотнях тысяч чанков.
Но энкодер не знает, какой будет запрос, когда кодирует документ. Он не понимает, что строка “3 real failures: circuit breaker got 000000” отвечает на запрос “circuit breaker bug” гораздо лучше, чем пространный текст “Found a bug in NORA. Let me look at the code.”
Cross-encoder
(Query, Doc) → [Encoder] → score
Запрос и документ подаются в трансформер одновременно, как единый текст. Модель видит оба сразу, может сопоставить слова, поймать парафразы и оценить контекст целиком.
Минус: предвычислить ничего нельзя. Для каждой пары (query, doc) нужен отдельный forward pass. Гонять эту математику по всему корпусу на каждый запрос – безумие. Но если ограничить выборку до 30 кандидатов из top-K, задача становится подъёмной.
Каскад
Классический паттерн: дешёвый грубый фильтр → дорогой точный scorer.
360K чанков → [Bi-encoder: ~70ms] → top-30
top-30 → [Cross-encoder: ~3s] → top-10 (переставленные)
Тот же принцип, что:
- Bloom filter → disk lookup в базах данных
- L1/L2/L3 кэш в CPU
- DNS cache → recursive resolver
- Compilation: lexer (быстрый, грубый) → parser (медленный, точный)
Дешёвый слой отсеивает 99.99% явного шлака, тяжёлый – ювелирно разбирает оставшийся 0.01%.
Реализация
Модель: BAAI/bge-reranker-v2-m3
Выбор модели:
| Модель | Параметры | Языки | Latency (15 pairs, CPU) |
|---|---|---|---|
| ms-marco-MiniLM-L-6 | 22M | EN | ~200ms |
| bge-reranker-base | 278M | EN+ZH | ~1.5s |
| bge-reranker-v2-m3 | 568M | 100+ (вкл. RU) | ~3.3s |
| bge-reranker-v2-gemma | 2B | 100+ | ~15s |
Критерий: мультиязычность (данные на русском + английском). bge-reranker-v2-m3 – наименьшая модель с полноценной поддержкой русского, которая не падает от кириллицы.
Код
# reranker.py — 150 строк, ключевая часть:
from sentence_transformers import CrossEncoder
# Lazy load: модель грузится при первом вызове, не при старте сервера
_cross_encoder = None
def rerank(query, candidates, text_key="text", top_k=None):
model = _load_model() # ~8s первый раз, потом из памяти
if model is None:
return candidates # fallback: вернуть как есть
# Жёсткий truncate для выживания на CPU
max_chars = 256
pairs = [(query, c[text_key][:max_chars]) for c in candidates]
scores = model.predict(pairs)
# Присвоить rerank_score, отсортировать
for c, score in zip(candidates, scores):
c["rerank_score"] = float(score)
return sorted(candidates, key=lambda x: x["rerank_score"], reverse=True)[:top_k]
Интеграция в pipeline
Три точки вызова:
# search_sessions: dense search → rerank
fetch_limit = limit * 3 # 15 вместо 5
results = qdrant.query_points(..., limit=fetch_limit)
formatted = [format(r) for r in results]
formatted = rerank(query, formatted, text_key="text_preview", top_k=limit)
# search_memory: dense search → rerank (аналогично)
# hybrid_search: dense + BM25 + RRF → rerank
# RRF выдаёт limit*3 кандидатов, cross-encoder сужает до limit
Паттерн один: overfetch × 3, rerank, trim.
Конфигурация
RERANKER_ENABLED=true # kill switch
RERANKER_MODEL=BAAI/bge-reranker-v2-m3
RERANKER_MAX_LENGTH=512 # max tokens per pair
RERANKER_MAX_DOC_CHARS=256 # truncate text before tokenization
RERANKER_ENABLED=false – рубильник. Если модель поймает OOM или ляжет диск, система мгновенно деградирует до стандартного RRF-поиска без падения всего сервиса.
Бенчмарк: A/B сравнение
30 вопросов из трёх категорий (IE, MR, KU) – те же вопросы, что в бенчмарке v4.
Методика: один и тот же запрос к search_sessions, с reranker и без. Сравниваю top-1 результат.
Количественные результаты
Без reranker С reranker
Avg latency/query 91 ms 3,879 ms
Top-1 изменился — 20/30 (67%)
67% запросов получили другой top-1 результат. Две трети.
Качественные примеры
Q3: “Какая XSS уязвимость была найдена в UI NORA?”
| Без reranker | С reranker | |
|---|---|---|
| Top-1 | [{"id": 1, "cat": "IE", "q":... (JSON файл) | ═══ NORA PIPELINE: fix/521-ui-xss-install-cmd ═══ |
| Rerank score | — | 0.680 |
Без reranker – случайный JSON. С reranker – правильный pipeline fix.
Q6: “Какой баг нашли в circuit breaker NORA?”
| Без reranker | С reranker | |
|---|---|---|
| Top-1 | “Нашёл баг в NORA. Посмотрю код подробнее.” | “3 настоящих фейла: circuit breaker: got 000000” |
| Rerank score | — | 0.975 |
Без reranker – бесполезный флуд из issue. С reranker – конкретный лог ошибки.
Q (search_memory): “nora storage backend”
| Без reranker | С reranker | |
|---|---|---|
| Top-1 | score 0.748, “## Block 8: S3 Storage Backend” | rerank 0.991, тот же чанк |
| Top-2 | score 0.704, “### Storage (local)” | rerank 0.563, тот же |
Здесь dense search уже угадал правильный порядок – cross-encoder подтвердил и усилил разрыв (0.991 vs 0.563 вместо 0.748 vs 0.704).
Латентность
15 кандидатов × 256 chars: медиана 3,296 ms
15 кандидатов × 500 chars: медиана 5,700 ms
10 кандидатов × 256 chars: медиана 2,200 ms
Всё на CPU (AMD EPYC, 4 cores). На GPU было бы ~100ms, но ресурсы железки монопольно сожраны инференсом Ollama, а ставить вторую карту ради 30 запросов в день – overkill.
Чтобы не уйти за психологическую отметку в 10 секунд, режем текст чанка до 256 символов перед отправкой в cross-encoder. Latency падает вдвое, а качество не страдает: модели достаточно увидеть ключевые фразы в начале куска, чтобы понять, стоит ли он внимания.
Почему cross-encoder работает лучше
Пример: “circuit breaker bug”
Кандидат A: “Нашёл баг в NORA. Посмотрю код подробнее и попробую решить.”
Bi-encoder: “баг” + “NORA” → высокий cosine similarity с “circuit breaker bug in NORA”. Но “circuit breaker” не упоминается.
Кандидат B: “3 настоящих фейла: 1. Circuit breaker: got 000000 – curl вернул 000, значит…”
Cross-encoder: видит “circuit breaker” в запросе И в документе. Видит “bug” в запросе и “фейла” в документе (парафраз). Score: 0.975.
Bi-encoder не может сделать этого – он кодирует документ не зная запроса. Cross-encoder читает пару целиком.
Когда cross-encoder не помогает
Нужного чанка нет в top-K – reranker не добавляет результаты, только переставляет. Если recall@15 = 0, никакой reranker не поможет.
Все кандидаты одинаково релевантны – “nora storage backend” уже на первом месте, cross-encoder просто подтверждает.
Запрос слишком общий – “что делали на прошлой неделе” – все чанки одинаково (не)релевантны.
Физика процесса: latency budget
Тайминги – самая болезненная часть. Полный путь запроса:
Embedding (Ollama, mxbai) ~50ms
Qdrant dense search ~20ms
BM25 sparse search ~5ms
RRF fusion ~1ms
────────────────────────────────────────
Subtotal (v4) ~91ms
Cross-encoder rerank (15 × 256ch) ~3,300ms
────────────────────────────────────────
Total (v5) ~3,400ms
3.4 секунды на поисковый запрос. Для CLI-инструментов автоматизации и асинхронных MCP-серверов – приемлемо. Для real-time autocomplete в IDE – смерть UX.
Оптимизации (не реализованы)
- GPU inference – ~100ms вместо ~3300ms. Требует выделенного GPU или time-sharing с Ollama.
- Quantization – INT8 bge-reranker-v2-m3 может дать 2x speedup на CPU.
- Adaptive reranking – не вызывать reranker, если top-1 dense score > порога (высокая confidence).
- Distilled model – ms-marco-MiniLM (22M параметров) за ~200ms, но без русского.
Пока 3.3 секунды терпимо – не оптимизирую. Premature optimization – root of all evil.
Архитектурная ретроспектива
RAG pipeline после 5 постов:
Данные
↓
Chunking (session-level, 7 turns, overlap 3) [пост 3/N]
↓
Indexing
├── Dense vectors (mxbai-embed-large, 1024d) [пост 2/N]
└── Sparse vectors (BM25, pymorphy лемматизация) [пост 4/N]
↓
Query
├── Dense search (Qdrant, top-50)
└── Sparse search (BM25, top-50)
↓
RRF Fusion (0.7 dense + 0.3 sparse) [пост 4/N]
↓
Top-30 candidates
↓
Cross-encoder rerank (bge-reranker-v2-m3) [этот пост]
↓
Top-10 results → user / LLM
Каждый слой добавляет точности, жертвуя latency:
| Слой | Recall вклад | Latency | Характер |
|---|---|---|---|
| Dense search | ~70% recall | ~50ms | Семантическое приближение |
| BM25 | +10% recall | ~5ms | Точные совпадения |
| RRF | +5% (dedup) | ~1ms | Комбинация рангов |
| Cross-encoder | +0% recall, reorder | ~3300ms | Точная релевантность |
Cross-encoder не увеличивает recall. Он не находит новые документы. Он берёт то, что уже найдено, и ставит самое релевантное наверх. Precision@1 – его метрика.
Выводы
Reranking ≠ retrieval. Cross-encoder не находит новые результаты – он тасует колоду, которую ему дали. Если recall на этапе гибридного поиска равен нулю, cross-encoder выдаст лучший кусок из худших. Сначала чиним полноту (chunking, hybrid search), затем – точность (reranking).
67% top-1 изменений – это много. Две трети запросов получили другой первый результат. Для RAG с LLM это означает, что контекст генерации в 67% случаев будет другим – потенциально лучше.
Каскад – универсальный паттерн. Bi-encoder + cross-encoder = L1 cache + L2 cache = bloom filter + disk. Дешёвый слой для coverage, дорогой для precision. Работает везде.
CPU latency – основное ограничение. 568M параметров на CPU = 3.3s. Это определяет архитектуру: reranking годится для async tools (MCP, CLI), не для realtime. GPU решает проблему, но добавляет инфра-сложность.
Truncation работает. 256 символов вместо 500 – latency падает вдвое, качество не страдает заметно. Для cross-encoder первые 256 символов документа достаточно информативны.
Отказоустойчивость на первом месте.
RERANKER_ENABLED=false– мгновенный откат. Модель может не загрузиться (OOM, network, disk). Инфраструктура должна уметь работать в degraded mode: сырой RRF-результат лучше, чем упавший сервис.
Следующий – про то, как 98% accuracy на своих данных разваливается на чужих.