### 🔍 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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -178,4 +178,6 @@ tmp
|
||||
test-results
|
||||
page_content.html
|
||||
test_output
|
||||
docs/progress/*
|
||||
docs/progress/*
|
||||
|
||||
panel/graphql/generated
|
||||
14
CHANGELOG.md
14
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
|
||||
|
||||
|
||||
@@ -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 \
|
||||
|
||||
56
codegen.ts
Normal file
56
codegen.ts
Normal 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
|
||||
@@ -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 эндпоинты
|
||||
|
||||
27
package-lock.json
generated
27
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -26,7 +26,7 @@ const ShoutBodyModal: Component<ShoutBodyModalProps> = (props) => {
|
||||
</div>
|
||||
<div class={styles['info-row']}>
|
||||
<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 class={styles['info-row']}>
|
||||
<span class={styles['info-label']}>Темы:</span>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
101
scripts/preload_models.py
Executable file
101
scripts/preload_models.py
Executable 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)
|
||||
@@ -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")
|
||||
|
||||
8
uv.lock
generated
8
uv.lock
generated
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user