В 2026 году Retrieval-Augmented Generation (RAG) стал стандартом де-факто для построения корпоративных AI-ассистентов. По данным Gartner, более 70% внедрений LLM в enterprise-сегменте используют RAG-архитектуру. Причина проста: языковые модели галлюцинируют, а RAG привязывает ответы к фактам из документов.
В этом руководстве я покажу, как собрать production-ready RAG-систему на Python с нуля. Мы пройдём полный пайплайн: извлечение текста из PDF и DOCX, чанкинг, эмбеддинги, векторное индексирование в Qdrant и интеграцию с LLM для генерации ответов. К концу статьи у вас будет работающий прототип, готовый к масштабированию.
Архитектура RAG: что внутри чёрного ящика
Классический RAG-пайплайн состоит из двух фаз:
- Индексация (offline): документы → чанки → эмбеддинги → векторная БД
- Инференс (online): запрос → эмбеддинг запроса → поиск top-k чанков → контекст + промпт → LLM → ответ
Ключевое преимущество RAG перед fine-tuning — вы не переобучаете модель. LLM остаётся замороженной, а знания обновляются через векторную БД. Это даёт:
- Актуальность: обновил документы — система сразу отвечает по-новому
- Прозрачность: можно показать пользователю, откуда взят ответ
- Контроль затрат: индексация копеечная, а инференс через LLM — только на запросах
Инструменты 2026 года: что используем
| Компонент | Инструмент | Версия | Зачем |
|---|---|---|---|
| Парсинг документов | unstructured |
0.16+ | Извлечение текста из PDF, DOCX, HTML |
| Чанкинг | langchain-text-splitters |
0.3+ | Рекурсивное разбиение текста |
| Эмбеддинги | voyage-ai (voyage-3-lite) |
2026 | 1024-мерные эмбеддинги, дешевле OpenAI |
| Векторная БД | Qdrant (self-hosted) |
1.13+ | Быстрый ANN-поиск с фильтрацией |
| LLM | DeepSeek-V3 через API |
2026 | 128K контекст, $0.27/M токенов |
| Оркестрация | LlamaIndex |
0.12+ | Управление индексом и ретривером |
Почему именно этот стек? Voyage-3-lite даёт качество эмбеддингов на уровне OpenAI text-embedding-3-small, но в 3 раза дешевле. Qdrant — лучшая open-source векторная БД с графовым HNSW-индексом. DeepSeek-V3 — самый дешёвый LLM с контекстом 128K токенов, идеально для RAG.
Шаг 1. Извлечение текста из документов
Первая проблема: документы редко бывают «чистым текстом». PDF могут быть отсканированными изображениями, а DOCX — сложной вёрсткой. Используем библиотеку unstructured — она умеет детектить layout и извлекать текст с учётом структуры.
import hashlib
from unstructured.partition.auto import partition
def extract_text(file_path: str) -> list[dict]:
"""Извлекает элементы документа с метаданными"""
elements = partition(
filename=file_path,
strategy="auto", # auto-определение типа документа
pdf_infer_table_structure=True, # распознавание таблиц в PDF
languages=["rus", "eng"] # поддержка русского и английского
)
docs = []
for el in elements:
if el.text.strip():
doc_id = hashlib.md5(el.text.encode()).hexdigest()[:12]
docs.append({
"doc_id": doc_id,
"text": el.text,
"type": str(type(el).__name__), # Title, NarrativeText, Table и т.д.
"page_number": el.metadata.page_number if el.metadata else None
})
return docs
# Пример: парсим PDF с отчётом
docs = extract_text("annual_report_2025.pdf")
print(f"Извлечено {len(docs)} элементов")
# Вывод: Извлечено 347 элементов
unstructured автоматически определяет, что делать: для текстового PDF использует PyPDF2, для сканов — OCR через Tesseract (если установлен), для DOCX — python-docx. Важно: на production-системах рекомендую запускать это в отдельном микросервисе, так как OCR потребляет много CPU.
Шаг 2. Чанкинг: режем правильно
LLM имеют ограничение на контекст (у DeepSeek-V3 — 128K токенов, но это не значит, что нужно пихать всё). Исследования показывают: оптимальный размер чанка для RAG — 512-1024 токенов с перекрытием 10-20%. Почему? Слишком мелкие чанки теряют контекст, слишком крупные — размывают семантику.
Используем рекурсивный сплиттер из LangChain, который уважает границы абзацев и предложений:
from langchain_text_splitters import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1024, # в символах, примерно 250-300 токенов
chunk_overlap=200, # перекрытие для сохранения контекста
separators=["\n\n", "\n", ". ", " ", ""], # приоритет разделителей
length_function=len,
is_separator_regex=False
)
def chunk_documents(docs: list[dict]) -> list[dict]:
chunks = []
for doc in docs:
texts = text_splitter.split_text(doc["text"])
for i, chunk_text in enumerate(texts):
chunks.append({
"chunk_id": f"{doc['doc_id']}_chunk_{i}",
"text": chunk_text,
"metadata": {
"source": doc.get("source", "unknown"),
"page": doc.get("page_number"),
"type": doc.get("type"),
"chunk_index": i
}
})
return chunks
chunks = chunk_documents(docs)
print(f"Получено {len(chunks)} чанков из {len(docs)} элементов")
# Вывод: Получено 892 чанков из 347 элементов
Важный нюанс: для таблиц стоит использовать отдельный сплиттер, который сохраняет строки как единое целое. unstructured возвращает таблицы как отдельные элементы с типом Table — их лучше не резать, а индексировать целиком.
Шаг 3. Эмбеддинги: превращаем текст в векторы
Эмбеддинги — это «мостик» между текстом и векторной БД. В 2026 году лидеры рынка: OpenAI text-embedding-3-small (1536 dim), Voyage-3-lite (1024 dim) и Cohere Embed v3 (1024 dim). Я выбираю Voyage AI по соотношению цена/качество.
import voyageai
import numpy as np
VOYAGE_API_KEY = "ваш-ключ" # храните в .env, не в коде!
vo = voyageai.Client(api_key=VOYAGE_API_KEY)
def embed_chunks(chunks: list[dict], batch_size: int = 128) -> np.ndarray:
"""Получает эмбеддинги для всех чанков батчами"""
texts = [chunk["text"] for chunk in chunks]
all_embeddings = []
for i in range(0, len(texts), batch_size):
batch = texts[i:i+batch_size]
result = vo.embed(
texts=batch,
model="voyage-3-lite",
input_type="document", # оптимизация для длинных текстов
truncation=True
)
all_embeddings.extend(result.embeddings)
print(f"Обработано {min(i+batch_size, len(texts))}/{len(texts)} чанков")
return np.array(all_embeddings, dtype=np.float32)
embeddings = embed_chunks(chunks)
print(f"Размерность эмбеддингов: {embeddings.shape}")
# Вывод: Размерность эмбеддингов: (892, 1024)
Совет: всегда используйте input_type="document" для индексации и input_type="query" для поиска. Voyage AI обучает разные projection heads для этих режимов, что повышает точность retrieval на 5-8% по метрике Recall@10.
Шаг 4. Векторная БД: Qdrant в Docker
Qdrant — это Rust-based векторная БД с HNSW-индексом, которая даёт latency <10ms на 1M векторов. Запускаем локально через Docker:
docker run -d --name qdrant \
-p 6333:6333 \
-p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:latest
Теперь создаём коллекцию и загружаем чанки:
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, PointStruct
client = QdrantClient(host="localhost", port=6333)
COLLECTION_NAME = "documents_rag"
# Создаём коллекцию, если её нет
client.recreate_collection(
collection_name=COLLECTION_NAME,
vectors_config=VectorParams(
size=1024, # размерность эмбеддингов Voyage-3-lite
distance=Distance.COSINE # косинусная близость
)
)
# Подготавливаем точки для вставки
points = []
for i, (chunk, embedding) in enumerate(zip(chunks, embeddings)):
points.append(PointStruct(
id=i, # уникальный integer ID
vector=embedding.tolist(),
payload={
"chunk_id": chunk["chunk_id"],
"text": chunk["text"],
"source": chunk["metadata"].get("source", ""),
"page": chunk["metadata"].get("page", 0)
}
))
# Вставляем батчами по 256 точек
BATCH_SIZE = 256
for i in range(0, len(points), BATCH_SIZE):
batch = points[i:i+BATCH_SIZE]
client.upsert(
collection_name=COLLECTION_NAME,
points=batch
)
print(f"Загружено {min(i+BATCH_SIZE, len(points))}/{len(points)} точек")
print("Индексация завершена!")
# Вывод: Загружено 892/892 точек
# Индексация завершена!
Почему Qdrant, а не Pinecone? В 2026 году Pinecone всё ещё популярен, но для self-hosted решений Qdrant даёт больше контроля: вы управляете данными, нет lock-in, и за 1M векторов платите только за сервер ($30/мес на Hetzner против $70/мес у Pinecone).
Шаг 5. Ретривер: ищем релевантные чанки
Теперь напишем функцию поиска, которая по запросу находит top-k чанков:
def search_documents(query: str, top_k: int = 5) -> list[dict]:
"""Ищет релевантные чанки по запросу"""
# Получаем эмбеддинг запроса (режим query!)
query_embedding = vo.embed(
texts=[query],
model="voyage-3-lite",
input_type="query"
).embeddings[0]
# Поиск в Qdrant
search_result = client.search(
collection_name=COLLECTION_NAME,
query_vector=query_embedding,
limit=top_k,
with_payload=True,
score_threshold=0.65 # отсекаем нерелевантные
)
results = []
for scored_point in search_result:
results.append({
"text": scored_point.payload["text"],
"score": scored_point.score,
"source": scored_point.payload.get("source", ""),
"page": scored_point.payload.get("page", 0)
})
return results
# Тест
results = search_documents("Какова выручка компании в 2025 году?", top_k=3)
for r in results:
print(f"Score: {r['score']:.3f} | Источник: {r['source']} стр.{r['page']}")
print(f"Текст: {r['text'][:100]}...")
print("---")
Обратите внимание на score_threshold=0.65. Это защита от мусорных результатов: если запрос вообще не про документы, ретривер вернёт пустой список, и LLM честно скажет «информация не найдена» вместо галлюцинации.
Шаг 6. Генерация ответа: LLM + контекст
Финальный этап — передать найденные чанки в LLM с правильным промптом. Используем DeepSeek-V3 через API:
from openai import OpenAI # DeepSeek совместим с OpenAI SDK
DEEPSEEK_API_KEY = "ваш-ключ"
client_llm = OpenAI(
api_key=DEEPSEEK_API_KEY,
base_url="https://api.deepseek.com/v1"
)
def generate_answer(query: str, context_chunks: list[dict]) -> str:
"""Генерирует ответ на основе контекста"""
# Формируем контекст из найденных чанков
context = "\n\n---\n\n".join([
f"[Источник: {chunk['source']}, стр. {chunk['page']}]\n{chunk['text']}"
for chunk in context_chunks
])
system_prompt = """Ты — AI-ассистент для поиска по корпоративным документам.
Отвечай на русском языке, используя только предоставленный контекст.
Если контекст не содержит ответа, напиши: "Информация не найдена в документах."
Всегда указывай источник информации в формате [Источник: название, стр. X]."""
user_prompt = f"Контекст:\n{context}\n\nВопрос: {query}"
response = client_llm.chat.completions.create(
model="deepseek-chat", # DeepSeek-V3
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
temperature=0.3, # низкая температура для фактологичности
max_tokens=1024
)
return response.choices[0].message.content
# Полный пайплайн
def rag_pipeline(query: str) -> str:
chunks = search_documents(query, top_k=5)
if not chunks:
return "Информация не найдена в документах."
return generate_answer(query, chunks)
# Тест
answer = rag_pipeline("Какова выручка компании в 2025 году?")
print(answer)
# Вывод: Согласно годовому отчёту, выручка компании в 2025 году составила 12.4 млрд рублей, что на 18% выше показателя 2024 года. [Источник: annual_report_2025.pdf, стр. 7]
Оптимизация: что реально важно в production
После того как прототип заработал, нужно думать о production-качестве. Вот три критических улучшения:
1. Hybrid search (keyword + vector)
Чисто векторный поиск плохо работает с аббревиатурами и точными названиями (например, «ИНН 7701234567»). Решение — добавить BM25-поиск через Tantivy или Elasticsearch и скомбинировать результаты с эмбеддингами. Qdrant поддерживает гибридный поиск нативно с версии 1.10.
2. Reranking
После retrieval первых 20-50 чанков используйте cross-encoder для реранжирования. Модель BAAI/bge-reranker-v2-m3 даёт +10-15% к точности ответов. Запускать её нужно на GPU или через API (например, Cohere Rerank).
3. Мета-фильтрация
Добавьте в Qdrant фильтры по метаданным: дата документа, тип, автор. Это позволяет делать запросы вида «найди в договорах за 2025 год пункт о неустойке». Фильтрация выполняется на уровне индекса до ANN-поиска, что не замедляет работу.
Сравнение затрат
| Компонент | Цена | На 10K запросов/мес |
|---|---|---|
| Эмбеддинги (Voyage-3-lite) | $0.0001/текст | $1.00 |
| Векторная БД (Qdrant self-hosted) | $30/мес | $30.00 |
| LLM (DeepSeek-V3) | $0.27/M входных токенов | ~$10.00 (при контексте 2K токенов) |
| Итого | ~$41/мес |
Для сравнения, использование GPT-4o-mini обойдётся в $0.15/M входных токенов, но качество ответов для RAG ниже, чем у DeepSeek-V3. GPT-4o — $2.50/M, в 10 раз дороже.
Заключение
Мы построили полноценную RAG-систему на Python, которая умеет индексировать PDF/DOCX, искать по ним и отвечать на вопросы с указанием источников. Весь код умещается в ~150 строк, а стоимость эксплуатации — $41 в месяц на 10K запросов.
Что дальше? Добавьте обработку изображений (диаграммы, графики) через мультимодальные эмбеддинги CLIP, подключите агентный слой с памятью для multi-turn диалогов и поставьте всё это на FastAPI для интеграции с корпоративными системами.
RAG — это не просто модный тренд. Это практичный инструмент, который решает реальную проблему: как сделать LLM полезной в работе с документами, не теряя контроль над фактами. Если вы работаете с данными — освойте этот пайплайн. Он окупится с первого же проекта.
Хотите глубже разобраться в Data Science и построении AI-систем? Обратите внимание на курс «Data Science с нуля» — там мы с нуля проходим Python для анализа данных, работу с Pandas и NumPy, визуализацию, статистику и машинное обучение на реальных проектах. RAG — лишь один из многих инструментов, которые вы освоите.
Комментарии