В апреле 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 цена" или "налоги для самозанятых".
Кандидаты прогоняются через цикл:
- Берётся следующий по охвату пост.
- Эмбеддинг его текста сравнивается с коллекцией
articlesтого же проекта (cosine threshold). - Если нашлась статья с похожестью выше порога — пост отбрасывается, переходим к следующему.
- Если все пять кандидатов отброшены — генерация падает с ошибкой "источник слишком бедный для качественной статьи". Юзер видит это сообщение и понимает, что либо надо добавить ещё источников, либо подождать пока появится что-то новое.
Порог — 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— boolsource_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. Логика прямая:
- Перечислить все project-коллекции в Qdrant.
- Для каждой делать
scrollс пагинацией. - По
post_idв payload запросить из Postgrespublished_atиis_processed. - Через
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.