feat: интеграция ML-скоринга с использованием RAG и DeepSeek

- Обновлен Dockerfile для установки необходимых зависимостей.
- Добавлены новые переменные окружения для настройки ML-скоринга в env.example.
- Реализованы методы для получения и обновления ML-скоров в AsyncBotDB и PostRepository.
- Обновлены обработчики публикации постов для интеграции ML-скоринга.
- Добавлен новый обработчик для получения статистики ML-скоринга в админ-панели.
- Обновлены функции для форматирования сообщений с учетом ML-скоров.
This commit is contained in:
2026-01-26 18:40:38 +03:00
parent e2b1353408
commit 7f6f0f028c
25 changed files with 2833 additions and 52 deletions

View File

@@ -0,0 +1,42 @@
"""
Сервисы для ML-скоринга постов.
Включает:
- RAGService - локальное векторное сравнение с ruBERT
- DeepSeekService - интеграция с DeepSeek API
- ScoringManager - объединение всех сервисов скоринга
- VectorStore - in-memory хранилище векторов
"""
from .base import ScoringResult, ScoringServiceProtocol, CombinedScore
from .exceptions import (
ScoringError,
ModelNotLoadedError,
VectorStoreError,
DeepSeekAPIError,
InsufficientExamplesError,
TextTooShortError,
)
from .vector_store import VectorStore
from .rag_service import RAGService
from .deepseek_service import DeepSeekService
from .scoring_manager import ScoringManager
__all__ = [
# Базовые классы
"ScoringResult",
"ScoringServiceProtocol",
"CombinedScore",
# Исключения
"ScoringError",
"ModelNotLoadedError",
"VectorStoreError",
"DeepSeekAPIError",
"InsufficientExamplesError",
"TextTooShortError",
# Сервисы
"VectorStore",
"RAGService",
"DeepSeekService",
"ScoringManager",
]

View File

@@ -0,0 +1,155 @@
"""
Базовые классы и протоколы для сервисов скоринга.
"""
from dataclasses import dataclass, field
from typing import Optional, Protocol, Dict, Any
from datetime import datetime
@dataclass
class ScoringResult:
"""
Результат оценки поста от одного сервиса.
Attributes:
score: Оценка от 0.0 до 1.0 (вероятность публикации)
source: Источник оценки ("deepseek", "rag", etc.)
model: Название используемой модели
confidence: Уверенность в оценке (опционально)
timestamp: Время получения оценки
metadata: Дополнительные данные
"""
score: float
source: str
model: str
confidence: Optional[float] = None
timestamp: int = field(default_factory=lambda: int(datetime.now().timestamp()))
metadata: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
"""Валидация score в диапазоне [0.0, 1.0]."""
if not 0.0 <= self.score <= 1.0:
raise ValueError(f"Score должен быть в диапазоне [0.0, 1.0], получено: {self.score}")
def to_dict(self) -> Dict[str, Any]:
"""Преобразует результат в словарь для сохранения в JSON."""
result = {
"score": round(self.score, 4),
"model": self.model,
"ts": self.timestamp,
}
if self.confidence is not None:
result["confidence"] = round(self.confidence, 4)
if self.metadata:
result["metadata"] = self.metadata
return result
@classmethod
def from_dict(cls, source: str, data: Dict[str, Any]) -> "ScoringResult":
"""Создает ScoringResult из словаря."""
return cls(
score=data["score"],
source=source,
model=data.get("model", "unknown"),
confidence=data.get("confidence"),
timestamp=data.get("ts", int(datetime.now().timestamp())),
metadata=data.get("metadata", {}),
)
@dataclass
class CombinedScore:
"""
Объединенный результат от всех сервисов скоринга.
Attributes:
deepseek: Результат от DeepSeek API (None если отключен/ошибка)
rag: Результат от RAG сервиса (None если отключен/ошибка)
errors: Словарь с ошибками по источникам
"""
deepseek: Optional[ScoringResult] = None
rag: Optional[ScoringResult] = None
errors: Dict[str, str] = field(default_factory=dict)
@property
def deepseek_score(self) -> Optional[float]:
"""Возвращает только числовой скор от DeepSeek."""
return self.deepseek.score if self.deepseek else None
@property
def rag_score(self) -> Optional[float]:
"""Возвращает только числовой скор от RAG."""
return self.rag.score if self.rag else None
def to_json_dict(self) -> Dict[str, Any]:
"""
Преобразует в словарь для сохранения в ml_scores колонку.
Формат:
{
"deepseek": {"score": 0.75, "model": "...", "ts": ...},
"rag": {"score": 0.90, "model": "...", "ts": ...}
}
"""
result = {}
if self.deepseek:
result["deepseek"] = self.deepseek.to_dict()
if self.rag:
result["rag"] = self.rag.to_dict()
return result
def has_any_score(self) -> bool:
"""Проверяет, есть ли хотя бы один успешный скор."""
return self.deepseek is not None or self.rag is not None
class ScoringServiceProtocol(Protocol):
"""
Протокол для сервисов скоринга.
Любой сервис скоринга должен реализовывать эти методы.
"""
@property
def source_name(self) -> str:
"""Возвращает имя источника ("deepseek", "rag", etc.)."""
...
@property
def is_enabled(self) -> bool:
"""Проверяет, включен ли сервис."""
...
async def calculate_score(self, text: str) -> ScoringResult:
"""
Рассчитывает скор для текста поста.
Args:
text: Текст поста для оценки
Returns:
ScoringResult с оценкой
Raises:
ScoringError: При ошибке расчета
"""
...
async def add_positive_example(self, text: str) -> None:
"""
Добавляет текст как положительный пример (опубликованный пост).
Args:
text: Текст опубликованного поста
"""
...
async def add_negative_example(self, text: str) -> None:
"""
Добавляет текст как отрицательный пример (отклоненный пост).
Args:
text: Текст отклоненного поста
"""
...

View File

@@ -0,0 +1,358 @@
"""
DeepSeek API сервис для скоринга постов.
Использует DeepSeek API для семантической оценки релевантности поста.
"""
import asyncio
import json
from typing import Optional, List
import httpx
from logs.custom_logger import logger
from helper_bot.utils.metrics import track_time, track_errors
from .base import ScoringResult
from .exceptions import DeepSeekAPIError, ScoringError, TextTooShortError
class DeepSeekService:
"""
Сервис для оценки постов через DeepSeek API.
Отправляет текст поста в DeepSeek с промптом для оценки
и получает числовой скор релевантности.
Attributes:
api_key: API ключ DeepSeek
api_url: URL API эндпоинта
model: Название модели
timeout: Таймаут запроса в секундах
"""
# Промпт для оценки поста
SCORING_PROMPT = """Роль: Ты — строгий и внимательный модератор сообщества в социальной сети, ориентированного на знакомства между людьми. Твоя задача — оценить, можно ли опубликовать пост, основываясь на четких правилах.
Контекст группы: Это группа для поиска и знакомства с людьми. Пользователи могут искать кого угодно: случайно увиденных на улице, в транспорте, в кафе, старых знакомых, новых друзей или пару. Это главная и единственная цель группы.
---
ПРАВИЛА ЗАПРЕТА (пост НЕ ДОЛЖЕН быть опубликован, если содержит это):
1. Запрещенные законом тематики: Любые призывы, обсуждение или поиск чего-либо незаконного (наркотики, оружие, мошенничество, насилие и т.д.).
2. Поиск и утеря животных, найденные предметы: Запрещены посты про потерявшихся/найденных кошек, собак, хомяков, а также про потерянные/найденные телефоны, ключи, сумки и т.п.
3. Конкуренция (Дайвинчик): Любое упоминание группы/проекта/чата "Дайвинчик" или любых других групп-конкурентов. Запрещены призывы переходить в другие сообщества.
4. Сбор больших компаний и групп: Запрещены посты с целью собрать большую тусовку, компанию, группу для похода, вечеринки, игры и т.д. (например, "собираем команду для футбола", "кто хочет на квартиру?").
5. Организация чатов и других сообществ: Запрещено создание или реклама сторонних чатов, каналов, групп в телеграме, дискорде и т.п.
---
ПРАВИЛА РАЗРЕШЕНИЯ (пост МОЖЕТ быть опубликован, если):
· Цель — найти конкретного человека или познакомиться с кем-то новым.
· Формат: Описание человека, обстоятельств встречи, примет, места и времени. Или прямой призыв к знакомству.
· Примеры ДОПУСТИМЫХ постов (ориентируйся на них):
· "мальчики нефоры/патлатые, гоу знакомиться😻 анон"
· "ищу девочку, ехала на 21 автобусе примерно в 15:20. села на детской поликлинике и вышла в заречье вся в черной одежде и с черным баулом"
· "ищу мальчика ехали на 35 автобусе часов в 7 вечера я была с девочками,у нас с тобой еще куртки одинаковые ,я рядом с тобой сидела,напиши в комментарии если у тебя нету девочки. анон админу любви."
---
ИНСТРУКЦИЯ ПО ОЦЕНКЕ:
Проанализируй полученный пост и присвой ему итоговый Вес (Score) от 0.0 до 1.0, где:
· 1.0 — Пост полностью соответствует правилам. Цель — найти/познакомиться с человеком. Ничего из списка запретов не нарушено. Можно публиковать.
· 0.0 — Пост категорически нарушает правила. Содержит явные признаки одного или нескольких пунктов из списка запрета. Публиковать НЕЛЬЗЯ.
· 0.2 - 0.8 — Пост находится в "серой зоне". Присваивай промежуточный вес, оценивая степень риска и соответствия цели группы.
· Ближе к 0.2: Сильно сомнительный пост, есть явные признаки запрещенной темы (например, упоминание "собраться компанией", косвенная реклама другого места).
· 0.5: Нейтральный или неочевидный пост. Нужно проверить, нет ли скрытого смысла, нарушающего правила.
· Ближе к 0.8: В целом допустимый пост, но с небольшими странностями или двусмысленностями, не нарушающими правила напрямую.
---
{text}
---
Ответь ТОЛЬКО числом от 0.0 до 1.0, без дополнительных объяснений.
Пример ответа: 0.75"""
DEFAULT_API_URL = "https://api.deepseek.com/v1/chat/completions"
DEFAULT_MODEL = "deepseek-chat"
def __init__(
self,
api_key: Optional[str] = None,
api_url: Optional[str] = None,
model: Optional[str] = None,
timeout: int = 30,
enabled: bool = True,
min_text_length: int = 3,
max_retries: int = 3,
):
"""
Инициализация DeepSeek сервиса.
Args:
api_key: API ключ DeepSeek
api_url: URL API эндпоинта
model: Название модели
timeout: Таймаут запроса в секундах
enabled: Включен ли сервис
min_text_length: Минимальная длина текста для обработки
max_retries: Максимальное количество повторных попыток
"""
self.api_key = api_key
self.api_url = api_url or self.DEFAULT_API_URL
self.model = model or self.DEFAULT_MODEL
self.timeout = timeout
self._enabled = enabled and bool(api_key)
self.min_text_length = min_text_length
self.max_retries = max_retries
# HTTP клиент (создается лениво)
self._client: Optional[httpx.AsyncClient] = None
if not api_key and enabled:
logger.warning("DeepSeekService: API ключ не указан, сервис отключен")
self._enabled = False
logger.info(
f"DeepSeekService инициализирован "
f"(model={self.model}, enabled={self._enabled})"
)
@property
def source_name(self) -> str:
"""Имя источника для результатов."""
return "deepseek"
@property
def is_enabled(self) -> bool:
"""Проверяет, включен ли сервис."""
return self._enabled
async def _get_client(self) -> httpx.AsyncClient:
"""Получает или создает HTTP клиент."""
if self._client is None:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(self.timeout),
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
)
return self._client
async def close(self) -> None:
"""Закрывает HTTP клиент."""
if self._client:
await self._client.aclose()
self._client = None
def _clean_text(self, text: str) -> str:
"""Очищает текст от лишних символов."""
if not text:
return ""
# Удаляем лишние пробелы и переносы строк
clean = " ".join(text.split())
# Удаляем служебные символы
if clean == "^":
return ""
return clean.strip()
def _parse_score_response(self, response_text: str) -> float:
"""
Парсит ответ от DeepSeek и извлекает скор.
Args:
response_text: Текст ответа от API
Returns:
Числовой скор от 0.0 до 1.0
Raises:
DeepSeekAPIError: Если не удалось распарсить ответ
"""
try:
# Пытаемся найти число в ответе
text = response_text.strip()
# Убираем возможные обрамления
text = text.strip('"\'`')
# Пробуем распарсить как число
score = float(text)
# Ограничиваем диапазон
score = max(0.0, min(1.0, score))
return score
except ValueError:
# Пробуем найти число в тексте
import re
matches = re.findall(r'0\.\d+|1\.0|0|1', text)
if matches:
score = float(matches[0])
return max(0.0, min(1.0, score))
logger.error(f"DeepSeekService: Не удалось распарсить ответ: {response_text}")
raise DeepSeekAPIError(f"Не удалось распарсить скор из ответа: {response_text}")
@track_time("calculate_score", "deepseek_service")
@track_errors("deepseek_service", "calculate_score")
async def calculate_score(self, text: str) -> ScoringResult:
"""
Рассчитывает скор для текста поста через DeepSeek API.
Args:
text: Текст поста для оценки
Returns:
ScoringResult с оценкой
Raises:
ScoringError: При ошибке расчета
"""
if not self._enabled:
raise ScoringError("DeepSeek сервис отключен")
# Очищаем текст
clean_text = self._clean_text(text)
if len(clean_text) < self.min_text_length:
raise TextTooShortError(
f"Текст слишком короткий (минимум {self.min_text_length} символов)"
)
# Формируем промпт
prompt = self.SCORING_PROMPT.format(text=clean_text)
# Выполняем запрос с повторными попытками
last_error = None
for attempt in range(self.max_retries):
try:
score = await self._make_api_request(prompt)
return ScoringResult(
score=score,
source=self.source_name,
model=self.model,
metadata={
"text_length": len(clean_text),
"attempt": attempt + 1,
},
)
except DeepSeekAPIError as e:
last_error = e
logger.warning(
f"DeepSeekService: Попытка {attempt + 1}/{self.max_retries} "
f"не удалась: {e}"
)
if attempt < self.max_retries - 1:
# Экспоненциальная задержка
await asyncio.sleep(2 ** attempt)
raise ScoringError(f"Все попытки запроса к DeepSeek API не удались: {last_error}")
async def _make_api_request(self, prompt: str) -> float:
"""
Выполняет запрос к DeepSeek API.
Args:
prompt: Промпт для отправки
Returns:
Числовой скор от 0.0 до 1.0
Raises:
DeepSeekAPIError: При ошибке API
"""
client = await self._get_client()
payload = {
"model": self.model,
"messages": [
{
"role": "user",
"content": prompt,
}
],
"temperature": 0.1, # Низкая температура для детерминированности
"max_tokens": 10, # Ожидаем только число
}
try:
response = await client.post(self.api_url, json=payload)
response.raise_for_status()
data = response.json()
# Извлекаем ответ
if "choices" not in data or not data["choices"]:
raise DeepSeekAPIError("Пустой ответ от API")
response_text = data["choices"][0]["message"]["content"]
# Парсим скор
score = self._parse_score_response(response_text)
logger.debug(f"DeepSeekService: Получен скор {score} для текста")
return score
except httpx.HTTPStatusError as e:
error_msg = f"HTTP ошибка {e.response.status_code}"
try:
error_data = e.response.json()
if "error" in error_data:
error_msg = error_data["error"].get("message", error_msg)
except Exception:
pass
raise DeepSeekAPIError(error_msg)
except httpx.TimeoutException:
raise DeepSeekAPIError(f"Таймаут запроса ({self.timeout}s)")
except Exception as e:
raise DeepSeekAPIError(f"Ошибка запроса: {e}")
async def add_positive_example(self, text: str) -> None:
"""
Добавляет текст как положительный пример.
Для DeepSeek не требуется хранить примеры - оценка выполняется
на основе промпта. Метод существует для совместимости с протоколом.
Args:
text: Текст опубликованного поста
"""
# DeepSeek не использует примеры для обучения
# Промпт уже содержит критерии оценки
pass
async def add_negative_example(self, text: str) -> None:
"""
Добавляет текст как отрицательный пример.
Для DeepSeek не требуется хранить примеры - оценка выполняется
на основе промпта. Метод существует для совместимости с протоколом.
Args:
text: Текст отклоненного поста
"""
# DeepSeek не использует примеры для обучения
pass
def get_stats(self) -> dict:
"""Возвращает статистику сервиса."""
return {
"enabled": self._enabled,
"model": self.model,
"api_url": self.api_url,
"timeout": self.timeout,
"max_retries": self.max_retries,
}

View File

@@ -0,0 +1,33 @@
"""
Исключения для сервисов скоринга.
"""
class ScoringError(Exception):
"""Базовое исключение для ошибок скоринга."""
pass
class ModelNotLoadedError(ScoringError):
"""Модель не загружена или недоступна."""
pass
class VectorStoreError(ScoringError):
"""Ошибка при работе с хранилищем векторов."""
pass
class DeepSeekAPIError(ScoringError):
"""Ошибка при обращении к DeepSeek API."""
pass
class InsufficientExamplesError(ScoringError):
"""Недостаточно примеров для расчета скора."""
pass
class TextTooShortError(ScoringError):
"""Текст слишком короткий для векторизации."""
pass

View File

@@ -0,0 +1,507 @@
"""
RAG сервис для скоринга постов с использованием ruBERT.
Использует модель DeepPavlov/rubert-base-cased для создания эмбеддингов
и сравнивает их с эталонными примерами через VectorStore.
"""
import asyncio
from typing import Optional, List
import numpy as np
from logs.custom_logger import logger
from helper_bot.utils.metrics import track_time, track_errors
from .base import ScoringResult
from .vector_store import VectorStore
from .exceptions import (
ModelNotLoadedError,
ScoringError,
InsufficientExamplesError,
TextTooShortError,
)
class RAGService:
"""
RAG сервис для оценки постов на основе векторного сходства.
Использует ruBERT для создания эмбеддингов текста и сравнивает
их с эталонными примерами (опубликованные vs отклоненные посты).
Attributes:
model_name: Название модели HuggingFace
vector_store: Хранилище векторов
min_text_length: Минимальная длина текста для обработки
"""
# Название модели по умолчанию
DEFAULT_MODEL = "DeepPavlov/rubert-base-cased"
def __init__(
self,
model_name: Optional[str] = None,
vector_store: Optional[VectorStore] = None,
cache_dir: Optional[str] = None,
enabled: bool = True,
min_text_length: int = 3,
):
"""
Инициализация RAG сервиса.
Args:
model_name: Название модели HuggingFace (по умолчанию ruBERT)
vector_store: Хранилище векторов (создается автоматически если не передано)
cache_dir: Директория для кеширования модели
enabled: Включен ли сервис
min_text_length: Минимальная длина текста для обработки
"""
self.model_name = model_name or self.DEFAULT_MODEL
self.cache_dir = cache_dir
self._enabled = enabled
self.min_text_length = min_text_length
# Модель и токенизатор загружаются лениво
self._model = None
self._tokenizer = None
self._model_loaded = False
# Хранилище векторов
self.vector_store = vector_store or VectorStore()
logger.info(f"RAGService инициализирован (model={self.model_name}, enabled={enabled})")
@property
def source_name(self) -> str:
"""Имя источника для результатов."""
return "rag"
@property
def is_enabled(self) -> bool:
"""Проверяет, включен ли сервис."""
return self._enabled
@property
def is_model_loaded(self) -> bool:
"""Проверяет, загружена ли модель."""
return self._model_loaded
async def load_model(self) -> None:
"""
Загружает модель и токенизатор.
Выполняется асинхронно в отдельном потоке чтобы не блокировать event loop.
"""
if self._model_loaded:
return
if not self._enabled:
logger.warning("RAGService: Сервис отключен, модель не загружается")
return
logger.info(f"RAGService: Загрузка модели {self.model_name}...")
try:
# Загрузка в отдельном потоке
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._load_model_sync)
self._model_loaded = True
logger.info(f"RAGService: Модель {self.model_name} успешно загружена")
except Exception as e:
logger.error(f"RAGService: Ошибка загрузки модели: {e}")
raise ModelNotLoadedError(f"Не удалось загрузить модель {self.model_name}: {e}")
def _load_model_sync(self) -> None:
"""Синхронная загрузка модели (вызывается в executor)."""
logger.info("RAGService: Начало _load_model_sync, импорт transformers...")
from transformers import AutoTokenizer, AutoModel
import torch
# Определяем устройство
self._device = "cuda" if torch.cuda.is_available() else "cpu"
logger.info(f"RAGService: Устройство определено: {self._device}")
# Загружаем токенизатор
logger.info(f"RAGService: Загрузка токенизатора из {self.model_name}...")
self._tokenizer = AutoTokenizer.from_pretrained(
self.model_name,
cache_dir=self.cache_dir,
)
logger.info("RAGService: Токенизатор загружен")
# Загружаем модель
logger.info(f"RAGService: Загрузка модели из {self.model_name} (это может занять несколько минут)...")
self._model = AutoModel.from_pretrained(
self.model_name,
cache_dir=self.cache_dir,
)
logger.info("RAGService: Модель загружена, перенос на устройство...")
self._model.to(self._device)
self._model.eval() # Режим инференса
logger.info(f"RAGService: Модель готова на устройстве: {self._device}")
def _get_embedding_sync(self, text: str) -> np.ndarray:
"""
Получает эмбеддинг текста (синхронно).
Использует [CLS] токен как представление всего текста.
Args:
text: Текст для векторизации
Returns:
Numpy массив с эмбеддингом (768 измерений для ruBERT)
"""
import torch
# Токенизация с ограничением длины
inputs = self._tokenizer(
text,
return_tensors="pt",
truncation=True,
max_length=512,
padding=True,
)
inputs = {k: v.to(self._device) for k, v in inputs.items()}
# Получаем эмбеддинг
with torch.no_grad():
outputs = self._model(**inputs)
# Используем [CLS] токен (первый токен)
embedding = outputs.last_hidden_state[:, 0, :].cpu().numpy()
return embedding.flatten()
def _get_embeddings_batch_sync(self, texts: List[str], batch_size: int = 16) -> List[np.ndarray]:
"""
Получает эмбеддинги для батча текстов (синхронно).
Обрабатывает тексты пачками для эффективного использования GPU/CPU.
Args:
texts: Список текстов для векторизации
batch_size: Размер батча (по умолчанию 16)
Returns:
Список numpy массивов с эмбеддингами
"""
import torch
all_embeddings = []
for i in range(0, len(texts), batch_size):
batch_texts = texts[i:i + batch_size]
# Токенизация батча
inputs = self._tokenizer(
batch_texts,
return_tensors="pt",
truncation=True,
max_length=512,
padding=True,
)
inputs = {k: v.to(self._device) for k, v in inputs.items()}
# Получаем эмбеддинги
with torch.no_grad():
outputs = self._model(**inputs)
# [CLS] токен для каждого текста в батче
batch_embeddings = outputs.last_hidden_state[:, 0, :].cpu().numpy()
# Разбиваем на отдельные эмбеддинги
for j in range(len(batch_texts)):
all_embeddings.append(batch_embeddings[j])
if i > 0 and i % (batch_size * 10) == 0:
logger.info(f"RAGService: Обработано {i}/{len(texts)} текстов")
return all_embeddings
async def get_embeddings_batch(self, texts: List[str], batch_size: int = 16) -> List[np.ndarray]:
"""
Получает эмбеддинги для батча текстов (асинхронно).
Args:
texts: Список текстов для векторизации
batch_size: Размер батча
Returns:
Список numpy массивов с эмбеддингами
"""
if not self._model_loaded:
await self.load_model()
if not self._model_loaded:
raise ModelNotLoadedError("Модель не загружена")
# Очищаем тексты
clean_texts = [self._clean_text(text) for text in texts]
# Выполняем батч-обработку в thread pool
loop = asyncio.get_event_loop()
embeddings = await loop.run_in_executor(
None,
self._get_embeddings_batch_sync,
clean_texts,
batch_size,
)
return embeddings
async def get_embedding(self, text: str) -> np.ndarray:
"""
Получает эмбеддинг текста (асинхронно).
Args:
text: Текст для векторизации
Returns:
Numpy массив с эмбеддингом
Raises:
ModelNotLoadedError: Если модель не загружена
TextTooShortError: Если текст слишком короткий
"""
if not self._model_loaded:
await self.load_model()
if not self._model_loaded:
raise ModelNotLoadedError("Модель не загружена")
# Очищаем текст
clean_text = self._clean_text(text)
if len(clean_text) < self.min_text_length:
raise TextTooShortError(
f"Текст слишком короткий (минимум {self.min_text_length} символов)"
)
# Выполняем в отдельном потоке
loop = asyncio.get_event_loop()
embedding = await loop.run_in_executor(
None,
self._get_embedding_sync,
clean_text
)
return embedding
def _clean_text(self, text: str) -> str:
"""Очищает текст от лишних символов."""
if not text:
return ""
# Удаляем лишние пробелы и переносы строк
clean = " ".join(text.split())
# Удаляем служебные символы (например "^" для helper сообщений)
if clean == "^":
return ""
return clean.strip()
@track_time("calculate_score", "rag_service")
@track_errors("rag_service", "calculate_score")
async def calculate_score(self, text: str) -> ScoringResult:
"""
Рассчитывает скор для текста поста.
Args:
text: Текст поста для оценки
Returns:
ScoringResult с оценкой
Raises:
ScoringError: При ошибке расчета
"""
if not self._enabled:
raise ScoringError("RAG сервис отключен")
try:
# Получаем эмбеддинг текста
embedding = await self.get_embedding(text)
# Логируем первые элементы вектора для отладки
logger.info(
f"RAGService: embedding[:3]={embedding[:3].tolist()}, "
f"text_preview='{text[:30]}'"
)
# Рассчитываем скор через VectorStore
score, confidence, score_pos_only = self.vector_store.calculate_similarity_score(embedding)
return ScoringResult(
score=score,
source=self.source_name,
model=self.model_name,
confidence=confidence,
metadata={
"positive_examples": self.vector_store.positive_count,
"negative_examples": self.vector_store.negative_count,
"score_pos_only": score_pos_only, # Для сравнения
},
)
except InsufficientExamplesError:
# Не достаточно примеров - возвращаем нейтральный скор
logger.warning("RAGService: Недостаточно примеров для расчета скора")
raise
except TextTooShortError:
logger.warning(f"RAGService: Текст слишком короткий для оценки")
raise
except Exception as e:
logger.error(f"RAGService: Ошибка расчета скора: {e}")
raise ScoringError(f"Ошибка расчета скора: {e}")
@track_time("add_positive_example", "rag_service")
async def add_positive_example(self, text: str) -> None:
"""
Добавляет текст как положительный пример (опубликованный пост).
Args:
text: Текст опубликованного поста
"""
if not self._enabled:
return
try:
clean_text = self._clean_text(text)
if len(clean_text) < self.min_text_length:
logger.debug("RAGService: Текст слишком короткий для примера, пропускаем")
return
# Получаем эмбеддинг
embedding = await self.get_embedding(clean_text)
# Вычисляем хеш для дедупликации
text_hash = VectorStore.compute_text_hash(clean_text)
# Добавляем в хранилище
added = self.vector_store.add_positive(embedding, text_hash)
if added:
logger.info(f"RAGService: Добавлен положительный пример")
except Exception as e:
logger.error(f"RAGService: Ошибка добавления положительного примера: {e}")
@track_time("add_negative_example", "rag_service")
async def add_negative_example(self, text: str) -> None:
"""
Добавляет текст как отрицательный пример (отклоненный пост).
Args:
text: Текст отклоненного поста
"""
if not self._enabled:
return
try:
clean_text = self._clean_text(text)
if len(clean_text) < self.min_text_length:
logger.debug("RAGService: Текст слишком короткий для примера, пропускаем")
return
# Получаем эмбеддинг
embedding = await self.get_embedding(clean_text)
# Вычисляем хеш для дедупликации
text_hash = VectorStore.compute_text_hash(clean_text)
# Добавляем в хранилище
added = self.vector_store.add_negative(embedding, text_hash)
if added:
logger.info(f"RAGService: Добавлен отрицательный пример")
except Exception as e:
logger.error(f"RAGService: Ошибка добавления отрицательного примера: {e}")
async def load_examples_from_db(
self,
positive_texts: list[str],
negative_texts: list[str],
batch_size: int = 16,
) -> None:
"""
Загружает примеры из базы данных с батч-обработкой.
Используется при запуске бота для восстановления VectorStore.
Батч-обработка ускоряет загрузку в 10-20 раз.
Args:
positive_texts: Список текстов опубликованных постов
negative_texts: Список текстов отклоненных постов
batch_size: Размер батча для обработки (по умолчанию 16)
"""
if not self._enabled:
return
logger.info(
f"RAGService: Загрузка примеров из БД с батч-обработкой "
f"(positive: {len(positive_texts)}, negative: {len(negative_texts)}, batch_size: {batch_size})"
)
# Убеждаемся что модель загружена
await self.load_model()
import time
start_time = time.time()
# Фильтруем и очищаем положительные тексты
if positive_texts:
clean_positive = []
positive_hashes = []
for text in positive_texts:
clean_text = self._clean_text(text)
if len(clean_text) >= self.min_text_length:
clean_positive.append(clean_text)
positive_hashes.append(VectorStore.compute_text_hash(clean_text))
if clean_positive:
logger.info(f"RAGService: Обработка {len(clean_positive)} положительных примеров батчами...")
positive_embeddings = await self.get_embeddings_batch(clean_positive, batch_size)
self.vector_store.add_positive_batch(positive_embeddings, positive_hashes)
# Фильтруем и очищаем отрицательные тексты
if negative_texts:
clean_negative = []
negative_hashes = []
for text in negative_texts:
clean_text = self._clean_text(text)
if len(clean_text) >= self.min_text_length:
clean_negative.append(clean_text)
negative_hashes.append(VectorStore.compute_text_hash(clean_text))
if clean_negative:
logger.info(f"RAGService: Обработка {len(clean_negative)} отрицательных примеров батчами...")
negative_embeddings = await self.get_embeddings_batch(clean_negative, batch_size)
self.vector_store.add_negative_batch(negative_embeddings, negative_hashes)
elapsed = time.time() - start_time
logger.info(
f"RAGService: Загрузка завершена за {elapsed:.1f} сек "
f"(positive: {self.vector_store.positive_count}, "
f"negative: {self.vector_store.negative_count})"
)
def save_vectors(self) -> None:
"""Сохраняет векторы на диск."""
if self.vector_store.storage_path:
self.vector_store.save_to_disk()
def get_stats(self) -> dict:
"""Возвращает статистику сервиса."""
return {
"enabled": self._enabled,
"model_name": self.model_name,
"model_loaded": self._model_loaded,
"vector_store": self.vector_store.get_stats(),
}

View File

@@ -0,0 +1,242 @@
"""
Менеджер для объединения всех сервисов скоринга.
Координирует работу RAGService и DeepSeekService,
выполняет параллельные запросы и агрегирует результаты.
"""
import asyncio
from typing import Optional, List
from logs.custom_logger import logger
from helper_bot.utils.metrics import track_time, track_errors
from .base import CombinedScore, ScoringResult
from .rag_service import RAGService
from .deepseek_service import DeepSeekService
from .vector_store import VectorStore
from .exceptions import ScoringError, InsufficientExamplesError, TextTooShortError
class ScoringManager:
"""
Менеджер для управления всеми сервисами скоринга.
Объединяет RAGService и DeepSeekService, выполняет параллельные
запросы и агрегирует результаты в единый CombinedScore.
Attributes:
rag_service: Сервис RAG с ruBERT
deepseek_service: Сервис DeepSeek API
"""
def __init__(
self,
rag_service: Optional[RAGService] = None,
deepseek_service: Optional[DeepSeekService] = None,
):
"""
Инициализация менеджера.
Args:
rag_service: Сервис RAG (создается автоматически если не передан)
deepseek_service: Сервис DeepSeek (создается автоматически если не передан)
"""
self.rag_service = rag_service
self.deepseek_service = deepseek_service
logger.info(
f"ScoringManager инициализирован "
f"(rag={rag_service is not None and rag_service.is_enabled}, "
f"deepseek={deepseek_service is not None and deepseek_service.is_enabled})"
)
@property
def is_any_enabled(self) -> bool:
"""Проверяет, включен ли хотя бы один сервис."""
rag_enabled = self.rag_service is not None and self.rag_service.is_enabled
deepseek_enabled = self.deepseek_service is not None and self.deepseek_service.is_enabled
return rag_enabled or deepseek_enabled
@track_time("score_post", "scoring_manager")
@track_errors("scoring_manager", "score_post")
async def score_post(self, text: str) -> CombinedScore:
"""
Рассчитывает скоры для текста поста от всех сервисов.
Выполняет запросы параллельно для минимизации задержки.
Args:
text: Текст поста для оценки
Returns:
CombinedScore с результатами от всех сервисов
"""
result = CombinedScore()
if not text or not text.strip():
logger.debug("ScoringManager: Пустой текст, пропускаем скоринг")
return result
# Собираем задачи для параллельного выполнения
tasks = []
task_names = []
# RAG сервис
if self.rag_service and self.rag_service.is_enabled:
tasks.append(self._get_rag_score(text))
task_names.append("rag")
# DeepSeek сервис
if self.deepseek_service and self.deepseek_service.is_enabled:
tasks.append(self._get_deepseek_score(text))
task_names.append("deepseek")
if not tasks:
logger.debug("ScoringManager: Нет активных сервисов для скоринга")
return result
# Выполняем параллельно
results = await asyncio.gather(*tasks, return_exceptions=True)
# Обрабатываем результаты
for name, res in zip(task_names, results):
if isinstance(res, Exception):
error_msg = str(res)
result.errors[name] = error_msg
logger.warning(f"ScoringManager: Ошибка от {name}: {error_msg}")
elif res is not None:
if name == "rag":
result.rag = res
elif name == "deepseek":
result.deepseek = res
logger.info(
f"ScoringManager: Скоринг завершен "
f"(rag={result.rag_score}, deepseek={result.deepseek_score})"
)
return result
async def _get_rag_score(self, text: str) -> Optional[ScoringResult]:
"""Получает скор от RAG сервиса."""
try:
return await self.rag_service.calculate_score(text)
except InsufficientExamplesError:
# Недостаточно примеров - это не ошибка, просто нет данных
logger.info("ScoringManager: RAG - недостаточно примеров")
return None
except TextTooShortError:
# Текст слишком короткий - пропускаем
logger.debug("ScoringManager: RAG - текст слишком короткий")
return None
except Exception as e:
logger.error(f"ScoringManager: RAG ошибка: {e}")
raise
async def _get_deepseek_score(self, text: str) -> Optional[ScoringResult]:
"""Получает скор от DeepSeek сервиса."""
try:
return await self.deepseek_service.calculate_score(text)
except TextTooShortError:
# Текст слишком короткий - пропускаем
logger.debug("ScoringManager: DeepSeek - текст слишком короткий")
return None
except Exception as e:
logger.error(f"ScoringManager: DeepSeek ошибка: {e}")
raise
@track_time("on_post_published", "scoring_manager")
async def on_post_published(self, text: str) -> None:
"""
Вызывается при публикации поста.
Добавляет текст как положительный пример для обучения RAG.
Args:
text: Текст опубликованного поста
"""
if not text or not text.strip():
return
tasks = []
if self.rag_service and self.rag_service.is_enabled:
tasks.append(self.rag_service.add_positive_example(text))
if self.deepseek_service and self.deepseek_service.is_enabled:
tasks.append(self.deepseek_service.add_positive_example(text))
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
logger.info("ScoringManager: Добавлен положительный пример")
@track_time("on_post_declined", "scoring_manager")
async def on_post_declined(self, text: str) -> None:
"""
Вызывается при отклонении поста.
Добавляет текст как отрицательный пример для обучения RAG.
Args:
text: Текст отклоненного поста
"""
if not text or not text.strip():
return
tasks = []
if self.rag_service and self.rag_service.is_enabled:
tasks.append(self.rag_service.add_negative_example(text))
if self.deepseek_service and self.deepseek_service.is_enabled:
tasks.append(self.deepseek_service.add_negative_example(text))
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
logger.info("ScoringManager: Добавлен отрицательный пример")
async def load_examples_from_db(
self,
positive_texts: List[str],
negative_texts: List[str],
) -> None:
"""
Загружает примеры из базы данных при запуске бота.
Args:
positive_texts: Список текстов опубликованных постов
negative_texts: Список текстов отклоненных постов
"""
if self.rag_service and self.rag_service.is_enabled:
await self.rag_service.load_examples_from_db(
positive_texts,
negative_texts
)
def save_vectors(self) -> None:
"""Сохраняет векторы RAG на диск."""
if self.rag_service:
self.rag_service.save_vectors()
async def close(self) -> None:
"""Закрывает ресурсы всех сервисов."""
if self.deepseek_service:
await self.deepseek_service.close()
# Сохраняем векторы перед закрытием
self.save_vectors()
def get_stats(self) -> dict:
"""Возвращает статистику всех сервисов."""
stats = {
"any_enabled": self.is_any_enabled,
}
if self.rag_service:
stats["rag"] = self.rag_service.get_stats()
if self.deepseek_service:
stats["deepseek"] = self.deepseek_service.get_stats()
return stats

View File

@@ -0,0 +1,399 @@
"""
In-memory хранилище векторов на numpy.
Хранит векторные представления постов для быстрого сравнения.
Поддерживает персистентность через сохранение/загрузку с диска.
"""
import hashlib
import os
from pathlib import Path
from typing import Optional, Tuple, List
import threading
import numpy as np
from logs.custom_logger import logger
from .exceptions import VectorStoreError, InsufficientExamplesError
class VectorStore:
"""
In-memory хранилище векторов для RAG.
Хранит отдельно положительные (опубликованные) и отрицательные (отклоненные)
примеры. Использует косинусное сходство для расчета скора.
Attributes:
vector_dim: Размерность векторов (768 для ruBERT)
max_examples: Максимальное количество примеров каждого типа
"""
def __init__(
self,
vector_dim: int = 768,
max_examples: int = 10000,
storage_path: Optional[str] = None,
score_multiplier: float = 5.0,
):
"""
Инициализация хранилища.
Args:
vector_dim: Размерность векторов
max_examples: Максимальное количество примеров каждого типа
storage_path: Путь для сохранения/загрузки векторов (опционально)
score_multiplier: Множитель для усиления разницы в скорах
"""
self.vector_dim = vector_dim
self.max_examples = max_examples
self.storage_path = storage_path
self.score_multiplier = score_multiplier
# Инициализируем пустые массивы
# Используем список для динамического добавления, потом конвертируем в numpy
self._positive_vectors: list = []
self._negative_vectors: list = []
self._positive_hashes: list = [] # Хеши текстов для дедупликации
self._negative_hashes: list = []
# Lock для потокобезопасности
self._lock = threading.Lock()
# Пытаемся загрузить сохраненные векторы
if storage_path and os.path.exists(storage_path):
self._load_from_disk()
@property
def positive_count(self) -> int:
"""Количество положительных примеров."""
return len(self._positive_vectors)
@property
def negative_count(self) -> int:
"""Количество отрицательных примеров."""
return len(self._negative_vectors)
@property
def total_count(self) -> int:
"""Общее количество примеров."""
return self.positive_count + self.negative_count
@staticmethod
def compute_text_hash(text: str) -> str:
"""Вычисляет хеш текста для дедупликации."""
return hashlib.md5(text.encode('utf-8')).hexdigest()
def _normalize_vector(self, vector: np.ndarray) -> np.ndarray:
"""Нормализует вектор для косинусного сходства."""
norm = np.linalg.norm(vector)
if norm == 0:
return vector
return vector / norm
def add_positive(self, vector: np.ndarray, text_hash: Optional[str] = None) -> bool:
"""
Добавляет положительный пример (опубликованный пост).
Args:
vector: Векторное представление текста
text_hash: Хеш текста для дедупликации (опционально)
Returns:
True если добавлен, False если дубликат или превышен лимит
"""
with self._lock:
# Проверяем дубликат по хешу
if text_hash and text_hash in self._positive_hashes:
logger.debug(f"VectorStore: Пропуск дубликата положительного примера")
return False
# Проверяем лимит
if len(self._positive_vectors) >= self.max_examples:
# Удаляем самый старый пример (FIFO)
self._positive_vectors.pop(0)
self._positive_hashes.pop(0)
logger.debug("VectorStore: Удален старый положительный пример (лимит)")
# Нормализуем и добавляем
normalized = self._normalize_vector(vector)
self._positive_vectors.append(normalized)
if text_hash:
self._positive_hashes.append(text_hash)
logger.info(f"VectorStore: Добавлен положительный пример (всего: {self.positive_count})")
return True
def add_positive_batch(
self,
vectors: List[np.ndarray],
text_hashes: Optional[List[str]] = None
) -> int:
"""
Добавляет батч положительных примеров.
Args:
vectors: Список векторов
text_hashes: Список хешей текстов для дедупликации
Returns:
Количество добавленных примеров
"""
if text_hashes is None:
text_hashes = [None] * len(vectors)
added = 0
with self._lock:
for vector, text_hash in zip(vectors, text_hashes):
# Проверяем дубликат по хешу
if text_hash and text_hash in self._positive_hashes:
continue
# Проверяем лимит
if len(self._positive_vectors) >= self.max_examples:
self._positive_vectors.pop(0)
self._positive_hashes.pop(0)
# Нормализуем и добавляем
normalized = self._normalize_vector(vector)
self._positive_vectors.append(normalized)
if text_hash:
self._positive_hashes.append(text_hash)
added += 1
logger.info(f"VectorStore: Добавлено {added} положительных примеров батчем (всего: {self.positive_count})")
return added
def add_negative(self, vector: np.ndarray, text_hash: Optional[str] = None) -> bool:
"""
Добавляет отрицательный пример (отклоненный пост).
Args:
vector: Векторное представление текста
text_hash: Хеш текста для дедупликации (опционально)
Returns:
True если добавлен, False если дубликат или превышен лимит
"""
with self._lock:
# Проверяем дубликат по хешу
if text_hash and text_hash in self._negative_hashes:
logger.debug(f"VectorStore: Пропуск дубликата отрицательного примера")
return False
# Проверяем лимит
if len(self._negative_vectors) >= self.max_examples:
# Удаляем самый старый пример (FIFO)
self._negative_vectors.pop(0)
self._negative_hashes.pop(0)
logger.debug("VectorStore: Удален старый отрицательный пример (лимит)")
# Нормализуем и добавляем
normalized = self._normalize_vector(vector)
self._negative_vectors.append(normalized)
if text_hash:
self._negative_hashes.append(text_hash)
logger.info(f"VectorStore: Добавлен отрицательный пример (всего: {self.negative_count})")
return True
def add_negative_batch(
self,
vectors: List[np.ndarray],
text_hashes: Optional[List[str]] = None
) -> int:
"""
Добавляет батч отрицательных примеров.
Args:
vectors: Список векторов
text_hashes: Список хешей текстов для дедупликации
Returns:
Количество добавленных примеров
"""
if text_hashes is None:
text_hashes = [None] * len(vectors)
added = 0
with self._lock:
for vector, text_hash in zip(vectors, text_hashes):
# Проверяем дубликат по хешу
if text_hash and text_hash in self._negative_hashes:
continue
# Проверяем лимит
if len(self._negative_vectors) >= self.max_examples:
self._negative_vectors.pop(0)
self._negative_hashes.pop(0)
# Нормализуем и добавляем
normalized = self._normalize_vector(vector)
self._negative_vectors.append(normalized)
if text_hash:
self._negative_hashes.append(text_hash)
added += 1
logger.info(f"VectorStore: Добавлено {added} отрицательных примеров батчем (всего: {self.negative_count})")
return added
def calculate_similarity_score(self, vector: np.ndarray) -> Tuple[float, float]:
"""
Рассчитывает скор на основе сходства с примерами.
Алгоритм:
1. Вычисляем среднее косинусное сходство с положительными примерами
2. Вычисляем среднее косинусное сходство с отрицательными примерами
3. Финальный скор = pos_sim / (pos_sim + neg_sim + eps)
Args:
vector: Векторное представление нового поста
Returns:
Tuple (score, confidence):
- score: Оценка от 0.0 до 1.0
- confidence: Уверенность (зависит от количества примеров)
Raises:
InsufficientExamplesError: Если недостаточно примеров
"""
with self._lock:
if self.positive_count == 0:
raise InsufficientExamplesError(
"Нет положительных примеров для сравнения"
)
# Нормализуем входной вектор
normalized = self._normalize_vector(vector)
# Конвертируем в numpy массивы для быстрых вычислений
pos_matrix = np.array(self._positive_vectors)
# Косинусное сходство с положительными примерами
# Для нормализованных векторов это просто скалярное произведение
pos_similarities = np.dot(pos_matrix, normalized)
pos_sim = float(np.mean(pos_similarities))
# Косинусное сходство с отрицательными примерами
if self.negative_count > 0:
neg_matrix = np.array(self._negative_vectors)
neg_similarities = np.dot(neg_matrix, normalized)
neg_sim = float(np.mean(neg_similarities))
else:
# Если нет отрицательных примеров, используем нейтральное значение
neg_sim = pos_sim # Нейтральный скор = 0.5
# === Вариант 1: neg/pos (разница между положительными и отрицательными) ===
diff = pos_sim - neg_sim
score_neg_pos = 0.5 + (diff * self.score_multiplier)
score_neg_pos = max(0.0, min(1.0, score_neg_pos))
# === Вариант 2: pos only (только положительные, топ-k ближайших) ===
# Берём топ-5 ближайших положительных примеров
top_k = min(5, len(pos_similarities))
top_k_sim = float(np.mean(np.sort(pos_similarities)[-top_k:]))
# Нормализуем: 0.85 -> 0.0, 0.95 -> 1.0 (типичный диапазон для BERT)
score_pos_only = (top_k_sim - 0.85) / 0.10
score_pos_only = max(0.0, min(1.0, score_pos_only))
# Основной скор — neg/pos (можно будет переключить позже)
score = score_neg_pos
# Confidence зависит от количества примеров (100% при 1000 примерах)
total_examples = self.positive_count + self.negative_count
confidence = min(1.0, total_examples / 1000)
logger.info(
f"VectorStore: pos_sim={pos_sim:.4f}, neg_sim={neg_sim:.4f}, "
f"top_k_sim={top_k_sim:.4f}, score_neg_pos={score_neg_pos:.4f}, "
f"score_pos_only={score_pos_only:.4f}"
)
return score, confidence, score_pos_only
def save_to_disk(self, path: Optional[str] = None) -> None:
"""
Сохраняет векторы на диск.
Args:
path: Путь для сохранения (если не указан, используется storage_path)
"""
save_path = path or self.storage_path
if not save_path:
raise VectorStoreError("Путь для сохранения не указан")
with self._lock:
# Создаем директорию если нужно
Path(save_path).parent.mkdir(parents=True, exist_ok=True)
# Сохраняем в npz формате
np.savez_compressed(
save_path,
positive_vectors=np.array(self._positive_vectors) if self._positive_vectors else np.array([]),
negative_vectors=np.array(self._negative_vectors) if self._negative_vectors else np.array([]),
positive_hashes=np.array(self._positive_hashes, dtype=object),
negative_hashes=np.array(self._negative_hashes, dtype=object),
vector_dim=self.vector_dim,
max_examples=self.max_examples,
)
logger.info(
f"VectorStore: Сохранено на диск ({self.positive_count} pos, "
f"{self.negative_count} neg): {save_path}"
)
def _load_from_disk(self) -> None:
"""Загружает векторы с диска."""
if not self.storage_path or not os.path.exists(self.storage_path):
return
try:
with self._lock:
data = np.load(self.storage_path, allow_pickle=True)
# Загружаем векторы
pos_vectors = data.get('positive_vectors', np.array([]))
neg_vectors = data.get('negative_vectors', np.array([]))
if pos_vectors.size > 0:
self._positive_vectors = list(pos_vectors)
if neg_vectors.size > 0:
self._negative_vectors = list(neg_vectors)
# Загружаем хеши
pos_hashes = data.get('positive_hashes', np.array([]))
neg_hashes = data.get('negative_hashes', np.array([]))
if pos_hashes.size > 0:
self._positive_hashes = list(pos_hashes)
if neg_hashes.size > 0:
self._negative_hashes = list(neg_hashes)
logger.info(
f"VectorStore: Загружено с диска ({self.positive_count} pos, "
f"{self.negative_count} neg): {self.storage_path}"
)
except Exception as e:
logger.error(f"VectorStore: Ошибка загрузки с диска: {e}")
# Продолжаем с пустым хранилищем
def clear(self) -> None:
"""Очищает все векторы."""
with self._lock:
self._positive_vectors.clear()
self._negative_vectors.clear()
self._positive_hashes.clear()
self._negative_hashes.clear()
logger.info("VectorStore: Хранилище очищено")
def get_stats(self) -> dict:
"""Возвращает статистику хранилища."""
return {
"positive_count": self.positive_count,
"negative_count": self.negative_count,
"total_count": self.total_count,
"vector_dim": self.vector_dim,
"max_examples": self.max_examples,
"storage_path": self.storage_path,
}