Files
anti-plagiarism/services/worker-gpu/app/model_manager.py
jze9 237e1767a7
All checks were successful
Deploy / test (push) Successful in 4m43s
Deploy / deploy (push) Successful in 1m53s
feat(embeddings): переключить эмбеддинги на Ollama/bge-m3 (GPU через Vulkan)
Раньше эмбеддинг-модель (уровень 3) гоняла на CPU внутри worker-gpu —
GPU CT108 использовался только под LLM-парафраз (уровень 4). Теперь
эмбеддинги идут через Ollama /api/embed на отдельной VM с RX 580
(Vulkan-бэкенд, без возни с ROCm/HIP для этой карты). EMBED_BACKEND
переключаемый ("ollama" | "sentence_transformers"), дефолт — ollama.

Модель сменилась на bge-m3 (1024-мерный вектор вместо 768 у
paraphrase-multilingual-mpnet-base-v2) — несовместимо с уже посчитанным
FAISS-индексом, нужна полная переиндексация корпуса после деплоя.

Заодно докстринг delete_task в tasks.py — снятое раньше ограничение
"нельзя удалить processing" оставляло враньё в докстринге.
2026-08-26 15:08:30 +05:00

111 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Singleton менеджер эмбеддинг-модели.
Бэкенд переключается через EMBED_BACKEND:
- "ollama" — HTTP-вызов к Ollama /api/embed (GGUF-модель через Vulkan, работает на AMD GPU)
- "sentence_transformers" — локальная загрузка через sentence-transformers (CUDA/CPU)
Модель (в случае sentence_transformers) загружается один раз при первом обращении
и кэшируется в памяти GPU.
"""
import logging
import httpx
import numpy as np
from app.config import settings
logger = logging.getLogger(__name__)
class ModelManager:
"""Singleton для управления эмбеддинг-моделью."""
_instance = None
_model = None
_device: str = settings.EMBED_DEVICE
@classmethod
def get_model(cls):
"""Получить или загрузить модель sentence-transformers."""
if cls._model is None:
# Импорт здесь, чтобы не блокировать импорт модуля если torch не установлен
from sentence_transformers import SentenceTransformer
logger.info(
f"Загрузка модели {settings.EMBED_MODEL!r} на устройство {settings.EMBED_DEVICE!r}..."
)
try:
cls._model = SentenceTransformer(
settings.EMBED_MODEL,
device=settings.EMBED_DEVICE,
)
logger.info(
f"Модель загружена. Размерность вектора: {cls._model.get_sentence_embedding_dimension()}"
)
except Exception as e:
# Graceful degradation на CPU если CUDA недоступна
logger.warning(f"Не удалось загрузить модель на CUDA: {e}. Переключение на CPU.")
cls._device = "cpu"
cls._model = SentenceTransformer(settings.EMBED_MODEL, device="cpu")
return cls._model
@classmethod
def encode(cls, texts: list[str]) -> np.ndarray:
"""
Закодировать тексты в векторы.
Args:
texts: Список текстов для кодирования
Returns:
numpy массив формы (len(texts), EMBED_DIM), нормализованный для cosine similarity
"""
if not texts:
return np.array([]).reshape(0, settings.EMBED_DIM)
if settings.EMBED_BACKEND == "ollama":
return cls._encode_ollama(texts)
model = cls.get_model()
vectors = model.encode(
texts,
batch_size=settings.EMBED_BATCH_SIZE,
normalize_embeddings=True, # Нормализация для cosine через inner product
show_progress_bar=len(texts) > 100,
convert_to_numpy=True,
)
return vectors.astype(np.float32)
@classmethod
def _encode_ollama(cls, texts: list[str]) -> np.ndarray:
"""Закодировать тексты через Ollama /api/embed (пакетами по EMBED_BATCH_SIZE)."""
all_vectors: list[list[float]] = []
batch_size = settings.EMBED_BATCH_SIZE
for i in range(0, len(texts), batch_size):
batch = texts[i : i + batch_size]
response = httpx.post(
f"{settings.OLLAMA_URL}/api/embed",
json={"model": settings.EMBED_MODEL, "input": batch},
timeout=120.0,
)
response.raise_for_status()
all_vectors.extend(response.json()["embeddings"])
vectors = np.array(all_vectors, dtype=np.float32)
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
norms[norms == 0] = 1.0
return vectors / norms # Нормализация для cosine через inner product
@classmethod
def encode_single(cls, text: str) -> np.ndarray:
"""Закодировать один текст. Удобный метод."""
return cls.encode([text])[0]
@classmethod
def unload(cls) -> None:
"""Выгрузить модель из памяти (для тестов/диагностики)."""
cls._model = None
logger.info("Модель выгружена из памяти")