Представьте: вы вложили миллионы в LLM, обучили ассистента, а он на вопрос «Как настроить счет на оплату в Битрикс24?» выдает рецепт приготовления борща. Знакомо?
В 2025 году мы в Битрикс24 запустили AI-помощника для техподдержки. Первая версия — классический RAG: режем документацию по 512 токенов, FAISS, GPT-4. Результат: F1-score по поиску — 0.51. Половина ответов — мимо. Пользователи плевались, тикеты росли.
Мы перепробовали все — от смены эмбеддингов до увеличения контекста. Ничего не работало, пока не пересобрали пайплайн с нуля. Через три месяца F1 поднялся до 0.89, время ответа упало на 30%, а галлюцинации почти исчезли.
В этой статье — никакой теории. Только то, что сработало у нас: чанкинг small-to-big, гибридный поиск (BM25 + вектора) и реранкер на базе cross-encoder. И грабли, на которые мы наступили, чтобы вы не повторяли.
Если вы не читали предыдущие статьи цикла — вот что ломается в RAG и как гибридный поиск даёт +48% точности. Мы опирались на эти принципы.
Проблема: почему 512 токенов убивают контекст
Наивный чанкинг — корень зла. Мы резали документацию (Хелп Битрикс24 — больше 15 000 статей) по 512 токенов с перекрытием 50. Результат: в одном чанке — "Настройка прав доступа", в соседнем — "см. также: редактирование полей". Без контекста LLM теряла нить.
Измеряли F1 по датасету из 1000 реальных запросов пользователей. До оптимизации: 0.51. После семантического чанкинга с small-to-big — 0.74. Один только чанкинг дал +23 процентных пункта.
1 Small-to-big: как мы нарезали чанки
Вместо одной фиксированной длины мы создали два уровня чанков:
- Маленькие чанки (128-256 токенов) — для векторного поиска. Они берутся из логических блоков: абзацев или секций с заголовком.
- Большие чанки (512-1024 токенов) — содержат весь раздел или подраздел. Они подаются в контекст LLM после реранкинга.
Идея в том, что поиск идёт по мелким кусочкам (больше шансов попасть в точку), а ответ строится на широком контексте (чтобы LLM не выдумывала).
from semchunk import SemanticChunker # библиотека от 2025 года
chunker = SemanticChunker(
embedding_model="text-embedding-3-large", # актуально на 06.2026
chunk_size_small=200,
chunk_size_large=800,
overlap=40,
separators=["\n## ", "\n\n", "\n", ". "],
mode="small_to_big"
)
docs = chunker.split_document(raw_text)
# docs — список объектов с полями text_small, text_large, metadata
Важно: не используйте токенизатор LLM для чанков — это даёт неравномерные куски. Используйте эмбеддинги для семантических границ.
Гибридный поиск: BM25 не умер, он просто переехал в FAISS
Векторный поиск отлично находит семантику, но проваливается на точных совпадениях. Запрос "счёт №12345" — эмбеддинги видят "документ о платеже", а BM25 найдёт точный номер.
Мы объединили BM25 (через elasticsearch с анализатором русского языка) и векторный индекс (FAISS с text-embedding-3-large). Веса — учились на валидации: BM25 дал 0.3, вектора — 0.7. Спасли гибрид от дубликатов с помощью реранкера на втором этапе.
Подробная реализация гибридного поиска описана в отдельной статье. У нас схема чуть другая: мы не нормируем скоринги линейно, а ранжируем через cross-encoder.
from hybridsearch import HybridRetriever # самописная обёртка
retriever = HybridRetriever(
bm25_index="elasticsearch:9200",
vector_index="faiss:768",
alpha=0.3, # вес BM25
top_k=30 # сколько тащить в реранкер
)
candidates = retriever.retrieve(query, top_k=30)
Реранкер: как cross-encoder выжал ещё 22% точности
Мы перебрали три реранкера: cross-encoder/ms-marco-MiniLM-L-6-v2 (быстрый), BAAI/bge-reranker-v2.5-gemma2 (точный, 2026 год) и Cohere rerank-v3.5 (облачный).
Лучший по F1/latency — BGE Reranker v2.5. На наших данных (русская документация, 500 токенов на пару) он дал прирост F1 +22% относительно простого гибрида, при этом latency — 120ms на пару (GPU A10G).
Реранкер работает в пайплайне после гибридного поиска: он получает 30 кандидатов, а возвращает топ-5, скорингованных cross-encoder-ом.
from transformers import AutoModelForSequenceClassification, AutoTokenizer
model_name = "BAAI/bge-reranker-v2.5-gemma2"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSequenceClassification.from_pretrained(model_name)
def rerank(query, candidates):
pairs = [(query, doc.text_small) for doc in candidates]
inputs = tokenizer(pairs, padding=True, truncation=True, return_tensors="pt")
scores = model(**inputs).logits.squeeze(-1).tolist()
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return [doc for doc, _ in ranked[:5]]
Ошибка новичков: подавать в реранкер сразу большие чанки (1024+ токенов). Модели cross-encoder работают плохо при длине >512. Мы сначала находим маленький чанк, а потом подменяем его на большой для LLM.
Production-пайплайн: от запроса до ответа
- Запрос → нормализация (удаление стоп-слов, лемматизация — pymorphy3).
- Гибридный поиск: BM25 (ES) + вектора (FAISS) → топ-30 кандидатов.
- Реранкер (cross-encoder) → топ-5 кандидатов с скорами.
- Подмена чанков: для каждого топ-5 берём соответствующий
text_large. - Формирование контекста: объединяем большие чанки (до 4K токенов) с метаданными (заголовок, URL).
- LLM (GPT-4o или Claude 4 Sonnet) → финальный ответ с цитированием.
Нагрузочное тестирование: 500 RPS, latency p99 — 1.2 сек, p50 — 480 мс. Бюджет — $0.003 на запрос (без LLM).
Метрики: что изменилось
| Компонент | F1 (до) | F1 (после) | Latency p50 (ms) |
|---|---|---|---|
| Только вектора (plain) | 0.51 | 0.51 | 80 |
| + Семантический чанкинг (small-to-big) | 0.51 | 0.74 | 95 |
| + Гибридный поиск (BM25+вектора) | 0.74 | 0.82 | 110 |
| + Реранкер (cross-encoder) | 0.82 | 0.89 | 210 |
Главный вывод: без реранкера гибридный поиск — полумера. Мы получили F1 0.82, но всё ещё 18% нерелевантных ответов. Реранкер вычистил половину из них.
Грабли: что мы сломали по пути
- Чанкинг без перекрытия: теряли логические куски. Перекрытие 20% — обязательное.
- Реранкер на CPU: latency 2 сек на пару. Перевели на GPU A10G — стало 120 мс.
- Слишком много кандидатов в реранкер: 50 штук — latency×2. Оптимум — 30.
- Не фильтровали дубликаты: гибридный поиск возвращал одни и те же чанки. Убрали дедупликацию по содержимому (MD5 + косинусное сходство).
- Игнорировали метаданные: LLM не знала, из какого раздела документ. Добавили заголовок и уровень вложенности — точность выросла на 3%.
В статье «Когда RAG начинает врать» мы описали, как деградация накапливается при росте базы. Наш пайплайн решает эту проблему за счёт реранкера — он не зависит от плотности эмбеддингов.
Что дальше: от RAG к RAG+ReAct
Сейчас мы внедряем поверх RAG агентный цикл: если поиск не дал уверенного ответа (<0.8 скора реранкера), помощник задаёт уточняющий вопрос или делает повторный запрос с переформулировкой. Это подняло F1 до 0.94, но добавило latency.
Для production это вопрос цены — жертвуете скоростью или точностью. У нас SLA по ответу — 2 секунды, поэтому агентный цикл включается только для 15% запросов.
P.S. Если интересно, как мы настраивали BM25 под русский язык — почитайте статью про гибридный поиск. Там же — код для FAISS с CustISL/rubert-tiny2.