Files
anti-plagiarism/services/worker-gpu/app/faiss_manager.py
jze9 a2d3625f2a
All checks were successful
Deploy / deploy (push) Successful in 15s
refactor(gpu): убрать мёртвый _reverse_map/_use_gpu из FAISSManager
_reverse_map был identity-map (faiss_id == doc_id для IndexIDMap2) —
использовался только как doc.faiss_id = _reverse_map[doc_id], т.е. = doc_id.
Убрал поле, метод _rebuild_reverse_map и его обслуживание в 5 местах;
_use_gpu нигде не читался. -30 строк, функционал тот же.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 16:57:03 +05:00

150 lines
6.0 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.
"""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 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]):
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, чтобы повторный эмбеддинг не создавал дубли
try:
cls._index.remove_ids(ids)
except Exception:
pass
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