RAG в production: Best practices и типичные ошибки
Retrieval-Augmented Generation (RAG) — это сейчас основа большинства enterprise AI-приложений. Но между «работает в demo» и «работает в production» — огромная пропасть.
За последний год мы запустили 12 RAG-систем для российских компаний. В этой статье — всё, что мы узнали методом проб и ошибок.
Что такое RAG (если вдруг не знаете)
RAG = поиск релевантной информации + генерация ответа
Вопрос пользователя
↓
Преобразование в embedding
↓
Поиск похожих документов в векторной БД
↓
Добавление найденных документов в промпт
↓
LLM генерирует ответ на основе документов
↓
Ответ пользователю
Зачем нужен RAG?
- LLM не знает ваших внутренних данных
- Fine-tuning слишком дорогой и медленный
- Нужны свежие данные (LLM обучены на старых данных)
- Важна прозрачность (можно показать источники)
Архитектура: как мы делаем RAG
Простая версия (MVP)
from langchain.vectorstores import Chroma
from langchain.embeddings import OpenAIEmbeddings
from langchain.llms import OpenAI
from langchain.chains import RetrievalQA
# 1. Загружаем документы
documents = load_documents("./docs")
# 2. Создаём embeddings
embeddings = OpenAIEmbeddings()
vectorstore = Chroma.from_documents(documents, embeddings)
# 3. Создаём retriever
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
# 4. Создаём QA chain
qa = RetrievalQA.from_chain_type(
llm=OpenAI(),
retriever=retriever
)
# 5. Спрашиваем
answer = qa.run("Как оформить возврат?")
Проблема: это работает только на игрушечных датасетах.
Production-версия
В реальной жизни нужно:
- Умное chunking (не просто split по 500 символов)
- Hybrid search (векторный + keyword поиск)
- Reranking (переранжирование результатов)
- Query expansion (расширение запроса)
- Caching (кэширование популярных запросов)
- Monitoring (отслеживание качества)
Разберём каждый пункт.
1. Chunking: разбивка документов
❌ Плохо: наивный split
# Делим текст каждые 500 символов
chunks = [text[i:i+500] for i in range(0, len(text), 500)]
Проблемы:
- Разрывает предложения и абзацы
- Теряется контекст
- Невозможно найти связные блоки информации
✅ Хорошо: семантическое chunking
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200, # Перекрытие между чанками
separators=["\n\n", "\n", ". ", " ", ""],
length_function=len,
)
chunks = splitter.split_text(text)
Почему лучше:
- Разбивка по логическим границам (абзацы, предложения)
- Перекрытие сохраняет контекст
- Размер чанка оптимизирован под embedding модель
⭐ Идеально: multi-level chunking
# Создаём чанки разных размеров
small_chunks = split_text(text, size=300) # Для точного поиска
medium_chunks = split_text(text, size=1000) # Основные
large_chunks = split_text(text, size=3000) # Для контекста
# При поиске:
# 1. Ищем в small chunks
# 2. Возвращаем соответствующие medium chunks с контекстом
Реальный кейс: для технической документации мы используем 3 уровня:
- Заголовки разделов (small) — для навигации
- Параграфы (medium) — для точных ответов
- Целые разделы (large) — для глубокого понимания
2. Embeddings: выбор модели
Сравнение популярных моделей
| Модель | Размерность | Качество (MTEB) | Скорость | Стоимость |
|---|---|---|---|---|
| OpenAI text-embedding-3-large | 3072 | 64.6% | Средняя | $0.13/1M tokens |
| OpenAI text-embedding-3-small | 1536 | 62.3% | Быстрая | $0.02/1M tokens |
| multilingual-e5-large | 1024 | 61.5% | Быстрая | Бесплатно (self-hosted) |
| sentence-transformers/paraphrase-multilingual | 768 | 58.2% | Очень быстрая | Бесплатно |
Наши рекомендации:
- Русский + английский:
intfloat/multilingual-e5-large— лучшее качество на русском среди open-source - Только английский: OpenAI
text-embedding-3-large - Бюджет ограничен:
sentence-transformers— бесплатно и достаточно хорошо - Специфичная domain: fine-tuned
e5на ваших данных
Fine-tuning embeddings
Для узкоспециализированных задач (медицина, юриспруденция, финтех) стандартные модели работают плохо.
Решение: fine-tune embedding модели на ваших данных.
from sentence_transformers import SentenceTransformer, InputExample, losses
from torch.utils.data import DataLoader
# Подготовка пар (вопрос, релевантный документ)
train_examples = [
InputExample(texts=['Как оформить возврат?', 'Процедура возврата...'], label=1.0),
InputExample(texts=['Сроки доставки', 'Доставляем в течение...'], label=1.0),
# ...
]
# Загрузка базовой модели
model = SentenceTransformer('intfloat/multilingual-e5-large')
# Fine-tuning
train_dataloader = DataLoader(train_examples, shuffle=True, batch_size=16)
train_loss = losses.CosineSimilarityLoss(model)
model.fit(
train_objectives=[(train_dataloader, train_loss)],
epochs=3,
warmup_steps=100
)
Результат: для одного финтех-клиента мы улучшили точность поиска с 67% до 89% после fine-tuning.
3. Vector Database: выбор и настройка
Основные варианты
Pinecone (управляемый сервис)
- ✅ Просто начать
- ✅ Автоматический scaling
- ❌ Дорого на больших объёмах
- ❌ Данные на зарубежных серверах
Qdrant (self-hosted или облако)
- ✅ Отличная производительность
- ✅ Rich filtering
- ✅ Можно развернуть локально
- ❌ Требует DevOps для self-hosted
Weaviate (self-hosted или облако)
- ✅ Hybrid search из коробки
- ✅ GraphQL API
- ❌ Сложнее в настройке
- ❌ Потребляет много памяти
PostgreSQL + pgvector (расширение Postgres)
- ✅ Не нужна дополнительная БД
- ✅ Трансакции и ACID
- ❌ Медленнее специализированных решений
- ❌ Плохо масштабируется >1M векторов
Наш выбор для production: Qdrant self-hosted на российских серверах.
Оптимизация Qdrant
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams
client = QdrantClient(host="localhost", port=6333)
# Создаём коллекцию
client.create_collection(
collection_name="documents",
vectors_config=VectorParams(
size=1024, # Размерность embeddings
distance=Distance.COSINE,
on_disk=False, # В RAM для скорости
),
# Индексация
hnsw_config={
"m": 16, # Количество связей
"ef_construct": 100,
"full_scan_threshold": 10000,
},
# Квантизация для экономии памяти
quantization_config={
"scalar": {
"type": "int8",
"quantile": 0.99,
"always_ram": True,
}
}
)
Результаты оптимизации:
- Использование RAM: -40%
- Скорость поиска: примерно та же
- Точность: -1% (незаметно на практике)
4. Hybrid Search: векторы + ключевые слова
Чистый векторный поиск плохо работает для:
- Точных названий (SKU, номера документов)
- Имён собственных
- Дат и чисел
- Специфичной терминологии
Решение: комбинируем векторный поиск с keyword search (BM25).
from langchain.retrievers import BM25Retriever, EnsembleRetriever
# Векторный retriever
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 10})
# Keyword retriever
bm25_retriever = BM25Retriever.from_documents(documents)
bm25_retriever.k = 10
# Гибридный retriever
ensemble_retriever = EnsembleRetriever(
retrievers=[vector_retriever, bm25_retriever],
weights=[0.7, 0.3] # 70% вес векторному поиску
)
Улучшение точности: на наших бенчмарках hybrid search даёт +15-20% к accuracy.
5. Reranking: улучшаем качество результатов
После retrieval получаем 10-20 потенциально релевантных документов. Но не все они одинаково полезны.
Reranker — это модель, которая переранжирует результаты по релевантности к конкретному запросу.
from sentence_transformers import CrossEncoder
# Получаем топ-20 документов
initial_docs = retriever.get_relevant_documents(query, k=20)
# Reranking модель
reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
# Переоценка релевантности
pairs = [[query, doc.page_content] for doc in initial_docs]
scores = reranker.predict(pairs)
# Сортируем по новым скорам
reranked_docs = [doc for _, doc in sorted(
zip(scores, initial_docs),
key=lambda x: x[0],
reverse=True
)][:5] # Берём топ-5 после reranking
Результат: precision@5 увеличивается с 68% до 84%.
Важно: reranking медленнее embedding search, поэтому делаем в 2 этапа:
- Быстрый retrieval → топ-20
- Медленный reranking → топ-5
6. Query Expansion: расширяем запрос
Пользователи формулируют вопросы по-разному. Query expansion помогает найти релевантные документы даже при нестандартных формулировках.
HyDE (Hypothetical Document Embeddings)
# Генерируем гипотетический ответ
hypothetical_answer = llm.generate(
f"Напиши подробный ответ на вопрос: {query}"
)
# Ищем по embedding гипотетического ответа
results = vectorstore.similarity_search(hypothetical_answer)
Идея: embedding хорошего ответа ближе к релевантным документам, чем embedding короткого вопроса.
Multi-query
# Генерируем вариации вопроса
variations = llm.generate(
f"Сгенерируй 3 вариации вопроса: {query}"
).split("\n")
# Ищем по всем вариациям
all_results = []
for variation in variations:
results = vectorstore.similarity_search(variation, k=3)
all_results.extend(results)
# Дедупликация и ранжирование
unique_results = deduplicate(all_results)
Применение: когда пользователь задаёт вопрос неточно или слишком кратко.
7. Кэширование
RAG-запрос — это дорого:
- Embedding: $0.02/1M tokens
- Vector search: 50-100ms
- LLM generation: $10-75/1M tokens
- Reranking: 200-300ms
Кэшируем популярные запросы:
from langchain.cache import RedisSemanticCache
# Semantic cache: кэширует семантически похожие запросы
cache = RedisSemanticCache(
redis_url="redis://localhost:6379",
embeddings=embeddings,
similarity_threshold=0.95
)
# При запросе сначала проверяем кэш
cached_answer = cache.lookup(query)
if cached_answer:
return cached_answer
# Если нет в кэше — делаем полный RAG
answer = rag_pipeline(query)
cache.update(query, answer)
Результаты:
- 40% запросов обрабатываются из кэша
- Latency для cached: 20ms vs 2000ms
- Экономия на LLM API: $800/месяц
8. Мониторинг и метрики
Ключевые метрики RAG
Retrieval metrics:
- Recall@k: сколько релевантных документов найдено в топ-k
- Precision@k: какой процент из топ-k действительно релевантен
- MRR (Mean Reciprocal Rank): на какой позиции первый релевантный результат
Generation metrics:
- Faithfulness: насколько ответ соответствует источникам
- Answer relevance: насколько ответ отвечает на вопрос
- Context relevance: насколько извлечённый контекст релевантен
User metrics:
- Thumbs up/down rate
- Abandon rate: пользователь переформулировал вопрос
- Time to answer
Автоматическая оценка качества
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy
# Готовим тестовый датасет
test_questions = [
{
"question": "Как оформить возврат?",
"ground_truth": "Для оформления возврата нужно...",
"contexts": retrieved_contexts,
"answer": generated_answer
},
# ...
]
# Оцениваем
results = evaluate(
test_questions,
metrics=[faithfulness, answer_relevancy]
)
print(f"Faithfulness: {results['faithfulness']}")
print(f"Answer Relevancy: {results['answer_relevancy']}")
Мы запускаем эту оценку каждую ночь на тестовом датасете из 100 вопросов. Если метрики падают — алерт в Slack.
Типичные ошибки (и как их избежать)
❌ Ошибка 1: Слишком маленький chunk size
Проблема: chunks по 200 символов теряют контекст
Решение: минимум 500-1000 символов, с перекрытием 10-20%
❌ Ошибка 2: Игнорирование метаданных
Проблема: все документы в одной куче, сложно фильтровать
Решение: добавляйте метаданные (дата, категория, источник)
vectorstore.add_texts(
texts=chunks,
metadatas=[
{"source": "user_manual", "date": "2026-01-15", "category": "setup"},
{"source": "faq", "date": "2026-02-01", "category": "returns"},
]
)
# При поиске фильтруем
results = vectorstore.similarity_search(
query,
filter={"category": "returns"}
)
❌ Ошибка 3: Не тестировать на edge cases
Проблема: RAG хорошо работает на типичных запросах, но ломается на необычных
Решение: создайте датасет из 100+ реальных вопросов, включая сложные
❌ Ошибка 4: Передавать в LLM слишком много контекста
Проблема: топ-20 документов = 20K tokens = медленно + дорого + "lost in the middle"
Решение: топ-3-5 после reranking достаточно
❌ Ошибка 5: Не обновлять индекс
Проблема: данные в векторной БД устаревают
Решение: автоматическая переиндексация (daily/weekly) или инкрементальные обновления
Production checklist
Перед выкаткой RAG в production проверьте:
- Chunking оптимизирован (семантическая разбивка, перекрытие)
- Embedding модель выбрана и протестирована
- Vector DB настроена и оптимизирована
- Hybrid search включен (векторы + keywords)
- Reranking работает
- Кэширование настроено
- Мониторинг метрик настроен
- Тестовый датасет создан (100+ вопросов)
- Latency p95 < 3 секунд
- Есть graceful degradation (fallback если RAG не работает)
Выводы
RAG — это не «просто подключить LangChain». Для production нужны:
✅ Умный chunking
✅ Правильный выбор embeddings
✅ Hybrid search
✅ Reranking
✅ Query expansion
✅ Кэширование
✅ Мониторинг
Хорошая новость: один раз настроенный RAG pipeline можно переиспользовать для разных проектов.
Нужна помощь с RAG? Мы в QZX Studio запустили 12 production RAG систем. Обсудим ваш проект.