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>
186 lines
6.4 KiB
Python
186 lines
6.4 KiB
Python
"""Алгоритм Winnowing для fingerprinting текстов.
|
||
|
||
Winnowing — алгоритм выбора минимального хэша в скользящем окне.
|
||
Используется для нахождения точных и частичных совпадений текстов.
|
||
"""
|
||
|
||
import xxhash
|
||
|
||
|
||
def get_ngrams(tokens: list[str], k: int = 5) -> list[str]:
|
||
"""
|
||
Сформировать n-граммы из токенов.
|
||
|
||
Args:
|
||
tokens: Список слов
|
||
k: Размер n-граммы
|
||
|
||
Returns:
|
||
Список n-грамм в виде строк
|
||
"""
|
||
if len(tokens) < k:
|
||
return []
|
||
return [" ".join(tokens[i : i + k]) for i in range(len(tokens) - k + 1)]
|
||
|
||
|
||
def hash_ngram(ngram: str) -> int:
|
||
"""
|
||
Хэшировать n-грамму через xxHash (быстро, детерминированно).
|
||
|
||
Args:
|
||
ngram: n-грамма для хэширования
|
||
|
||
Returns:
|
||
Знаковый 64-битный хэш (умещается в PostgreSQL BIGINT)
|
||
"""
|
||
# xxh64 даёт unsigned 64-bit (до 2^64-1), а PostgreSQL BIGINT — signed
|
||
# (макс 2^63-1). Приводим к диапазону signed int64.
|
||
unsigned = xxhash.xxh64(ngram.encode("utf-8")).intdigest()
|
||
return unsigned - (1 << 64) if unsigned >= (1 << 63) else unsigned
|
||
|
||
|
||
def winnow(text: str, k: int = 5, window: int = 4) -> set[int]:
|
||
"""
|
||
Алгоритм Winnowing для построения fingerprint документа.
|
||
|
||
Шаги:
|
||
1. Токенизация (lowercase)
|
||
2. Построение k-грамм
|
||
3. Хэширование k-грамм
|
||
4. Скользящее окно — выбор минимального хэша в каждой позиции
|
||
|
||
Args:
|
||
text: Исходный текст
|
||
k: Размер k-граммы (n-gram)
|
||
window: Размер скользящего окна
|
||
|
||
Returns:
|
||
Множество отобранных хэшей (fingerprint)
|
||
"""
|
||
tokens = text.lower().split()
|
||
|
||
if len(tokens) < k:
|
||
return set()
|
||
|
||
ngrams = get_ngrams(tokens, k)
|
||
hashes = [hash_ngram(ng) for ng in ngrams]
|
||
|
||
if not hashes:
|
||
return set()
|
||
|
||
# Скользящее окно — выбираем минимальный хэш в каждом окне
|
||
fingerprint: 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:
|
||
fingerprint.add(min_val)
|
||
prev_min_idx = min_idx
|
||
|
||
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'ов.
|
||
|
||
Args:
|
||
fp_a: Fingerprint документа A
|
||
fp_b: Fingerprint документа B
|
||
|
||
Returns:
|
||
Коэффициент сходства от 0.0 до 1.0
|
||
"""
|
||
if not fp_a or not fp_b:
|
||
return 0.0
|
||
|
||
intersection = len(fp_a & fp_b)
|
||
union = len(fp_a | fp_b)
|
||
|
||
return intersection / union if union > 0 else 0.0
|
||
|
||
|
||
def compute_similarity(text_a: str, text_b: str, k: int = 5, window: int = 4) -> float:
|
||
"""
|
||
Вычислить схожесть двух текстов через Winnowing.
|
||
|
||
Args:
|
||
text_a: Первый текст
|
||
text_b: Второй текст
|
||
k: Размер k-граммы
|
||
window: Размер окна Winnowing
|
||
|
||
Returns:
|
||
Схожесть от 0.0 до 1.0
|
||
"""
|
||
fp_a = winnow(text_a, k=k, window=window)
|
||
fp_b = winnow(text_b, k=k, window=window)
|
||
return jaccard_similarity(fp_a, fp_b)
|