Почему 4 миллиона документов — это не просто "чуть больше"
Представьте: у вас есть архив. Не просто папка с файлами, а настоящий цифровой склад — 2-4 миллиона PDF, сканов, договоров, отчётов. Каждый день к нему обращаются десятки людей, и каждый раз они тратят часы на поиск нужной информации. Классический поиск по тексту уже не работает — он не понимает смысла, не находит синонимы, пропускает сканированные документы.
Решение очевидно — RAG (Retrieval-Augmented Generation). Но все облачные решения отпадают сразу: конфиденциальность данных, стоимость API-вызовов для такого объёма, зависимость от интернета. Нужно локальное решение. И вот здесь начинается настоящая инженерия, а не просто "установи библиотеку и запусти".
Главная ошибка новичков: пытаться обработать 4 миллиона документов так же, как 4 тысячи. Разница не линейная, а экспоненциальная. Проблемы с памятью, дисковым I/O, временем индексации возникают неожиданно и ломают всю систему.
Архитектура, которая не упадёт под нагрузкой
Давайте сразу отбросим учебные примеры. В них всё работает в памяти, документы — чистый текст, а поиск — по 100 записям. Наша реальность другая.
1 Слоистая архитектура: разделяй и властвуй
Одна монолитная система для 4 миллионов документов — гарантия падения. Нужно разделить ответственность:
- Ingestion Pipeline — загрузка и предобработка документов. Работает асинхронно, не блокирует поиск.
- Vector Store — хранилище эмбиддингов. Отдельный сервис с собственной оптимизацией.
- Search API — принимает запросы, комбинирует результаты, возвращает ответы.
- LLM Service — генерация ответов на основе найденных фрагментов.
Каждый слой можно масштабировать независимо. Если индексация тормозит — добавляем воркеров в Ingestion Pipeline. Если поиск медленный — оптимизируем Vector Store.
2 Выбор инструментов: не модные, а рабочие
Здесь много соблазнов взять "самое популярное на GitHub". Не делайте этого. Для 4 миллионов документов нужны инструменты, которые доказали свою устойчивость в production.
| Задача | Инструмент | Почему именно он |
|---|---|---|
| OCR для сканов | Tesseract 5 + pre-processing | Локальный, бесплатный, поддерживает 100+ языков. Ключ — правильная предобработка изображений. |
| Векторная БД | Qdrant или Weaviate | Поддерживают фильтрацию по метаданным, горизонтальное масштабирование, эффективное хранение. |
| Текстовые эмбеддинги | BGE-M3 или E5-large-v2 | Мультиязычные, хорошее качество, работают на CPU при необходимости. |
| Локальная LLM | Qwen2.5-7B-Instruct или Llama 3.1 8B | Баланс качества и требований к ресурсам. Запускаются на GPU с 8-12 ГБ памяти. |
Забудьте про FAISS для такого объёма. Он отлично работает для in-memory поиска по нескольким миллионам векторов, но не умеет в персистентное хранение, репликацию, фильтрацию. Вам нужна настоящая база данных.
OCR, который не превратит сканы в абракадабру
Сканированные документы — особая боль. Старые сканы с пятнами, плохим разрешением, наклонным текстом. Стандартный Tesseract на сырых изображениях даст 30% точности. Нам нужно 90%+.
# Не делайте так:
text = pytesseract.image_to_string(image)
# Делайте так:
def enhance_image_for_ocr(image):
# 1. Конвертация в grayscale
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# 2. Удаление шума
denoised = cv2.fastNlMeansDenoising(gray)
# 3. Повышение контраста (CLAHE)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
enhanced = clahe.apply(denoised)
# 4. Бинаризация (adaptive threshold)
binary = cv2.adaptiveThreshold(enhanced, 255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY, 11, 2)
# 5. Исправление наклона (deskew)
# ... код для определения угла и поворота
return binary
# Только потом OCR
processed = enhance_image_for_ocr(image)
text = pytesseract.image_to_string(processed, lang='rus+eng')
Этот пайплайн увеличивает точность распознавания в 2-3 раза. Но он медленный. Для 4 миллионов документов нужно распараллеливание.
Используйте GPU-ускорение для OpenCV операций. CUDA-версия OpenCV ускоряет предобработку изображений в 10-50 раз. Для масштабирования добавьте очередь задач и воркер-пул.
Безопасность: когда данные не должны уйти даже случайно
Локальное развёртывание — не гарантия безопасности. Данные могут "утечь" через:
- Модели, которые загружают что-то в интернет "для улучшения"
- Логи, которые пишутся в открытые файлы
- Временные файлы, остающиеся на диске
- Сетевые порты, открытые для всех в локальной сети
3 Жёсткие меры, которые работают
# Dockerfile с безопасной базой
FROM ubuntu:22.04
# 1. Отключаем телеметрию везде, где можно
ENV HF_HUB_DISABLE_TELEMETRY=1
ENV TRANSFORMERS_OFFLINE=1
ENV LLAMA_NO_METRICS=1
ENV ANONYMIZED_TELEMETRY=false
# 2. Используем только локальные модели
COPY models/ /app/models/
# 3. Запрещаем исходящие соединения (кроме необходимых)
# В docker-compose или Kubernetes NetworkPolicy
# 4. Шифрование данных на диске
VOLUME /encrypted_data
# 5. Запуск от непривилегированного пользователя
RUN useradd -m -u 1000 raguser
USER raguser
Дополнительные меры:
- Шифрование векторов: храните эмбеддинги зашифрованными. При поиске — расшифровывайте в памяти. Добавляет 5-10% к времени поиска, но защищает от кражи базы.
- Audit log: кто, когда, какой запрос искал. Пишется в отдельную, защищённую БД.
- Network isolation: система в отдельном VLAN, доступ только через VPN или jump-host.
Оптимизация производительности: где искать резервы
С 4 миллионами документов каждая миллисекунда имеет значение.
4 Хитрости, которые дают 10x ускорение
- Иерархический поиск: сначала быстрый coarse search по уменьшенным векторам (например, 128 вместо 768 измерений), потом точный search только по кандидатам.
- Кэширование запросов: 80% запросов повторяются. Redis с TTL 1 час снижает нагрузку на векторную БД в разы.
- Пакетная обработка: вместо embedding по одному документу — батчами по 32-128. Экономит вызовы GPU/CPU.
- Квантование моделей: INT8 квантование эмбеддинг-модели почти не теряет качество, но ускоряет inference в 2 раза и уменьшает память.
# Пример иерархического поиска в Qdrant
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance
# Создаём две коллекции: coarse (128 dim) и fine (768 dim)
client.create_collection(
collection_name="coarse_vectors",
vectors_config=VectorParams(size=128, distance=Distance.COSINE)
)
client.create_collection(
collection_name="fine_vectors",
vectors_config=VectorParams(size=768, distance=Distance.COSINE)
)
# Поиск: сначала coarse, потом fine
def hierarchical_search(query_embedding, coarse_embedding):
# 1. Быстрый поиск по coarse (top 1000)
coarse_results = client.search(
collection_name="coarse_vectors",
query_vector=coarse_embedding,
limit=1000
)
# 2. Точный поиск только по кандидатам (top 100)
candidate_ids = [r.id for r in coarse_results]
fine_results = client.search(
collection_name="fine_vectors",
query_vector=query_embedding,
query_filter=Filter(must=[
FieldCondition(key="id", match=Match(any=candidate_ids))
]),
limit=100
)
return fine_results
Этот подход сокращает время поиска с 200 мс до 50 мс при 4 миллионах векторов.
Развёртывание: от тестового стенда к production
Не пытайтесь сразу запустить всё на production-серверах. Это путь к катастрофе.
- Этап 1: MVP на подмножестве данных — возьмите 10 000 документов, отработайте весь пайплайн. Убедитесь, что OCR работает, поиск находит, LLM отвечает адекватно.
- Этап 2: Нагрузочное тестирование — добавьте ещё 100 000 документов. Проверьте, как ведёт себя система при параллельных запросах, сколько памяти ест, не падает ли при длительной индексации.
- Этап 3: Полномасштабная индексация — запустите на всех 4 миллионах. Разбейте на батчи, мониторьте прогресс, будьте готовы остановиться и пофиксить проблемы.
- Этап 4: Production с мониторингом — добавьте Prometheus метрики, алерты на рост времени ответа, ошибки OCR, заполнение диска.
Что делать, когда всё равно медленно
Даже после всех оптимизаций поиск по 4 миллионам документов — ресурсоёмкая операция. Если нужно быстрее:
- Гибридный поиск: комбинируйте семантический поиск с классическим BM25. Как в нашем руководстве по гибридному поиску, это даёт +48% точности и можно предфильтровать кандидатов быстрым BM25.
- Метаданные для фильтрации: если пользователи ищут документы за определённый период или от определённого отдела — добавьте фильтрацию по метаданным ДО семантического поиска. Это сокращает пространство поиска в разы.
- Аппаратное ускорение: GPU для инференса эмбеддингов, NVMe диски для векторной БД, много ядер для параллельного OCR. Но сначала прочитайте про сравнение железа для локальных LLM — не все GPU одинаково полезны.
Чего ждать в будущем
Через год текущее решение будет выглядеть архаично. Уже сейчас появляются:
- Специализированные векторные процессоры — в 10-100 раз быстрее GPU для поиска.
- Кросс-модальные модели — один эмбеддинг для текста, таблиц и изображений в документе.
- Умное chunking — разбиение документов не по фиксированному размеру, а по смысловым границам.
Но самая большая проблема, с которой столкнётесь вы — не технологии, а данные. Старые сканы с грязным текстом, PDF с картинками вместо текста, документы в 10 разных кодировках. Решение этих проблем — 80% работы. Остальные 20% — выбор и настройка инструментов.
Начните с малого. Возьмите 1000 документов, сделайте работающий прототип. Потом масштабируйте. И помните: идеального решения нет. Есть решение, которое работает достаточно хорошо для ваших конкретных документов и запросов.