TECH 7 хв

Як ми векторизуємо 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 року. Розбивка по типу судочинства:

У 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, падіння контейнера — означає втрати годин роботи. Тому:

Це вже врятувало нас двічі. Уперше — коли закінчилась пам'ять у 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

Прод крутиться у tmux на виділеному EC2, чекпойнт тригається кожні 1000 документів. Snapshot-синк на прод Qdrant кожні 6 годин через cron. Нудна надійна інженерія замість героїки.