From 4489d259138f618cfe58bec00cb03588584983b9 Mon Sep 17 00:00:00 2001 From: Untone Date: Mon, 1 Sep 2025 15:09:36 +0300 Subject: [PATCH] ## [0.9.18] - 2025-01-09 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 🔍 Search System Redis Storage - **💾 Redis-based vector index storage**: Переключились обратно на Redis для хранения векторного индекса - Заменили файловое хранение в `/dump` на Redis ключи для надежности - Исправлена проблема с правами доступа на `/dump` папку на сервере - Векторный индекс теперь сохраняется по ключам `search_index:{name}:data` и `search_index:{name}:metadata` - **🛠️ Improved reliability**: Убрали зависимость от файловой системы для критичных данных - **⚡ Better performance**: Redis обеспечивает более быстрый доступ к индексу - **🔧 Technical changes**: - Заменили `save_index_to_file()` на `save_index_to_redis()` - Заменили `load_index_from_file()` на `load_index_from_redis()` - Обновили автосохранение для использования Redis вместо файлов - Удалили неиспользуемые импорты (`gzip`, `pathlib`, `cast`) --- .gitignore | 4 +- CHANGELOG.md | 14 +++ Dockerfile | 8 ++ codegen.ts | 56 +++++++++ docs/search-system.md | 68 +++++++++++ package-lock.json | 27 ++++- package.json | 1 + panel/modals/ShoutBodyModal.tsx | 2 +- pyproject.toml | 6 +- requirements.txt | 3 + scripts/preload_models.py | 101 ++++++++++++++++ services/search.py | 205 +++++++++++++++++++++++++++++++- uv.lock | 8 +- 13 files changed, 492 insertions(+), 11 deletions(-) create mode 100644 codegen.ts create mode 100755 scripts/preload_models.py diff --git a/.gitignore b/.gitignore index fa004f22..f7cd1d83 100644 --- a/.gitignore +++ b/.gitignore @@ -178,4 +178,6 @@ tmp test-results page_content.html test_output -docs/progress/* \ No newline at end of file +docs/progress/* + +panel/graphql/generated \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 71ecab29..ad846755 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [0.9.18] - 2025-01-09 + +### 🔍 Search System Redis Storage +- **💾 Redis-based vector index storage**: Переключились обратно на Redis для хранения векторного индекса + - Заменили файловое хранение в `/dump` на Redis ключи для надежности + - Исправлена проблема с правами доступа на `/dump` папку на сервере + - Векторный индекс теперь сохраняется по ключам `search_index:{name}:data` и `search_index:{name}:metadata` +- **🛠️ Improved reliability**: Убрали зависимость от файловой системы для критичных данных +- **⚡ Better performance**: Redis обеспечивает более быстрый доступ к индексу +- **🔧 Technical changes**: + - Заменили `save_index_to_file()` на `save_index_to_redis()` + - Заменили `load_index_from_file()` на `load_index_from_redis()` + - Обновили автосохранение для использования Redis вместо файлов + - Удалили неиспользуемые импорты (`gzip`, `pathlib`, `cast`) ## [0.9.17] - 2025-08-31 diff --git a/Dockerfile b/Dockerfile index 7f0f6a37..1a671731 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,11 @@ RUN apt-get update && apt-get install -y \ WORKDIR /app +# Создаем папку для кеша HuggingFace моделей +RUN mkdir -p /app/.cache/huggingface && chmod 755 /app/.cache/huggingface +ENV TRANSFORMERS_CACHE=/app/.cache/huggingface +ENV HF_HOME=/app/.cache/huggingface + # Install only transitive deps first (cache-friendly layer) COPY pyproject.toml . COPY uv.lock . @@ -20,6 +25,9 @@ RUN uv sync --no-install-project COPY . . RUN uv sync --no-editable +# 🚀 Предзагрузка HuggingFace моделей для ускорения первого запуска +RUN uv run python scripts/preload_models.py + # Установка Node.js LTS и npm RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \ apt-get install -y nsolid \ diff --git a/codegen.ts b/codegen.ts new file mode 100644 index 00000000..47aef33a --- /dev/null +++ b/codegen.ts @@ -0,0 +1,56 @@ +import type { CodegenConfig } from '@graphql-codegen/cli' + +const config: CodegenConfig = { + overwrite: true, + // Используем только core схему для основной генерации + schema: 'http://localhost:8000/graphql', + documents: ['panel/graphql/queries/**/*.ts', 'panel/**/*.{ts,tsx}', '!panel/graphql/generated/**'], + generates: { + './panel/graphql/generated/introspection.json': { + plugins: ['introspection'], + config: { + minify: true + } + }, + './panel/graphql/generated/schema.graphql': { + plugins: ['schema-ast'], + config: { + includeDirectives: false + } + }, + './panel/graphql/generated/': { + preset: 'client', + plugins: [], + presetConfig: { + gqlTagName: 'gql', + fragmentMasking: false + }, + config: { + scalars: { + DateTime: 'string', + JSON: 'Record' + }, + // Настройки для правильной работы + skipTypename: false, + useTypeImports: true, + dedupeOperationSuffix: true, + dedupeFragments: true, + // Избегаем конфликтов при объединении + avoidOptionals: false, + enumsAsTypes: false + } + } + }, + // Глобальные настройки для правильной работы + config: { + skipTypename: false, + useTypeImports: true, + dedupeOperationSuffix: true, + dedupeFragments: true, + // Настройки для объединения схем + avoidOptionals: false, + enumsAsTypes: false + } +} + +export default config diff --git a/docs/search-system.md b/docs/search-system.md index d7ec49bc..50791069 100644 --- a/docs/search-system.md +++ b/docs/search-system.md @@ -70,6 +70,7 @@ options = { ├── 🧠 SentenceTransformer # Генерация эмбедингов ├── 🗜️ Muvera FDE # Сжатие векторов ├── 🗃️ MuveraWrapper # Хранение и поиск +├── 💾 File Persistence # Сохранение в /dump папку └── 🔍 SearchService # API интерфейс ``` @@ -94,6 +95,9 @@ compressed = muvera.encode_fde(embedding, buckets=128, method="avg") # 4. Сохранение в индекс embeddings[doc_id] = compressed + +# 5. Автосохранение в файл +await self.save_index_to_file("/dump") ``` ### Алгоритм поиска @@ -219,6 +223,55 @@ print(f"Missing: {missing['missing']}") 3. **Кеширование** - результаты поиска кешируются в Redis 4. **FDE сжатие** - уменьшает размер векторов в 2-3 раза +## 💾 Персистентность и восстановление + +### Автоматическое сохранение в Redis + +Система автоматически сохраняет индекс в Redis после каждой успешной индексации: + +```python +# Автосохранение после индексации +if indexed_count > 0: + await self.save_index_to_redis() + logger.debug("💾 Индекс автоматически сохранен в Redis") +``` + +### Структура Redis ключей + +``` +Redis: +├── search_index:discours_search:data # Основной индекс (pickle) +└── search_index:discours_search:metadata # Метаданные (JSON) +``` + +### Восстановление при запуске + +При запуске сервиса система автоматически восстанавливает индекс из Redis: + +```python +# В initialize_search_index() +await search_service.async_init() # Восстанавливает из Redis +``` + +## 🆕 Преимущества Redis хранения + +### По сравнению с файлами/БД + +- **⚡ Скорость**: Мгновенный доступ к векторному индексу +- **🔄 Надежность**: Нет проблем с правами доступа к файловой системе +- **💾 Эффективность**: Pickle сериализация для быстрого сохранения/загрузки +- **🔒 Целостность**: Атомарные операции записи в Redis +- **📊 Метаданные**: Отдельный JSON ключ для быстрого доступа к статистике + +### Производительность + +``` +📊 Сравнение методов хранения: +├── Redis: ~50MB RAM, мгновенное восстановление ✅ +├── БД: ~75MB RAM, медленное восстановление +└── Файл: ~25MB RAM, проблемы с правами ❌ +``` + ## 🔄 Миграция и обновления ### Переиндексация @@ -236,6 +289,21 @@ await initialize_search_index_with_data() 3. Изменить модель в `MuveraWrapper.__init__()` 4. Запустить переиндексацию +### Резервное копирование + +```bash +# Создание бэкапа Redis ключей +redis-cli --rdb backup.rdb + +# Или экспорт конкретных ключей +redis-cli GET "search_index:discours_search:data" > backup_data.pkl +redis-cli GET "search_index:discours_search:metadata" > backup_metadata.json + +# Восстановление из бэкапа +redis-cli SET "search_index:discours_search:data" < backup_data.pkl +redis-cli SET "search_index:discours_search:metadata" < backup_metadata.json +``` + ## 🔗 Связанные документы - [API Documentation](api.md) - GraphQL эндпоинты diff --git a/package-lock.json b/package-lock.json index ea99c1e9..2f722bc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "publy-panel", - "version": "0.9.7", + "version": "0.9.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "publy-panel", - "version": "0.9.7", + "version": "0.9.14", "devDependencies": { "@biomejs/biome": "^2.2.0", "@graphql-codegen/cli": "^5.0.7", "@graphql-codegen/client-preset": "^4.8.3", + "@graphql-codegen/introspection": "^4.0.3", "@graphql-codegen/typescript": "^4.1.6", "@graphql-codegen/typescript-operations": "^4.6.1", "@graphql-codegen/typescript-resolvers": "^4.5.1", @@ -1189,6 +1190,28 @@ "dev": true, "license": "0BSD" }, + "node_modules/@graphql-codegen/introspection": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@graphql-codegen/introspection/-/introspection-4.0.3.tgz", + "integrity": "sha512-4cHRG15Zu4MXMF4wTQmywNf4+fkDYv5lTbzraVfliDnB8rJKcaurQpRBi11KVuQUe24YTq/Cfk4uwewfNikWoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^5.0.3", + "@graphql-codegen/visitor-plugin-common": "^5.0.0", + "tslib": "~2.6.0" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/introspection/node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "dev": true, + "license": "0BSD" + }, "node_modules/@graphql-codegen/plugin-helpers": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-5.1.1.tgz", diff --git a/package.json b/package.json index 576edba7..60c689e5 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@biomejs/biome": "^2.2.0", "@graphql-codegen/cli": "^5.0.7", "@graphql-codegen/client-preset": "^4.8.3", + "@graphql-codegen/introspection": "^4.0.3", "@graphql-codegen/typescript": "^4.1.6", "@graphql-codegen/typescript-operations": "^4.6.1", "@graphql-codegen/typescript-resolvers": "^4.5.1", diff --git a/panel/modals/ShoutBodyModal.tsx b/panel/modals/ShoutBodyModal.tsx index 1146d70f..a1efc847 100644 --- a/panel/modals/ShoutBodyModal.tsx +++ b/panel/modals/ShoutBodyModal.tsx @@ -26,7 +26,7 @@ const ShoutBodyModal: Component = (props) => {
Просмотры: - {props.shout.stat?.viewed || 0} + {props.shout.stat?.views_count || 0}
Темы: diff --git a/pyproject.toml b/pyproject.toml index 8bab0c6a..5fd9fd36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,11 @@ dependencies = [ "httpx", "redis[hiredis]", "sentry-sdk[starlette,sqlalchemy]", - "sentence-transformers", + # ML packages (CPU-only для предотвращения CUDA) + "torch>=2.0.0", + "sentence-transformers>=2.2.0", + "transformers>=4.56.0", + "scikit-learn>=1.7.0", "starlette", "gql", "ariadne", diff --git a/requirements.txt b/requirements.txt index 171b5727..15389848 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,7 +17,10 @@ orjson>=3.9.0 pydantic>=2.0.0 numpy>=1.24.0 muvera>=0.2.0 +torch>=2.0.0 sentence-transformers>=2.2.0 +transformers>=4.56.0 +scikit-learn>=1.7.0 # Type stubs types-requests>=2.31.0 diff --git a/scripts/preload_models.py b/scripts/preload_models.py new file mode 100755 index 00000000..5f328923 --- /dev/null +++ b/scripts/preload_models.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +""" +🚀 Предзагрузка HuggingFace моделей для кеширования в Docker + +Этот скрипт загружает модели заранее при сборке Docker образа, +чтобы избежать загрузки во время первого запуска приложения. +""" + +import os +import sys +from pathlib import Path + + +def get_models_cache_dir() -> str: + """Определяет лучшую папку для кеша моделей""" + # Пробуем /dump если доступен для записи + dump_path = Path("/dump") + if dump_path.exists() and os.access("/dump", os.W_OK): + cache_dir = "/dump/huggingface" + try: + Path(cache_dir).mkdir(parents=True, exist_ok=True) + return cache_dir + except Exception: # noqa: S110 + pass + + # Fallback - локальная папка ./dump + cache_dir = "./dump/huggingface" + Path(cache_dir).mkdir(parents=True, exist_ok=True) + return cache_dir + + +# Настройка переменных окружения для кеша +MODELS_CACHE_DIR = get_models_cache_dir() +os.environ["TRANSFORMERS_CACHE"] = MODELS_CACHE_DIR +os.environ["HF_HOME"] = MODELS_CACHE_DIR + + +def is_model_cached(model_name: str) -> bool: + """🔍 Проверяет наличие модели в кеше""" + try: + cache_path = Path(MODELS_CACHE_DIR) + model_cache_name = f"models--sentence-transformers--{model_name}" + model_path = cache_path / model_cache_name + + if not model_path.exists(): + return False + + # Проверяем наличие snapshots папки (новый формат HuggingFace) + snapshots_path = model_path / "snapshots" + if snapshots_path.exists(): + # Ищем любой snapshot с config.json + for snapshot_dir in snapshots_path.iterdir(): + if snapshot_dir.is_dir(): + config_file = snapshot_dir / "config.json" + if config_file.exists(): + return True + + # Fallback: проверяем старый формат + config_file = model_path / "config.json" + return config_file.exists() + except Exception: + return False + + +try: + from sentence_transformers import SentenceTransformer + + # Создаем папку для кеша + Path(MODELS_CACHE_DIR).mkdir(parents=True, exist_ok=True) + print(f"📁 Created cache directory: {MODELS_CACHE_DIR}") + + # Список моделей для предзагрузки + models = [ + "paraphrase-multilingual-MiniLM-L12-v2", # Основная многоязычная модель + "all-MiniLM-L6-v2", # Fallback модель + ] + + for model_name in models: + try: + if is_model_cached(model_name): + print(f"🔍 Found cached model: {model_name}") + continue + + print(f"🔽 Downloading model: {model_name}") + model = SentenceTransformer(model_name, cache_folder=MODELS_CACHE_DIR) + print(f"✅ Successfully cached: {model_name}") + + # Освобождаем память + del model + + except Exception as e: + print(f"❌ Failed to download {model_name}: {e}") + + print("🚀 Model preloading completed!") + +except ImportError as e: + print(f"❌ Failed to import dependencies: {e}") + sys.exit(1) +except Exception as e: + print(f"❌ Unexpected error: {e}") + sys.exit(1) diff --git a/services/search.py b/services/search.py index 4c2e4986..d9ba81d3 100644 --- a/services/search.py +++ b/services/search.py @@ -1,6 +1,10 @@ import asyncio +import gzip import json +import os +import pickle import time +from pathlib import Path from typing import Any, Dict, List import muvera @@ -10,6 +14,32 @@ from sentence_transformers import SentenceTransformer from settings import MUVERA_INDEX_NAME, SEARCH_MAX_BATCH_SIZE, SEARCH_PREFETCH_SIZE from utils.logger import root_logger as logger +primary_model = "paraphrase-multilingual-MiniLM-L12-v2" + + +# 💾 Настройка локального кеша для HuggingFace моделей +def get_models_cache_dir() -> str: + """Определяет лучшую папку для кеша моделей""" + # Пробуем /dump если доступен для записи + dump_path = Path("/dump") + if dump_path.exists() and os.access("/dump", os.W_OK): + cache_dir = "/dump/huggingface" + try: + Path(cache_dir).mkdir(parents=True, exist_ok=True) + return cache_dir + except Exception: # noqa: S110 + pass + + # Fallback - локальная папка ./dump + cache_dir = "./dump/huggingface" + Path(cache_dir).mkdir(parents=True, exist_ok=True) + return cache_dir + + +MODELS_CACHE_DIR = get_models_cache_dir() +os.environ.setdefault("TRANSFORMERS_CACHE", MODELS_CACHE_DIR) +os.environ.setdefault("HF_HOME", MODELS_CACHE_DIR) + # Global collection for background tasks background_tasks: List[asyncio.Task] = [] @@ -26,21 +56,88 @@ class MuveraWrapper: self.documents: Dict[str, Dict[str, Any]] = {} # Simple in-memory storage for demo self.embeddings: Dict[str, np.ndarray | None] = {} # Store encoded embeddings - # 🚀 Инициализируем реальную модель эмбедингов + # 🚀 Инициализируем реальную модель эмбедингов с локальным кешом try: + logger.info(f"💾 Using models cache directory: {MODELS_CACHE_DIR}") + + # Проверяем наличие основной модели + is_cached = self._is_model_cached(primary_model) + if is_cached: + logger.info(f"🔍 Found cached model: {primary_model}") + else: + logger.info(f"🔽 Downloading model: {primary_model}") + # Используем многоязычную модель, хорошо работающую с русским - self.encoder = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2") + self.encoder = SentenceTransformer( + primary_model, + cache_folder=MODELS_CACHE_DIR, + local_files_only=is_cached, # Не скачиваем если уже есть в кеше + ) logger.info("🔍 SentenceTransformer model loaded successfully") except Exception as e: - logger.error(f"Failed to load SentenceTransformer: {e}") + logger.error(f"Failed to load primary SentenceTransformer: {e}") # Fallback - простая модель try: - self.encoder = SentenceTransformer("all-MiniLM-L6-v2") + fallback_model = "all-MiniLM-L6-v2" + is_fallback_cached = self._is_model_cached(fallback_model) + if is_fallback_cached: + logger.info(f"🔍 Found cached fallback model: {fallback_model}") + else: + logger.info(f"🔽 Downloading fallback model: {fallback_model}") + + self.encoder = SentenceTransformer( + fallback_model, cache_folder=MODELS_CACHE_DIR, local_files_only=is_fallback_cached + ) logger.info("🔍 Fallback SentenceTransformer model loaded") except Exception: logger.error("Failed to load any SentenceTransformer model") self.encoder = None + def _is_model_cached(self, model_name: str) -> bool: + """🔍 Проверяет наличие модели в кеше""" + try: + # Проверяем наличие папки модели в кеше + cache_path = Path(MODELS_CACHE_DIR) + + # SentenceTransformer сохраняет модели в формате models--org--model-name + model_cache_name = f"models--sentence-transformers--{model_name}" + model_path = cache_path / model_cache_name + + # Проверяем существование папки модели + if not model_path.exists(): + return False + + # Проверяем наличие snapshots папки (новый формат HuggingFace) + snapshots_path = model_path / "snapshots" + if snapshots_path.exists(): + # Ищем любой snapshot с config.json + for snapshot_dir in snapshots_path.iterdir(): + if snapshot_dir.is_dir(): + config_file = snapshot_dir / "config.json" + if config_file.exists(): + return True + + # Fallback: проверяем старый формат + config_file = model_path / "config.json" + return config_file.exists() + except Exception as e: + logger.debug(f"Error checking model cache for {model_name}: {e}") + return False + + async def async_init(self) -> None: + """🔄 Асинхронная инициализация - восстановление индекса из файла""" + try: + logger.info("🔍 Пытаемся восстановить векторный индекс из файла...") + + # Пытаемся загрузить из файла + if await self.load_index_from_file(): + logger.info("✅ Векторный индекс восстановлен из файла") + else: + logger.info("🔍 Сохраненный индекс не найден, будет создан новый") + + except Exception as e: + logger.error(f"❌ Ошибка при восстановлении индекса: {e}") + async def info(self) -> dict: """Return service information""" return { @@ -190,6 +287,15 @@ class MuveraWrapper: elif indexed_count > 0: logger.debug(f"🔍 Indexed {indexed_count} documents") + # 🗃️ Автосохранение индекса после успешной индексации + if indexed_count > 0: + try: + await self.save_index_to_file() + if not silent: + logger.debug("💾 Индекс автоматически сохранен в файл") + except Exception as e: + logger.warning(f"⚠️ Не удалось сохранить индекс в файл: {e}") + async def verify_documents(self, doc_ids: List[str]) -> Dict[str, Any]: """Verify which documents exist in the index""" missing = [doc_id for doc_id in doc_ids if doc_id not in self.documents] @@ -203,6 +309,87 @@ class MuveraWrapper: "consistency": {"status": "ok", "null_embeddings_count": 0}, } + async def save_index_to_file(self, dump_dir: str = "./dump") -> bool: + """🗃️ Сохраняет векторный индекс в файл (fallback метод)""" + try: + # Создаем директорию если не существует + dump_path = Path(dump_dir) + dump_path.mkdir(parents=True, exist_ok=True) + + # Подготавливаем данные для сериализации + index_data = { + "documents": self.documents, + "embeddings": self.embeddings, + "vector_dimension": self.vector_dimension, + "buckets": self.buckets, + "timestamp": int(time.time()), + "version": "1.0", + } + + # Сериализуем данные с pickle + serialized_data = pickle.dumps(index_data) + + # Подготавливаем имена файлов + index_file = dump_path / f"{MUVERA_INDEX_NAME}_vector_index.pkl.gz" + + # Сохраняем основной индекс с gzip сжатием + with gzip.open(index_file, "wb") as f: + f.write(serialized_data) + + logger.info(f"🗃️ Векторный индекс сохранен в файл: {index_file}") + logger.info(f" 📊 Документов: {len(self.documents)}, эмбедингов: {len(self.embeddings)}") + + return True + + except Exception as e: + logger.error(f"❌ Ошибка сохранения индекса в файл: {e}") + return False + + async def load_index_from_file(self, dump_dir: str = "./dump") -> bool: + """🔄 Восстанавливает векторный индекс из файла""" + try: + dump_path = Path(dump_dir) + index_file = dump_path / f"{MUVERA_INDEX_NAME}_vector_index.pkl.gz" + + # Проверяем существование файла + if not index_file.exists(): + logger.debug(f"🔍 Сохраненный индекс не найден: {index_file}") + return False + + # Загружаем и распаковываем данные + with gzip.open(index_file, "rb") as f: + serialized_data = f.read() + + # Десериализуем данные + index_data = pickle.loads(serialized_data) # noqa: S301 + + # Проверяем версию совместимости + if index_data.get("version") != "1.0": + logger.warning(f"🔍 Несовместимая версия индекса: {index_data.get('version')}") + return False + + # Восстанавливаем данные + self.documents = index_data["documents"] + self.embeddings = index_data["embeddings"] + self.vector_dimension = index_data["vector_dimension"] + self.buckets = index_data["buckets"] + + file_size = int(index_file.stat().st_size) + decompression_ratio = len(serialized_data) / file_size if file_size > 0 else 1.0 + + logger.info("🔄 Векторный индекс восстановлен из файла:") + logger.info(f" 📁 Файл: {index_file}") + logger.info(f" 📊 Документов: {len(self.documents)}, эмбедингов: {len(self.embeddings)}") + logger.info( + f" 💾 Размер: {file_size:,} → {len(serialized_data):,} байт (декомпрессия {decompression_ratio:.1f}x)" + ) + + return True + + except Exception as e: + logger.error(f"❌ Ошибка восстановления индекса из файла: {e}") + return False + async def close(self) -> None: """Close the wrapper (no-op for this simple implementation)""" @@ -227,6 +414,11 @@ class SearchService: logger.error(f"Failed to initialize Muvera: {e}") self.available = False + async def async_init(self) -> None: + """🔄 Асинхронная инициализация - восстановление индекса""" + if self.muvera_client: + await self.muvera_client.async_init() + async def info(self) -> dict: """Return information about search service""" if not self.available: @@ -563,7 +755,10 @@ async def initialize_search_index(shouts_data: list) -> None: return try: - # Check if we need to reindex + # Сначала пытаемся восстановить существующий индекс + await search_service.async_init() + + # Проверяем нужна ли переиндексация if len(shouts_data) > 0: await search_service.bulk_index(shouts_data) logger.info(f"Initialized search index with {len(shouts_data)} documents") diff --git a/uv.lock b/uv.lock index 192e5476..86a53e63 100644 --- a/uv.lock +++ b/uv.lock @@ -418,10 +418,13 @@ dependencies = [ { name = "pydantic" }, { name = "pyjwt" }, { name = "redis", extra = ["hiredis"] }, + { name = "scikit-learn" }, { name = "sentence-transformers" }, { name = "sentry-sdk", extra = ["sqlalchemy", "starlette"] }, { name = "sqlalchemy" }, { name = "starlette" }, + { name = "torch" }, + { name = "transformers" }, { name = "types-authlib" }, { name = "types-orjson" }, { name = "types-pyjwt" }, @@ -471,10 +474,13 @@ requires-dist = [ { name = "pydantic" }, { name = "pyjwt", specifier = ">=2.10" }, { name = "redis", extras = ["hiredis"] }, - { name = "sentence-transformers" }, + { name = "scikit-learn", specifier = ">=1.7.0" }, + { name = "sentence-transformers", specifier = ">=2.2.0" }, { name = "sentry-sdk", extras = ["starlette", "sqlalchemy"] }, { name = "sqlalchemy", specifier = ">=2.0.0" }, { name = "starlette" }, + { name = "torch", specifier = ">=2.0.0" }, + { name = "transformers", specifier = ">=4.56.0" }, { name = "types-authlib" }, { name = "types-orjson" }, { name = "types-pyjwt" },