LEX — AI Legal Platform for Law Firms

AI-powered legal analysis platform for law firms and corporate counsel.

Features

Resources

Blog Articles

Technology

Built on AWS (EC2, Bedrock Claude AI, ALB, WAF, S3, ACM, KMS). PostgreSQL, Redis, Qdrant vector database. TypeScript, React, Node.js.

Start free — 50 credits on registration. Sign up

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. Нудна надійна інженерія замість героїки.