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>
This commit is contained in:
83
services/worker-gpu/tests/test_qdrant_manager.py
Normal file
83
services/worker-gpu/tests/test_qdrant_manager.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Юнит-тесты 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 персистит сам — вызов не должен падать
|
||||
Reference in New Issue
Block a user