Как мы векторизуем 33.7M судебных решений ЕГРСР через Voyage AI
ЕГРСР — Единый государственный реестр судебных решений Украины — это по сути вся судебная практика в открытом доступе. Сейчас в проде крутится векторизация последней большой когорты — 33.7M гражданских дел через Voyage AI voyage-3.5. Разбираем пайплайн: чанкинг, параллелизм, checkpoint/resume, прод-инцидент с postgres OOM, и сколько это стоит.
Как мы векторизуем 33.7M судебных решений ЕГРСР через Voyage AI
ЕГРСР — Единый государственный реестр судебных решений — это фактически вся судебная практика Украины в открытом доступе. Мы уже векторизовали уголовные, административные, хозяйственные и КоАП-решения. Сейчас в проде крутится векторизация последней большой когорты — гражданских дел (ГПК, justice_kind=1), 33.7 миллиона документов. Разбираем, как это устроено под капотом: какие модели, какой пайплайн, сколько стоит, какие грабли.
Зачем векторизовать суды
Когда юрист ищет "есть ли практика по взысканию с банка комиссии за досрочное погашение" — он не хочет открывать 40 решений и читать их целиком. Он хочет, чтобы система нашла 5 самых релевантных, вытащила ключевые абзацы, показала аргументацию судов. Полнотекстовый поиск (FTS) по ключевым словам этого не даёт — он найдёт все документы, где встречается слово "комиссия", и их будут тысячи.
Для такой семантической задачи нужны векторные представления текста. Модель превращает абзац из решения в точку в 1024-мерном пространстве; семантически близкие абзацы — рядом. Далее kNN-поиск в Qdrant возвращает топ-K ближайших, и LLM формирует ответ на базе именно этих релевантных фрагментов.
Проблема одна: реестр большой. Очень.
Масштаб
В нашей прод-базе лежат полные тексты решений начиная с 2006 года. Разбивка по типу судопроизводства:
- Гражданское (ГПК) — 33.7M документов. Самая большая категория. ЖКХ, потребительские споры, трудовые, семейные.
- Уголовное (УПК) — 12M+
- Административное (КАС) — 14M+
- Хозяйственное (ХПК) — 6M+
- КоАП — 6M+
В Qdrant-коллекции edrsr_decisions сейчас 76.3M векторов — уже проиндексированные уголовные, админ, хозяйственные, КоАП и первые 3.37M ГПК. После завершения ГПК будет около 195M векторов в одной коллекции.
Для сравнения: типичный RAG-проект содержит 100K — 1M векторов. Наш — на два порядка больше.
Стек
Embedding-модель. voyage-3.5 от Voyage AI. 1024-мерный выход, 6 центов за миллион токенов. Мы тестировали Voyage 3 Large и OpenAI text-embedding-3-large, но выигрыш в качестве для юридических текстов не перекрывал разницы в цене (Voyage 3 Large в 3 раза дороже). На 3.5 у нас уже был индекс предыдущих юрисдикций, поэтому остаёмся на ней для совместимости.
Vector DB. Qdrant, self-hosted в Docker. Одна коллекция edrsr_decisions с HNSW-индексом. Payload содержит doc_id, court_code, judge, cause_num, justice_kind, adjudication_date, judgment_code, category_code, а также chunk_index/total_chunks и текст чанка.
Source-of-truth. PostgreSQL 15, partitioned tables: RANGE по adjudication_date, LIST по adj_year. Полные тексты лежат в edrsr_fulltext, метаданные — в edrsr_documents. JOIN по всем партициям — это 30M+ строк, поэтому пайплайн ходит по году отдельно.
Runtime. Python 3.11, asyncio, aiohttp. Никаких фреймворков — прямой HTTP к Voyage и к Qdrant. 440 строк кода, один файл.
Как нарезаем текст
Судебные решения — длинные. Среднее ГПК-решение — 8-12K символов, самые длинные — до 200K. Voyage принимает до 32K токенов на вход, но качество падает на длинных контекстах, да и один длинный вектор — это плохой retrieval: LLM не поймёт, какой именно абзац релевантен.
Поэтому чанкуем: максимум 2048 символов на чанк, overlap 50 слов между соседями. Разбиваем по абзацам, сохраняя семантическую связность. В среднем одно решение даёт 2.7 чанка.
Каждый чанк в Qdrant получает composite ID (doc_id × 1000 + chunk_index) — без коллизий, и одним payload-filter запросом вытаскиваются все чанки конкретного решения.
Параллелизм и throttling
У Voyage есть rate limit — 2000 RPM на ключ для voyage-3.5. У нас два ключа и round-robin между ними — теоретический потолок 4000 RPM. На практике держим concurrency 50 и получаем стабильно 63 документа в секунду. Это ~170 запросов в минуту на ключ — с большим запасом под rate limit.
Пробовали concurrency 70 — на первых двух миллионах всё ок, дальше процесс зависал на GIL (13% CPU, без прогресса, без ошибок — просто stuck на thread lock). Снизили до 50 — всё пошло ровно, без deadlock и без 429.
Каждая сотня документов триггерит пачку на Voyage (batch_size=500 чанков/запрос), получает эмбеддинги, формирует точки для Qdrant и делает один upsert. При ошибке от Voyage (429, сеть) — exponential backoff с джиттером, максимум 5 ретраев. При ошибке от Qdrant — retry той же пачки.
Checkpoint и resume
На 33.7M документов любой сбой — сеть, OOM, падение контейнера — означает потерю часов работы. Поэтому:
- После каждых 1000 обработанных документов пайплайн пишет checkpoint в JSON:
{last_doc_id, processed_docs, total_chunks, total_tokens, timestamp} - При старте — читает checkpoint и начинает с
WHERE doc_id > last_doc_id - Все метрики (документы, чанки, токены, стоимость) аккумулируются через checkpoint
Это уже спасло нас дважды. Первый раз — когда кончилась память у postgres-прод (об этом ниже). Второй — когда Qdrant рестартанулся и потерял API-ключ из env. В обоих случаях мы просто перезапустили с того же checkpoint без дублирования работы.
Прод-инцидент: postgres OOM
На 2.86M документов postgres-прод упал в recovery mode. Причина — несоответствие конфига: shared_buffers=16GB, но контейнерный лимит памяти — 12G. PG пытался аллоцировать больше, чем ему дано, OOM killer убивал процесс.
Фикс в PR #1453: mem_limit: 24G, shm_size: 16g. После перезапуска контейнера с новыми лимитами PG поднялся за 4 секунды и больше не падал. Этот эпизод подсветил важный инфра-паттерн: параметры postgresql.conf (shared_buffers, work_mem, maintenance_work_mem) должны быть согласованы с лимитами контейнера. Иначе система работает до первого всплеска нагрузки, а потом ложится в recovery.
Заодно увеличили swap на локальной dev-машине с 8GB до 24GB — мощная нагрузка на Voyage API генерирует много временных объектов в памяти Python-процесса, особенно когда ещё и Qdrant в фоне перестраивает индекс.
Сколько стоит
Один гражданский документ в среднем даёт 2.7 чанка × 850 токенов = 2300 токенов. При цене voyage-3.5 в 6 центов за миллион токенов один документ стоит 0.014 цента — около 138 микродолларов.
На 10% (3.37M документов) мы потратили 467 долларов за 14.8 часа. Осталось 30.33M документов — это ещё примерно 3,100 долларов и 130 часов (около 5.4 суток непрерывного прогона). Суммарная стоимость полной векторизации ГПК-когорты — около 3,600 долларов.
Для масштаба: за те же деньги на OpenAI text-embedding-3-large мы бы получили только четверть объёма. Voyage выигрывает именно на таких масштабах.
Что это даёт пользователю
Когда гражданская когорта полностью проиндексируется, семантический поиск в LEX AI будет видеть все 195M чанков единой коллекции. Юрист задаёт запрос естественным языком — "судебная практика по признанию недействительным договора купли-продажи из-за недееспособности продавца" — и система возвращает самые релевантные решения из правильной юрисдикции, с извлечением ключевых абзацев, со ссылками на ЕГРСР.
Это другой класс продукта по сравнению с FTS. FTS находит документы, где встречается фраза. Семантический поиск находит документы, где обсуждается ваш сюжет — даже если суд использовал совсем другие слова.
TL;DR
- 33.7M гражданских дел ЕГРСР → Voyage voyage-3.5 → Qdrant
- 63 документа/сек, concurrency 50, два API-ключа round-robin
- ~3,600 долларов суммарная стоимость полной векторизации ГПК
- Checkpoint/resume JSON, уже пережили два инцидента
- После завершения — 195M векторов в одной коллекции, единый семантический поиск по всей судебной практике Украины
Прод крутится в tmux, checkpoint триггерится каждые 1000 документов, мониторинг — tail -1 /tmp/vectorize-cpk.log. Скучная надёжная инженерия вместо героики.