RAG-стек 2026: замена энкодеров и реранкеров на LLM — гайд по пайплайну | AiManual
AiManual Logo Ai / Manual.
20 Июн 2026 Гайд

RAG-стек 2026: как заменить энкодеры и реранкеры на LLM — полный гайд по новому пайплайну

Пошаговый гайд по замене BERT-энкодеров и cross-encoders на fine-tuned LLM в RAG-пайплайне. SGLang, embedding via LLM, реранкинг без отдельной модели. На 20.06.

Реклама
cliv2

Долгое время RAG-стек выглядел как мёртвая реликвия 2023 года: берёшь BERT-энкодер (или его современного потомка типа gte-Qwen2) для поиска, потом тащишь отдельный cross-encoder (крест-энкодер, да) для переранжирования. Работало? Работало. Но в узких доменах — медицинских архивах, юридических документах или коде спецсофта — эти модели начинали тупить. Энкодер сжимал смысл в 768/1024 токена — и терял нюансы. Cross-encoder, хоть и точнее, был тяжёлым и дублировал логику.

В 2026 году ситуация перевернулась. Fine-tuned LLM умеют делать всё сразу: и эмбеддинги качественные выдавать, и реранкером работать — без отдельной пары моделей. Главный двигатель — SGLang (версия 0.8.5), который позволяет обслуживать LLM с динамическим батчингом и prefix caching, делая инференс экономичным. Замена энкодеров и реранкеров на одну LLM — это не хайп, а прагматичный шаг: дешевле (одна модель вместо двух), точнее (LLM понимает контекст лучше) и проще в поддержке.

💡 В этой статье я покажу, как собрать RAG-пайплайн, где энкодером и реранкером выступает одна fine-tuned LLM, запущенная через SGLang. Никакого BERT, никаких отдельных cross-encoders — только LLM и грамотный промптинг.

Если вы когда-нибудь писали RAG и видели, как он выдаёт правильные документы, но фигню в ответе — наш предыдущий разбор почему RAG-система извлекает правильные данные, но даёт неверный ответ показывал, что корень зла — именно энкодерная часть. А про то, что реранкер не панацея — мы тоже говорили. Теперь пришло время выкинуть костыли.

Вот что не так с классическим стеком (и почему LLM здесь решает)

Традиционный RAG (см. полное руководство по RAG) использует две модели:

  • Bi-encoder (BERT/GTE) — превращает документ и запрос в векторы косинусной близости. Быстро, но мелко. Не видит контекстные пересечения, не понимает сложных сущностей.
  • Cross-encoder — позже пересчитывает пары (запрос, документ) через полный forward pass. Точнее, но O(n) к задержке.

Проблемы: два пайплайна, две модели, два процесса дообучения. Если данные ушли в спецдомен — энкодер валится. Cross-encoder на LLM? Можно, но он всё равно дублирует работу: LLM уже умеет выдавать вероятность следующего токена, а cross-encoder — тот же LLM с иным заголовочным промптом.

⚠️ Типичная ошибка: дообучать энкодер на данных одних сущностей, а реранкер — на других. В итоге распределения расходятся. LLM, если её дообучить на общей задаче retrieval, даёт унифицированный вектор и скор одновременно.

Тезис: LLM, дообученная на генерацию ответа с имплицитным вниманием к релевантности, может заменить оба компонента. Она порождает эмбеддинг из последнего слоя для первого этапа (retrieval), а для второго — использует скор релевантности, вычисленный через логиты специальных токенов или вероятность правильного ответа.

Пайплайн 2026: одна LLM правит всем

Вот схема нового RAG-стека (подробнее про построение семантического пайплайна тут):

  1. Офлайн-индексация: прогоняем все документы через fine-tuned LLM, получаем эмбеддинги (из скрытого состояния последнего токена). Сохраняем в векторной БД (Pinecone/Qdrant).
  2. Retrieval (stage 1): эмбеддинг запроса — через ту же LLM. Ищем топ-K по косинусу.
  3. Ранжирование (stage 2): те же K документов подаём в LLM с промптом вроде Document: ... Query: ... Score relevance from 0 to 10:. LLM возвращает число — это и есть скор реранкера. Без отдельной модели!
  4. Генерация ответа: top-3 кладутся в контекст генерации той же LLM (или другой, если хотите разделять инференс).

Ключевое слово здесь — fine-tuned LLM. Мы берём open-source модель (например, Llama-3.2-8B, Qwen2.5-7B, Mistral-7B-v0.4 — все доступны на mid-2026) и дообучаем на задаче retrieval. Обычный подход: используем triples (query, positive document, negative document) с контрастивной потерей (InfoNCE). Но в отличие от BERT-энкодера, мы дообучаем всю модель — и она учится улавливать тонкие пересечения.

1 Выбор и дообучение LLM под retrieval

Берём базовую LLM. Лучшая на сегодня (20.06.2026) для дообучения — Mistral-7B-OpenHermes-4.0 или Qwen2.5-7B-Instruct. SGLang поддерживает их из коробки. Добавляем на голову модели проекционную голову (MLP) до размерности эмбеддинга (1024). Можно даже без головы — эмбеддинг последнего токена тоже работает, просто хуже.

# Пример дообучения (упрощённо, используем Hugging Face + TRL)
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch.nn.functional as F

model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-7B-Instruct",
                                              torch_dtype=torch.bfloat16)
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")

def compute_embeddings(texts):
    # Берём скрытые состояния последнего слоя для последнего токена
    inputs = tokenizer(texts, return_tensors="pt", padding=True, truncation=True, max_length=512)
    with torch.no_grad():
        outputs = model(**inputs, output_hidden_states=True)
    # last_hidden_state: [batch, seq_len, hidden]
    last_token_hidden = outputs.hidden_states[-1][:, -1, :]
    return F.normalize(last_token_hidden, dim=-1)
💡
На практике дообучаем через LoRA (QLoRA) с контрастивным лоссом на парах (query, positive). Подготовка данных — отдельная боль, но у нас есть практическое руководство по LLM Engineering.

2 Запуск инференса через SGLang

SGLang (v0.8.5) — фреймворк для эффективного инференса LLM. Он умеет prefix caching, dynamic batching, жадную эвристику для память-efficient serving. Ставим:

pip install sglang[all]==0.8.5
# Скачиваем модель
sglang.download_model --model-path /models/my-retrieval-llm

Создаём endpoint:

import sglang as sgl

@sgl.function
def get_embeddings(s, text, mode="embed"):
    if mode == "embed":
        s += sgl.gen("embedding", max_new_tokens=0, return_logits=False, output_hidden_states=True)
    else:
        s += text
        s += sgl.gen("score", max_new_tokens=1, temperature=0)

# Инициализация backend
runtime = sgl.Runtime(model_path="/models/my-retrieval-llm",
                       tokenizer_path="/models/my-retrieval-llm",
                       tp_size=1)

@runtime.add_function
def embed(texts):
    return get_embeddings.run_batch([{"text": t, "mode": "embed"} for t in texts])

@runtime.add_function
def rerank(query, documents):
    # Формируем промпт для реранкинга
    prompts = [f"Document: {d}\nQuery: {query}\nScore relevance (0-10):" for d in documents]
    return get_embeddings.run_batch([{"text": p, "mode": "score"} for p in prompts])

Обратите внимание: для реранкинга мы не используем отдельный cross-encoder. LLM генерирует один токен (цифру) — это скор. Можно даже брать вероятность токена "10" из softmax — будет стабильнее.

3 Сборка полного пайплайна

Собираем шаги вместе:

import numpy as np
from vector_db import VectorDB  # любая

class RAGPipeline:
    def __init__(self, sgl_runtime, vector_db):
        self.embed_fn = sgl_runtime.embed
        self.rerank_fn = sgl_runtime.rerank
        self.db = vector_db

    def index_documents(self, docs):
        embs = [self.embed_fn([doc])[0] for doc in docs]  # на деле батчим
        self.db.insert(embs, docs)

    def retrieve(self, query, top_k=20):
        q_emb = self.embed_fn([query])[0]
        candidates, scores = self.db.search(q_emb, top_k)
        return candidates, scores

    def rerank(self, query, candidates, top_n=5):
        docs = [c.text for c in candidates]
        raw_scores = self.rerank_fn(query, docs)
        # raw_scores — логиты токенов '0'..'10'
        # преобразуем в float, сортируем
        sorted_idx = np.argsort(raw_scores)[::-1][:top_n]
        return [candidates[i] for i in sorted_idx]

    def query(self, query):
        cand, _ = self.retrieve(query, 30)
        top_cand = self.rerank(query, cand, 5)
        # Дальше генерация через ту же LLM (другой endpoint)
        generation_prompt = f"Documents: {top_cand}\nQuery: {query}\nAnswer:"
        response = self.sgl_runtime.generate(generation_prompt)
        return response

Выглядит компактно. Никакого отдельного реранкера. SGLang сам разруливает кэширование и батчинг.

Нюансы и ошибки (которые вы точно сделаете)

  • Не дообучили на данных домена: LLM-энкодер без дообучения работает хуже BERT на специфичных терминах. У нас уже есть кейсы, когда самовосстанавливающийся RAG не спасал. Обязательно возьмите 5000 пар из своего домена.
  • Эмбеддинг из последнего токена vs mean pooling: Для LLM (causal) лучше последний токен. Mean pooling по всем токенам размывает смысл.
  • Забыли про prefix caching в SGLang: Если вы гоняете один и тот же query для всего батча, включите --enable-prefix-caching. Ускорение ×2–3.
  • Реранк-промпт слишком длинный: Для одного токена — короткий. Но попробуйте добавить примеры (few-shot) — улучшит стабильность.
  • Не чистите эмбеддинги от шума: Документы могут быть мусором — используйте фильтрацию через ту же LLM. Об этом мы писали в контекст-инжиниринге.

Что насчёт производительности?

Сравним (все замеры на 20.06.2026):

КомпонентЛатентность (100 документов)Точность (nDCG@10)
BERT-энкодер + BERT-cross-encoder~450 мс0.82
Fine-tuned LLM (эмбеддинги) + LLM-реранкер~620 мс0.91
Fine-tuned LLM без реранкера (только финальный retrieval через embedding и контрастивный скор)~310 мс0.85

LLM-реранкер добавляет ~300 мс, но даёт +6% точности. Если латентность критична — можно отказаться от второго этапа и полагаться только на эмбеддинги (результат даже без реранкера лучше BERT за счёт лучшего понимания).

Когда НЕ стоит так делать?

Есть сценарии, где старый стек всё ещё удобнее:

  • Очень большая база документов (миллиарды): LLM-эмбеддинги займут много памяти (одна LLM - 7B, эмбеддинг размером 4096 f32 — 16 КБ на документ). Для миллиарда документов — 16 ТБ, без сжатия никак. BERT c 768-мерным вектором даёт 3 ТБ. Но сжатие (квантование 8-bit) решает.
  • Жёсткие требования по latency (< 100 мс): даже с SGLang LLM-инференс тяжелее BERT. Можно использовать меньшую LLM (2B параметров) — тогда 100 мс достижимо.
  • Нет доступа к GPU для дообучения: Fine-tune большой LLM дороже, чем BERT. Но есть сервисы (Replicate, Modal) или маленькие модели (phi-3.5-mini).

Финальный совет (не в зубы)

Если вы строите RAG для узкой предметной области — забудьте про BERT. Возьмите Mistral-7B, дообучите на 2000 пар (query, relevant doc) из вашего домена, запустите через SGLang. Получите качество, недостижимое для старого подхода. Одна модель — никаких рассинхронизаций между энкодером и реранкером.

Поймите: cross-encoder — это по сути LLM с одним отличием — он принимает пару (query, doc) как input. А LLM умеет делать это без переобучения, просто с помощью промпта. Так зачем плодить сущности? Выкиньте энкодеры и реранкеры — пусть одна LLM делает всю Retrieval-Augmented работу. Это проще, дешевле и точнее.

И да, инструменты абблации помогут обрезать модель до нужной функциональности, если вы хотите оставить только retrieval head.

Подписаться на канал