Як побудувати семантичний пошук з трансформерами та embedding’ами для українських текстів

Стаття розповідає про створення семантичного пошуку для українських текстів за допомогою трансформерів та ембедингів

Звичайний пошук за ключовими словами часто підводить: запит “автомобільна аварія” не знайде документ зі словом “ДТП”, хоча зміст однаковий. Семантичний пошук вирішує цю проблему — він розуміє сенс, а не просто символи. У цьому туторіалі ми побудуємо повноцінну систему семантичного пошуку для українських текстів з нуля за 2–3 години. Для старту потрібен Python 3.10+, базові знання командного рядка та 4 ГБ оперативної пам’яті.

🛠️ Що знадобиться

  • Python 3.10+ — основна мова розробки; завантажити безкоштовно з python.org
  • sentence-transformers — бібліотека для генерації embedding’ів; безкоштовна, pip-пакет
  • FAISS (faiss-cpu) — векторна база даних від Meta для швидкого пошуку серед тисяч векторів; безкоштовна
  • Модель paraphrase-multilingual-mpnet-base-v2 — мультимовний трансформер від HuggingFace, чудово працює з українською; безкоштовна (~400 МБ)
  • Jupyter Notebook або VS Code — середовище для запуску коду; обидва безкоштовні
  • pandas — для роботи з текстовими даними у табличному форматі; безкоштовний

📋 Покрокова інструкція

Крок 1: Створення віртуального середовища та встановлення залежностей

Відкрий термінал і виконай такі команди по черзі. Спочатку створи папку проєкту: mkdir semantic-search-ua && cd semantic-search-ua. Потім створи віртуальне середовище: python -m venv venv і активуй його — на Windows: venv\Scripts\activate, на Mac/Linux: source venv/bin/activate. Тепер встанови всі потрібні пакети однією командою: pip install sentence-transformers faiss-cpu pandas numpy jupyter. Встановлення займе 3–5 хвилин — завантажиться близько 1.5 ГБ залежностей разом із PyTorch.

Крок 2: Підготовка корпусу українських текстів

Створи файл data.py і додай туди тестовий набір документів українською мовою. Використай такий код:

documents = [
    "Верховна Рада України ухвалила новий закон про освіту",
    "ДТП на Хрещатику: постраждали троє людей",
    "Київський метрополітен запускає нову лінію у 2026 році",
    "Українські вчені розробили вакцину проти грипу",
    "Автомобільна аварія заблокувала рух у центрі міста",
    "Парламент проголосував за реформу середньої школи",
    "Нова підземна траса метро з'єднає Виноградар і Троєщину",
    "Медики знайшли ефективний засіб від сезонних вірусів",
]

Зверни увагу: документи 2 і 5 говорять про одне (аварія), а 1 і 6 — про освіту. Саме це ми перевіримо під час пошуку. Якщо маєш власні дані — завантаж CSV через pandas.read_csv() і передай колонку з текстом як список.

Крок 3: Генерація embedding’ів для всіх документів

Створи файл embeddings.py. Перший запуск завантажить модель із HuggingFace (~400 МБ) — це відбувається автоматично і лише один раз:

from sentence_transformers import SentenceTransformer
import numpy as np
from data import documents

# Завантажуємо модель
model = SentenceTransformer('paraphrase-multilingual-mpnet-base-v2')

# Генеруємо вектори для всіх документів
# show_progress_bar=True покаже прогрес
embeddings = model.encode(documents, show_progress_bar=True, normalize_embeddings=True)

# Зберігаємо на диск, щоб не рахувати щоразу
np.save('embeddings.npy', embeddings)
print(f"Розмірність векторів: {embeddings.shape}")
# Очікуваний вивід: (8, 768)

Запусти: python embeddings.py. Ти отримаєш матрицю 8×768 — кожен документ перетворено на вектор із 768 чисел. Параметр normalize_embeddings=True — обов’язковий, він нормалізує вектори до одиничної довжини, що необхідно для коректного косинусного порівняння.

Крок 4: Побудова FAISS-індексу та реалізація пошуку

Тепер підключимо векторну базу даних. Створи файл search.py:

import faiss
import numpy as np
from sentence_transformers import SentenceTransformer
from data import documents

# Завантажуємо модель і збережені вектори
model = SentenceTransformer('paraphrase-multilingual-mpnet-base-v2')
embeddings = np.load('embeddings.npy').astype('float32')

# Створюємо FAISS-індекс для косинусної схожості
dimension = embeddings.shape[1]  # 768
index = faiss.IndexFlatIP(dimension)  # IP = Inner Product (скалярний добуток)
index.add(embeddings)

def semantic_search(query: str, top_k: int = 3):
    # Перетворюємо запит на вектор
    query_vector = model.encode([query], normalize_embeddings=True).astype('float32')
    
    # Шукаємо top_k найближчих сусідів
    scores, indices = index.search(query_vector, top_k)
    
    print(f"\n🔍 Запит: '{query}'")
    print("=" * 50)
    for rank, (idx, score) in enumerate(zip(indices[0], scores[0]), 1):
        print(f"{rank}. [{score:.3f}] {documents[idx]}")

# Тестуємо
semantic_search("автомобільна катастрофа на дорозі")
semantic_search("реформа навчальних закладів")
semantic_search("підземний транспорт Києва")

Запусти: python search.py. Ти побачиш, що запит “автомобільна катастрофа на дорозі” знаходить обидва документи про аварію — навіть той, де слова “катастрофа” немає. Це і є семантичний пошук у дії.

Крок 5: Збереження індексу та створення простого API

Для реального використання потрібно зберігати індекс і не перераховувати його щоразу. Створи фінальний файл app.py:

import faiss
import numpy as np
from sentence_transformers import SentenceTransformer
from data import documents
import os

MODEL_NAME = 'paraphrase-multilingual-mpnet-base-v2'
INDEX_FILE = 'faiss_index.bin'
EMBED_FILE = 'embeddings.npy'

model = SentenceTransformer(MODEL_NAME)

# Будуємо або завантажуємо індекс
if os.path.exists(INDEX_FILE):
    index = faiss.read_index(INDEX_FILE)
    print("✅ Індекс завантажено з диску")
else:
    embeddings = model.encode(documents, normalize_embeddings=True).astype('float32')
    np.save(EMBED_FILE, embeddings)
    index = faiss.IndexFlatIP(embeddings.shape[1])
    index.add(embeddings)
    faiss.write_index(index, INDEX_FILE)
    print("✅ Індекс побудовано і збережено")

def search(query: str, top_k: int = 3):
    vec = model.encode([query], normalize_embeddings=True).astype('float32')
    scores, ids = index.search(vec, top_k)
    return [(documents[i], float(s)) for i, s in zip(ids[0], scores[0])]

# Інтерактивний режим
while True:
    q = input("\nВведіть запит (або 'вихід'): ")
    if q.lower() == 'вихід':
        break
    for text, score in search(q):
        print(f"  [{score:.3f}] {text}")

Запусти python app.py і вводь будь-які запити українською. На виході отримаєш інтерактивну систему пошуку, яка зберігає індекс між запусками і відповідає за частки секунди.

⚠️ Типові помилки та як їх уникнути

  • Забули normalize_embeddings=True — без нормалізації IndexFlatIP повертає некоректні результати; завжди додавай цей параметр і при індексації, і при кодуванні запиту
  • Змішані типи float64 і float32 — FAISS працює виключно з float32; якщо отримуєш помилку TypeError, додай .astype('float32') після кожного encode()
  • Модель завантажується щоразу при пошуку — не створюй SentenceTransformer() всередині функції пошуку; ініціалізуй модель один раз на рівні модуля
  • Індекс не відповідає поточним документам — якщо ти додав нові тексти, але використовуєш старий faiss_index.bin, результати будуть хибними; видаляй файл індексу після зміни корпусу
  • Помилка пам’яті на великих корпусах — при >100 000 документів замість IndexFlatIP використовуй IndexIVFFlat з кластеризацією; це зменшує використання RAM у 10–20 разів

💡 Поради для кращого результату

По-перше, для суто українських текстів спробуй модель sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 — вона вдвічі швидша і займає 120 МБ, якість для більшості задач майже не поступається. По-друге, якщо документи довгі (статті, юридичні тексти), ділі їх на абзаци по 200–300 токенів перед індексацією — трансформери мають ліміт контексту 512 токенів, і довгий текст “розмивається” в одному векторі. По-третє, додай перезапит через CrossEncoder: спочатку FAISS знаходить топ-20 кандидатів швидко, а потім cross-encoder/mmarco-mMiniLMv2-L12-H384-v1 точніше переранжовує їх — якість зростає на 15–20% без великих витрат. По-четверте, зберігай метадані (заголовок, дату, джерело) окремо у словнику з ключем-індексом документа, щоб повертати не лише текст, а й корисний контекст.

❓ Часті запитання (FAQ)

1. Чи працює це на комп’ютері без GPU?
Так, всі наведені команди використовують CPU. На CPU кодування 1000 документів займає близько 30 секунд. Якщо є GPU — sentence-transformers автоматично його задіє через PyTorch, і швидкість зросте у 10–15 разів.

2. Яка максимальна кількість документів для FAISS IndexFlatIP?
Технічно — мільйони, але RAM обмежує: 1 мільйон векторів розміром 768 займає ~3 ГБ. Для корпусів понад 500 000 документів переходь на IndexIVFFlat або розгляни хмарні рішення на кшталт Qdrant чи Weaviate.

3. Чому результати іноді здаються нелогічними?
Модель навчалась на загальних текстах, тому специфічна термінологія (юридична, медична, технічна) може оброблятись гірше. Вирішення — дофайнтюнити модель на своєму домені за допомогою sentence_transformers.losses.CosineSimilarityLoss з парами схожих речень.

4. Як оновлювати індекс при додаванні нових документів?
FAISS IndexFlatIP підтримує інкрементальне додавання через index.add(new_vectors) — просто кодуй нові документи і додавай їх вектори. Головне — зберігай синхронізований список документів паралельно з індексом, бо FAISS зберігає лише вектори, а не тексти.

5. Чи можна використати OpenAI Embeddings замість локальної моделі?
Так, API text-embedding-3-small від OpenAI дає дуже якісні вектори і підтримує українську. Замінюй виклик model.encode() на client.embeddings.create() — решта коду залишається незмінною. Мінус — платна послуга (~$0.02 за 1 млн токенів) і залежність від інтернету.

🏁 Підсумок

Ти побудував повноцінну систему семантичного пошуку для українських текстів: від встановлення середовища до інтерактивного застосунку з персистентним FAISS-індексом. Система розуміє синоніми, парафрази та семантично близькі поняття — те, чого не вміє звичайний пошук за ключовими словами.

Прямо зараз відкрий термінал, виконай pip install sentence-transformers faiss-cpu і запусти перший крок — завантаження моделі займе 5 хвилин, а вже за годину матимеш працюючий прототип. Далі — підключи власні дані та спробуй переранжування через CrossEncoder.

РОЗСИЛКА

📬 Щотижневий AI-дайджест

Найкращі статті про ШІ та автоматизацію — без спаму, лише суть

Без спаму · Відписатись будь-коли

Telegram