Второй CI-гейт после тестов: ruff как статический анализатор всего Python-кода (services + scripts). Раньше ни линта, ни проверки типов в CI не было вовсе. Конфиг ruff.toml: правила E/F/W/I/UP/B/SIM/C4, line-length 100. Осознанно выключены E501 (длину держит форматтер; длинные RU-комментарии — норма), B008 (Depends()/Query() в дефолтах — идиома FastAPI, не баг) и UP042 ((str, Enum)→StrEnum меняет __str__/сериализацию — не трогаем). Починено под ноль находок: - B904 (11): raise ... from exc / from None — читаемые цепочки исключений в Celery-ретраях и HTTPException, ошибки обработки не маскируют исходные. - SIM105 (5): try/except/pass → contextlib.suppress (faiss remove_ids, lsh.remove, сброс кэша, ws-disconnect, парс года). - C416/SIM108/B905/F841/UP035/UP017/F401/I001: dict(rows), тернарник, zip strict, мёртвая переменная, устаревшие импорты, timezone.utc→UTC, чистка/сортировка. Обвязка: scripts/run_lint.sh (ruff в изолированном python:3.11-slim), шаг «Линт» в job test перед юнит-тестами (падаем раньше). make lint / make lint-fix. Все 41 юнит-тест по-прежнему зелёные, изменённые файлы компилируются. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
149 lines
6.0 KiB
Python
149 lines
6.0 KiB
Python
"""Singleton менеджер FAISS индекса.
|
||
|
||
Используется плоский индекс IndexFlatIP, обёрнутый в IndexIDMap2, что даёт:
|
||
- отсутствие этапа обучения (в отличие от IVFFlat) — индекс работоспособен сразу,
|
||
начиная с первого вектора;
|
||
- хранение doc_id прямо внутри индекса (add_with_ids) — не нужен отдельный
|
||
маппинг на диске, а поиск сразу возвращает doc_id из PostgreSQL;
|
||
- корректную персистентность через faiss.write_index / read_index.
|
||
|
||
Векторы модели нормализованы (normalize_embeddings=True), поэтому inner product
|
||
эквивалентен косинусной близости. Для масштаба проекта (сотни тысяч — единицы
|
||
миллионов документов) полный перебор по FlatIP по скорости приемлем.
|
||
"""
|
||
|
||
import contextlib
|
||
import logging
|
||
import os
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
|
||
from app.config import settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class FAISSManager:
|
||
"""Singleton для управления FAISS индексом (IndexIDMap2 поверх IndexFlatIP)."""
|
||
|
||
_index = None
|
||
|
||
@classmethod
|
||
def _new_index(cls):
|
||
"""Создать новый пустой индекс нужного типа."""
|
||
import faiss
|
||
|
||
base = faiss.IndexFlatIP(settings.EMBED_DIM)
|
||
return faiss.IndexIDMap2(base)
|
||
|
||
@classmethod
|
||
def load_or_create(cls) -> None:
|
||
"""Загрузить индекс с диска или создать новый.
|
||
|
||
Старый несовместимый индекс (например, IVFFlat от предыдущей версии,
|
||
который не хранит id_map) безопасно пересоздаётся — полезных векторов в
|
||
нём всё равно не было.
|
||
"""
|
||
import faiss
|
||
|
||
index_path = settings.FAISS_INDEX_PATH
|
||
if os.path.exists(index_path):
|
||
try:
|
||
loaded = faiss.read_index(index_path)
|
||
if hasattr(loaded, "id_map"):
|
||
cls._index = loaded
|
||
logger.info(f"Загрузка FAISS индекса из {index_path} ({loaded.ntotal} векторов)")
|
||
return
|
||
logger.warning("На диске несовместимый FAISS индекс (без id_map) — пересоздаём")
|
||
except Exception as e:
|
||
logger.warning(f"Не удалось загрузить FAISS индекс ({e}) — создаём новый")
|
||
else:
|
||
logger.info("Создание нового FAISS индекса IndexIDMap2(IndexFlatIP)...")
|
||
cls._index = cls._new_index()
|
||
|
||
@classmethod
|
||
def _ensure(cls) -> None:
|
||
"""Ленивая инициализация индекса при первом обращении."""
|
||
if cls._index is None:
|
||
cls.load_or_create()
|
||
|
||
@classmethod
|
||
def search(cls, query_vector: np.ndarray, k: int = 20) -> list[tuple[int, float]]:
|
||
"""Поиск k ближайших векторов.
|
||
|
||
Args:
|
||
query_vector: Нормализованный вектор запроса, форма (768,)
|
||
k: Количество результатов
|
||
|
||
Returns:
|
||
Список кортежей (doc_id, cosine_score), отсортированных по убыванию score
|
||
"""
|
||
cls._ensure()
|
||
|
||
if cls._index.ntotal == 0:
|
||
return []
|
||
|
||
try:
|
||
query = query_vector.reshape(1, -1).astype(np.float32)
|
||
distances, ids = cls._index.search(query, min(k, cls._index.ntotal))
|
||
|
||
results = []
|
||
for idx, dist in zip(ids[0], distances[0], strict=False):
|
||
if idx == -1:
|
||
continue
|
||
# Для IDMap2 idx — это уже doc_id из PostgreSQL
|
||
results.append((int(idx), float(dist)))
|
||
|
||
return results
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка поиска FAISS: {e}")
|
||
return []
|
||
|
||
@classmethod
|
||
def add_vectors(cls, vectors: np.ndarray, doc_ids: list[int]) -> None:
|
||
"""Добавить (или обновить) векторы в индекс.
|
||
|
||
Идемпотентно по doc_id: при повторном эмбеддинге старый вектор документа
|
||
удаляется перед добавлением нового, чтобы не плодить дубли.
|
||
|
||
Args:
|
||
vectors: numpy массив формы (N, 768)
|
||
doc_ids: Список doc_id из PostgreSQL
|
||
"""
|
||
cls._ensure()
|
||
|
||
if len(doc_ids) == 0:
|
||
return
|
||
|
||
vectors = np.asarray(vectors, dtype=np.float32)
|
||
ids = np.asarray(doc_ids, dtype=np.int64)
|
||
|
||
# Удалить существующие id, чтобы повторный эмбеддинг не создавал дубли
|
||
with contextlib.suppress(Exception):
|
||
cls._index.remove_ids(ids)
|
||
|
||
cls._index.add_with_ids(vectors, ids)
|
||
logger.info(f"Добавлено {len(doc_ids)} векторов в FAISS. Всего: {cls._index.ntotal}")
|
||
|
||
@classmethod
|
||
def save(cls) -> None:
|
||
"""Сохранить индекс на диск."""
|
||
cls._ensure()
|
||
|
||
import faiss
|
||
|
||
index_path = Path(settings.FAISS_INDEX_PATH)
|
||
index_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
faiss.write_index(cls._index, str(index_path))
|
||
logger.info(f"FAISS индекс сохранён: {index_path} ({cls._index.ntotal} векторов)")
|
||
|
||
@classmethod
|
||
def total_vectors(cls) -> int:
|
||
"""Количество векторов в индексе."""
|
||
if cls._index is None:
|
||
return 0
|
||
return cls._index.ntotal
|