Друкарня від WE.UA

MLOps:101. Побудова та оркестрація мультимодального RAG

Зміст

Я повертаюсь з серією статей про MLOps, що будуть включати DevOps, DataOps та платформи, які потрібні для оркестрації. Ця стаття, перша з циклу, є прикладом побудови RAG та оптимізації пайплайнів на дуже обмежених ресурсів, та без платних LLM


Як Японія побудувала DevOps

Дуже далеко від України розташована країна інновацій, технічного прогресу та педантичності. З давніх давен педантичність японців вшоновувалась та допомагала робити неможливі речі  - можливими: ідеальні тротуари, традиційна архітектура, на основі якої будуються сучасні будинки, здатні витримувати сильні землетруси, та уважність до, здавалось, незначних деталей. 

Це все уособлює слово Kaizen: постійне вдосконалення замість радикальної миттєвої зміни. Мені імпонує це як і філософія життя, але з нею виросло те, що сьогодні відомо як CI/CD/CO - continuous integration, delivery, operation. Три кити, на яких будується кожна стійка система.  

  • CI (Continuous Integration):Ресторани з зіркою Мішлен мають неймовірну якість та швидкість. Як цього можна досягти в кулінарії, де на готову страву впливають мільйони чинників? Делегація обов’язків. Саме вона дозволяє зрозуміти межі обов’язків кожного кухаря. Перший може займатись тістом, другий соусами, третій підготовкою продуктів. Кожен з них підлаштовується під те, що готує інший, щоб в кінці отримати ідеальний баланс - вони інтегруються в роботу один одного, щоб при помилці не подати страву з занадто водянистим соусом, але при цьому вони і не перетинають межі роботи один одного - в цьому є основна ідея CI. 

В розробці автоматизація відповідає за впровадження змін кожен раз при push чи pull request. CI не зупиняється ні на мить, як безкінечний цикл. 

Типовий CI виглядає десь так:

  • Push/ Pull запускають

  • Git Actions, який запускає тестовий скрипт, що 

  • створює артефакти (результати тестування, бінарні файли або docker image

  • CD (Continuous Delivery): Страва готова, її можна передати офіціанту, який донесе страву до клієнта, але тільки після перевірки. Офіціант буде повертатись на кухню кожен раз, коли потрібно буде нести перевірену страву до клієнта, це - CD. Якщо CI пройшов успішно, на стадії CD зміни інтегруються у продакшен версію коду.

  • CO (Continuous Operation): В ресторані зламалась плита, замість повного закриття кухарі швидко замінять її на іншу, можливо портативну, клієнти навіть не будуть знати, що сталось. Новий продакшн код працює, але про застосунок дізнались дуже дуже багато людей і контейнер, в якому працював застосунок з грохотом рухнув, користувачі більше не мають доступ і компанія за хвилини втрачає тисячі. Або ж, компанія може зробити декілька реплік застосунку: якщо один контейнер падає, створюється інший, або якщо великий наплив користувачів - розгортаються одразу 3 контейнера у різних зонах.

Ці три елементи не дають миттєвого результату, але вони дають стабільність: виправляти маленькі баги легше зараз, та і вони, скоріш за все, не зруйнують всю систему.

Як працює RAG

Я не буду вдаватись у подробиці того, як працюють трансформери та attention моделі, які є в основі LLM, тут я розповім саме про RAG та його підводні камені.

Більшість компаній мають гігабайти документації (яку мало хто читає) і ось, в один прекрасний день, вам треба знайти, що ж це за RobustScrinningFromThatOneMethod(). Назва неймовірно описова, одразу викликає головний біль та передчуття риття в документації та пальці, зажаті на CTRL + F.  За цим незрозумілим методом тягнеться десятки інших, які потрібно прочитати. А що, якщо б LLM могла дати розгорнуту відповідь та ще й включити всі пов’язані методи, але так, щоб секрети компанії залишались недоступними для не сторонніх? Це і є RAG - система з LLM, яка має базу даних, але яка є локальною та захищеною, дуже грубо кажучи - це дуже швидкий пошук та суммаризація документів.

В основі RAG є 3 основних компонента: векторне сховище, де зберігаються дані, метод знаходження потрібних даних та сама LLM. 

Навіщо потрібне векторне сховище та чому не можна використовувати звичайну бд. 

Всі моделі машинного навчання, будь то LLM або моделі для передбачення цін на акції, працюють на математиці - вони не можуть розуміти людський побут та мову так, як розуміє людина. Моделі можуть тільки імітувати результат через вірогідність. Тому, щоб LLM працювала, потрібно дати закодовані документи, оскільки LLM мають в основі трансфоромери або attention моделі, кодування - це вектори. Чому саме вектори, бо вектори дають величезний спектр методів для роботи та обробки текстів, окрім того, що такі мови як Python оптимізовані для дій з векторами та матрицями. 

Короткий екскурс у прекрасний світ векторів

Які є способи, щоб перевести текст у числову послідовність? Можливо кожне слово - це порядковий номер, або закодувати в бінарний код? З цього і починалась обробка тексту ще в 1970х, але семантичної користі доволі мало. То як же навчити модель “розуміти”, що яблуко та людина мають мало що схоже? На цьому етапі і зародилась ідея vector embeddings -  nD матриць, в яких схожі слова розташовані близько. Але як порахувати схожість слів, тим паче, що чим більше вимірність, тим швидше постукає прокляття багатовимірності, коли дистанцію між словами неможливо порахувати - вона стає однаковою. Один зі способів знайти відстань, методи як cosine similarity - знаходження найменшої дистанції між словами через косинус.

Розташування слів в embedding

Чим менша відстань між словами, тим вони ближче семантично. Ембеддінги не створюються вручну, а навчаються на терабайтах даних, які можуть бути неетичними, сесксистськими, тощо. Виправлення ембеддінгів є також дуже важливою частиною, яку не можна пропускати, та намагатись через промпти виправити не дані, а модель.

Як працюють Ембедінги

Multimodal RAG

Стандартний RAG працює тільки з текстовими даними, плюс такої системи в швидкості і дешевізні: для текстового або ж звичайного RAG можна використовувати моделі з меншою кількістю параметрів, які використовують менше токенів та не потребують потужної VM. Але тільки текст часто недостатній, потрібно бачити інструкцію або архітектуру (медіафайли). Мультимодальні системи можуть обробляти як медіа, так і текст, вони більш дорогі та потребують набагато потужнішої VM та більше токенів, але як і звичайний RAG їх можна оптимізувати.

Три священні кити RAG

RAG складається з 3 частин: LLM, retriever (vector db), reranker. Найпростіше принцип роботи можна пояснити на прикладі бібліотеки (дуже умовної): retriever - автоматичний пошук книг за словами та термінами, бібліотекар (reranker) обирає книги, які найбільш семантично підходять за запитом, LLM робить короткий опис вмісту всіх знайдених бібліотекарем книжок. Золотим стандартом у побудові RAG є використання Two-Stage Retrieval pattern:  retriever+reranker, для спрощення можна використовувати і тільки retriever, якість системи може бути хорошою тільки якщо працює з дуже чіткою документацією, де пошук за словами достатній, без семантичного навантаження. 

  • Retriever - грубо кажучи, пошук за словами. Запит від користувача векторизується та порівнюється з вже наявними векторами у базі (документацією наприклад). Найчастіше використовують cosine similarity для знаходження відстані між словами - їх схожості.

    • Плюси: retriever дуже швидкий, в його основі cosine similarity, не дуже складна математична операція, яка не потребує багато ресурсів. 

    • Мінуси: пошук за словами може знаходити синоніми слів, але не самі слова - терміни часто губляться, результат погано відповідає на питання користувача. Пошук за термінами через спарс - вектори (BM25) робить пошук більш точним, але retriever все ще не має основного - семантичного сенсу.

  • Reranker - семантичний сенс через cross - encoders (BERT).  Retriever  знаходить n- кількість векторів, reranker обирає з них ті, що “відповідають” на питання. Такі системи набагато повільніші, але прибирають непотрібне сміття, яке дає retriever.

    Архітектура RAG

Як будувала Multimodal RAG

Цей проект є фінальним проектом курсу від Softserve, який дуже раджу. Я розбила проект на 6 основних частини: data scrapping, RAG engine, monitoring, dashboard, Qdrant, API. Модальність коду як свята святих - кожен модуль фізично не залежить від іншого: помилка одного модулю не має ламати всю систему. Це виглядає не дуже важливим на етапі проектування, але стає головною біллю на продакшені.

Що повинен повертати RAG:

  • суммаризацію знайдених статей

  • релевантні медіа та фільтрація лого - вони несуть 0 сенс і тільки перевантажують систему

  • посилання на статті, які використані для суммаризації

Я покладаюсь на data - driven підхід: якість даних важливіша за якість моделі, навіть GPT 4 буде погано працювати на поганих даних. 

Коротко про кожен модуль:

  • Data scrapping (data_processing/mapping.py) - pain in the a**, збирання та обробка даних. Скраппінг через теги в <div>: текст та медіа зі статті. У створеній у Qdrant колекції завантажуються векторизовані артефакти статті, всі зображення зменшуються для оптимізації пайплайнів. Це один з важливіших пунктів для оптимізації, тому про нього розповім детальніше нижче.

  • RAG engine(src/engine/rag_engine.py) - мозок системи, тут живе  LLM (LLAMA 4 via Groq), Retriever(Qdrant), reranker (Cross - encoder). 

  • API (api.py) via FastApi - з’єднує RAG, моніторінг та БД з оцінками попередніх промптів . Як і RAG, api також асинхронне, оскільки мені потрібно мати основний (виклик RAG_engine для генерації) та бекграунд (audit для оцінки генерації) процеси.

  • Dashboard(dashboard.py). UI трекер метрик, зроблений через Streamlit.

  • Monitoring(metrics.py). Перевіряє якість генерації одразу. Значення метрик записують у БД, до якої має доступ dashboard.

Повну діаграму можна подивитись тут.

Щоб розсередити навантаження використовую 2 моделі: 

  • LLAMA 4  - це мультимодальна модель, генерує сумаризацію на основі промпту та опис зображень. Запускати таку модель локально було б невдячною справою, тому використовую токени від Groq, це безкоштовний аналог GPT, але підходить тільки для проектів без великого навантаження. 

  • qwen2.5:1.5b - це текстова генераційна модель, також від Ollama. Маленька, але швидка та якісна, ідеально підходить для генерації варіацій промпту користувача. Завантажувала локально та запускала через ollama serve. Це не дуже хороший підхід для великих систем, але з обмеженими ресурсами, як в мене, працює дуже добре.

  Воркфлоу виглядає так:

  • class MultimodalRAG - тут живе мозок, спочатку перевіряється, чи було є в кеші такий самий промпт (методи async def _ensure_cache_exist() та async def get_semantic_cache()), якщо так, то повертається вже згенерована раніше відповідь з колекції llm_cache, якщо ж ні, то починається повний цикл генераціЇ:

    • vector_results = await asyncio.gather(*[self.get_all_vecs(q) for q in queries]) генерується 3 схожих питання до питання користувача - query expansion на локальній моделі qwen.

    • Retriever results = await self.client.query_points шукає 20 векторів з qdrant колекції 

    • Reranker “оцінює” знайдені вектори: scores = await asyncio.to_thread(self.reranker.predict, pairs)

    • В parents_results фетчиться id знайдених векторів з payload (це heavy lifter, де знаходяться всі артефакти статті) parent_results = await asyncio.gather(*fetch_tasks)

    • Далі parents_results  розділяється на: parent_doc (текст статті, посилання та тайтл), images_and_descriptions - згенерований опис до медіа та саме медіа, тут же фільтрується від логотипів.

    • Далі йдуть два кола до LLM: базова генерація на основі комбінованого тексту з parent_doc  та медіа з images_and_descriptions - ця генерація може бути неточною або містити непотрібну інформацію, тому вона є основою для judge генерація, яка є фінальною генерація. 

    • Повторне генерування, якщо метрики погані - refine_answer(). Користувач отримує перший результат(final result), запускається процес перевірки якості через стандартні LLM метрики (AnswerRelevancy,ContextUtilization, Faithfulness), якщо Faithfulness менше заданої норми, результат регенерується.

Повну діаграму можна подивитись тут.

Проблема потоків та Semaphore

Як і з RAG виникає проблема перерваних потоків, щоб їх не було використовую @asynccontextmanager разом з global gpu_semaphore як lifespan: 

app = FastAPI(title="Oracle RAG WebSocket API", lifespan=lifespan)
  • async def audit_and_push_correction(question, initial_result, websocket) запускає Evaluator з metrics.py для оцінки генерації. Цей процес на бекграунді, тобто користувач отримує згенеровану відповідь, після чого запускається її оцінка. Я перевіряю тільки faithfulness - це хороша метрика, вона одна не є дуже показовою, якщо RAG повинен згенерувати відповідь з технічно конкретною, наприклад медичною інформацією, але для малих систем її достатньо. Якщо faithfulness <0.7 викликається refine_answer() з RAG_engine для повторної генерації. Для judge обрала локальний qwen, той самий, що генерує варіації промпту.

  • async def websocket_endpoint(websocket: WebSocket) - головний процес, викликає await engine.run_hybrid_rag(). Я приділила велику увагу для перевірки результату, отриманого від RAG, користувачу потрібно розуміти, що система працює, навіть якщо тимчасово стався збій:

    • Якщо результат пустий або містить ⚠️ : "System is currently overloaded. Please try a shorter question.". Користувач розуміє, що система не працює зараз, але можна ввести новий промпт через якийсь час.

    • Якщо confidence_score <0.2: reranker не знайшов підходящі вектори, тобто Qdrant не має векторів по темі: "answer": "I cannot answer this. It seems outside my current knowledge base." Користувач скоріш за все перефразує промпт, а не піде.

Також, по класиці, якщо користувач ввів просто привітання, можливо помилково натисну ввід без повного промпту, API не викликає генерацію і просто вітається.

System monitoring

Monitoring(src/monitoring/metrics.py)

Як і в кожній системі, оцінка результативності займає далеко не останнє місце. Діаграму можна подивитись тут.

Основні труднощі для мене - це знайти метрики, які не використовують GPT як judge моделі, оскільки для цього потрібен токен, та, звичайно, швидкість оцінки. Основні метрики: AnswerRelevancy, ContextUtilization, Faithfulness з Ragas, одна з найкращих бібліотек по кількості метрик. 

Деякі метрики, як ContextPrecision потребують референсного значення, з якими порівнюється згенерована відповідь, гарна метрика для тестування, але не для продакшену - генерувати це референсне значення було б занадто затратно, тому використовую її різновид, який не потребує референса - ContextUtilization.

1.RAGAS

  • Faithfulness: лакмусовий папірець галюцинацій. Порівнює відповідь із контекстом і визначає, чи всі твердження у відповіді підкріплені джерелами. (Низький бал = галюцинації).

  • Answer Relevancy: релевантність. Визначає, наскільки точно відповідь стосується промпту. Ігнорує джерела і фокусується лише на тому, чи отримав користувач те, що запитав.

  • Context Utilization: Оцінює якість пошуку (retrieval). Показує, наскільки ефективно модель використала знайдену інформацію для формування відповіді.

2. Лінгвістичні та NER метрики (NLP & InterpretEval)

Математичний аналіз тексту та перевірка іменованих сутностей (імена, дати). У деяких бібліотеках ці метрики використовують разом з GPT, тому для них потрібен токен.

  • BLEU Score: класична метрика, що вимірює текстову схожість між контекстом та відповіддю. Чим більше однакових послідовностей слів, тим вищий бал.

  • ROUGE-L: оцінює найдовшу спільну послідовність слів. Корисна для перевірки того, чи зберегла модель структуру та головну думку з context.

  • NER Coverage: відсоток важливих назв, дат та цифр із контексту, які потрапили у відповідь. (Високий бал = висока деталізація).

  • NER Hallucination: виявляє "вигадані" власні назви або цифри, яких не було в оригінальному тексті.

  • NER Density: співвідношення кількості фактів (сутностей) до загальної кількості слів. Показує, наскільки "насиченою" фактами є відповідь.

3. Логічна відповідність (FactCC)

Salesforce використовує GPT для аналізу послідовності відповідей, тому я зробила аналог цієї метрики, але з локлальною qwen моделлю.

  • FactCC Consistency: перевірка на критичні помилки:

    1. Підміна об'єктів (наприклад, переплутані назви компаній).

    2. Числові помилки (неправильні ціни чи дати).

    3. Помилки заперечення (коли джерело каже "так", а модель - "ні").

4. Безпека та етика (RAI Harm)

Метрики відповідального ШІ (Microsoft RAI). Для якого проекту не потрібен був би RAG, хоч для пошуку товарів на сайті, сумаризації документації для холодильників - не можна забувати, що LLM вчиться на величезному об’ємі даних, частина яких може бути небезпечною для людини, безпека користувача не повинна бути як додатковий бонус.

  • Harm Score: оцінка генерації від 0 до 1, де 0 — безпечно, а 1 — небезпечно. ШІ аналізує текст на наявність:

    • Мови ворожнечі (Hate Speech).

    • Пропаганди насильства (Violence).

    • Схиляння до самокаліцтва (Self-harm).

  • Harm Category: вказує конкретну категорію порушення, якщо harm_score високий.

Всі метрики записуються до БД evaluation_logs (src/monitoring_db)

Розгортання RAG та Vector Storage 

Тепер перейду до більш конкретних деталей, я буду використовувати MultimodalRAG проект як основний та NotionRAG  як фан - проект. В цій статті не буду конкретно розповідати про Docker, скажу тільки, що скоро буде стаття його повного розбору.

Існує багато векторних сховищ та двигунів, найбільш мені особисто подобається Qdrant: легко розгортається, є веб інтерфейс, можна аналізувати та перевіряти вектори. У багатьох початкових туторіалах замість Qdrant локальне сховище FAISS, але воно повільніше та не має всього того, що має Qdrant, але обидва повністю безкоштовні. 

Qdrant є мультимодальним сховищем, може зберігати текст та медіафайли. У фан проекті я векторизувала власні Notion помітки у локальний Qdrant, це хороший спосіб розпочати вивчення векторних сховищ з малими даними. Професійний підхід - це розгортання сховища на контейнері як сервіс, який потім можна оркеструвати або розгорнути через aws чи gcp. 

Я розгортала qdrant на докер контейнері, але допустила неприємну помилку. Під час тестування створила створила окремий контейнер, а не compose stuck, через що довелося імпортувати вектори до qdrant контейнера вже в compose stuck, це можна зробити через 

curl -X POST "http://localhost:6333/collections/{collection_name}/snapshots/recover" \

     -H "Content-Type: application/json" \

     --data '{"location": "http://url-to-your-snapshot-file"}'

 В залежності від розміру даних та типу диску може зайняти дуже багато часу.  

ETL: обробка та підготовка даних

(data_processing/mapping.py)

Способів безліч і вони в більшості залежать від платформи, наприклад у Notion є api, яке дає стороннім сервісам доступ до вибраних файлів, хадкорний метод - це data scrapping, що я і робила.

  • Scrape. Для RAG потрібно зібрати: основний текст статті, заголовок, медіа та її опис. У Batch доволі складна верстка: 9 категорій статей з різною системою побудову, багато медіа не має опису, тому його потрібно генерувати також.

  • Extract. BeautifulSoup та Regex для збирання та очистки від метаданих (get_content(), get_article_links()).

  • Splitting через RecursiveCharacterTextSplitter. Розділення на child та parent колекції має три підводних каменя: чи розділяти текст, як розділяти, скільки перекриття (overlap). 

Це є вибором між ефективністю та контекстом: одна стаття розділена на великі шматки (parent), які розділені на менші шматочки (child) - оптимізований пошук, але потрібно очищати від дублікатів, або parent буде цілою статтею, яка розділена на маленькі (child) шматки для пошуку, це дасть більший контекст для LLM, але повільну та дорогу систему. Я обрала перший підхід, оскільки контекст можна зберегти через overlap батьківських шматків, разом з тим зберегти і швидкість. Тепер детальніше про overlap. Розділення тексту за кількістю символів призводить до втрату сенсу речення, тобто LLM може помилитись. Щоб уникнути такого використовують overlap - буферний текст, кінець одного шматка тексту - це початок іншого. Дах, який складається з накладених плит міцніший за той, де плит покладені поряд.

Small - to - Big та як працює overlap

Весь код для scrapping можна розділити на 4 частини:

  • Scrapping. Пошук потрібних статей

  • Resize. Зменшення зображення щоб зекономити токени та пам’ять.

  • Embed. Векторизація тексту та зображення.

  • Upsert. Завантаження до відповідної колекції Qdrant. 

Data engineering

  1. Small-to-Big (Child-to-Parent)

Якщо уявити дані у вигляді датасету, то це буде один стовпець з 5 артефактами для кожної статті: 

payload={

   "full_text": p_text,

   "image_b64": img_b64,

   "headline": headline,

   "url": article_url,

   "type": name

}

По такому стовбцю важко буде шукати, тим паче робити це швидко. Вихід - розділити стовпець на 5 окремих. Основна проблема: пошук по всьому тексту довгий - залишається. Логічно було б припустити, що якщо розділити  full_text на частинки по 100 символів, пошук пришвидшиться - це Small-to-Big expansion.

Small (також називають child) містить id big (parent) елементу та частинку full_text. Пошук векторів ведеться саме по small колекції, а як content до LLM потрапляє вже big (parent) колекція.

Наприклад: 

  • Parent Chunk 1 (Chars 0-3000): ID: AAA. Всі діти зсилаються на ААА.

  • Parent Chunk 2 (Chars 2800-5800): ID: BBB. Всі діти зсилаються на BBB.

  • Parent Chunk 3 (Chars 5600-8600): ID: CCC. Всі діти зсилаються на CCC.

  • Parent Chunk 4 (Chars 8600 - 9000): ID: DDD. Всі діти зсилаються на DDD.

Child (Small) має 2 поля, але довжину більше 1, на відміну від Parent (Big)
  1. Зменшення та векторизація зображення 

LLM не потрібне високоякісне зображення, але високоякісне зображення може займати 50к токенів, що дуже сильно впливає на час обробки промпту. У _process_and_resize_image() зменшую до 128х128 та створюю два різних типа зображень:

  • v_image векторизоване через self.vision_model.encode. Це зображення подається як частина context для моделі, також Qdrant  може математично зрозуміти, що на зображенні та використовувати для пошуку.

  • img_b64 - це зображення, яке конвертоване у string для оптимізації, оскільки якщо векторизувати, то повернути назад у звичну для людини форму вже не вийде. Саме це зображення бачить користувач.

Prompt Engineering - як підняти якість моделі

Без правильної команди жодна техніка не буде працювати, для LLM такі команди - це промпти. Якщо LLM буде отримувати промпти напряму від користувача, відповідь може буде згенерована, але без важливих уточнень.

Prompt engineering піднімає якість генерації через:

  • Детермінізм (Reliability): моделі за своєю природою стохастичні. Prompt engineering (через техніки Few-Shot або Chain-of-Thought) мінімізує дисперсію відповідей.

  • Вартість та Latency (Efficiency): чим краще спроектований промпт, тим менше токенів витрачається на спробу “вгадати”, що хоче користувач. Це напряму впливає на кількість витрачених токенів.

  • Безпека (Prompt Injection): правильно побудований системний промпт — це перший рівень захисту від спроб користувача змусити модель ігнорувати інструкції: злити API або особисту інформацію.

Системний промпт, тобто доповнений промпт від користувача, який подається на моделі має таку архітектуру:

"system", "Roles of LLM and rules of generation"
{context} #from vector DB
[USER REQUEST]: {question} 
[USER REQUEST]: {question}

Кожна LLM має Context Window - “пам’ять“ про діалог, дуже часто це вікно урізає промпт від користувача, через що результат генерації поганий. Я використовую Prompt Repetition методику, яка в цьому дослідженні показала хороші результати.

Надважливо не передавати до системного промпту зайву інформації:

  • Не передавати медіафайли.

  • Не передавати повну history, тільки останні n токенів, наприклад 1000.

Оскільки використовую подвійну генерацію, мені потрібно два промпти: перший для першої, базової генерації, другий для judge:

base_prompt = ChatPromptTemplate.from_messages(
        [
            (
                "system",
                """You are an expert AI Researcher. Use the provided context to answer the question. If the context contains multiple viewpoints or multifaceted information, synthesize them into a single coherent answer. Do not contradict previous statements within the same response. If information is missing, state what is known and what is not .

        STRICT OPERATING RULES:
        1. NO REDUNDANCY: Do not create separate 'Summary' and 'Details' sections. Merge all information into one unified list.
        2. TOTAL COVERAGE: You must briefly address EVERY news item found in the context (including editorial notes or milestones mentioned).
        3. ZERO FILLER: Start immediately with facts. Do not say "The articles discuss..."
        4. NO HALLUCINATION: If a detail isn't in the context, do not mention it.
        5. NO DISCLAIMERS: Do not end with apologies or statements about missing info.
        6. NO SEPARATORS: dont add * as separator.
        7. NO WRAP-UP: Do not conclude with 'Overall...' or 'In summary...'
        """,
            ),
            MessagesPlaceholder(variable_name="history"),
            (
                "human",
                """[NEWS SEGMENTS]:
        ---
        {context}
        ---

        [USER REQUEST]: {question}
        [USER REQUEST]: {question}

        Provide the report in bullet points below:""",
            ),
        ],
    )
    critique_chain = ChatPromptTemplate.from_template("""
            ### ROLE: Expert Technical Editor & Verifier. Your goal is to analyse {answer}.
            If the provided context does not contain a direct answer, use the most relevant milestones or news items provided to give a high-level update instead of stating you don't have the info. Do not contradict previous statements within the same response. If information is missing, state what is known and what is not. If the provided context does not contain the answer to the question, state clearly that you do not have that information. Do not mention other unrelated topics from the context unless they directly answer the user's query.
            ### STRICT INSTRUCTIONS:
            1. NO INTROS/OUTROS: Start directly with the first bullet point. Remove "Here is the report," "The articles discuss," and "Note:".
            2. ATOMIC SYNTHESIS: Merge identical news items. If two bullets discuss the same startup or model, combine them into one dense sentence.
            3. THE GROUNDING RULE: For every statement in the 'Proposed Summary', verify it against the 'Context'.
               - If a detail is NOT in the context, DELETE it immediately.
                - Do not use outside knowledge (e.g., don't add info about GPT-5 if it's not in the text).
            4. DENSITY: Use Bold Headers followed by one clear sentence.
            5. NO REPETITION: Ensure no two bullets say the same thing using different words.
            6. NO SEPARATORS: dont add * as separator.
            7. NO WRAP-UP: Do not conclude with 'Overall...' or 'In summary...'


            Context: {context}
            Original Question: {question}
            Original Question: {question}
            Proposed Summary: {answer}

            ### FINAL DENSE REPORT (NO ASTERISKS):
        """)

Docker containers

Один контейнер для всього застосунку, або окремий контейнер для кожного модуля, де зробити entrypoint, один dockerfile для всіх контейнерів, чи окремий для кожного, slim або distroless? Docker є базою, трьома китами, коректне використання дає портативність, некоректне - нічний кошмар.

Перш за все, основа основ - dockerfile, інструкція для побудови кожного контейнера. Two stage building є золотим стандартом хорошого dockerfile: легкі, захищені та ефективні docker image через розділення environment та runtime environment

  • 1 стадія (heavy lifter): завантаження самих важких бібліотек, requirements.

  • 2 стадія (light): копіює 1 стадію, завантажує легші бібліотеки та створює entrypoint.

# --- STAGE 1: Builder ---
FROM python:3.11-slim as builder
LABEL authors="AnnacKK"
WORKDIR /app
ENV PATH="/root/.local/bin:${PATH}"
# Install build essentials for heavy ML libraries like sentence-transformers
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    gcc \
    libmagic1 \
    && rm -rf /var/lib/apt/lists/*
#copy requirements
COPY requirements.txt .
RUN pip install --user --no-cache-dir \
    --default-timeout=100 \
    --retries 5 \
    -r requirements.txt

# --- STAGE 2: Final Image ---
FROM python:3.11-slim
LABEL authors="AnnacKK"
WORKDIR /app
# Install system dependencies for OpenCV and Vision tasks (required by Moondream/Llama logic)
RUN apt-get update && apt-get install -y --no-install-recommends \
    libgl1 \
    libglib2.0-0 \
    libmagic1 \
    && rm -rf /var/lib/apt/lists/*
# Copy installed libraries
COPY --from=builder /root/.local /root/.local
RUN mkdir -p /app/entrypoint
COPY entrypoint/entrypoint.sh /app/entrypoint/entrypoint.sh
RUN chmod +x /app/entrypoint/entrypoint.sh
# Copy project structure
COPY . .
# Environment setup
ENV PATH=/root/.local/bin:$PATH
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONPATH=/app
ENV OLLAMA_BASE_URL="http://host.docker.internal:11434"
#base port
EXPOSE 8000
#way to reparate one dockertfile for each containers
ENTRYPOINT ["/bin/bash", "/app/entrypoint/entrypoint.sh"]

#this will work only for API, but not Dashboard
#CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

Кількість dockerfile є частою причиною суперечок. Немає універсальної таблиці, туторіалу, розуміння як краще приходить вже на етапі тестування.

1. Один Dockerfile (Monolithic / Multi-stage)

  • Плюси:

    • Простота управління: один файл — одна логіка оновлення залежностей.

    • Multi-stage Efficiency: можна зібрати додаток в одному image, а потім скопіювати лише готовий бінарний файл у мінімальний образ (Alpine), що робить фінальний результат дуже легким, різниця може бути у десятках ГБ.

  • Мінуси:

    • Довга збірка: якщо потрібно змінити лише одну частину коду, Docker може почати перезбирати завеликі частини кешу, який потрібно видаляти або prune.

    • Складність: один файл на 200+ рядків стає важко читати.

    • Непотрібність бібліотек: API image може мати непотрібні для нього бібліотеки для RAG, і навпаки.

2. Багато Dockerfiles (Microservices / Component-based)

Кожен сервіс (API, Worker, Frontend, DB) має власний Dockerfile, зазвичай у своїй підпапці.

  • Плюси:

    • Швидкість: кожен сервіс збирається незалежно. зміна в одному сервісі не чіпає інший.

    • Ізоляція: можна використовувати різні базові образи (наприклад, Python 3.12 для ML та Node 20 для UI).

    • Масштабованість: ідеально для Kubernetes або Docker Swarm, де кожен контейнер має виконувати лише одне завдання.

  • Мінуси:

    • Дублювання:  доведеться копіювати схожі команди (встановлення, оновлення пакетів) у кожен файл.

Я обрала один dockerfile з 2 entrypoint: для dashboard та api, оскільки в мене dashboard як окремий сервіс, команди для api, наприклад: 

CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

будуть просто класти його і доведеться прописувати окремі входи вже у файлах кластеру, що не дуже хороша практика. 

Всі контейнери зв’язані у compose stuck, з яким легше працювати: редагувати, запускати, зупиняти.

Оптимізація image

Чим більше image, тим більшим буде контейнер, тим потужніше VM треба. Оптимізація може звучати як щось складне, але насправді зводиться до одного: додавати до dockerfile тільки те, що необхідне для функціонування контейнера. На практиці це означає створити .dockerignore та внести у нього все, що не повинна бути у контейнері. В моєму випадку це виглядає ось так: 

.venv/

__pycache__

.env

src/monitoring_DB/*.db

*.pyc

.git/

.idea/

.vscode/

.documentation/

.tests/

qdrant_storage/

Тести, гіт, БД для метрик та qdrant storage, де знаходиться копія векторів будуть “впаюватись“ y docker image та збільшувати розмір, якщо не внести у .dockerignore.

Docker-compose.yml та Docker Watch

Для налаштування кожного контейнера окремо використовується маніфест .yml файл, де задаються параметри кожного контейнера: порти, healtcheck, volumes та залежності. Для стабільної роботи контейнери залежать один від одного, тому дуже важливо налаштувати test - команду для перевірки стану сервісу.

Під час тестування хочеться бачити, як зміни застосовуються одразу, а не перезапускати контейнери. Є багато бібліотек, які можуть відслідковувати зміни в коді, але для докера самим простим та ефективним є Docker Watch, його єдина складність - це налаштування volumes контейнерів так, щоб файли, за якими треба слідкувати були у volumes, але при тому не стали blind. Наприклад, код БД знаходиться у monitoring_db/, там же, де і сама БД, яка мені не потрібна, але при тому мені потрібно бачити зміни у коді одразу. Оскільки .dockerignore працює лише під час збірки образу (docker build), він не має впливу на під час docker run / docker-compose up, а ось volumes якраз мають, на цьому будується overriding.

Феномен overriding volumes

Це не є універсальним костилем для всіх архітектур, але часто працює є необхідним костилем: - /app/.venv (без крапки попереду!!!!), створює Anonymous Volume.Docker монтує поточну папку . у /app. Це зазвичай перекриває все, що було всередині /app в image.

  • Проблема: якщо є папка .venv, вона "залетить" у контейнер і зламає все, бо бінарні файли Python у віртуальному середовищі не сумісні між Linux та Windows.

  • Рішення: запис - /app/.venv каже docker: "Візьми папку .venv, яка була створена всередині образу під час pip install, і сховай її від локальної папки розробника". Це захищає внутрішні залежності контейнера від затирання вашими локальними файлами.

Distroless vs slim

Образ (python:3.11-slim) також дуже сильно впливає на розмір image, але також і на захист системи.

Slim - це легкий образ, частіше з Debian або Alpine, має shell та package manager, що дуже зручно, адже через shell можна редагувати, однак редагувати можете не тільки ви.

  • Більше інструментів входу: образи slim містять apt, sh, bash та curl. Якщо зловмисник проникає в контейнер, ці інструменти допомагають переглядати файлову систему, підвищувати привілеї або завантажувати вірус.

  • Більше вразливостей (CVE): образи slim містять більше бібліотек та пакетів ОС, вони за своєю суттю мають більше потенційних вразливостей безпеки (CVE) порівняно з мінімальною природою distroless.

  • Менеджери пакетів: наявність apt або apk в образах slim дозволяє отримати доступ, встановлювати шкідливі пакети для підтримки персистентності.

  • ІДебаггінг: це зручно для розробки, але наявність оболонок (sh, bash) та інструментів користувача в образах slim порушує принцип найменших привілеїв, тоді як distroless повністю видаляє їх для безпеки.

Тобто, потрібно обирати між високим рівнем безпеки та зручністю.

Компіляція image

Через docker compose up  –build запускаєnmcz локальне збирання compose stuck. Як же docker знає, що потрібно зібрати?

  • Команда: docker compose (compose stuck) vs docker build (один контейнер) 

  • Одне ім’я: docker compose up обирає ім’я папки, якщо вона не задана.

  • Залежності: depends on вказують, що контейнери пов’язані між собою.

  • Одна мережа: при збиранні кожного контейнера окремо вони будуть мати власні окремі мережі, тоді як у compose stuck кожен контейнер має спальну мережу.

Ось як виглядає мій стек:

Compose stack

K8s або сказання про великого мага

При локальному оркеструванні ви робите всі дії з контейнерами: старт, стоп, рестарт - самі, один контейнер впав, його треба підіймати, що не дуже зручно. K8S (Kubernetes) - це один з найвідоміших оркестраторів, бо краще мати дирижера, ніж грати на всіх інструментах самому одночасно. Звичайно, є випадки коли можна обійтись і без оркестратора, але для мене зручніше продумати архітектуру та зібрати все в одному місці, ніж кастувати мільйон костилів.

K8S справді доволі складний, якщо вдаватись в подробиці, правильне налаштування покладається на десятки маленьких та, на перший погляд, не дуже потрібних налаштувань. 

Швиденько про архітектуру:

  • Pods: найменші будівні блоки. Кожен контейнер знаходиться у власному поді.

  • Nodes: керують подами. K8S не є легкою архітектурою і керування всіма подами на одній ноді банально призведе до хаосу, треба розподіляти ресурси в залежності від ролей:

    • control plane: “мозок”, що управляє подами - регулює стан кластера, розкладом (коли зупинити, тощо) та запитами. Багато пам’яті не потрібно.

    • workers: там, де все “живе”. Кількість робітників дуже сильно залежить від вимог до застосунку та від VM, бо саме на них хоститься все.

  • Clusters: кожна окрема нода розташовано у власній VM, кластери “з’єднують” ноду єдину мережу, грубо кажучи, схоже на compose stuck. Кластери управляють load balancing - коли треба створити більше нод, щоб витримати навантаження, або коли можна зменшити їх кількість.

Архітектура кластера

Docker має K8s як у самому застосунку, так і як CLI. Як саме управляти кластерами залежить від вас, але мені зручніше через CLI. Увімикнути можна у застосунку: налаштування - Kubernetes. Docker дає по дефолту 2 види кластерів: kind (кластер може мати декілька нод) та kubeadm (single node). 

Як увімкнути K8s у Docker та базові кластери

Окрім kind для локального оркестрування також є minikube, він є більш простим для першого знайомства з кластерами, але не дає тої свободи, що дає kind. особливо з CI/CD, тому я буду використовувати саме kind.

Я не буду зупинятись у цей раз на інших видах кластерів, але маю дуже хорошу статтю на цю тему.

Побудова pods: jobs vs service

Контейнер data_processing обробляє дату та завантажує у Qdrant, це не відбувається постійно, бо і не є потрібним, але колекції у Qdrant треба оновлювати, щоб на голову різко не звалилось data drift і RAG не почав розказувати казки. Потрібно знайти часовий проміжок, коли дані, якщо і змінюються, то не сильно. Оскільки контексом для моделі є журнал, який оновлюється пару разів на тиждень, я можу оновлювати колекції один раз на тиждень. Якщо зробити у вигляді сервісу, він буде займати місце і окремою біллю буде його оркеструвати. Саме для таких випадків є jobs - те, що виконується один раз за тригером. Такий підхід називають Scheduled - простий у побудові, але може витрачати ресурси в пусту, якщо дані за тиждень не змінились. Робастним є гібридний підхід: виконання job і по розкладу, і по тригеру. Звичайна job не може виконуватись по розкладу, а от її різновид - cronjob, якраз для цього і створений.

Я обрала наступний підхід: 

  • CronJob (1): запуск data_processing один раз на тиждень по дефолту.

  • CronJob (2): перевірка на data drift кожен день.

  • Job: запуск data_processing, якщо помічен data drift, тригером є CronJob (2).

діаграма для docker_jobs_deployment.yml

Три основних контейнера: api, dashboard, qdrant - залежать один від одного та повинні бути доступними завжди, тобто, вони повинні бути сервісами. Сервіси можуть знаходить в різних нодах, залежить від того, що вони повинні робити та скільки ресурсів на це треба, якщо у .yml не зазначене це, кластер сам обирає де буде кожен сервіс. 

Діаграма для docker-deployment.yml

Цей YAML-маніфест описує трикомпонентну архітектуру (Database, API, Dashboard) для розгортання Multimodal RAG системи в Kubernetes. Нижче наведено детальний опис кожної секції та команди українською мовою для вашої документації. Для швидкості я маю по одній репліці на кожен сервіс, але якби розгортала для широкого користування, поставила мінімум 5.

Коротко про кожен сервіс:

Qdrant
  • kind: Deployment: вид пода (оновлення, масштабування).

  • strategy: type: Recreate: стратегія оновлення, при якій стара версія Pod видаляється перед створенням нової. Це необхідно для баз даних з одним диском (ReadWriteOnce), щоб уникнути конфлікту доступу до даних.

  • replicas: 1: Кількість копій бази даних (одна для стабільності транзакцій).

  • selector / matchLabels: вказує Kubernetes, які саме Pod-и належать до цього розгортання (мітки app: qdrant).

  • image: qdrant/qdrant:latest: image Qdrant, яке потрібно використовувати.

  • ports: containerPort: 6333: порт 6333 всередині контейнера для API-запитів.

  • resources (requests/limits):

    • requests: мінімальні ресурси (250m CPU, 512Mi RAM), які контейнер може використати.

    • limits: максимальні ресурси (1Gi RAM).

  • volumeMounts: внутрішнє сховище.

  • volumes: зовнішнє сховище, яке посилається на PersistentVolumeClaim.

Service

  • kind: Service: стабільна внутрішня DNS-адресу (qdrant-db), щоб сервіси могли комунікувати.

PersistentVolumeClaim

  • kind: PersistentVolumeClaim: виділення постійного дискового простору (1Gi), який не зникне після перезавантаження Pod-а.

  • accessModes: ReadWriteOnce: диск може бути підключений лише до одного вузла кластера одночасно.

RAG API 

Deployment

  • env

    • QDRANT_URL / QDRANT_HOST: адреса Qdrant.

    • ROLE: "api"**: entrypoint, оскільки я використовую один dockerfile для всіх контейнерів мені потрібно дати роль та команду запуску окремо для api та dasnboard.

    • OLLAMA_BASE_URL: адреса локальної LLM моделі (Ollama).

  • envFrom / secretKeyRef: токени доступу.

  • livenessProbe: перевірка "живучості". Якщо порт 8000 не відповідає, перезапустить контейнер.

  • readinessProbe: перевірка "готовності". Трафік не піде на цей Pod, поки контейнер повністю не завантажиться.

Service

  • type: ClusterIP: API доступний лише всередині кластера для інших сервісів 

Dashboard 

Deployment

  • nodeSelector: kubernetes.io/hostname: kind-worker: примусовий запуск на конкретній ноді кластера (kind-worker).

  • env: ROLE: "dashboard": аналогічна ситуація як і з api: entrypoint для команди.

  • env: API_URL: адреса, за якою звертається до API.

  • resources: limits: жорстке обмеження ресурсів (512Mi).

Service

  • port: 8501: порт за замовчуванню для Streamlit-додатків.

Окремо про image та imagePullPolicy розкажу у наступній частині, оскільки як саме делегувати сильно залежить від стратегії CI/CD.

Створення nodes та podes 

Вся архітектура йде від верху, найбільшої структури, - кластера, до найменшої - поду.

  1. Створити кластер

kind create <NAME>
  1. Завантажити image

kind load docker-image docker.io/collection/<NAME>
  1. Завантажити маніфести 

kubectl apply -f <MANIFEST>.yml
  1. Відкрити порт 

kubectl port-forward pod/<pod-name> 8080:8080

- для локального тестування,

kubectl expose deployment <deployment-name> --port=8080 --target-port=8080 --name=my-service 

- для деплойменту.

Для перевірки нодів:

 kubectl get nodes

Повинні бути всі ноди, в моєму випадку два:

NAME STATUS ROLES AGE VERSION

kind-control-plane Ready control-plane 7d20h v1.34.0

kind-worker Ready <none> 7d20h v1.34.0

Для перевірки подів:

kubectl get pods

Поди створені успішно тільки тоді, коли мають статус Running. Оскільки в мене контейнери залежать один від одного, то останнім запускається API:

NAME READY STATUS RESTARTS AGE

qdrant-db- <TAG> 1/1 Running 1 (23h ago) 5d18h

rag-api- <TAG> 1/1 Running 3 5d17h

rag-dashboard-<TAG> 1/1 Running 16 (23h ago) 5d18h

data_processing-<TAG> 1/1 Running 1(23h ago) 3d18h

Якщо ж замість Running - Unknown, можу бути тимчасова плутанина кластерів, може допомогти експорт налаштувань кластеру:

 kind export kubeconfig --name <NAME>

CI/CD: коли система сходить з розуму 

Серед багатьох фреймворків, які забезпечують CI/CD я обрала стандартні: Github Actions для CI та ArgoCD для CD, Jenkins та інші не підходять, оскільки потрібно саме K8S - native.

GitHub Actions та unit test для LLM

Як і всі ml - based системи, RAG не можна тестувати стандартними Unit тестами - треба трекати не тільки зміни у коді та їх вплив, але і зміну даних. 

  • CI - GitHub Actions

  • CD - ArgoCD

GitHub Actions виконує всі jobs, які прописані в маніфесті .yml. Я розділила на 4 частини, приклад маніфесту:

  • Job(1) - Linting and Formatting: форматування коду, щоб прибрати зайві if через ruff.

  • Job (2): Functional Testing and Judicial Audit: тестування системи по основним критеріям.

  • Job (3): Dockerize and Deploy: контейнеризація у єдиний image для спрощення та завантаження у реєстр контейнерів GHCR.

  • Job (4): Update ArgoCD Manifests: контроль за версіями контейнерів та оновлення.

name: Multimodal RAG CI/CD

on:
  push:
    branches: [ main, master ]
    paths-ignore:
      - 'documentation/**'
      - 'manifest_examples/**'
  pull_request:
    branches: [ main ]


jobs:
  # Job 1: Linting and Formatting
  quality-check:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.9'

      - name: Run Ruff Auto-Fix
        continue-on-error: true
        run: |
          pip install ruff
          ruff check src/ --fix --unsafe-fixes
          ruff format src/

      - name: Commit and Push Fixes
        uses: stefanzweifel/git-auto-commit-action@v5
        with:
          commit_message: "🤖 chore: automated ruff quality fixes"
          branch: ${{ github.head_ref || github.ref_name }}

  # Job 2: Functional Testing and Judicial Audit
  test:
    needs: [quality-check]
    runs-on: ubuntu-latest
    timeout-minutes: 60
    services:
      qdrant:
        image: qdrant/qdrant:latest
        ports:
          - 6333:6333
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.9'
          cache: 'pip'

      - name: Install Dependencies
        run: |
            python -m pip install --upgrade pip
            pip install --upgrade "giskard[llm]" litellm evaluate google-generativeai
            pip install -r requirements.txt pytest pytest-asyncio
            pip install flashrank
            

      - name: Install & Background Ollama
        run: |
            curl -fsSL https://ollama.com/install.sh | sh
            nohup ollama serve > ollama.log 2>&1 &
            # Wait for the server to respond
            until curl -s http://localhost:11434/api/tags > /dev/null; do sleep 2; done
            ollama pull qwen2.5:1.5b
            # Wait for the model to be actually pulled and loaded
            sleep 30

      - name: Sanitize Test Files
        run: |
          find src/tests/ -name "*.py" -exec sed -i 's/\x00//g' {} +

      - name: Run Giskard & Judicial Tests
        env:
          QDRANT_URL: http://localhost:6333
          OLLAMA_BASE_URL: http://localhost:11434
          PYTHONPATH: ${{ github.workspace }}
          GR_TOKEN: ${{ secrets.GR_TOKEN }}
          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
        run: pytest src/tests/test_rag.py

      - name: Upload Giskard Report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: giskard-report
          path: giskard_report_*.html
      - name: Upload Judge Results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: judge-logs
          path: judge_results_*.json
      - name: Display Judge Results
        if: always()
        run: |
            echo "### ⚖️ Judge JSON Output" >> $GITHUB_STEP_SUMMARY
            echo "\`\`\`json" >> $GITHUB_STEP_SUMMARY
            cat judge_results_*.json >> $GITHUB_STEP_SUMMARY
            echo "\`\`\`" >> $GITHUB_STEP_SUMMARY

  # Job 3: Dockerize and Deploy
  build-and-push:
    needs: [test]
    runs-on: ubuntu-latest
    if: github.event_name == 'push'
    permissions:
      contents: read
      packages: write
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Lowercase Repo Name
        run: |
          echo "IMAGE_NAME=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV

      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and Push RAG API
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ env.IMAGE_NAME }}:latest
            ghcr.io/${{ env.IMAGE_NAME }}:${{ github.sha }}
    # Job 4: Update ArgoCD Manifests
  deploy-gitops:
      needs: [ build-and-push ]
      runs-on: ubuntu-latest
      if: github.event_name == 'push'
      steps:
        - name: Checkout Infra Repo
          uses: actions/checkout@v4
          with:
            repository: AnnacKK/multimodal-rag-infra
            token: ${{ secrets.GITOPS_TOKEN }}
            path: infra

        - name: Update K8s Manifest Tag
          run: |
            #
            cd infra/base 
            kustomize edit set image ghcr.io/annackk/multimodalrag=ghcr.io/annackk/softserve_multimodal_rag:${{ github.sha }}
        - name: Commit and Push
          run: |
            cd infra
            git config user.name "github-actions[bot]"
            git config user.email "github-actions[bot]@users.noreply.github.com"
            git add .
            git commit -m "🚀 deploy: multimodal-rag version ${{ github.sha }}"
            git push

Налаштування Github Actions

Крок 1: Створення структури каталогів

GitHub шукає файли конфігурації у спеціальній папці.

  • У корені вашого репозиторію створіть папку .github.

  • Всередині неї створіть підпапку workflows.

  • Створіть маніфест(наприклад, mlops-ci.yml).

Крок 2: Визначення тригерів (Events)

Виберіть події, які запускатимуть пайплайн. Важливо використовувати paths-ignore, щоб не запускати важкі тести при зміні лише документації. На їх основі створіть потрібну структуру job.

on:
  push:
    branches: [ main, master ]
    paths-ignore:
      - 'documentation/**'
      - 'manifest_examples/**'
  pull_request:
    branches: [ main ]

Крок 3: Налаштування середовища (Runner)

Визначте ОС та версію Python, краще щоб збігалась з версією .dockerfile. Кожна job повинна мати власне середовище.

    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'

Крок 4: Налаштування steps

Кожен тест виконується покроково:

Наприклад, для тесту steps будуть наступними: налаштування Python, завантаження бібліотек та Ollama, запуск test.py, завантаження репортів Giskard та Judge LLM.

Повну діаграму можна подивитись тут.

Крок 5: Git push

Запуск тестів при комміті:

git add .
git commit -m "adding Action"
git push [BRANCH]

Giskard

Звичайно писати тести з нуля це таке собі, тому я знайшла Giskard - фреймворк для тестування моделі, повний код тут. Для більшості тестів використовує GPT, замість якої використаю локальну qwen модель як judge.

Giskard тестує всю систему на різні слабкості: галюцинації, faitfulness, замість генерації десятків assert генерує складні промпти. Проблема в тому, що Giskard не розуміє семантики і не може визначити, чи згенерована відповідь дійсно коректна. Тому для перевірки семантики я використовую judge LLM, аналогічно до ragas у API.

Дано:

  • Датасет промптів

  • RAG

  • Context для RAG - Qdrant

  • Фреймворк тестування - Giskard

  • Маніфест для Github Actions

Потрібно: максимально покрити всі вразливі місця.

Проблема: розмір Qdrant перевищує тимчасове сховище, яке створюється при кожному push main: 

on:

 push:

   branches: [ main, master ]

 pull_request:

   branches: [ main ]

Для вирішення проблеми з завеликими розмірами Qdrant я створила меньші сніппети (scripts/) кожної з колекції на 300 векторів: така кількість займає небагато місця, але покриває достатню кількість статей для нормально тестування. Github Actions створює нову VM під час кожного push, тому я не можу мати доступ до колекцій Qdrant у докері, через що або створювати колекції з нуля, що я і обрала, або розгортати у хмарі.

 get_context_from_qdrant()

Щоб пришвидшити тест я зробила дві версії векторів: перша для judge llm, для якої через reranker з json сніппетів знаходяться релевантні вектори, тоді як для Giskard створюю нову колекцію з нуля та завантажую туди сніппети.

  • Dense (текст та зображення): семантичний пошук.

  • Sparse (BM25): пошук за ключовими словами.

  • Fusion (RRF): об’єднання результатів.

  • FlashRank (Rerank): переранжування, щоб знайти 5 найбільш релевантних уривків для судді.

qwen_judge_relevance()

Локальна модель (Qwen 2.5) виступає в ролі експерта (Judge). Вона отримує промпт, відповідь системи та знайдений контекст, після чого виносить вердикт: PASS (відповідь релевантна) або FAIL (відповідь не по суті або ігнорує контекст).

 recreate_qdrant()

Відтворювання всіх ориганільних колекцій з Qdrant (parent, child, cache). 

 test_batch_rag_evaluation() 

Тестування Judge та Giskard.

  • model_predict: виклик RAG_engine.

  • giskard.scan: автоматично шукає вразливості системи (галюцинації, підлабузництво, вірність контексту).

  • run_judge: запускає семантичний тест з Judge.

Якщо всі тести успішні, у реєстр контейнерів завантажується новий образ:

Приклад успішного Action
Новий образ, це й же тег повинен бути у кластері, якщо ArgoCD працює

Агресивність Giskard та NaN

Giskard є дуже агресивним у тестуванні, і це добре, бо частково імітує реального користувача та краще знаходить прогалини системи, але у мене ця агресивність вилилась у смішну ситуацію. Giskard може змінювати назву стовпця, з якого бере тестові промпти, в мене це стовпець “questions“:

#тестовий датасет
test_samples = [
        {"question": "How are AI monopolies affecting the market?", "category": "Business"},
        {"question": "What is the latest in transformer world models?", "category": "ML Research"}]
#feature_names відповідають датасету
giskard_model=giskard.Model(
        model=model_predict,
        model_type="text_generation",
        name="RAG_Batch_Evaluator",
        description="A RAG engine that retrieves context from Qdrant and generates answers about AI research, business, culture news.",
        feature_names=["question", "category"]
    )
#передаю тестовий датасет
giskard_dataset=giskard.Dataset(df=test_df, name="The_Batch_Multimodal_Sample")

Здавалось, в чому може виникнути помилка?

Giskard додав стовпець query

Якщо не додати перевірку на стовпець тестових промптів, RAG буде генерувати тільки коли query:nan.

З перевіркою все працює добре, така помилка зокрема через те, що у Python NaN має числову величину, тому навіть якщо стовпець пустий, треба передавати пусту строку.

def model_predict(df: pd.DataFrame):
        async def wrapped_predict(q_str):
            async with test_sem:
                try:
                    q = str(q_str) if pd.notna(q_str) else ""

                    if not q.strip() or q.lower() == 'nan':
                        return "I am sorry, but I do not have enough information to answer that question."

                    rag_engine.chat_history.clear()
                    res = await asyncio.wait_for(rag_engine.run_hybrid_rag(q), timeout=500.0)

                    if res is None:
                        return "Error: Engine returned None"

                    answer = res.get("answer", "")
                    if "I couldn't find any relevant snippets" in answer:
                        return "I am sorry, plese refrase so i can search again later."
                    return answer

                except Exception as e:
                    return f"Error: {str(e)}"

        async def run_batch():
            results = []
            for _, row in df.iterrows():
                q_val = row.get('question')
                query_val = row.get('query')

                if pd.notna(q_val) and str(q_val).lower() != 'nan':
                    q = q_val
                elif pd.notna(query_val) and str(query_val).lower() != 'nan':
                    q = query_val
                else:
                    q = ""  # Both were nan
                results.append(await wrapped_predict(q))
                await asyncio.sleep(0.2)
            return results

        future = asyncio.run_coroutine_threadsafe(run_batch(), test_loop)
        return future.result()
Тести пройдені успішно

Giskard також “дивиться“ на семантику error message, наприклад:

return {
                "headline": "No Direct Match Found",
                "answer": "I found some articles, but nothing specifically answering your question.",
                "confidence_score": best_score,
                "image_b64": "",
            }

може пройти перевірку Giskard, тоді як пряме повідомлення “Error“ точно не пройде:

return {
                "headline": "Error",
                "answer": "Error",
                "confidence_score": best_score,
                "image_b64": "",
            }

ArgoCD як інструмент GitOps

Основна концепція GitOps - це контроль за версіями, у призмі K8S - контроль за версіюванням контерів на заміна на найновіші версії. Наприклад, :latest модель з docker не завжди означає, що зараз система працює на оновленому контейнері, що може призвести до проблем з безпекою та ефективністю. 

ArgoCD слідує принципам GitOps та використовує репозиторії як source of truth та є окремою нодою, яка буде змінювати версії у кластері.

Argo CD automates the deployment of the desired application states in the specified target environments. Application deployments can track updates to branches, tags, or be pinned to a specific version of manifests at a Git commit.

Перед створенням argo ноди всі ноди повинні бути в стані running. Я слідувала офіційній документації, яка пропонує створити репозиторій як source of truth з усіма маніфестами, які потрібні для деплою, без маніфестів для докера. Також потрібно створити окремий токен, що дає права змінювати маніфести.

Приватний репозиторій для ground truth маніфестів

ArgoCD змінює imageTag, який показує, який image треба використовувати:

Оновлення newTag

image та imagePullPolicy

docker-deployment.yml, docker_jobs_deployment.yml - маніфести, що відповідають за роботу кластера, тобто координають ноди та версію контейнерів. Контейнери не повинні оновлюватись кожен раз при запуску кластера, але повинні оновлюватись тільки якщо всі тести з GithubActions успішні, за це відповідає imagePullPolicy.

  • imagePullPolicy: Always. Kubelet запитує реєстр образів контейнера, щоб отримати реєстр образу (qdrant-db-5d6c8587fb-brs5j, після db- реєстр), коли контейнер запускається. Витягує образ, навіть якщо існує локально. 

  • IfNotPresent: image оновлюється тільки якщо немає на ноді все ще.

  • Never: image ніколи не завантажується.

Оптимальним є imagePullPolicy: IfNotPresent.

Під час локальної оркестрації використовується image з локальної колекції докера image: docker.io/collections/multimodal-rag:latest, яке оновлюється тільки при docker compose, але ніяк не вписується у концепції CI/CD. Для завантаження оновленого image після push потрібно посилатись на реєстр контейнерів GHCR: image: ghcr.io/annackk/multimodalrag:latest.

Налаштування ArgoCD

Для налаштування потрібно рекомендується створити три маніфести: перший для конфігурації самої ноди (infra-repo-creds.yaml), другий для доступу до репозиторія(argo-app.yaml) та третій де AgroCD буде оновлювати тег image (kustomization.yaml). Приклади маніфестів у manifest_examples/.

Створення кластеру AgroCD аналогічно до кластеру для RAG:

  1. Створення кластеру

kubectl create namespace argocd
  1. Завантаження маніфестів

 
#образ
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
#маніфест Argo
kubectl apply -f multimodal-rag-infra/base/argo-app.yaml
#маніфест доступу 
kubectl apply -f multimodal-rag-infra/base/infra-repo-creds.yaml
  1. Перевірка стану

kubectl get applications -n argocd

Готовий кластер повинен статус Synced :

  1. NAME SYNC STATUS HEALTH STATUS

    multimodal-rag Synced Progressing

    Якщо ж ви бачите статус Uknown, скоріш за все це показник недостатніх прав токена, або його відсутності, для перевірки:

#повна інформація про кластер
kubectl get app multimodal-rag -n argocd -o yaml 
kubectl get secret infra-repo-creds -n argocd

показує статус секрету, тобто токену, якщо його немає, буде помилка:

Error from server (NotFound): secrets "infra-repo-creds" not found

  1. Перевірка поточного image

 kubectl get pod -l app=rag-api -o jsonpath='{.items[0].spec.containers[0].image}'

Оновлення тегу у kustomization.yml не буде застосовано, якщо образ взятий не з реєстру, а, наприклад, з докеру:

docker.io/library/multimodalrag-rag-api:latest

Щоб все працювало, тег повинен бути ідентичним до нового image:

ghcr.io/annackk/softserve_multimodal_rag:468bacd664fc11a86234ccf7493e1235b8a25447

Оптимізація системи

Recap всіх методів, завдяки яким система стала швидшою та надійнішою:

Як експерт із MLOps, я підготував повний список стратегій оптимізації, які ми впровадили та обговорили для вашого проекту Multimodal RAG. Ця документація охоплює весь життєвий цикл — від завантаження даних до судової оцінки (judicial evaluation), класифіковану за їхнім технічним впливом.

1. Пошук та векторна БД(Qdrant)
  • Hybrid Search: поєднання Dense (семантичних), Vision (на основі CLIP) та Sparse (BM25/SPLADE) векторів для пошуку по тегам, назвам та з семантичним змістом.

  • Reciprocal Rank Fusion (RRF): використання models.FusionQuery для оцінки знайдених векторів.

  • Prefetching: models.Prefetch для кількох підзапитів за один цикл звернення Qdrant, що зменшує latency.

  • Payload Indexing: індексація специфічних полів (наприклад, category або metadata) у Qdrant для швидшої фільтрації.

  • Quantization: зміна векторів з float32 до int8 для економії до 75% оперативної пам'яті з мінімальною втратою точності.

2. Inference
  • Асинхронне виконання (asyncio): асинхронність для Qdrant та FastAPI для обробки одночасних запитів кількох користувачів без блокування основного потоку.

  • Thread Pooling: обмеження через asyncio.to_thread, щоб запобігти заморожуванню циклу під час важких локальних обчислень ембеддінгів.

  • Reranking через FlashRank: легкий крос-енкодера (Ranker) після початкового пошуку для переупорядкування топ-15 результатів, щоб прибрати “сміття“ - вектори, що не сильно відносяться до промпту користувача.

  • One model per One task: Qwen 2.5 (1.5B) через Ollama як Judge та OLLAMA 4 через Groq.

3. Пайплайн та архітектура (MLOps)
  • Idempotent Ingestion: mapping.py з UPSERT дозволяє перезапускати пайплайн без створення дублікатів Qdrant.

  • Small - to - Big (Parent-Child): маленькі вектори для пошуку, великі вектори для зберігання.

  • Stateless Scaling: змінні оточення (GR_TOKEN, QDRANT_HOST) та секрети для горизонтального масштабування.

  • Container Healthcheck: трафік надходить до API лише після того, як контейнери готові.

4. Оцінка та контроль якості
  • Автоматичне сканування (Giskard): автоматичні тести для виявлення галюцинацій, проблем із вірністю контексту (faithfulness) та упередженості (sycophancy).

  • LLM-as-a-Judge: judge model для семантичної валідації генерацій.

  • Фільтрація шляхів у CI/CD: GitHub Actions із paths-ignore, щоб не запускати тестів, коли змінюються неважливі для системи дані, наприклад документація.

  • Drift Detection: CronJob для розрахунку data drif запускає data_processing лише при значній зміні розподілу даних.

5. Prompt Engineering
  • Self-Correction: base prompt та judge prompt.

  • Query Expansion: генерація 3 варіацій промпту користувача для покращення повноти (recall) векторного пошуку.

  • Конвертація Vision-у-Текст: генерація опису медіафайлів.

На цьому закінчую свою розповідь, якщо маєте питання або зауваження - з радістю чекаю у коментарях.

Що почитати далі

Evaluating Large Language Model (LLM) systems: Metrics, challenges, and best practices

Reranker vs. Retriever: Who Does What in RAG?

CI/CD 101: From Code Commit to Production

Switched to hardened distroless images thinking CVEs would stop being my problem, they didn't

Prompt Engineering Guide

Статті про вітчизняний бізнес та цікавих людей:

Поділись своїми ідеями в новій публікації.
Ми чекаємо саме на твій довгочит!
Нейромереживо
Нейромереживо@mlfromjun

ML engineer

7Довгочити
3.5KПерегляди
33Підписники
На Друкарні з 31 березня

Більше від автора

  • Не бійтеся інтегралів

    Основна складність у розвязанні інтегралів полягає у плутанині великої кількості технік інтегрування. У цій статті я детально розповім про суть інтегралів та систематизую техніки інтегрування. З цим, розвязання навіть складного інтеграла можна зробити у декілька строк.

    Теми цього довгочиту:

    Математика
  • MLOps: Введення та трансформація даних з TFX

    Як створюються, тестуються та контролюються моделі машинного навчання у реальних проєктах. Розглянемо автоматизацію трансформації даних за допомогою TFX.

    Теми цього довгочиту:

    Ai
  • Класифікація 101: Низька якість, ансамблювання та незбалансовані класи

    Класифікації за допомогою методів машинного навчання та підвищення її точності за допомогою балансування класів та ансамблюванню моделей

    Теми цього довгочиту:

    Ml

Це також може зацікавити:

Коментарі (2)

Раді Вашому новому довгочиту!

Це також може зацікавити: