fix(L1): обрезать отпечатки равномерно по тексту, а не произвольно
winnow() возвращает set, поэтому list(fp)[:LIMIT] брал случайное подмножество: у длинного документа целые куски оставались без отпечатков, и списывание именно из них не находилось. Обнаружено при разборе того, почему фрагмент статьи PMC не искался. Добавлены winnow_ordered() — отпечатки в порядке появления в тексте, и sample_evenly() — выборка каждого n-го элемента вместо первых N. Применено в add_document и store_full_text. 7 тестов на главное свойство: выборка растянута по всей длине документа, шаг ровный, порядок сохранён. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -85,6 +85,68 @@ def winnow(text: str, k: int = 5, window: int = 4) -> set[int]:
|
|||||||
return fingerprint
|
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:
|
def jaccard_similarity(fp_a: set[int], fp_b: set[int]) -> float:
|
||||||
"""
|
"""
|
||||||
Коэффициент Жаккара для двух fingerprint'ов.
|
Коэффициент Жаккара для двух fingerprint'ов.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from sqlalchemy import func, select, update
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from app.algorithms.minhash import add_to_lsh, find_similar
|
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.celery_app import celery_app
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import db_session, get_minio, refund_plagiarism_quota, update_task_status
|
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
|
# Вычислить fingerprints
|
||||||
text = doc_data.get("full_text") or doc_data.get("abstract", "") or ""
|
text = doc_data.get("full_text") or doc_data.get("abstract", "") or ""
|
||||||
if text:
|
if text:
|
||||||
fp = winnow(text)
|
fingerprints_to_add = sample_evenly(
|
||||||
fingerprints_to_add = list(fp)[: settings.MAX_FINGERPRINTS_PER_DOC]
|
winnow_ordered(text), settings.MAX_FINGERPRINTS_PER_DOC
|
||||||
|
)
|
||||||
for i, hash_val in enumerate(fingerprints_to_add):
|
for i, hash_val in enumerate(fingerprints_to_add):
|
||||||
session.add(Fingerprint(doc_id=doc_id, hash_value=hash_val, position=i))
|
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",
|
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:
|
with db_session() as session:
|
||||||
doc = session.get(Document, doc_id)
|
doc = session.get(Document, doc_id)
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ from app.algorithms.winnowing import (
|
|||||||
get_ngrams,
|
get_ngrams,
|
||||||
hash_ngram,
|
hash_ngram,
|
||||||
jaccard_similarity,
|
jaccard_similarity,
|
||||||
|
sample_evenly,
|
||||||
winnow,
|
winnow,
|
||||||
|
winnow_ordered,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Достаточно длинный текст, чтобы окно Winnowing реально отработало
|
# Достаточно длинный текст, чтобы окно Winnowing реально отработало
|
||||||
@@ -77,3 +79,57 @@ def test_compute_similarity_partial_overlap_is_between():
|
|||||||
modified = LONG + " добавлен ещё один совершенно новый хвост предложения здесь"
|
modified = LONG + " добавлен ещё один совершенно новый хвост предложения здесь"
|
||||||
sim = compute_similarity(LONG, modified)
|
sim = compute_similarity(LONG, modified)
|
||||||
assert 0.0 < sim < 1.0
|
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 # шаг ровный
|
||||||
|
|||||||
Reference in New Issue
Block a user