Как собрать RAG-систему для поиска по документам на Python: пошаговое руководство 2026

В 2026 году Retrieval-Augmented Generation (RAG) стал стандартом де-факто для построения корпоративных AI-ассистентов. По данным Gartner, более 70% внедрений LLM в enterprise-сегменте используют RAG-архитектуру. Причина проста: языковые модели галлюцинируют, а RAG привязывает ответы к фактам из документов.

В этом руководстве я покажу, как собрать production-ready RAG-систему на Python с нуля. Мы пройдём полный пайплайн: извлечение текста из PDF и DOCX, чанкинг, эмбеддинги, векторное индексирование в Qdrant и интеграцию с LLM для генерации ответов. К концу статьи у вас будет работающий прототип, готовый к масштабированию.

Архитектура RAG: что внутри чёрного ящика

Классический RAG-пайплайн состоит из двух фаз:

  1. Индексация (offline): документы → чанки → эмбеддинги → векторная БД
  2. Инференс (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 — лишь один из многих инструментов, которые вы освоите.

← Все статьи

Комментарии