Как мы сократили расходы на LLM в 12 раз
Кэширование, маршрутизация, батчинг, дистилляция. Конкретные числа.
Retrieval-Augmented Generation выглядит понятно на демо: берём документы, разбиваем на чанки, кладём в векторное хранилище, при запросе достаём релевантные кусочки и передаём в LLM. На практике – в каждом шаге есть нюансы, которые не очевидны заранее.
Этот текст – выводы из реального проекта: медиамониторинговый сервис, несколько сотен тысяч документов, требование к latency <2 секунды, несколько пользователей одновременно. Не туториал – список конкретных граблей.
flowchart LR\n Q([Query]) --> E[Embed Model]\n E --> VS[(Vector Store)]\n VS --> RR[Reranker]\n RR --> CTX[Context Builder]\n DOCS[(Documents)] --> CH[Chunking]\n CH --> E2[Embed Model]\n E2 --> VS\n CTX --> LLM[LLM]\n LLM --> A([Answer])\n\n style VS fill:#0e110e,stroke:#3d7a3d\n style LLM fill:#0e110e,stroke:#3d7a3d\n style RR fill:#0e110e,stroke:#6abf6a
Самое влиятельное решение во всём пайплайне – как именно вы режете документы. Фиксированный размер по токенам – самое простое, но редко оптимальное. Проблема: смысловые границы не совпадают с токенными. Предложение разрезается посередине, контекст теряется.
Recursive character text splitting с chunk_size=512 и chunk_overlap=64 – базовый вариант. Но лучше работал semantic chunking: разбиение по смысловым блокам через перплексию следующего предложения. Latency индексирования выше, но качество retrieval заметно лучше на длинных документах.
# Пример: semantic chunking через embeddings similarity\n\ndef semantic_chunk(text, threshold=0.85):\n sentences = sent_tokenize(text)\n embeddings = embed_model.encode(sentences)\n chunks, current = [], [sentences[0]]\n\n for i in range(1, len(sentences)):\n sim = cosine_similarity(embeddings[i - 1], embeddings[i])\n if sim < threshold:\n chunks.append(\" \".join(current))\n current = []\n current.append(sentences[i])\n\n if current:\n chunks.append(\" \".join(current))\n\n return chunks OpenAI text-embedding-3-small – хороший выбор для русскоязычных документов. Но если у вас специфический домен (юридический, медицинский, технический) – стоит смотреть на fine-tuned модели или multilingual-e5-large.
Важный момент: эмбеддинг-модель для индексирования и для запросов должна быть одна и та же версия. Звучит очевидно, но при обновлении модели нужно переиндексировать весь корпус – иначе пространства не совпадают, и качество поиска деградирует незаметно.
Деградация качества при несовпадении версий эмбеддинга – тихая. Recall падает на 15-20%, но никаких явных ошибок нет. Метрики нужно считать регулярно.
Стандартный профиль запроса: embed запроса → similarity search → fetch документов → LLM completion. В большинстве случаев узкое место – не LLM, а retrieval.
LLM галлюцинирует, когда retrieval возвращает нерелевантный контекст. Это случается чаще при высокой нагрузке – не потому что модель "устаёт", а потому что кэши retrieval переполняются, и качество поиска снижается.
Добавили relevance_score_threshold: если ни один чанк не набирает >0.75 – не передаём в LLM вообще, возвращаем стандартный ответ "недостаточно данных". Пользователи воспринимают это лучше, чем уверенную ложь.
Второе – cross-encoder reranking поверх первичного retrieval. Дороже по latency (~200ms), но заметно снижает процент плохого контекста. В нашем случае оправдано: стоимость ошибки высокая.
RAG – это не одно решение, это пайплайн из независимых компонентов, каждый из которых нужно тюнить под конкретный домен и нагрузку. Метрики нужны на каждом уровне: не только финальное качество ответа, но и recall@k, MRR, latency p95 по каждому этапу.
Если строите RAG систему – закладывайте время на эксперименты с chunking и эмбеддингами. Это не инфраструктурная задача, это исследовательская.