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