From 237e1767a7d55e07e0a3583315bd291511cc874a Mon Sep 17 00:00:00 2001 From: jze9 Date: Wed, 26 Aug 2026 15:08:30 +0500 Subject: [PATCH] =?UTF-8?q?feat(embeddings):=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B8=D1=82=D1=8C=20=D1=8D=D0=BC=D0=B1?= =?UTF-8?q?=D0=B5=D0=B4=D0=B4=D0=B8=D0=BD=D0=B3=D0=B8=20=D0=BD=D0=B0=20Oll?= =?UTF-8?q?ama/bge-m3=20(GPU=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20Vulkan)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше эмбеддинг-модель (уровень 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" оставляло враньё в докстринге. --- services/api/app/api/tasks.py | 2 +- services/worker-gpu/app/config.py | 9 ++++-- services/worker-gpu/app/model_manager.py | 35 ++++++++++++++++++++++-- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/services/api/app/api/tasks.py b/services/api/app/api/tasks.py index 684f614..e36091f 100644 --- a/services/api/app/api/tasks.py +++ b/services/api/app/api/tasks.py @@ -107,7 +107,7 @@ async def delete_task( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> None: - """Удалить задачу. Нельзя удалить задачу в статусе 'processing'.""" + """Удалить задачу.""" result = await db.execute( select(Task).where( Task.public_id == public_id, diff --git a/services/worker-gpu/app/config.py b/services/worker-gpu/app/config.py index 9a0076d..f2175c0 100644 --- a/services/worker-gpu/app/config.py +++ b/services/worker-gpu/app/config.py @@ -41,10 +41,13 @@ class Settings(BaseSettings): # FAISS / ML FAISS_INDEX_PATH: str = "/data/index/faiss.index" FAISS_ID_MAP_PATH: str = "/data/index/faiss_id_map.json" - EMBED_MODEL: str = "paraphrase-multilingual-mpnet-base-v2" - EMBED_DEVICE: str = "cuda" + # EMBED_BACKEND: "ollama" (GGUF-модель через Vulkan, для AMD GPU без ROCm/atomics) + # или "sentence_transformers" (локальная загрузка, CUDA/CPU) + EMBED_BACKEND: str = "ollama" + EMBED_MODEL: str = "bge-m3" + EMBED_DEVICE: str = "cuda" # используется только при EMBED_BACKEND=sentence_transformers EMBED_BATCH_SIZE: int = 64 - EMBED_DIM: int = 768 # Размерность вектора paraphrase-multilingual-mpnet-base-v2 + EMBED_DIM: int = 1024 # bge-m3; было 768 у paraphrase-multilingual-mpnet-base-v2 FAISS_NLIST: int = 1024 # Количество кластеров для IVFFlat FAISS_NPROBE: int = 64 # Количество кластеров для поиска diff --git a/services/worker-gpu/app/model_manager.py b/services/worker-gpu/app/model_manager.py index e26445a..2c2b7d7 100644 --- a/services/worker-gpu/app/model_manager.py +++ b/services/worker-gpu/app/model_manager.py @@ -1,10 +1,16 @@ -"""Singleton менеджер sentence-transformers модели. +"""Singleton менеджер эмбеддинг-модели. -Модель загружается один раз при первом обращении и кэшируется в памяти GPU. +Бэкенд переключается через 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 @@ -54,11 +60,14 @@ class ModelManager: texts: Список текстов для кодирования Returns: - numpy массив формы (len(texts), 768), нормализованный для cosine similarity + 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, @@ -69,6 +78,26 @@ class ModelManager: ) 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: """Закодировать один текст. Удобный метод."""