1. Главная
  2. Блог
  3. Почти-дубли в корпоративном архиве: как MinHash + LSH спасли индексацию для ИИ-агента

Почти-дубли в корпоративном архиве: как MinHash + LSH спасли индексацию для ИИ-агента

Почти-дубли в корпоративном архиве: как MinHash + LSH спасли индексацию для ИИ-агента

Пришли к нам с классической задачей: у крупного дистрибьютора десятилетний архив документов — коммерческие предложения, спецификации, письма, приложения к договорам. Несколько миллионов файлов. Нужно посадить всё в pgvector, чтобы ИИ-агент отвечал менеджерам на вопросы вроде «а мы такому клиенту в 2021-м какие условия давали?». Мы разрабатываем ИИ-агентов для бизнеса не первый год, и подобные архивы попадаются нам регулярно — но этот сюрпризов подкинул больше обычного.

Индексацию запустили в понедельник. К середине недели счётчик embedding-вызовов улетел за миллион, и на внутреннем дашборде я увидел неприятное: топ-10 самых частых кластеров в HNSW-индексе выглядели как один и тот же документ, только с разными датами в шапке. Остановил пайплайн, пошёл смотреть глазами. Оказалось, что заметная часть архива — это почти-дубли. Одно и то же коммерческое предложение, разосланное десяткам клиентов с заменой ФИО и суммы. Один и тот же прайс-лист, обновлённый три раза за неделю. Одна и та же спецификация, где меняются только реквизиты.

Первым делом мелькнула мысль просто продолжить: pgvector это переживёт. Но качество поиска у RAG было бы стеклянное — агент приносит менеджеру пять «похожих» кусков, которые на самом деле один кусок в пяти костюмах. Плюс счёт за эмбеддинги рос там, где расти не должен был.

Почему SHA-256 не сработал

Первая попытка была очевидная — простой SHA-хеш от нормализованного текста. Убрал пробелы, привёл к нижнему регистру, посчитал. Идентичных байт-в-байт оказалось совсем немного, около пары процентов от архива. Всё остальное — «почти-дубли», где различий больше нуля, но по смыслу это один документ.

Классический пример: два коммерческих предложения, одинаковых почти целиком. Различия — ФИО получателя в шапке, ИНН, номер договора, дата. Для SHA это два разных документа. Для менеджера, который ищет «какие условия мы обычно даём на кабель ВВГ», это один шаблон, который засоряет выдачу десятками копий.

Нужен был инструмент, который считает похожесть, а не равенство. И делает это на миллионах документов за разумное время — не за квартал.

MinHash + LSH: как найти похожие тексты без попарного сравнения

Наивное решение — сравнить каждый документ с каждым — это триллионы пар. Даже если тратить сотню микросекунд на пару, получаются годы. Не вариант.

Взяли классическую связку MinHash + Locality-Sensitive Hashing. Идея простая: для каждого документа считаем компактную «сигнатуру» из 128 чисел, а потом LSH группирует документы так, что похожие сигнатуры попадают в один бакет. Сравнивать нужно только внутри бакетов, а их на несколько порядков меньше, чем документов.

Пайплайн вышел такой:

from datasketch import MinHash, MinHashLSH

def shingles(text: str, k: int = 5) -> set[str]:
    tokens = text.lower().split()
    return {" ".join(tokens[i:i+k]) for i in range(len(tokens) - k + 1)}

def signature(text: str, num_perm: int = 128) -> MinHash:
    m = MinHash(num_perm=num_perm)
    for sh in shingles(text):
        m.update(sh.encode("utf-8"))
    return m

lsh = MinHashLSH(threshold=0.85, num_perm=128)
for doc_id, text in stream_documents():
    lsh.insert(doc_id, signature(text))

Шинглы — это скользящее окно из пяти слов. Для строки «интеллект технологии внедряет ai агентов и mcp серверы» шинглами будут «интеллект технологии внедряет ai агентов», «технологии внедряет ai агентов и» и так далее. MinHash из этих шинглов делает компактную сигнатуру, по которой можно оценить Jaccard-сходство исходных множеств.

Обработка всего архива на одной машине с 16 ядрами заняла ночь плюс пол-утра. Не быстро, но однократно — и уже реалистично.

Как подбирали порог

Первый прогон с порогом 0.7 схлопнул слишком много: письма разным клиентам в одном месяце оказались «дублями», хотя условия отличались заметно. RAG после такой дедупликации терял детали.

Порог 0.95 схлопнул слишком мало — почти как SHA. Даже в шаблонных документах меняющиеся ФИО и суммы дают на MinHash сдвиг больше пяти процентов.

Сели с менеджерами заказчика, взяли размеченную выборку из нескольких сотен пар — где эксперт сказал «это один документ» и «это разные». Прогнали пороги от 0.75 до 0.92 с шагом 0.02. Оптимум по F1 оказался в районе 0.83. Это и стало продакшен-порогом.

Дополнительно ввели пост-проверку внутри кандидатов: если MinHash говорит «похожие», досчитываем точный Jaccard на шинглах — это уже дёшево, кандидатов немного — и только тогда подтверждаем.

Что делать с найденными дублями (спойлер: не удалять)

Соблазн «удалить лишнее и забыть» мы поборол в первый же день. Дубли в архиве — не мусор. Это история отношений с клиентом: мы прислали такому-то заказчику вот такое КП вот такого числа. Если стереть половину архива, поиск ускорится, а бизнес-контекст исчезнет. Менеджер этого никогда нам не простит, и правильно сделает.

Построили граф: узлы — документы, рёбра — «почти-дубль» с весом. Прогнали connected components. Получили сотни тысяч кластеров, где-то с двумя документами, где-то с несколькими сотнями. Внутри каждого кластера выбрали «канонический» документ — обычно самый ранний или самый полный по длине.

В pgvector попал только каноник — с эмбеддингом и метаданными. Остальные документы кластера остались в отдельной таблице document_aliases со ссылкой на каноник. Когда RAG находит каноник, автономный ИИ-агент видит и алиасы: «этот шаблон использован в нескольких десятках отправок, последняя — на прошлой неделе такому-то клиенту». Для менеджера это ровно то, что нужно: и общий смысл, и конкретные факты.

Что стало по итогу

Индекс похудел заметно — примерно на треть по объёму. Latency поиска с k=20 упал в полтора раза: агенту больше не приходится тащить пять копий одного шаблона и потом фильтровать. Retrieval precision по внутренней разметке подрос ощутимо — как раз за счёт того, что топ-10 перестал быть «шаблон, шаблон, шаблон, шаблон, шаблон».

Отдельно про экономику. Меня часто спрашивают, сколько стоит ИИ-агент под такую задачу — и почти всегда основная часть бюджета уходит не на разработку, а на эмбеддинги и хранение индекса. Разовая индексация обошлась заметно дешевле, но интереснее другое: при каждом добавлении новых документов теперь работает тот же пайплайн. Новичок сначала прогоняется через LSH, и если он попадает в существующий кластер — эмбеддинг не считается вообще, документ просто добавляется как алиас. За первые пару месяцев после запуска в этот режим ушло больше половины входящих файлов. Это уже не разовая экономия, это постоянная.

Что не сработало

Честно попробовали SimHash — компактнее MinHash, работает через хеш-биты. Для длинных документов давал больше ложных срабатываний: две страницы с одинаковыми колонтитулами но разным телом схлопывались в дубли. Для нашего архива, где у большинства документов шаблонная шапка и футер, это оказалось хуже MinHash.

Ещё попробовали дедупликацию на уровне чанков, а не документов. Логика была такая: «в pgvector идут чанки, значит и дедуп нужен на чанках». Проиграло по двум фронтам: во-первых, дороже — единиц сравнения на порядок больше, во-вторых, теряется контекст документа. Кластеризация сверху вниз — сначала документы, потом внутри уникальных документов чанки — оказалась и быстрее, и точнее.

Был ещё соблазн подключить эмбеддинги для похожести — мол, «раз всё равно считаем, давайте на них и кластеризовать». Отказались: слишком дорого именно на этапе, где мы пытаемся сэкономить на эмбеддингах. Замкнутый круг ломается только классическим MinHash.

Итог

Дедупликация перед RAG — это не «удалить мусор», а инженерная задача с двумя противоположными рисками. Схлопнешь слишком агрессивно — потеряешь бизнес-детали, и менеджер перестанет доверять агенту. Оставишь всё как есть — переплатишь за эмбеддинги и получишь замусоренную выдачу, и менеджер тоже перестанет доверять агенту, только по другой причине.

MinHash + LSH с порогом, подобранным на размеченной выборке, плюс канонизация с алиасами вместо удаления — рабочая связка для корпоративных архивов, где шаблонность высокая, а история отправок важна сама по себе. Мы используем её теперь по умолчанию, когда собираем платформу ИИ-агентов для клиентов с большими накопленными базами документов.

Вопрос, который стоит задать до индексации любой большой базы: какая часть вашего архива — это один и тот же документ в десятках костюмах? И готовы ли вы платить за эмбеддинги каждого костюма отдельно?

Похожие статьи

Хотите так же?

Обсудим, как это внедрить у вас

Опишите задачу — подберём ИИ-решение под ваш процесс и бюджет: что сделать агентом, какие нужны данные и интеграции, с чего начать пилот.