В предыдущих сериях: Qdrant (1/N), эмбеддинги (2/N), нарезка на чанки (3/N), гибридный поиск (4/N). Нужный чанк стабильно залетает в top-15. Но порядок внутри этих 15 результатов определяет, какой чанк окажется в top-1. А top-1 – это то, что видит пользователь (или LLM при генерации ответа).

Этот пост о том, как заставить систему вчитываться в результаты. Переранжирование: дешёвый двухступенчатый каскад, который не меняет recall, но переставляет результаты так, чтобы лучший оказывался первым.

Проблема: RRF не читает документы

Гибридный поиск (пост 4/N) работает так:

  1. Dense search (векторный поиск; mxbai-embed-large, 1024d) – находит семантически похожие чанки
  2. BM25 – караулит точные совпадения
  3. 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-622MEN~200ms
bge-reranker-base278MEN+ZH~1.5s
bge-reranker-v2-m3568M100+ (вкл. RU)~3.3s
bge-reranker-v2-gemma2B100+~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 score0.680

Без reranker – случайный JSON. С reranker – правильный pipeline fix.

Q6: “Какой баг нашли в circuit breaker NORA?”

Без rerankerС reranker
Top-1“Нашёл баг в NORA. Посмотрю код подробнее.”“3 настоящих фейла: circuit breaker: got 000000”
Rerank score0.975

Без reranker – бесполезный флуд из issue. С reranker – конкретный лог ошибки.

Q (search_memory): “nora storage backend”

Без rerankerС reranker
Top-1score 0.748, “## Block 8: S3 Storage Backend”rerank 0.991, тот же чанк
Top-2score 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 не помогает

  1. Нужного чанка нет в top-K – reranker не добавляет результаты, только переставляет. Если recall@15 = 0, никакой reranker не поможет.

  2. Все кандидаты одинаково релевантны – “nora storage backend” уже на первом месте, cross-encoder просто подтверждает.

  3. Запрос слишком общий – “что делали на прошлой неделе” – все чанки одинаково (не)релевантны.

Физика процесса: 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.

Оптимизации (не реализованы)

  1. GPU inference – ~100ms вместо ~3300ms. Требует выделенного GPU или time-sharing с Ollama.
  2. Quantization – INT8 bge-reranker-v2-m3 может дать 2x speedup на CPU.
  3. Adaptive reranking – не вызывать reranker, если top-1 dense score > порога (высокая confidence).
  4. 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 – его метрика.

Выводы

  1. Reranking ≠ retrieval. Cross-encoder не находит новые результаты – он тасует колоду, которую ему дали. Если recall на этапе гибридного поиска равен нулю, cross-encoder выдаст лучший кусок из худших. Сначала чиним полноту (chunking, hybrid search), затем – точность (reranking).

  2. 67% top-1 изменений – это много. Две трети запросов получили другой первый результат. Для RAG с LLM это означает, что контекст генерации в 67% случаев будет другим – потенциально лучше.

  3. Каскад – универсальный паттерн. Bi-encoder + cross-encoder = L1 cache + L2 cache = bloom filter + disk. Дешёвый слой для coverage, дорогой для precision. Работает везде.

  4. CPU latency – основное ограничение. 568M параметров на CPU = 3.3s. Это определяет архитектуру: reranking годится для async tools (MCP, CLI), не для realtime. GPU решает проблему, но добавляет инфра-сложность.

  5. Truncation работает. 256 символов вместо 500 – latency падает вдвое, качество не страдает заметно. Для cross-encoder первые 256 символов документа достаточно информативны.

  6. Отказоустойчивость на первом месте. RERANKER_ENABLED=false – мгновенный откат. Модель может не загрузиться (OOM, network, disk). Инфраструктура должна уметь работать в degraded mode: сырой RRF-результат лучше, чем упавший сервис.


Следующий – про то, как 98% accuracy на своих данных разваливается на чужих.