From 476682741ee4fccec2b5a4155217272a09969b55 Mon Sep 17 00:00:00 2001 From: jze9 Date: Sat, 5 Sep 2026 19:13:01 +0500 Subject: [PATCH] =?UTF-8?q?fix(L1):=20=D0=BE=D0=B1=D1=80=D0=B5=D0=B7=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BE=D1=82=D0=BF=D0=B5=D1=87=D0=B0=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=80=D0=B0=D0=B2=D0=BD=D0=BE=D0=BC=D0=B5=D1=80=D0=BD?= =?UTF-8?q?=D0=BE=20=D0=BF=D0=BE=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D1=83,?= =?UTF-8?q?=20=D0=B0=20=D0=BD=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B8=D0=B7=D0=B2?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit winnow() возвращает set, поэтому list(fp)[:LIMIT] брал случайное подмножество: у длинного документа целые куски оставались без отпечатков, и списывание именно из них не находилось. Обнаружено при разборе того, почему фрагмент статьи PMC не искался. Добавлены winnow_ordered() — отпечатки в порядке появления в тексте, и sample_evenly() — выборка каждого n-го элемента вместо первых N. Применено в add_document и store_full_text. 7 тестов на главное свойство: выборка растянута по всей длине документа, шаг ровный, порядок сохранён. Co-Authored-By: Claude Opus 5 --- .../app/algorithms/winnowing.py | 62 +++++++++++++++++++ services/worker-indexer/app/tasks/index.py | 9 +-- .../worker-indexer/tests/test_winnowing.py | 56 +++++++++++++++++ 3 files changed, 123 insertions(+), 4 deletions(-) diff --git a/services/worker-indexer/app/algorithms/winnowing.py b/services/worker-indexer/app/algorithms/winnowing.py index 732fdec..5a29245 100644 --- a/services/worker-indexer/app/algorithms/winnowing.py +++ b/services/worker-indexer/app/algorithms/winnowing.py @@ -85,6 +85,68 @@ def winnow(text: str, k: int = 5, window: int = 4) -> set[int]: return fingerprint +def winnow_ordered(text: str, k: int = 5, window: int = 4) -> list[int]: + """То же, что winnow, но с сохранением порядка появления отпечатков в тексте. + + Нужно там, где отпечатки приходится обрезать по лимиту: множество не хранит + порядка, и `list(winnow(text))[:limit]` берёт произвольное подмножество — + у длинного документа целые куски остаются без покрытия, и списывание из них + не находится. Со списком в порядке текста обрезку можно делать равномерной + (см. sample_evenly). + + Args: + text: Исходный текст + k: Размер k-граммы + window: Размер скользящего окна + + Returns: + Отпечатки в порядке появления в тексте, без повторов + """ + tokens = text.lower().split() + if len(tokens) < k: + return [] + + hashes = [hash_ngram(ng) for ng in get_ngrams(tokens, k)] + if not hashes: + return [] + + ordered: list[int] = [] + seen: set[int] = set() + prev_min_idx = -1 + + for i in range(len(hashes) - window + 1): + window_hashes = hashes[i : i + window] + min_val = min(window_hashes) + min_idx = i + window_hashes.index(min_val) + + if min_idx != prev_min_idx: + if min_val not in seen: + seen.add(min_val) + ordered.append(min_val) + prev_min_idx = min_idx + + return ordered + + +def sample_evenly(items: list[int], limit: int) -> list[int]: + """Оставить не больше limit элементов, равномерно по всей длине списка. + + Берём каждый n-й элемент, а не первые limit штук: обрезка «с начала» + оставила бы без отпечатков весь конец документа. + + Args: + items: Отпечатки в порядке текста (winnow_ordered) + limit: Максимум отпечатков; 0 или меньше — не ограничивать + + Returns: + Подсписок длиной не больше limit, сохраняющий порядок + """ + if limit <= 0 or len(items) <= limit: + return items + step = len(items) / limit + return [items[int(i * step)] for i in range(limit)] + + def jaccard_similarity(fp_a: set[int], fp_b: set[int]) -> float: """ Коэффициент Жаккара для двух fingerprint'ов. diff --git a/services/worker-indexer/app/tasks/index.py b/services/worker-indexer/app/tasks/index.py index 41ce0cd..3b0f379 100644 --- a/services/worker-indexer/app/tasks/index.py +++ b/services/worker-indexer/app/tasks/index.py @@ -10,7 +10,7 @@ from sqlalchemy import func, select, update from sqlalchemy.exc import IntegrityError from app.algorithms.minhash import add_to_lsh, find_similar -from app.algorithms.winnowing import winnow +from app.algorithms.winnowing import sample_evenly, winnow, winnow_ordered from app.celery_app import celery_app from app.config import settings from app.db import db_session, get_minio, refund_plagiarism_quota, update_task_status @@ -284,8 +284,9 @@ def add_document(doc_data: dict[str, Any], dispatch_embed: bool = True) -> dict[ # Вычислить fingerprints text = doc_data.get("full_text") or doc_data.get("abstract", "") or "" if text: - fp = winnow(text) - fingerprints_to_add = list(fp)[: settings.MAX_FINGERPRINTS_PER_DOC] + fingerprints_to_add = sample_evenly( + winnow_ordered(text), settings.MAX_FINGERPRINTS_PER_DOC + ) for i, hash_val in enumerate(fingerprints_to_add): session.add(Fingerprint(doc_id=doc_id, hash_value=hash_val, position=i)) @@ -375,7 +376,7 @@ def store_full_text(doc_id: int, text: str) -> dict[str, Any]: content_type="text/plain; charset=utf-8", ) - hashes = list(winnow(text))[: settings.MAX_FINGERPRINTS_PER_DOC] + hashes = sample_evenly(winnow_ordered(text), settings.MAX_FINGERPRINTS_PER_DOC) with db_session() as session: doc = session.get(Document, doc_id) diff --git a/services/worker-indexer/tests/test_winnowing.py b/services/worker-indexer/tests/test_winnowing.py index 69a0060..52912a3 100644 --- a/services/worker-indexer/tests/test_winnowing.py +++ b/services/worker-indexer/tests/test_winnowing.py @@ -5,7 +5,9 @@ from app.algorithms.winnowing import ( get_ngrams, hash_ngram, jaccard_similarity, + sample_evenly, winnow, + winnow_ordered, ) # Достаточно длинный текст, чтобы окно Winnowing реально отработало @@ -77,3 +79,57 @@ def test_compute_similarity_partial_overlap_is_between(): modified = LONG + " добавлен ещё один совершенно новый хвост предложения здесь" sim = compute_similarity(LONG, modified) assert 0.0 < sim < 1.0 + + +# ─── Обрезка отпечатков по лимиту ──────────────────────────────────────────── +# Регрессия: winnow() возвращает set, и list(fp)[:limit] брал произвольное +# подмножество — у длинного документа целые куски оставались без покрытия, +# и списывание из них не находилось. + + +def test_winnow_ordered_matches_winnow_by_content(): + """Тот же набор отпечатков, что и у winnow, только с порядком.""" + assert set(winnow_ordered(LONG)) == winnow(LONG) + + +def test_winnow_ordered_has_no_duplicates(): + ordered = winnow_ordered(LONG) + assert len(ordered) == len(set(ordered)) + + +def test_winnow_ordered_follows_text_order(): + """Отпечатки начала текста идут раньше отпечатков продолжения.""" + tail = " совершенно другой хвост про выпечку хлеба и закваску в тёплой печи" + ordered = winnow_ordered(LONG + tail) + head_prints = set(winnow_ordered(LONG)) + positions = [i for i, h in enumerate(ordered) if h in head_prints] + # Отпечатки первой половины сосредоточены в начале списка, а не разбросаны + assert max(positions) < len(ordered) + assert positions[0] == 0 + + +def test_sample_evenly_keeps_everything_under_limit(): + items = [1, 2, 3] + assert sample_evenly(items, 10) == items + assert sample_evenly(items, 0) == items # 0 = без ограничения + + +def test_sample_evenly_respects_limit(): + items = list(range(1000)) + assert len(sample_evenly(items, 100)) == 100 + + +def test_sample_evenly_covers_whole_document(): + """Главное свойство: выборка растянута по всей длине, а не обрезана с начала.""" + items = list(range(1000)) + sampled = sample_evenly(items, 10) + assert sampled[0] == 0 + assert sampled[-1] >= 900 # хвост документа тоже покрыт + assert sampled == sorted(sampled) # порядок сохранён + + +def test_sample_evenly_spreads_uniformly(): + items = list(range(100)) + sampled = sample_evenly(items, 10) + gaps = [b - a for a, b in zip(sampled, sampled[1:], strict=False)] + assert max(gaps) - min(gaps) <= 1 # шаг ровный