RAG-дедупликация в MAX: как одно событие публикуется только один раз даже из 10 источников

Как мы построили дедупликацию контента поверх Qdrant с pushdown-фильтрами по published_at и is_processed: разбор архитектуры, цифры с прода (33583 точки бэкфилла), кейс РедРока, где фильтр Bitcoin цена выдавал 0 свежих постов, и LLM-судья на пограничных значениях cosine similarity.

В апреле 2026 у нас сломался фильтр источников в FROM_SOURCES-генерации. Юзер собирал проект из 30 крипто-каналов в Telegram, ставил фильтр "Bitcoin цена" и получал ошибку NotEnoughSourcesError: Постов после фильтра: 0. При этом в Qdrant по тому же проекту лежало 3255 проиндексированных постов, top-100 по cosine-similarity возвращал 100 совпадений. Но в SQL-окне (последние семь дней, is_processed=False, enriched) пересечение давало пустое множество. Этот пост — про то, что мы делаем со схожестью векторов, как устроен retrieval-слой над Qdrant в Content Pilot и почему наивная схема "сначала ANN, потом SQL-фильтр" не масштабируется на проекты, которые живут больше пары месяцев.

Зачем нам вообще RAG в системе генерации постов

Content Pilot — SaaS, который собирает посты из подключённых источников (Telegram, VK, RSS), переписывает их через LLM с заданным стилем и публикует в каналы юзера: Telegram, VK, MAX. Основной режим — FROM_SOURCES rewrite=True. Из топа сегодняшних постов выбирается лучший, прогоняется через Claude или Gemini, обогащается обложкой и уходит в очередь публикации.

Здесь и возникает первая проблема. Проект юзера крутится месяцами. За это время каждый источник раз в час подкидывает по несколько постов, а агрегаторы крипто-новостей или политических каналов в часы пиков выдают тот же сюжет в десяти формулировках. Если не вычищать дубли, через две недели юзер получит свой же тейк про "новый all-time high биткоина" пятый раз подряд, просто переписанный другими словами. А подписчик отпишется.

Вторая проблема — продолжение сюжета. Если вчера мы написали "ФРС оставила ставку 5.25%", а сегодня вышла новость "ФРС намекает на снижение в декабре" — это не дубль. Это развитие темы, и публиковать надо. Простой cosine threshold между этими двумя новостями выдаст ~0.85 — выше любого разумного порога. Чисто косинусной похожести недостаточно: нужна семантика "одно и то же событие" против "следующий шаг события".

Третья проблема — производительность. Юзер жмёт "сгенерировать пост", и ему нужен ответ в тридцать секунд. Если для каждого кандидата мы будем делать k-NN по миллиону точек без фильтров, а потом ещё SQL-запрос по всему пулу неотыгранных постов и пересечение в Python — мы упрёмся в латентность раньше, чем доберёмся до LLM.

Топология коллекций

В Qdrant у нас три типа коллекций. Каждый проект юзера получает изолированное пространство — это упрощает удаление проектов, изоляцию данных между тенантами и ускоряет ANN, потому что HNSW-граф меньше.

  • articles — сгенерированные и опубликованные статьи. Эмбеддинги полного текста после рерайта. Используются для дедупа на уровне выхода: не публиковать вторую статью, семантически близкую к уже опубликованной.
  • source_posts — спарсенные посты из подключённых источников. Сюда летит каждый SourcePost сразу после нормализации текста. Используются на этапе кандидат-отбора.
  • embeddings (search) — отдельная коллекция под семантический поиск (source_filter). Сюда же индексируется текст source_post, но с другой моделью эмбеддинга — оптимизированной под short-form ретривал, не под кластеризацию похожести.

Структура коллекций — стандартный distance: Cosine, dim соответствует выбранной модели эмбеддинга. Payload в каждой точке содержит post_id (FK к Postgres), source_id, published_at_ts (unix int), is_processed (bool) и опционально external_post_id для трассировки.

Что делает фильтр кандидатов

Когда юзер запускает генерацию по расписанию или вручную, article_generation вызывает post_fetcher, который должен вернуть пять кандидатов — отсортированных по охватам в источнике, не старше семи дней (MAX_POST_AGE_DAYS=7), не использованных ранее (is_processed=False), и опционально соответствующих source_filter — это пользовательский запрос вроде "Bitcoin цена" или "налоги для самозанятых".

Кандидаты прогоняются через цикл:

  1. Берётся следующий по охвату пост.
  2. Эмбеддинг его текста сравнивается с коллекцией articles того же проекта (cosine threshold).
  3. Если нашлась статья с похожестью выше порога — пост отбрасывается, переходим к следующему.
  4. Если все пять кандидатов отброшены — генерация падает с ошибкой "источник слишком бедный для качественной статьи". Юзер видит это сообщение и понимает, что либо надо добавить ещё источников, либо подождать пока появится что-то новое.

Порог — cosine similarity = 0.75. Это значение мы откалибровали эмпирически: на 0.85 мимо проходило слишком много ленивых рерайтов того же события (LLM меняет три слова, эмбеддинг почти не сдвигается), на 0.65 ложноположительно отбрасывались статьи на смежные темы (две разные новости про крипту в один день — это нормально). 0.75 даёт компромисс, при котором ручная модерация в нашей внутренней выборке расходилась с автоматическим вердиктом примерно в 8% случаев. Дальше я расскажу, что мы делаем с этими 8%.

Почему наивная схема развалилась

Изначальный фильтр был написан в лоб: VectorStore.search_similar(query_embedding, limit=100) возвращал top-100 ближайших точек по cosine, а потом отдельный SQL-запрос забирал из Postgres все SourcePost, где created_at > now() - 7d AND is_processed = false AND project_id = ?. Затем питон-код пересекал два множества по post_id.

Эта схема работала, пока проект был молодой. Через пару месяцев картина менялась радикально. Возьмём реальные цифры с РедРока, нашего prod-клиента в крипто-нише. На 20 апреля 2026 в Qdrant по проекту лежало 3255 точек. В семидневном SQL-окне с непроцессенным флагом — 227. Это 7%.

Что это означает для top-100 ANN-поиска: Qdrant находит сто ближайших точек по семантике запроса, и почти все они — старые посты, которые когда-то были релевантны "Bitcoin цена", но уже отыграны или вышли за окно. Пересечение с свежим SQL-окном даёт ноль совпадений. Поднимать limit до 500 или 1000 не помогает: чем дольше живёт проект, тем хуже соотношение свежих точек к старым, и в какой-то момент даже limit=2000 не вытащит ни одной свежей точки в верхней части ANN-выдачи.

Это контринтуитивно: казалось бы, чем больше данных — тем точнее retrieval. Здесь ровно наоборот. Качество фильтра деградирует с возрастом проекта.

Pushdown в Qdrant

Решение — фильтровать на стороне Qdrant до того, как считается top-K. У Qdrant есть Filter с must, must_not, should и условиями типа range, match, geo. Если payload-индексы построены, фильтрация дешёвая.

Что мы поменяли:

VectorStore.search_similar(
    query_embedding,
    payload_filter=Filter(
        must=[
            FieldCondition(
                key="published_at_ts",
                range=Range(gte=int(seven_days_ago.timestamp())),
            ),
        ],
        must_not=[
            FieldCondition(
                key="is_processed",
                match=MatchValue(value=True),
            ),
        ],
    ),
    limit=200,
)

Сигнатура search_similar получила новый параметр payload_filter: Filter с приоритетом над старым dict-API. Внутри метода вызывается VectorStore._ensure_payload_indexes, который автоматически создаёт payload-индексы:

  • published_at_ts — integer (unix timestamp, в секундах)
  • is_processed — bool
  • source_id — keyword (на случай дальнейшего sharding по источнику)

Индексы создаются идемпотентно при первом обращении к коллекции. Если коллекция новая или индексы уже есть — операция бесплатная. Если коллекция старая и индексов нет, Qdrant строит их в фоне; первый запрос может быть медленным, дальше — миллисекунды.

Индексация на входе

Pushdown работает только если в payload точек реально лежат нужные поля. Мы прошлись по всем местам, где пишется в Qdrant, и добавили запись published_at_ts и is_processed=False при upsert:

  • hybrid_post_enricher — основной путь, через который посты попадают в индекс после парсинга
  • telegram_parser — Telethon-userbot, парсящий каналы
  • vk_parser — VK API community wall

RSS-парсер пишет туда же через общий VectorStore.upsert_post() — отдельных правок не потребовалось. Time-stamp пишется как int(published_at.timestamp()) — Qdrant range-фильтр работает только с числовыми типами, datetime-объекты он не понимает.

Синхронизация is_processed

Когда пост попадает в генерацию и используется как материал для статьи, в Postgres у него выставляется is_processed=True. Если эту синхронизацию пропустить, Qdrant продолжит выдавать его как кандидата следующие семь дней.

Мы добавили sync-метод VectorStore.set_payload_by_post_id(post_id, {"is_processed": True}), который вызывается сразу после SQL-апдейта. Реализация — best-effort: если Qdrant недоступен, ошибка проглатывается и логируется, но SQL-транзакция не откатывается. Логика: лучше один раз показать юзеру повторный кандидат, чем потерять факт обработки в основной БД. SQL-интерсекция в любом случае остаётся как защита второго уровня.

Эта часть пайплайна нестабильна, когда Qdrant в этот момент рестартится — между SQL-коммитом и Qdrant-апдейтом может быть гонка. На практике мы наблюдали такое два раза за месяц при деплоях llm-service, оба раза выглядело как один лишний кандидат, который дедупер потом всё равно отбросил по cosine-similarity. Не критично, но при желании можно вытащить через outbox-паттерн с реплеем по unprocessed-журналу.

Бэкфилл существующих коллекций

Код, который пишет правильный payload, помогает только новым точкам. Старые 33000+ точек на проде не имели ни published_at_ts, ни is_processed. Без миграции pushdown-фильтр на старых проектах продолжал бы возвращать ноль свежих кандидатов — он бы просто отсёк все точки без поля, что хуже, чем выдать всё подряд.

Мы написали scripts/backfill_qdrant_payload.py. Логика прямая:

  1. Перечислить все project-коллекции в Qdrant.
  2. Для каждой делать scroll с пагинацией.
  3. По post_id в payload запросить из Postgres published_at и is_processed.
  4. Через set_payload записать обновлённые поля.

Скрипт идемпотентный — повторный запуск перезатирает тем же значением. Поддерживает фильтр по project_uuid для точечного бэкфилла.

Прогон на проде:

МетрикаЗначение
Обработано точек33583
Project-коллекций35
Orphaned (post_id отсутствует в SQL)0
Skipped (битый payload)0

Скрипт лежит в репозитории llm-services/scripts/, но в Docker-образ не копируется (мы стараемся не пихать одноразовые миграции в production-image). Запуск — через docker cp в работающий контейнер плюс docker exec python backfill_qdrant_payload.py. На проде с СУБД и Qdrant в одной приватной сети занял около двадцати минут.

Что показал РедРок

Проект РедРок — крипто-канал, 30+ источников, ~3000 постов в индексе. До фикса фильтр "Bitcoin цена" возвращал 0 свежих необработанных постов и стабильно валил FROM_SOURCES с NotEnoughSourcesError. Юзер несколько раз писал в поддержку. После выкатки фикса и бэкфилла тот же запрос вернул 123 свежих необработанных поста, ранжированных по семантической близости, и генерация прошла с первой попытки.

Здесь интересный момент: до фикса не помогало даже расширить top-K до 1000. После фикса limit=200 более чем достаточен для любого realistic-запроса. Это потому что в pushdown-схеме Qdrant сначала отсекает по фильтру, потом считает k-NN на отфильтрованном множестве. Top-200 берётся из 227 актуальных точек, а не из 3255 всех.

LLM-судья на пограничных кейсах

Cosine 0.75 — компромисс. Зона неопределённости — примерно 0.55..0.99. Ниже 0.55 — точно разные темы, можно публиковать без вопросов. Выше 0.99 — очевидный дубль, такое редко проходит дальше регэксп-чистки источника, но если прошло — режем без обсуждения.

Между этими порогами у нас работает LLM-судья. Это отдельный шаг в пайплайне. Когда cosine-similarity между кандидатом и существующей статьёй попадает в зону AUTO_PASS=0.55 .. AUTO_SKIP=0.99, мы вызываем Claude Haiku 4.5 с минимальным промптом: тексты обоих постов, просим вернуть один из трёх вердиктов:

  • DUPLICATE — то же событие, тот же угол, тот же объём фактуры. Отбрасываем.
  • DEVELOPMENT — продолжение сюжета. Та же тема, но новая информация (новые факты, реакция рынка, заявления участников). Публикуем.
  • DIFFERENT — формально близко по эмбеддингу, но по сути разное (общая тема "крипта", но разные события). Публикуем.

Haiku 4.5 — самая дешёвая из современных моделей у Anthropic, и для бинарной-троичной классификации она избыточно умная. Стоимость одного вердикта — порядка десятых долей рубля. Но эта стоимость накапливается: на проекте, где в день генерится 30 статей и в среднем два кандидата попадают в зону неопределённости, это уже минута-две работы LLM-судьи в сутки.

Кеш — Valkey с TTL=1ч на пару (post_a_id, post_b_id). Если за час тот же кандидат сравнивается с той же статьёй (например, при ручном повторе генерации или при гонке шедулеров), второй раз LLM не дёргается. Час выбран эмпирически — за это время свежесть пары не меняется существенно, а большая часть повторов происходит в первые минуты.

Зона нестабильности судьи — короткие посты длиной до 200 символов. На длинных постах вердикты Haiku сходятся с ручной проверкой почти всегда. На анонсах из RSS-фидов на 50 символов модель часто выдаёт DEVELOPMENT там, где правильнее было бы DUPLICATE, потому что в коротком тексте нет ничего, что можно интерпретировать как "новая информация". Мы это знаем и в backlog держим задачу обогащать source-text через trafilatura до подачи в судью — но пока не выкатили.

Что осталось сложным

Несколько мест, где система ведёт себя неидеально и где честно стоит признать ограничения.

Первое — per-project коллекции и cold-start. Когда юзер создаёт новый проект, коллекция пустая. Cosine-фильтр на пустой коллекции тривиально проходит — не с чем сравнивать. Первые два-три поста проекта могут быть взаимно близкими, но дедуп их не отловит просто потому, что между ними нет накопленного индекса. Можно было бы делать кросс-проектный дедуп, но это ломает изоляцию тенантов и в нашей продуктовой модели не оправдано.

Второе — смена модели эмбеддинга. Если мы решим заменить модель (например, перейти с одной OpenRouter-модели на другую с другой размерностью), все 33000+ точек придётся переиндексировать. Это не новость, любой векторный стор так работает, но означает несколько часов downtime для дедупа. План — иметь две коллекции на проект во время миграции, писать в обе, переключать чтение атомарно.

Третье — LLM-судья и долгие сюжеты. Если событие тянется неделями (выборы, война, регуляторное расследование), все посты по нему попадают в DEVELOPMENT, и юзер получает 50 постов о том же сюжете с разных углов. Технически это работает по спецификации — каждый пост приносит новую информацию — но продуктово начинает раздражать. Здесь нужен более высокоуровневый сигнал "о, мы про это уже писали 12 раз за неделю, давай сменим тему", и это будет уже отдельный модуль над дедупом, не часть его.

Четвёртое — bool в payload. Qdrant индексирует is_processed как keyword по факту (true/false как два значения), что неэффективно с точки зрения распределения. На наших объёмах это не проблема, но если коллекция вырастет до миллионов точек — стоит подумать о переходе на разделение коллекций (processed vs unprocessed) или partitioning по source_id.

Когда наша схема не подходит

Эта архитектура заточена под конкретный контекст — SaaS с многотенантной моделью, проектами на десятки тысяч постов, latency budget около десяти секунд на retrieval, и продуктовой допустимостью эпизодических ложных срабатываний дедупа. Если у вас другие вводные, ставка на Qdrant с pushdown-фильтрами может оказаться лишней.

Для одного канала с тысячей постов в индексе можно обойтись Postgres + pgvector — никакого отдельного сервиса, простая схема. Для миллиардов точек и sub-second SLA Qdrant хватит, но придётся серьёзно работать с шардированием, отдельными HNSW-параметрами на коллекцию и вероятно отдельной моделью эмбеддинга на класс контента. Для batch-сценариев, где можно позволить себе минутные задержки, вообще не нужен ANN — точное cosine-сходство по полному пулу за минуту считается без всякого Qdrant.

В нашем случае Qdrant с pushdown-payload-фильтрами и Haiku-судьёй на пограничных значениях оказался устойчивым решением, которое не деградирует с возрастом проекта и которое мы можем поддерживать командой из нескольких человек. Если столкнётесь с похожей задачей — три рекомендации, выводы из нашего опыта.

Не полагайтесь на ANN top-K + SQL-intersect. Эта схема выглядит чистой архитектурно, но ломается на длинном горизонте. Pushdown в стор всегда даст более стабильный результат.

Считайте payload-индексы такой же частью схемы, как индексы в Postgres. Их нужно создавать сознательно при создании коллекции, а не надеяться, что range-фильтр будет работать на raw payload — на больших коллекциях это означает full-scan.

Не пытайтесь натянуть LLM-судью на весь поток. Это экономически невыгодно, добавляет 1-2 секунды к каждой генерации и уязвимо к промпт-инъекциям из source-каналов. Используйте его как fallback в узкой зоне cosine-неопределённости, ловите вердикты в кеш.

Подробнее про устройство всей системы генерации можно почитать в материалах про кросспост из Telegram в MAX/VK и про авто-извлечение стиля из эталонных постов — оба этих модуля живут рядом с дедупом и используют тот же Qdrant.

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