<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Cross-Encoder on DevOps Way - Практические гайды</title>
    <link>https://devopsway.ru/tags/cross-encoder/</link>
    <description>Recent content in Cross-Encoder on DevOps Way - Практические гайды</description>
    <image>
      <title>DevOps Way - Практические гайды</title>
      <url>https://devopsway.ru/images/devopsway-og.png</url>
      <link>https://devopsway.ru/images/devopsway-og.png</link>
    </image>
    <generator>Hugo -- 0.162.1</generator>
    <language>ru</language>
    <lastBuildDate>Wed, 03 Jun 2026 12:04:12 -0400</lastBuildDate>
    <atom:link href="https://devopsway.ru/tags/cross-encoder/feed.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>RAG Pipeline 5/N: Cross-encoder reranking – когда порядок важнее полноты</title>
      <link>https://devopsway.ru/posts/rag-05-reranking/</link>
      <pubDate>Tue, 02 Jun 2026 12:00:00 +0300</pubDate>
      <guid>https://devopsway.ru/posts/rag-05-reranking/</guid>
      <description>RRF голосует за порядок результатов, но ни один из голосующих не читал документ вместе с запросом. Cross-encoder читает пару целиком – как человек. 67% top-1 результатов стали другими. Реальный код, бенчмарк, архитектура каскада.</description>
      <content:encoded><![CDATA[<p>В предыдущих сериях: <a href="/posts/rag-01-qdrant-vectors/">Qdrant (1/N)</a>, <a href="/posts/rag-02-embeddings/">эмбеддинги (2/N)</a>, <a href="/posts/rag-03-chunking/">нарезка на чанки (3/N)</a>, <a href="/posts/rag-04-hybrid-search/">гибридный поиск (4/N)</a>. Нужный чанк стабильно залетает в top-15. Но порядок внутри этих 15 результатов определяет, какой чанк окажется в top-1. А top-1 – это то, что видит пользователь (или LLM при генерации ответа).</p>
<p>Этот пост о том, как заставить систему вчитываться в результаты. Переранжирование: дешёвый двухступенчатый каскад, который не меняет recall, но переставляет результаты так, чтобы лучший оказывался первым.</p>
<h2 id="проблема-rrf-не-читает-документы">Проблема: RRF не читает документы</h2>
<p>Гибридный поиск (<a href="/posts/rag-04-hybrid-search/">пост 4/N</a>) работает так:</p>
<ol>
<li>Dense search (векторный поиск; mxbai-embed-large, 1024d) – находит семантически похожие чанки</li>
<li>BM25 – караулит точные совпадения</li>
<li>RRF (Reciprocal Rank Fusion) – сливает два списка по рангам</li>
</ol>
<p>RRF формула: <code>score = 0.7 / (60 + rank_dense) + 0.3 / (60 + rank_sparse)</code></p>
<p>По сути, это слепое голосование. BM25 ставит чанк вторым, dense – пятым, итоговый балл – взвешенная сумма обратных рангов. Ни один из &ldquo;голосующих&rdquo; не читал документ вместе с запросом. Они кодировали запрос и документы <strong>независимо</strong> друг от друга.</p>
<p>Конкретный пример. Запрос: &ldquo;XSS vulnerability in NORA UI&rdquo;. В top-15 после RRF:</p>
<ul>
<li>Позиция 1: случайный файл с json-содержимым (score 0.011)</li>
<li>Позиция 4: описание pipeline fix/521-ui-xss-install-cmd (score 0.010)</li>
</ul>
<p>Разница в скорах – на уровне шума (0.001). RRF выкинул JSON на первое место только потому, что dense search давеча дал ему ранг чуть выше. Но инженеру очевидно, что pipeline-фикс – стопроцентный ответ, а JSON – мусор.</p>
<h2 id="bi-encoder-vs-cross-encoder">Bi-encoder vs Cross-encoder</h2>
<h3 id="bi-encoder-то-что-уже-есть">Bi-encoder (то, что уже есть)</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">Query  → [Encoder] → vec_q ─┐
</span></span><span class="line"><span class="cl">                              ├─ cosine(vec_q, vec_d) → score
</span></span><span class="line"><span class="cl">Doc    → [Encoder] → vec_d ─┘
</span></span></code></pre></div><p>Запрос и документ кодируются <strong>отдельно</strong>. Можно предвычислить векторы для всего корпуса, сложить в индекс и искать за O(log N). Быстро: embedding + поиск ~70ms на сотнях тысяч чанков.</p>
<p>Но энкодер не знает, какой будет запрос, когда кодирует документ. Он не понимает, что строка &ldquo;3 real failures: circuit breaker got 000000&rdquo; отвечает на запрос &ldquo;circuit breaker bug&rdquo; гораздо лучше, чем пространный текст &ldquo;Found a bug in NORA. Let me look at the code.&rdquo;</p>
<h3 id="cross-encoder">Cross-encoder</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">(Query, Doc) → [Encoder] → score
</span></span></code></pre></div><p>Запрос и документ подаются в трансформер <strong>одновременно, как единый текст</strong>. Модель видит оба сразу, может сопоставить слова, поймать парафразы и оценить контекст целиком.</p>
<p>Минус: предвычислить ничего нельзя. Для каждой пары (query, doc) нужен отдельный forward pass. Гонять эту математику по всему корпусу на каждый запрос – безумие. Но если ограничить выборку до 30 кандидатов из top-K, задача становится подъёмной.</p>
<h3 id="каскад">Каскад</h3>
<p>Классический паттерн: дешёвый грубый фильтр → дорогой точный scorer.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">360K чанков  → [Bi-encoder: ~70ms]  → top-30
</span></span><span class="line"><span class="cl">top-30       → [Cross-encoder: ~3s] → top-10 (переставленные)
</span></span></code></pre></div><p>Тот же принцип, что:</p>
<ul>
<li>Bloom filter → disk lookup в базах данных</li>
<li>L1/L2/L3 кэш в CPU</li>
<li>DNS cache → recursive resolver</li>
<li>Compilation: lexer (быстрый, грубый) → parser (медленный, точный)</li>
</ul>
<p>Дешёвый слой отсеивает 99.99% явного шлака, тяжёлый – ювелирно разбирает оставшийся 0.01%.</p>
<h2 id="реализация">Реализация</h2>
<h3 id="модель-baaibge-reranker-v2-m3">Модель: BAAI/bge-reranker-v2-m3</h3>
<p>Выбор модели:</p>
<table>
	<thead>
			<tr>
					<th>Модель</th>
					<th>Параметры</th>
					<th>Языки</th>
					<th>Latency (15 pairs, CPU)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>ms-marco-MiniLM-L-6</td>
					<td>22M</td>
					<td>EN</td>
					<td>~200ms</td>
			</tr>
			<tr>
					<td>bge-reranker-base</td>
					<td>278M</td>
					<td>EN+ZH</td>
					<td>~1.5s</td>
			</tr>
			<tr>
					<td><strong>bge-reranker-v2-m3</strong></td>
					<td><strong>568M</strong></td>
					<td><strong>100+ (вкл. RU)</strong></td>
					<td><strong>~3.3s</strong></td>
			</tr>
			<tr>
					<td>bge-reranker-v2-gemma</td>
					<td>2B</td>
					<td>100+</td>
					<td>~15s</td>
			</tr>
	</tbody>
</table>
<p>Критерий: мультиязычность (данные на русском + английском). <code>bge-reranker-v2-m3</code> – наименьшая модель с полноценной поддержкой русского, которая не падает от кириллицы.</p>
<h3 id="код">Код</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># reranker.py — 150 строк, ключевая часть:</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">sentence_transformers</span> <span class="kn">import</span> <span class="n">CrossEncoder</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Lazy load: модель грузится при первом вызове, не при старте сервера</span>
</span></span><span class="line"><span class="cl"><span class="n">_cross_encoder</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">rerank</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="n">candidates</span><span class="p">,</span> <span class="n">text_key</span><span class="o">=</span><span class="s2">&#34;text&#34;</span><span class="p">,</span> <span class="n">top_k</span><span class="o">=</span><span class="kc">None</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">model</span> <span class="o">=</span> <span class="n">_load_model</span><span class="p">()</span>  <span class="c1"># ~8s первый раз, потом из памяти</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">model</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">candidates</span>  <span class="c1"># fallback: вернуть как есть</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Жёсткий truncate для выживания на CPU</span>
</span></span><span class="line"><span class="cl">    <span class="n">max_chars</span> <span class="o">=</span> <span class="mi">256</span>
</span></span><span class="line"><span class="cl">    <span class="n">pairs</span> <span class="o">=</span> <span class="p">[(</span><span class="n">query</span><span class="p">,</span> <span class="n">c</span><span class="p">[</span><span class="n">text_key</span><span class="p">][:</span><span class="n">max_chars</span><span class="p">])</span> <span class="k">for</span> <span class="n">c</span> <span class="ow">in</span> <span class="n">candidates</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">scores</span> <span class="o">=</span> <span class="n">model</span><span class="o">.</span><span class="n">predict</span><span class="p">(</span><span class="n">pairs</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Присвоить rerank_score, отсортировать</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">c</span><span class="p">,</span> <span class="n">score</span> <span class="ow">in</span> <span class="nb">zip</span><span class="p">(</span><span class="n">candidates</span><span class="p">,</span> <span class="n">scores</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">c</span><span class="p">[</span><span class="s2">&#34;rerank_score&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="nb">float</span><span class="p">(</span><span class="n">score</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">candidates</span><span class="p">,</span> <span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">x</span><span class="p">:</span> <span class="n">x</span><span class="p">[</span><span class="s2">&#34;rerank_score&#34;</span><span class="p">],</span> <span class="n">reverse</span><span class="o">=</span><span class="kc">True</span><span class="p">)[:</span><span class="n">top_k</span><span class="p">]</span>
</span></span></code></pre></div><h3 id="интеграция-в-pipeline">Интеграция в pipeline</h3>
<p>Три точки вызова:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># search_sessions: dense search → rerank</span>
</span></span><span class="line"><span class="cl"><span class="n">fetch_limit</span> <span class="o">=</span> <span class="n">limit</span> <span class="o">*</span> <span class="mi">3</span>  <span class="c1"># 15 вместо 5</span>
</span></span><span class="line"><span class="cl"><span class="n">results</span> <span class="o">=</span> <span class="n">qdrant</span><span class="o">.</span><span class="n">query_points</span><span class="p">(</span><span class="o">...</span><span class="p">,</span> <span class="n">limit</span><span class="o">=</span><span class="n">fetch_limit</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">formatted</span> <span class="o">=</span> <span class="p">[</span><span class="nb">format</span><span class="p">(</span><span class="n">r</span><span class="p">)</span> <span class="k">for</span> <span class="n">r</span> <span class="ow">in</span> <span class="n">results</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="n">formatted</span> <span class="o">=</span> <span class="n">rerank</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="n">formatted</span><span class="p">,</span> <span class="n">text_key</span><span class="o">=</span><span class="s2">&#34;text_preview&#34;</span><span class="p">,</span> <span class="n">top_k</span><span class="o">=</span><span class="n">limit</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># search_memory: dense search → rerank (аналогично)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># hybrid_search: dense + BM25 + RRF → rerank</span>
</span></span><span class="line"><span class="cl"><span class="c1"># RRF выдаёт limit*3 кандидатов, cross-encoder сужает до limit</span>
</span></span></code></pre></div><p>Паттерн один: overfetch × 3, rerank, trim.</p>
<h3 id="конфигурация">Конфигурация</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">RERANKER_ENABLED</span><span class="o">=</span><span class="nb">true</span>          <span class="c1"># kill switch</span>
</span></span><span class="line"><span class="cl"><span class="nv">RERANKER_MODEL</span><span class="o">=</span>BAAI/bge-reranker-v2-m3
</span></span><span class="line"><span class="cl"><span class="nv">RERANKER_MAX_LENGTH</span><span class="o">=</span><span class="m">512</span>        <span class="c1"># max tokens per pair</span>
</span></span><span class="line"><span class="cl"><span class="nv">RERANKER_MAX_DOC_CHARS</span><span class="o">=</span><span class="m">256</span>     <span class="c1"># truncate text before tokenization</span>
</span></span></code></pre></div><p><code>RERANKER_ENABLED=false</code> – рубильник. Если модель поймает OOM или ляжет диск, система мгновенно деградирует до стандартного RRF-поиска без падения всего сервиса.</p>
<h2 id="бенчмарк-ab-сравнение">Бенчмарк: A/B сравнение</h2>
<p>30 вопросов из трёх категорий (IE, MR, KU) – те же вопросы, что в бенчмарке v4.</p>
<p>Методика: один и тот же запрос к <code>search_sessions</code>, с reranker и без. Сравниваю top-1 результат.</p>
<h3 id="количественные-результаты">Количественные результаты</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">                        Без reranker    С reranker
</span></span><span class="line"><span class="cl">Avg latency/query       91 ms           3,879 ms
</span></span><span class="line"><span class="cl">Top-1 изменился         —               20/30 (67%)
</span></span></code></pre></div><p>67% запросов получили другой top-1 результат. Две трети.</p>
<h3 id="качественные-примеры">Качественные примеры</h3>
<p><strong>Q3: &ldquo;Какая XSS уязвимость была найдена в UI NORA?&rdquo;</strong></p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Без reranker</th>
					<th>С reranker</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Top-1</td>
					<td><code>[{&quot;id&quot;: 1, &quot;cat&quot;: &quot;IE&quot;, &quot;q&quot;:...</code> (JSON файл)</td>
					<td><code>═══ NORA PIPELINE: fix/521-ui-xss-install-cmd ═══</code></td>
			</tr>
			<tr>
					<td>Rerank score</td>
					<td>—</td>
					<td>0.680</td>
			</tr>
	</tbody>
</table>
<p>Без reranker – случайный JSON. С reranker – правильный pipeline fix.</p>
<p><strong>Q6: &ldquo;Какой баг нашли в circuit breaker NORA?&rdquo;</strong></p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Без reranker</th>
					<th>С reranker</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Top-1</td>
					<td>&ldquo;Нашёл баг в NORA. Посмотрю код подробнее.&rdquo;</td>
					<td>&ldquo;3 настоящих фейла: circuit breaker: got 000000&rdquo;</td>
			</tr>
			<tr>
					<td>Rerank score</td>
					<td>—</td>
					<td>0.975</td>
			</tr>
	</tbody>
</table>
<p>Без reranker – бесполезный флуд из issue. С reranker – конкретный лог ошибки.</p>
<p><strong>Q (search_memory): &ldquo;nora storage backend&rdquo;</strong></p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Без reranker</th>
					<th>С reranker</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Top-1</td>
					<td>score 0.748, &ldquo;## Block 8: S3 Storage Backend&rdquo;</td>
					<td>rerank 0.991, тот же чанк</td>
			</tr>
			<tr>
					<td>Top-2</td>
					<td>score 0.704, &ldquo;### Storage (local)&rdquo;</td>
					<td>rerank 0.563, тот же</td>
			</tr>
	</tbody>
</table>
<p>Здесь dense search уже угадал правильный порядок – cross-encoder подтвердил и усилил разрыв (0.991 vs 0.563 вместо 0.748 vs 0.704).</p>
<h3 id="латентность">Латентность</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">15 кандидатов × 256 chars:  медиана 3,296 ms
</span></span><span class="line"><span class="cl">15 кандидатов × 500 chars:  медиана 5,700 ms
</span></span><span class="line"><span class="cl">10 кандидатов × 256 chars:  медиана 2,200 ms
</span></span></code></pre></div><p>Всё на CPU (AMD EPYC, 4 cores). На GPU было бы ~100ms, но ресурсы железки монопольно сожраны инференсом Ollama, а ставить вторую карту ради 30 запросов в день – overkill.</p>
<p>Чтобы не уйти за психологическую отметку в 10 секунд, режем текст чанка до 256 символов перед отправкой в cross-encoder. Latency падает вдвое, а качество не страдает: модели достаточно увидеть ключевые фразы в начале куска, чтобы понять, стоит ли он внимания.</p>
<h2 id="почему-cross-encoder-работает-лучше">Почему cross-encoder работает лучше</h2>
<h3 id="пример-circuit-breaker-bug">Пример: &ldquo;circuit breaker bug&rdquo;</h3>
<p><strong>Кандидат A</strong>: &ldquo;Нашёл баг в NORA. Посмотрю код подробнее и попробую решить.&rdquo;</p>
<p>Bi-encoder: &ldquo;баг&rdquo; + &ldquo;NORA&rdquo; → высокий cosine similarity с &ldquo;circuit breaker bug in NORA&rdquo;. Но &ldquo;circuit breaker&rdquo; не упоминается.</p>
<p><strong>Кандидат B</strong>: &ldquo;3 настоящих фейла: 1. Circuit breaker: got 000000 – curl вернул 000, значит&hellip;&rdquo;</p>
<p>Cross-encoder: видит &ldquo;circuit breaker&rdquo; в запросе И в документе. Видит &ldquo;bug&rdquo; в запросе и &ldquo;фейла&rdquo; в документе (парафраз). Score: 0.975.</p>
<p>Bi-encoder не может сделать этого – он кодирует документ не зная запроса. Cross-encoder читает пару целиком.</p>
<h3 id="когда-cross-encoder-не-помогает">Когда cross-encoder не помогает</h3>
<ol>
<li>
<p><strong>Нужного чанка нет в top-K</strong> – reranker не добавляет результаты, только переставляет. Если recall@15 = 0, никакой reranker не поможет.</p>
</li>
<li>
<p><strong>Все кандидаты одинаково релевантны</strong> – &ldquo;nora storage backend&rdquo; уже на первом месте, cross-encoder просто подтверждает.</p>
</li>
<li>
<p><strong>Запрос слишком общий</strong> – &ldquo;что делали на прошлой неделе&rdquo; – все чанки одинаково (не)релевантны.</p>
</li>
</ol>
<h2 id="физика-процесса-latency-budget">Физика процесса: latency budget</h2>
<p>Тайминги – самая болезненная часть. Полный путь запроса:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">Embedding (Ollama, mxbai)       ~50ms
</span></span><span class="line"><span class="cl">Qdrant dense search             ~20ms
</span></span><span class="line"><span class="cl">BM25 sparse search              ~5ms
</span></span><span class="line"><span class="cl">RRF fusion                      ~1ms
</span></span><span class="line"><span class="cl">────────────────────────────────────────
</span></span><span class="line"><span class="cl">Subtotal (v4)                   ~91ms
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Cross-encoder rerank (15 × 256ch)  ~3,300ms
</span></span><span class="line"><span class="cl">────────────────────────────────────────
</span></span><span class="line"><span class="cl">Total (v5)                      ~3,400ms
</span></span></code></pre></div><p>3.4 секунды на поисковый запрос. Для CLI-инструментов автоматизации и асинхронных MCP-серверов – приемлемо. Для real-time autocomplete в IDE – смерть UX.</p>
<h3 id="оптимизации-не-реализованы">Оптимизации (не реализованы)</h3>
<ol>
<li><strong>GPU inference</strong> – ~100ms вместо ~3300ms. Требует выделенного GPU или time-sharing с Ollama.</li>
<li><strong>Quantization</strong> – INT8 bge-reranker-v2-m3 может дать 2x speedup на CPU.</li>
<li><strong>Adaptive reranking</strong> – не вызывать reranker, если top-1 dense score &gt; порога (высокая confidence).</li>
<li><strong>Distilled model</strong> – ms-marco-MiniLM (22M параметров) за ~200ms, но без русского.</li>
</ol>
<p>Пока 3.3 секунды терпимо – не оптимизирую. Premature optimization – root of all evil.</p>
<h2 id="архитектурная-ретроспектива">Архитектурная ретроспектива</h2>
<p>RAG pipeline после 5 постов:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">Данные
</span></span><span class="line"><span class="cl">  ↓
</span></span><span class="line"><span class="cl">Chunking (session-level, 7 turns, overlap 3)     [пост 3/N]
</span></span><span class="line"><span class="cl">  ↓
</span></span><span class="line"><span class="cl">Indexing
</span></span><span class="line"><span class="cl">  ├── Dense vectors (mxbai-embed-large, 1024d)   [пост 2/N]
</span></span><span class="line"><span class="cl">  └── Sparse vectors (BM25, pymorphy лемматизация) [пост 4/N]
</span></span><span class="line"><span class="cl">  ↓
</span></span><span class="line"><span class="cl">Query
</span></span><span class="line"><span class="cl">  ├── Dense search (Qdrant, top-50)
</span></span><span class="line"><span class="cl">  └── Sparse search (BM25, top-50)
</span></span><span class="line"><span class="cl">  ↓
</span></span><span class="line"><span class="cl">RRF Fusion (0.7 dense + 0.3 sparse)             [пост 4/N]
</span></span><span class="line"><span class="cl">  ↓
</span></span><span class="line"><span class="cl">Top-30 candidates
</span></span><span class="line"><span class="cl">  ↓
</span></span><span class="line"><span class="cl">Cross-encoder rerank (bge-reranker-v2-m3)        [этот пост]
</span></span><span class="line"><span class="cl">  ↓
</span></span><span class="line"><span class="cl">Top-10 results → user / LLM
</span></span></code></pre></div><p>Каждый слой добавляет точности, жертвуя latency:</p>
<table>
	<thead>
			<tr>
					<th>Слой</th>
					<th>Recall вклад</th>
					<th>Latency</th>
					<th>Характер</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Dense search</td>
					<td>~70% recall</td>
					<td>~50ms</td>
					<td>Семантическое приближение</td>
			</tr>
			<tr>
					<td>BM25</td>
					<td>+10% recall</td>
					<td>~5ms</td>
					<td>Точные совпадения</td>
			</tr>
			<tr>
					<td>RRF</td>
					<td>+5% (dedup)</td>
					<td>~1ms</td>
					<td>Комбинация рангов</td>
			</tr>
			<tr>
					<td>Cross-encoder</td>
					<td>+0% recall, reorder</td>
					<td>~3300ms</td>
					<td>Точная релевантность</td>
			</tr>
	</tbody>
</table>
<p>Cross-encoder <strong>не увеличивает recall</strong>. Он не находит новые документы. Он берёт то, что уже найдено, и ставит самое релевантное наверх. Precision@1 – его метрика.</p>
<h2 id="выводы">Выводы</h2>
<ol>
<li>
<p><strong>Reranking ≠ retrieval</strong>. Cross-encoder не находит новые результаты – он тасует колоду, которую ему дали. Если recall на этапе гибридного поиска равен нулю, cross-encoder выдаст лучший кусок из худших. Сначала чиним полноту (chunking, hybrid search), затем – точность (reranking).</p>
</li>
<li>
<p><strong>67% top-1 изменений – это много</strong>. Две трети запросов получили другой первый результат. Для RAG с LLM это означает, что контекст генерации в 67% случаев будет другим – потенциально лучше.</p>
</li>
<li>
<p><strong>Каскад – универсальный паттерн</strong>. Bi-encoder + cross-encoder = L1 cache + L2 cache = bloom filter + disk. Дешёвый слой для coverage, дорогой для precision. Работает везде.</p>
</li>
<li>
<p><strong>CPU latency – основное ограничение</strong>. 568M параметров на CPU = 3.3s. Это определяет архитектуру: reranking годится для async tools (MCP, CLI), не для realtime. GPU решает проблему, но добавляет инфра-сложность.</p>
</li>
<li>
<p><strong>Truncation работает</strong>. 256 символов вместо 500 – latency падает вдвое, качество не страдает заметно. Для cross-encoder первые 256 символов документа достаточно информативны.</p>
</li>
<li>
<p><strong>Отказоустойчивость на первом месте</strong>. <code>RERANKER_ENABLED=false</code> – мгновенный откат. Модель может не загрузиться (OOM, network, disk). Инфраструктура должна уметь работать в degraded mode: сырой RRF-результат лучше, чем упавший сервис.</p>
</li>
</ol>
<hr>
<p>Следующий – про то, как 98% accuracy на своих данных разваливается на чужих.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
