Files
anti-plagiarism/services/worker-gpu/tests/test_qdrant_manager.py
jze9 012b17304f feat(gpu): Qdrant как векторный бэкенд под флагом — снимает SPOF FAISS
FAISS-индекс — файловый синглтон в RAM одного воркера (save на каждую запись,
без блокировок, без HA, не горизонтален). Добавлен альтернативный бэкенд Qdrant
с тем же classmethod-интерфейсом, выбор через VECTOR_BACKEND — аддитивно и
безопасно: дефолт остаётся faiss, ничего не ломается, пока не переключат.

- app/qdrant_manager.py — search/add_vectors/save(no-op)/total_vectors поверх
  qdrant-client (коллекция Cosine, id точки = doc_id, upsert идемпотентен);
- app/vector_store.py — get_backend() по настройке; задачи search/plagiarism
  переведены на него (больше не импортируют FAISSManager напрямую);
- app/migrate_faiss_to_qdrant.py — перелив существующих векторов (reconstruct
  из IndexIDMap2 → upsert), идемпотентно;
- docker-compose.prod.yml — сервис qdrant под профилем `qdrant` (по умолчанию не
  поднимается, ресурсов не ест) + volume; README — раздел про переключение.

Тесты (9) гоняют QdrantManager против ВСТРОЕННОГО Qdrant (qdrant-client :memory:,
не моки) + диспетчеризацию бэкенда. Всего тестов: 82 (indexer 24, gost 24, gpu 34).
Плюсы Qdrant активируются только после явного переключения + миграции.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:34:43 +05:00

84 lines
2.9 KiB
Python
Raw Permalink 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.
"""Юнит-тесты QdrantManager против встроенного Qdrant (:memory:) — реальный бэкенд.
Не моки: qdrant-client в local-режиме поднимает in-process Qdrant, поэтому
проверяется настоящее поведение коллекции (косинус, upsert, count).
"""
import numpy as np
import pytest
pytest.importorskip("qdrant_client")
from app.config import settings # noqa: E402
from app.qdrant_manager import QdrantManager # noqa: E402
from qdrant_client import QdrantClient # noqa: E402
DIM = settings.EMBED_DIM
def _unit(vecs: np.ndarray) -> np.ndarray:
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 in_memory_backend():
"""Свежий встроенный Qdrant на каждый тест."""
QdrantManager._reset()
QdrantManager._client = QdrantClient(location=":memory:")
yield
QdrantManager._reset()
def test_empty_search_returns_nothing():
assert QdrantManager.search(np.zeros(DIM, dtype=np.float32)) == []
assert QdrantManager.total_vectors() == 0
def test_add_empty_is_noop():
QdrantManager.add_vectors(np.zeros((0, DIM), dtype=np.float32), [])
assert QdrantManager.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)))
QdrantManager.add_vectors(vecs, [101, 202, 303])
assert QdrantManager.total_vectors() == 3
res = QdrantManager.search(vecs[1], k=1)
assert res, "поиск ничего не вернул"
top_id, score = res[0]
assert top_id == 202 # id точки == doc_id из PostgreSQL
assert score == pytest.approx(1.0, abs=1e-3) # self-match: косинус ≈ 1
def test_upsert_is_idempotent_by_doc_id():
rng = np.random.default_rng(0)
QdrantManager.add_vectors(_unit(rng.standard_normal((2, DIM))), [1, 2])
updated = _unit(rng.standard_normal((2, DIM)))
QdrantManager.add_vectors(updated, [1, 2]) # те же id → перезапись, не дубли
assert QdrantManager.total_vectors() == 2
top_id, score = QdrantManager.search(updated[0], k=1)[0]
assert top_id == 1
assert score == pytest.approx(1.0, abs=1e-3)
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]
QdrantManager.add_vectors(np.stack([near, far]), [10, 20])
res = QdrantManager.search(v, k=2)
assert res[0][0] == 10 # ближайший — near
def test_save_is_noop():
QdrantManager.save() # Qdrant персистит сам — вызов не должен падать