Як ми векторизуємо 33.7M судових рішень ЄДРСР через Voyage AI
ЄДРСР — вся судова практика України у відкритому доступі. 44M+ векторів у Qdrant, 14.3M цивільних справ уже оброблено з 33.7M. Розбираємо пайплайн: чанкінг, паралелізм, checkpoint/resume, виділений EC2 для Qdrant, і скільки це коштує.
Як ми векторизуємо 33.7M судових рішень ЄДРСР через Voyage AI
ЄДРСР — Єдиний державний реєстр судових рішень — це фактично вся судова практика України у відкритому доступі. На сьогодні у Qdrant 44M+ векторів: кримінальні (19M), цивільні (14.3M), господарські (5.1M), КУпАП (5.6M). Векторизація цивільних справ (ЦПК, justice_kind=1) — найбільшої когорти з 33.7M документів — йде на виділеному EC2 (r6a.xlarge, 32 GB RAM, 2 TB gp3). Розбираємо, як це влаштовано під капотом: моделі, пайплайн, ціна, граблі і поточний стан.
Навіщо векторизувати суди
Коли юрист шукає "чи є практика по стягненню з банку комісії за дострокове погашення" — він не хоче відкривати 40 рішень і читати текстом. Він хоче, щоб система знайшла 5 найрелевантніших, витягла ключові абзаци, показала, як суди аргументували. Повнотекстовий пошук (FTS) за ключовими словами цього не дає — він знайде всі документи, де зустрічається слово "комісія", і їх будуть тисячі.
Для такої семантичної задачі потрібні векторні представлення тексту. Модель перетворює абзац із рішення на точку в 1024-вимірному просторі; схожі за змістом абзаци — поруч. Далі kNN-пошук у Qdrant повертає топ-K найближчих, і LLM формує відповідь на базі саме цих релевантних фрагментів.
Проблема лише одна: реєстр великий. Дуже.
Масштаб
У нашій прод-базі лежать повні тексти рішень починаючи з 2006 року. Розбивка по типу судочинства:
- Цивільне (ЦПК) — 33.7M документів. Найбільша категорія. ЖКГ, споживчі спори, трудові, сімейні.
- Кримінальне (КПК) — 12M+
- Адміністративне (КАС) — 14M+
- Господарське (ГПК) — 6M+
- КУпАП — 6M+
У Qdrant-колекції edrsr_decisions на виділеному EC2 зараз 44M+ векторів (122 сегменти, on_disk=true):
| Тип судочинства | justice_kind | Векторів |
|---|---|---|
| Кримінальне (КПК) | 2 | 19,036,347 |
| Цивільне (ЦПК) | 1 | 14,328,427 |
| КУпАП | 5 | 5,579,432 |
| Господарське (ГПК) | 3 | 5,098,662 |
| Разом | 44,042,868 |
Цивільних оброблено 14.3M з 33.7M — це 42%. Після завершення ЦПК буде близько 63M+ векторів у одній колекції.
Для порівняння: типовий проект на 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 v1.17, self-hosted у Docker на виділеному EC2 (r6a.xlarge — 4 CPU, 32 GB RAM, 2 TB gp3). Колекція edrsr_decisions з HNSW-індексом, on_disk=true для і векторів, і payload. Payload містить doc_id, court_code, judge, justice_kind, adjudication_date, а також chunk_index/total_chunks і текст чанка. Виділений інстанс — бо 44M+ точок із HNSW на проді вбивали RAM і блокували чат-сервіс (OOM kills при оптимізації сегментів).
Source-of-truth. PostgreSQL 15, partitioned tables: RANGE по adjudication_date, LIST по adj_year. Повні тексти лежать у edrsr_fulltext, метадані — у edrsr_documents. JOIN по всіх партиціях — це мільйонів 30 рядків, тому пайплайн ходить по року окремо.
Runtime. Python 3.11, asyncio, aiohttp. Ніяких фреймворків — прямий HTTP до Voyage і до Qdrant. 440 рядків коду, один файл.
Як нарізаємо текст
Судові рішення — довгі. Середнє ЦПК-рішення — 8-12K символів, найдовші — до 200K. Voyage приймає до 32K токенів на вхід, але якість падає на довгих контекстах, та й один довгий вектор — це поганий retrieval: LLM не зрозуміє, який саме абзац релевантний.
Тому чанкуємо: максимум 2048 символів на чанк, оверлап 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 оброблених документів пайплайн пише чекпойнт у JSON:
{last_doc_id, processed_docs, total_chunks, total_tokens, timestamp} - При старті — читає чекпойнт і починає з
WHERE doc_id > last_doc_id - Всі метрики (документи, чанки, токени, вартість) акумулюються через чекпойнти
Це вже врятувало нас двічі. Уперше — коли закінчилась пам'ять у postgres-прод (про це нижче). Удруге — коли Qdrant рестартанувся і загубив API-ключ із env. У обох випадках ми просто перезапустили з того самого чекпойнта без дублювання роботи.
Прод-інцидент: 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 мікродоларів.
На сьогодні оброблено 14.3M документів з 33.7M — це 42% когорти. Витрачено приблизно 1,980 доларів на Voyage API і близько 63 годин роботи пайплайна. Залишилося ще 19.4M документів — це приблизно 2,680 доларів і 85 годин (3.5 доби безперервного прогону). Сумарна вартість повної векторизації ЦПК-когорти — близько 4,660 доларів.
Плюс EC2 r6a.xlarge для Qdrant — ~$0.20/год (on-demand), приблизно $145/міс. Це дешевше, ніж OOM-інциденти на проді.
Для розуміння масштабу: за ті самі гроші на OpenAI text-embedding-3-large ми б отримали тільки чверть обʼєму. Voyage виграє саме на таких масштабах.
Що це дає користувачу
Вже зараз семантичний пошук працює по 44M+ векторів. Коли цивільна когорта повністю проіндексується, у колекції буде 63M+ чанків. Юрист ставить запит природною мовою — "судова практика по визнанню недійсним договору купівлі-продажу через недієздатність продавця" — і система повертає найрелевантніші рішення із правильної юрисдикції, з витягом ключових абзаців, з посиланнями на ЄДРСР.
Це інший клас продукту порівняно з FTS. FTS знаходить документи, де зустрічається фраза. Семантичний пошук знаходить документи, де обговорюється ваш сюжет — навіть якщо суд використовував зовсім інші слова.
TL;DR
- 33.7M цивільних справ ЄДРСР → Voyage voyage-3.5 → Qdrant (14.3M / 33.7M = 42% готово)
- 44M+ векторів у Qdrant на виділеному EC2 (r6a.xlarge, 32 GB RAM)
- 63 документа/сек, concurrency 50, два API-ключі round-robin
- ~4,660 доларів сумарна вартість повної векторизації ЦПК + ~$145/міс EC2
- Checkpoint/resume JSON, уже вижили два інциденти
- Після завершення — 63M+ векторів у одній колекції, єдиний семантичний пошук по всій судовій практиці України
Прод крутиться у tmux на виділеному EC2, чекпойнт тригається кожні 1000 документів. Snapshot-синк на прод Qdrant кожні 6 годин через cron. Нудна надійна інженерія замість героїки.