## [0.9.18] - 2025-01-09
Some checks failed
Deploy on push / deploy (push) Failing after 1m34s

### 🔍 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`)
This commit is contained in:
2025-09-01 15:09:36 +03:00
parent 35af07f067
commit 4489d25913
13 changed files with 492 additions and 11 deletions

4
.gitignore vendored
View File

@@ -178,4 +178,6 @@ tmp
test-results test-results
page_content.html page_content.html
test_output test_output
docs/progress/* docs/progress/*
panel/graphql/generated

View File

@@ -1,5 +1,19 @@
# Changelog # 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 ## [0.9.17] - 2025-08-31

View File

@@ -11,6 +11,11 @@ RUN apt-get update && apt-get install -y \
WORKDIR /app 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) # Install only transitive deps first (cache-friendly layer)
COPY pyproject.toml . COPY pyproject.toml .
COPY uv.lock . COPY uv.lock .
@@ -20,6 +25,9 @@ RUN uv sync --no-install-project
COPY . . COPY . .
RUN uv sync --no-editable RUN uv sync --no-editable
# 🚀 Предзагрузка HuggingFace моделей для ускорения первого запуска
RUN uv run python scripts/preload_models.py
# Установка Node.js LTS и npm # Установка Node.js LTS и npm
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \ RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
apt-get install -y nsolid \ apt-get install -y nsolid \

56
codegen.ts Normal file
View File

@@ -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<string, any>'
},
// Настройки для правильной работы
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

View File

@@ -70,6 +70,7 @@ options = {
├── 🧠 SentenceTransformer # Генерация эмбедингов ├── 🧠 SentenceTransformer # Генерация эмбедингов
├── 🗜️ Muvera FDE # Сжатие векторов ├── 🗜️ Muvera FDE # Сжатие векторов
├── 🗃️ MuveraWrapper # Хранение и поиск ├── 🗃️ MuveraWrapper # Хранение и поиск
├── 💾 File Persistence # Сохранение в /dump папку
└── 🔍 SearchService # API интерфейс └── 🔍 SearchService # API интерфейс
``` ```
@@ -94,6 +95,9 @@ compressed = muvera.encode_fde(embedding, buckets=128, method="avg")
# 4. Сохранение в индекс # 4. Сохранение в индекс
embeddings[doc_id] = compressed embeddings[doc_id] = compressed
# 5. Автосохранение в файл
await self.save_index_to_file("/dump")
``` ```
### Алгоритм поиска ### Алгоритм поиска
@@ -219,6 +223,55 @@ print(f"Missing: {missing['missing']}")
3. **Кеширование** - результаты поиска кешируются в Redis 3. **Кеширование** - результаты поиска кешируются в Redis
4. **FDE сжатие** - уменьшает размер векторов в 2-3 раза 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__()` 3. Изменить модель в `MuveraWrapper.__init__()`
4. Запустить переиндексацию 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 эндпоинты - [API Documentation](api.md) - GraphQL эндпоинты

27
package-lock.json generated
View File

@@ -1,16 +1,17 @@
{ {
"name": "publy-panel", "name": "publy-panel",
"version": "0.9.7", "version": "0.9.14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "publy-panel", "name": "publy-panel",
"version": "0.9.7", "version": "0.9.14",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.2.0", "@biomejs/biome": "^2.2.0",
"@graphql-codegen/cli": "^5.0.7", "@graphql-codegen/cli": "^5.0.7",
"@graphql-codegen/client-preset": "^4.8.3", "@graphql-codegen/client-preset": "^4.8.3",
"@graphql-codegen/introspection": "^4.0.3",
"@graphql-codegen/typescript": "^4.1.6", "@graphql-codegen/typescript": "^4.1.6",
"@graphql-codegen/typescript-operations": "^4.6.1", "@graphql-codegen/typescript-operations": "^4.6.1",
"@graphql-codegen/typescript-resolvers": "^4.5.1", "@graphql-codegen/typescript-resolvers": "^4.5.1",
@@ -1189,6 +1190,28 @@
"dev": true, "dev": true,
"license": "0BSD" "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": { "node_modules/@graphql-codegen/plugin-helpers": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-5.1.1.tgz", "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-5.1.1.tgz",

View File

@@ -16,6 +16,7 @@
"@biomejs/biome": "^2.2.0", "@biomejs/biome": "^2.2.0",
"@graphql-codegen/cli": "^5.0.7", "@graphql-codegen/cli": "^5.0.7",
"@graphql-codegen/client-preset": "^4.8.3", "@graphql-codegen/client-preset": "^4.8.3",
"@graphql-codegen/introspection": "^4.0.3",
"@graphql-codegen/typescript": "^4.1.6", "@graphql-codegen/typescript": "^4.1.6",
"@graphql-codegen/typescript-operations": "^4.6.1", "@graphql-codegen/typescript-operations": "^4.6.1",
"@graphql-codegen/typescript-resolvers": "^4.5.1", "@graphql-codegen/typescript-resolvers": "^4.5.1",

View File

@@ -26,7 +26,7 @@ const ShoutBodyModal: Component<ShoutBodyModalProps> = (props) => {
</div> </div>
<div class={styles['info-row']}> <div class={styles['info-row']}>
<span class={styles['info-label']}>Просмотры:</span> <span class={styles['info-label']}>Просмотры:</span>
<span class={styles['info-value']}>{props.shout.stat?.viewed || 0}</span> <span class={styles['info-value']}>{props.shout.stat?.views_count || 0}</span>
</div> </div>
<div class={styles['info-row']}> <div class={styles['info-row']}>
<span class={styles['info-label']}>Темы:</span> <span class={styles['info-label']}>Темы:</span>

View File

@@ -31,7 +31,11 @@ dependencies = [
"httpx", "httpx",
"redis[hiredis]", "redis[hiredis]",
"sentry-sdk[starlette,sqlalchemy]", "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", "starlette",
"gql", "gql",
"ariadne", "ariadne",

View File

@@ -17,7 +17,10 @@ orjson>=3.9.0
pydantic>=2.0.0 pydantic>=2.0.0
numpy>=1.24.0 numpy>=1.24.0
muvera>=0.2.0 muvera>=0.2.0
torch>=2.0.0
sentence-transformers>=2.2.0 sentence-transformers>=2.2.0
transformers>=4.56.0
scikit-learn>=1.7.0
# Type stubs # Type stubs
types-requests>=2.31.0 types-requests>=2.31.0

101
scripts/preload_models.py Executable file
View File

@@ -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)

View File

@@ -1,6 +1,10 @@
import asyncio import asyncio
import gzip
import json import json
import os
import pickle
import time import time
from pathlib import Path
from typing import Any, Dict, List from typing import Any, Dict, List
import muvera 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 settings import MUVERA_INDEX_NAME, SEARCH_MAX_BATCH_SIZE, SEARCH_PREFETCH_SIZE
from utils.logger import root_logger as logger 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 # Global collection for background tasks
background_tasks: List[asyncio.Task] = [] 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.documents: Dict[str, Dict[str, Any]] = {} # Simple in-memory storage for demo
self.embeddings: Dict[str, np.ndarray | None] = {} # Store encoded embeddings self.embeddings: Dict[str, np.ndarray | None] = {} # Store encoded embeddings
# 🚀 Инициализируем реальную модель эмбедингов # 🚀 Инициализируем реальную модель эмбедингов с локальным кешом
try: 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") logger.info("🔍 SentenceTransformer model loaded successfully")
except Exception as e: except Exception as e:
logger.error(f"Failed to load SentenceTransformer: {e}") logger.error(f"Failed to load primary SentenceTransformer: {e}")
# Fallback - простая модель # Fallback - простая модель
try: 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") logger.info("🔍 Fallback SentenceTransformer model loaded")
except Exception: except Exception:
logger.error("Failed to load any SentenceTransformer model") logger.error("Failed to load any SentenceTransformer model")
self.encoder = None 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: async def info(self) -> dict:
"""Return service information""" """Return service information"""
return { return {
@@ -190,6 +287,15 @@ class MuveraWrapper:
elif indexed_count > 0: elif indexed_count > 0:
logger.debug(f"🔍 Indexed {indexed_count} documents") 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]: async def verify_documents(self, doc_ids: List[str]) -> Dict[str, Any]:
"""Verify which documents exist in the index""" """Verify which documents exist in the index"""
missing = [doc_id for doc_id in doc_ids if doc_id not in self.documents] 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}, "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: async def close(self) -> None:
"""Close the wrapper (no-op for this simple implementation)""" """Close the wrapper (no-op for this simple implementation)"""
@@ -227,6 +414,11 @@ class SearchService:
logger.error(f"Failed to initialize Muvera: {e}") logger.error(f"Failed to initialize Muvera: {e}")
self.available = False self.available = False
async def async_init(self) -> None:
"""🔄 Асинхронная инициализация - восстановление индекса"""
if self.muvera_client:
await self.muvera_client.async_init()
async def info(self) -> dict: async def info(self) -> dict:
"""Return information about search service""" """Return information about search service"""
if not self.available: if not self.available:
@@ -563,7 +755,10 @@ async def initialize_search_index(shouts_data: list) -> None:
return return
try: try:
# Check if we need to reindex # Сначала пытаемся восстановить существующий индекс
await search_service.async_init()
# Проверяем нужна ли переиндексация
if len(shouts_data) > 0: if len(shouts_data) > 0:
await search_service.bulk_index(shouts_data) await search_service.bulk_index(shouts_data)
logger.info(f"Initialized search index with {len(shouts_data)} documents") logger.info(f"Initialized search index with {len(shouts_data)} documents")

8
uv.lock generated
View File

@@ -418,10 +418,13 @@ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pyjwt" }, { name = "pyjwt" },
{ name = "redis", extra = ["hiredis"] }, { name = "redis", extra = ["hiredis"] },
{ name = "scikit-learn" },
{ name = "sentence-transformers" }, { name = "sentence-transformers" },
{ name = "sentry-sdk", extra = ["sqlalchemy", "starlette"] }, { name = "sentry-sdk", extra = ["sqlalchemy", "starlette"] },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
{ name = "starlette" }, { name = "starlette" },
{ name = "torch" },
{ name = "transformers" },
{ name = "types-authlib" }, { name = "types-authlib" },
{ name = "types-orjson" }, { name = "types-orjson" },
{ name = "types-pyjwt" }, { name = "types-pyjwt" },
@@ -471,10 +474,13 @@ requires-dist = [
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pyjwt", specifier = ">=2.10" }, { name = "pyjwt", specifier = ">=2.10" },
{ name = "redis", extras = ["hiredis"] }, { 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 = "sentry-sdk", extras = ["starlette", "sqlalchemy"] },
{ name = "sqlalchemy", specifier = ">=2.0.0" }, { name = "sqlalchemy", specifier = ">=2.0.0" },
{ name = "starlette" }, { name = "starlette" },
{ name = "torch", specifier = ">=2.0.0" },
{ name = "transformers", specifier = ">=4.56.0" },
{ name = "types-authlib" }, { name = "types-authlib" },
{ name = "types-orjson" }, { name = "types-orjson" },
{ name = "types-pyjwt" }, { name = "types-pyjwt" },