Files
fckbot/rag/services/reranker_service.py
2026-06-30 21:26:27 +00:00

148 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
Сервис переранжирования с использованием кросс-энкодеров.
Загружает модель асинхронно при первом вызове с использованием asyncio.Lock.
"""
import asyncio
import logging
from typing import List, Tuple, Optional
logger = logging.getLogger(__name__)
# Импортируем sentence-transformers с обработкой ошибок
try:
from sentence_transformers import CrossEncoder
HAS_CROSS_ENCODER = True
except ImportError:
HAS_CROSS_ENCODER = False
logger.warning("sentence-transformers не установлен. Используйте LLM-переранжирование.")
class RerankerService:
"""
Сервис переранжирования на основе кросс-энкодеров.
Загружает модель асинхронно при инициализации или при первом вызове.
"""
# Рекомендуемая модель для русского языка
DEFAULT_MODEL = "intfloat/multilingual-e5-reranker"
def __init__(self, model_name: Optional[str] = None, device: Optional[str] = None):
"""
Инициализация сервиса.
Аргументы:
model_name: имя модели (если None, используется DEFAULT_MODEL).
device: устройство для вычислений ('cpu' или 'cuda'). Если None, автоопределение.
"""
self.model_name = model_name or self.DEFAULT_MODEL
self.device = device or ("cuda" if self._cuda_available() else "cpu")
self._model = None
self._load_lock = asyncio.Lock()
self._model_loaded = asyncio.Event()
self._loading = False
logger.info(f"RerankerService инициализирован с моделью {self.model_name} (device={self.device})")
@staticmethod
def _cuda_available() -> bool:
"""Проверяет доступность CUDA (для GPU)."""
try:
import torch
return torch.cuda.is_available()
except ImportError:
return False
async def _load_model_async(self) -> None:
"""Загружает модель кросс-энкодера асинхронно (ленивая загрузка)."""
async with self._load_lock:
if self._model_loaded.is_set():
return
if self._loading:
await self._model_loaded.wait()
return
self._loading = True
try:
loop = asyncio.get_running_loop()
self._model = await loop.run_in_executor(None, self._load_model_sync)
self._model_loaded.set()
logger.info(f"Модель {self.model_name} загружена")
except Exception as e:
logger.error(f"Ошибка загрузки модели: {e}")
self._loading = False
raise
finally:
self._loading = False
def _load_model_sync(self):
"""Синхронная загрузка модели (выполняется в отдельном потоке)."""
if not HAS_CROSS_ENCODER:
raise RuntimeError("sentence-transformers не установлен. Установите: pip install sentence-transformers")
return CrossEncoder(self.model_name, device=self.device)
async def rerank(
self,
query: str,
fragments: List[str],
top_k: Optional[int] = None
) -> List[Tuple[str, float]]:
"""
Ранжирует фрагменты по релевантности запросу.
Аргументы:
query: текст запроса.
fragments: список текстовых фрагментов.
top_k: максимальное количество возвращаемых фрагментов (если None все).
Возвращает:
List[Tuple[str, float]]: список кортежей (фрагмент, оценка_релевантности),
отсортированный по убыванию оценки.
"""
if not fragments:
return []
await self._load_model_async()
# Формируем пары (запрос, фрагмент) для оценки
pairs = [(query, frag) for frag in fragments]
try:
# Выполняем предсказание синхронно в отдельном потоке
loop = asyncio.get_running_loop()
scores = await loop.run_in_executor(None, self._model.predict, pairs)
results = list(zip(fragments, scores))
results.sort(key=lambda x: x[1], reverse=True)
if top_k:
results = results[:top_k]
return results
except Exception as e:
logger.error(f"Ошибка при оценке кросс-энкодером: {e}")
return [(frag, 0.0) for frag in fragments]
async def rerank_context(
self,
query: str,
context: str,
top_k: Optional[int] = None
) -> str:
"""
Удобная обёртка: принимает строку контекста с фрагментами, разделёнными '\n\n',
возвращает отранжированную строку.
Аргументы:
query: текст запроса.
context: исходный контекст (фрагменты разделены '\n\n').
top_k: максимальное количество возвращаемых фрагментов.
Возвращает:
str: отранжированный контекст (фрагменты разделены '\n\n').
"""
if not context:
return ""
fragments = [f.strip() for f in context.split("\n\n") if f.strip()]
if not fragments:
return ""
ranked = await self.rerank(query, fragments, top_k)
return "\n\n".join([frag for frag, _ in ranked])