"""Юнит-тесты FAISSManager (уровень 3 — семантический индекс). Проверяют то, что было сломано в старой реализации и переписано: - возврат doc_id из PostgreSQL (IndexIDMap2), а не позиционного номера; - идемпотентность add_vectors по doc_id (remove-before-add, без дублей); - корректность self-match (косинус ≈ 1) и ранжирования. Работают целиком в памяти — диск (FAISS_INDEX_PATH) не трогается. """ import numpy as np import pytest pytest.importorskip("faiss") from app.config import settings # noqa: E402 from app.faiss_manager import FAISSManager # noqa: E402 DIM = settings.EMBED_DIM def _unit(vecs: np.ndarray) -> np.ndarray: """Нормировать строки к единичной длине (для косинуса через inner product).""" vecs = np.asarray(vecs, dtype=np.float32) norms = np.linalg.norm(vecs, axis=1, keepdims=True) norms[norms == 0] = 1.0 return vecs / norms @pytest.fixture(autouse=True) def fresh_index(): """Свежий пустой индекс в памяти на каждый тест; диск не читаем и не пишем.""" FAISSManager._index = FAISSManager._new_index() yield FAISSManager._index = None def test_empty_search_returns_nothing(): assert FAISSManager.search(np.zeros(DIM, dtype=np.float32)) == [] assert FAISSManager.total_vectors() == 0 def test_add_and_self_search_returns_postgres_doc_id(): rng = np.random.default_rng(42) vecs = _unit(rng.standard_normal((3, DIM))) FAISSManager.add_vectors(vecs, [101, 202, 303]) assert FAISSManager.total_vectors() == 3 results = FAISSManager.search(vecs[1], k=1) assert results, "поиск ничего не вернул" top_id, score = results[0] assert top_id == 202 # IndexIDMap2 возвращает doc_id, а не позицию assert score == pytest.approx(1.0, abs=1e-4) # self-match: косинус ≈ 1 def test_add_is_idempotent_by_doc_id(): rng = np.random.default_rng(0) FAISSManager.add_vectors(_unit(rng.standard_normal((2, DIM))), [1, 2]) # Повторный эмбеддинг тех же id не должен плодить дубли (remove-before-add) updated = _unit(rng.standard_normal((2, DIM))) FAISSManager.add_vectors(updated, [1, 2]) assert FAISSManager.total_vectors() == 2 # ...и поиск возвращает ОБНОВЛЁННЫЙ вектор top_id, score = FAISSManager.search(updated[0], k=1)[0] assert top_id == 1 assert score == pytest.approx(1.0, abs=1e-4) def test_search_ranks_nearest_first(): rng = np.random.default_rng(7) v = _unit(rng.standard_normal((1, DIM)))[0] near = _unit((v + 0.01 * rng.standard_normal(DIM))[None, :])[0] far = _unit(rng.standard_normal((1, DIM)))[0] FAISSManager.add_vectors(np.stack([near, far]), [10, 20]) results = FAISSManager.search(v, k=2) assert [doc_id for doc_id, _ in results][0] == 10 # ближайший — near def test_search_respects_k_and_index_size(): rng = np.random.default_rng(1) FAISSManager.add_vectors(_unit(rng.standard_normal((2, DIM))), [5, 6]) # k больше числа векторов не должно приводить к падению или id == -1 results = FAISSManager.search(_unit(rng.standard_normal((1, DIM)))[0], k=100) assert len(results) == 2 assert all(doc_id in (5, 6) for doc_id, _ in results)