diff --git a/services/worker-indexer/app/algorithms/minhash.py b/services/worker-indexer/app/algorithms/minhash.py index 57fd22b..474653b 100644 --- a/services/worker-indexer/app/algorithms/minhash.py +++ b/services/worker-indexer/app/algorithms/minhash.py @@ -1,29 +1,73 @@ -"""MinHash LSH для нечёткого поиска похожих документов. +"""MinHash LSH для нечёткого поиска похожих документов (уровень 2). -Позволяет быстро находить документы с похожим содержимым -без точного сравнения всех пар. +Индекс хранится в общем Redis (тот же, что кэш/rate-limits, см. REDIS_URL), +а не в памяти процесса. Это даёт: +- переживаемость рестарта воркера (индекс не теряется); +- общий индекс для всех воркеров-индексеров (add в одном видно в query другого). + +При недоступности Redis — graceful-фолбэк в in-memory (деградация: индекс +локальный и теряется при рестарте), чтобы воркер не падал целиком. """ import logging +from urllib.parse import urlparse from datasketch import MinHash, MinHashLSH +from app.config import settings + logger = logging.getLogger(__name__) # Параметры MinHash LSH LSH_THRESHOLD = 0.5 # Минимальная схожесть для включения в результаты LSH_NUM_PERM = 128 # Количество хэш-функций (точность vs память) +# Префикс ключей в общем Redis — изолирует индекс от кэша/rate-limits в той же БД +LSH_BASENAME = b"antiplag_lsh" -# Глобальный LSH индекс (in-memory) _lsh: MinHashLSH | None = None +_redis_unavailable = False # чтобы не долбить недоступный Redis на каждый вызов + + +def _redis_storage_config() -> dict | None: + """Собрать storage_config datasketch из REDIS_URL. None — если разбор не удался.""" + try: + u = urlparse(settings.REDIS_URL) + redis_kwargs: dict = { + "host": u.hostname or "localhost", + "port": u.port or 6379, + "db": int((u.path or "/0").lstrip("/") or 0), + } + if u.username: + redis_kwargs["username"] = u.username + if u.password: + redis_kwargs["password"] = u.password + return {"type": "redis", "basename": LSH_BASENAME, "redis": redis_kwargs} + except Exception as e: + logger.error(f"MinHash LSH: не удалось разобрать REDIS_URL: {e}") + return None def get_lsh() -> MinHashLSH: - """Получить или создать глобальный LSH индекс.""" - global _lsh - if _lsh is None: - _lsh = MinHashLSH(threshold=LSH_THRESHOLD, num_perm=LSH_NUM_PERM) - logger.info("MinHash LSH индекс создан") + """Получить или создать глобальный LSH индекс (Redis-backed, фолбэк — память).""" + global _lsh, _redis_unavailable + if _lsh is not None: + return _lsh + + if not _redis_unavailable: + cfg = _redis_storage_config() + if cfg is not None: + try: + _lsh = MinHashLSH( + threshold=LSH_THRESHOLD, num_perm=LSH_NUM_PERM, storage_config=cfg + ) + logger.info("MinHash LSH: общий Redis-бэкенд подключён") + return _lsh + except Exception as e: + logger.error(f"MinHash LSH: Redis недоступен ({e}) — фолбэк в память") + _redis_unavailable = True + + _lsh = MinHashLSH(threshold=LSH_THRESHOLD, num_perm=LSH_NUM_PERM) + logger.warning("MinHash LSH: in-memory режим (индекс не шарится и теряется при рестарте)") return _lsh @@ -62,7 +106,10 @@ def text_to_minhash(text: str) -> MinHash: def add_to_lsh(doc_key: str, text: str) -> None: """ - Добавить документ в LSH индекс. + Добавить (или обновить) документ в LSH индекс. + + Upsert: если документ уже есть (например, был добавлен по аннотации, а теперь + пересчитывается по полному тексту) — старая подпись удаляется, вставляется новая. Args: doc_key: Уникальный ключ документа (например, "doc:{id}") @@ -71,10 +118,13 @@ def add_to_lsh(doc_key: str, text: str) -> None: lsh = get_lsh() m = text_to_minhash(text) try: + try: + lsh.remove(doc_key) # снять прежнюю версию, если была + except Exception: + pass # ключа не было — это норма lsh.insert(doc_key, m) - except ValueError: - # Документ уже в индексе — игнорируем - pass + except Exception as e: + logger.warning(f"MinHash LSH: не удалось добавить {doc_key!r}: {e}") def find_similar(text: str) -> list[str]: @@ -113,6 +163,7 @@ def compute_jaccard_minhash(text_a: str, text_b: str) -> float: def reset_lsh() -> None: - """Сбросить LSH индекс (для тестов).""" - global _lsh + """Сбросить handle LSH (для тестов). Данные в Redis не трогает.""" + global _lsh, _redis_unavailable _lsh = None + _redis_unavailable = False