- BrainTools - https://www.braintools.ru -
TL;DR — @futur_e_news_bot [1]. Двуязычная (RU/EN) лента новостей. По умолчанию — только хорошие и нейтральные, негатив подключается в настройках на 4 уровнях. ИИ убирает дубли, одно событие = одна карточка с несколькими источниками, перевод на лету, выдача подстраивается под реакции [2]. Внутри: aiogram, локальные эмбеддинги, sqlite-vec вместо pgvector, бесплатные LLM через OpenRouter и одна машина на Fly.io за ~$5/мес. В статье — разбор архитектуры, код, цифры и грабли.
Я устал от трёх вещей одновременно:
Дубли. Одна и та же новость прилетает из пяти каналов с разными заголовками, превратив ленту в эхо-камеру одного события.
Шум. 90% повестки мне не интересны, но чтобы найти свои 10%, надо пролистать всё.
Тяжесть. Лента новостей сегодня — это поток катастроф. Я не хочу полностью отключаться от мира, но и не хочу начинать каждое утро с трёх смертей и одной войны.
Хотелось ленту, которая а) схлопывает дубли в одну карточку с указанием всех изданий, б) сама понимает, что мне заходит (без ручной разметки тегов), в) показывает это на двух языках без копипасты в переводчик и — главное — г) по умолчанию молчит про плохое, но даёт включить тяжёлый контент тумблером, если я к этому готов. Платные агрегаторы есть, но они либо не персонализируются, либо не понимают русский, либо стоят как абонемент в спортзал. Поэтому — выходные, кофе, git init.
Спустя несколько недель в проде бот живёт на одной shared-машине Fly.io, обрабатывает ~1.5k новостей в базе, и обходится примерно в стоимость чашки кофе в месяц. Под капотом — несколько архитектурных решений, которые имеет смысл разобрать отдельно. Этим и займёмся.
🌞 Хорошие новости по умолчанию. Каждая новость на лету оценивается LLM по шкале негатива 0–3 (от «нейтрального» до «тяжёлой трагедии»). У юзера ceiling по дефолту 0 — видит только позитив и технические апдейты. В настройках можно сдвинуть на «+ лёгкий негатив», «+ заметный» или «без фильтра».
Персональная лента. Жмёшь 🔥 / ❤️ / 😢 — бот сдвигает твой «вектор вкуса» и в следующий раз поднимает похожее выше. Никаких ручных тегов: первичные интересы вытягиваются из TG-профиля, дальше всё уточняется по реакциям.
Кластеризация дублей. Если 5 изданий написали об одном событии — увидишь одну карточку с пометкой 📰 5 источников и кнопкой со списком всех ссылок. Сигнал из количества источников учитывается в ранжировании: мультиисточные события естественно поднимаются выше.
Двуязычность. Каждая новость доступна на RU и EN, перевод делает LLM. Язык переключается в один тап.
Форматы доставки. Live-лента, сводка за час, сводка за день, моментальные пуши по «срочному». Всё с тумблерами в настройках, по дефолту включена только дневная сводка (чтобы не спамить).
Свои каналы. Можно добавить любой публичный TG-канал — он подключится через self-hosted RSSHub и попадёт в общий пайплайн обогащения и ранжирования. Есть переключатель «только мои каналы».
Inline-режим. Набираешь @futur_e_news_bot AI в любом чате — и вставляешь свежую новость по теме прямо в переписку.
Управление интересами на естественном языке. Пишешь «больше про космос, меньше про политику» — LLM разбирает и применяет.
Админ-фичи. /stats со срезами по пользователям и категориям, /broadcast с предпросмотром и подтверждением — чтобы не разослать миллиону людей опечатку.
┌─────────────────────────┐ ┌────────────────────┐
│ Fly machine #1 (512 МБ) │ │ Fly machine #2 │
│ │ │ (256 МБ, приватная)│
│ ┌─────────────────────┐ │ │ │
│ │ aiogram long-polling│ │ │ RSSHub │
│ ├─────────────────────┤ │ │ (Telegram → RSS) │
│ │ APScheduler │◄┼────── 6PN ────────────┤ │
│ │ • pipeline (15 мин)│ │ ainews-rsshub. │ │
│ │ • deliver (20 мин) │ │ internal:1200 │ │
│ │ • breaking (1 мин)│ │ │ │
│ │ • daily digest │ │ │ │
│ └─────────────────────┘ │ └────────────────────┘
│ ┌─────────────────────┐ │
│ │ SQLite + sqlite-vec │ │
│ │ (на Fly-volume) │ │
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ fastembed (ONNX) │ │ ── locally, no API
│ └─────────────────────┘ │
└─────────┬───────────────┘
│
│ HTTPS
▼
OpenRouter (LLM chain)
Никакого публичного HTTP, балансировщиков, отдельной БД-машины и Redis. Воркер опрашивает Telegram через long-polling, APScheduler гоняет джобы, всё состояние — в SQLite на томе. RSSHub живёт отдельным приложением и доступен только по внутренней сети Fly (*.internal:1200) — наружу не торчит.
Цена этого:
|
Компонент |
Память [3] |
~$/мес |
|---|---|---|
|
Бот (app + swap) |
512 МБ + 512 МБ |
~$3.2 |
|
RSSHub (приватный) |
256 МБ |
~$1.9 |
|
Том SQLite |
1 ГБ |
~$0.15 |
|
OpenRouter (LLM) |
— |
$0–1 |
|
Итого |
|
~$5–6 |
При текущей нагрузке (десятки активных пользователей) машина простаивает: CPU loadavg около нуля, RAM ~167 МБ из ~459 МБ. Запас до сотен пользователей — без апгрейда.
Дальше — по техническим решениям, которые сделали это возможным.
Изначально была связка Postgres + pgvector. Она прекрасно работает, но требует отдельной машины под БД (минимум +$5/мес на Fly за самый простой инстанс), отдельных секретов, бэкапов, миграций — куча инфры ради того, чтобы хранить эмбеддинги.
В какой-то момент я попробовал sqlite-vec [4] — это нативное расширение SQLite, которое добавляет виртуальные таблицы vec0 с косинусной/L2/Hamming-метриками и KNN-поиском прямо в SQL. По сути — pgvector, только встраиваемый и без сервера.
Создаётся таблица так:
# app/db/vec.py
async def create_table(conn) -> None:
await conn.exec_driver_sql(
f"CREATE VIRTUAL TABLE IF NOT EXISTS story_vec "
f"USING vec0(embedding float[384] distance_metric=cosine)"
)
KNN-запрос — обычный SELECT с MATCH:
async def knn(session, vector, k: int) -> list[tuple[int, float]]:
rows = (await session.execute(
text(
"SELECT rowid, distance FROM story_vec "
"WHERE embedding MATCH :v AND k = :k ORDER BY distance"
),
{"v": sqlite_vec.serialize_float32(list(vector)), "k": k},
)).all()
return [(r[0], r[1]) for r in rows]
Этого хватает для трёх вещей в боте:
Дедуп при инжесте. Для каждой свежей новости делаем KNN, если ближайший сосед ближе порога — это дубль, не сохраняем как новую историю (а прицепляем как дополнительный источник, об этом ниже).
«Похожее». Когда читаешь новость, есть кнопка «ещё про это» — тот же KNN, только результаты не отбрасываем как дубли, а показываем.
Inline-поиск. Запрос пользователя @futur_e_news_bot AI → эмбеддим строку → KNN по базе. Это не поиск по подстроке, это семантический поиск.
Ранжирование персональной ленты делается отдельно — там нужен не KNN, а скоринг каждого кандидата по нескольким признакам (cosine + категория + теги + freshness). Поэтому для ленты — brute-force по последним 600 кандидатам в numpy. При наших объёмах это миллисекунды.
# app/reco/engine.py
_RANK_SCAN = 600
async def _rank(session, user, conds):
stories = (await session.execute(
select(Story).where(*conds).order_by(Story.created_at.desc()).limit(_RANK_SCAN)
)).scalars().all()
tv = np.asarray(user.taste_vec, dtype=np.float32)
tnorm = float(np.linalg.norm(tv)) or 1.0
scored = []
for st in stories:
ev = np.asarray(st.embedding, dtype=np.float32)
denom = (float(np.linalg.norm(ev)) * tnorm) or 1.0
dist = 1.0 - float(np.dot(ev, tv)) / denom
scored.append((st, _score(st, dist, interests, tag_interests)))
scored.sort(key=lambda p: p[1], reverse=True)
return [s for s, _ in scored]
Итог: одна машина вместо двух, нулевая стоимость хранения векторов, нулевая инфраструктурная сложность. Минус: SQLite не умеет concurrent writers, но для одного воркера это не проблема — PRAGMA journal_mode=WAL + PRAGMA busy_timeout=5000 решают всё, что могло возникнуть.
LLM нужен для нескольких вещей: типизация (категория + теги + важность + флаг «срочное» + теперь ещё тональность), краткое содержание (1-2 предложения для карточки), перевод RU↔EN. Это всё гонится одним промптом для каждой свежей новости.
OpenRouter — это маршрутизатор API: один ключ, доступ к десяткам моделей, в т.ч. полностью бесплатным (с rate-limit). Идея: основной поток обработки делаем на бесплатных моделях с фолбэком на дешёвую платную, когда бесплатные отдают 429.
В конфиге это просто список:
openrouter_models = [
"qwen/qwen3-next-80b-a3b-instruct:free",
"meta-llama/llama-3.3-70b-instruct:free",
"mistralai/mistral-nemo", # paid fallback (~$0.15 / M tokens)
]
OpenRouter принимает массив models в запросе и сам пробует по очереди:
payload = {
"models": settings.model_chain[:3], # max 3
"messages": [...],
"temperature": 0.2,
"response_format": {"type": "json_object"},
}
Сверху — глобальный rate-limiter (15 запросов в минуту, общий на все модели) и экспоненциальный backoff с jitter на 429/5xx. По факту бесплатные тянут 90%+ трафика, в платный fallback падает редко. Реальный счёт за месяц: $0-1 (зависит от того, сколько мусора прилетает в брейкинг-канал).
Цена качества тут невысокая: для типизации/перевода новостной заметки 70B-модели хватает с запасом, а если временами free 429 — fallback просто доделает. В UX это не видно.
Эмбеддинги нужны двух типов: для всех новостей (в базу) и для запросов inline-поиска. Ходить за ними во внешнее API — это (а) деньги, (б) латентность, (в) приватность.
fastembed [5] — библиотека от Qdrant, которая запускает sentence-transformers на ONNX без PyTorch. Я взял paraphrase-multilingual-MiniLM-L12-v2 (поддерживает 50+ языков, в т.ч. русский), 384 измерения, mean-pooling. Считает на CPU, ~10-30 мс на текст, прекрасно.
from fastembed import TextEmbedding
_model = TextEmbedding("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
def embed(text: str) -> list[float]:
return next(_model.embed([text])).tolist()
Расход RAM на загрузке: одноразовый спайк до ~300 МБ, потом стабильно ~150-200. На 512 МБ Fly-машине именно из-за этого выделено 512 МБ swap — модель загружается один раз, кеш страниц устаканивается, swap не активируется.
Это самая интересная часть. Хочется, чтобы лента училась без сбора кучи метаданных и без ручной разметки от пользователя.
У каждого юзера есть taste_vec — вектор размерности 384, тот же формат, что и эмбеддинги новостей. Сначала он NULL. При первой реакции 🔥/❤️ — копируется эмбеддинг этой новости. При следующих — обновляется по экспоненциальной скользящей:
# app/reco/engine.py
alpha = 0.6 if kind == "open" else 0.85 # open = link click, сильнее лайка
user.taste_vec = [alpha * o + (1 - alpha) * n for o, n in zip(old, emb)]
«Открыл ссылку» (зарегистрировано на клике из карточки) — это куда более сильный сигнал, чем просто лайк, поэтому alpha ниже и сдвиг вектора больше.
Реакция 😢 двигает вектор от эмбеддинга новости (отрицательное обучение [6]):
user.taste_vec = [o - 0.1 * (n - o) for o, n in zip(old, emb)]
Параллельно ведётся interests: dict[category, weight] и tag_interests: dict[tag, weight] — категории и теги бот определяет той же LLM-обработкой. Это даёт второй сигнал поверх вектора: можно резко поднять «Космос» из-за одного клика, даже если вектор ещё не уехал.
Скоринг — комбинация:
score = (1 − cosine_distance) * w_taste
+ interests[story.category] * w_cat
+ mean(tag_interests[t] for t in story.tags) * w_tag
+ recency_bonus
+ importance_bonus
− duplicate_penalty
Веса подобраны эмпирически, но главная идея: вектор даёт «семантический вкус», а категории/теги — быстрое доменное обновление.
Анти-бабл. Если просто всегда показывать топ-N по скорингу, лента схлопывается в эхо-камеру. У меня есть простой инжект серендипности:
def _inject_discovery(ranked, n):
top = ranked[:n]
tail = ranked[n:]
if random.random() < 0.35: # в трети случаев
if random.random() < 0.25 and len(tail) > 5:
pick = random.choice(tail[len(tail)//2:]) # совсем далёкое
else:
pick = random.choice(tail[:len(tail)//2]) # соседнее
top = top[:-1] + [pick]
return top
Это очень примитивно, но рабоче: ~трети итераций один слот в выдаче занят чем-то новым. В половине этих случаев — слегка соседнее (рядом с интересами), в половине — что-то совсем из тейла. По ощущениям и метрикам реакций — заметно улучшает удержание на длинной дистанции.
Раньше «дубль» при инжесте просто отбрасывался: повторная новость не сохранялась, и факт того, что её осветили ещё 4 издания, терялся. Это плохо по двум причинам: пользователь не видит масштаб, и ранжирование теряет очень важный сигнал.
Сейчас у каждой Story есть таблица story_sources (1 история → N источников). При первом сохранении в неё добавляется «оригинальный» источник. Когда приходит дубль:
# app/pipeline/process.py
near = await vec.knn(session, vector, k=3)
if near and near[0][1] < DEDUP_THRESHOLD:
canonical_id = near[0][0]
await attach_source(canonical_id, raw.source_id) # idempotent
bump_importance(canonical_id, +0.05) # +вес на каждое издание
return # не создаём новую story
В карточке появляется пометка 📰 5 источников (с правильной русской плюрализацией) и кнопка «Источники» — раскрывает callback со списком всех ссылок. В UX это значит «вижу, что событие важное — про него написали все».
Параллельный эффект: importance растёт у мультиисточных новостей, и они естественно поднимаются в ранжировании. Это бесплатный, ничем не подкручиваемый сигнал «реальной значимости» — изданий нельзя обмануть так же, как можно накрутить лайки.
Самая свежая фича, и, кажется, самая важная по UX. Идея простая: я не хочу, чтобы человек, открывший бота в первый раз, получил в лицо войну, катастрофу и три скандала. Если он сам захочет — включит в настройках. Но по умолчанию — только хорошие и нейтральные новости.
Технически это два изменения: классификатор тональности на стороне LLM и per-user ceiling на стороне фильтра выдачи.
В тот же JSON-промпт обработки новости я добавил ещё одно поле — negativity от 0 до 3 с явной рубрикой:
0 — позитивное, нейтральное, технический апдейт, бизнес-новость
(запуски, открытия, достижения, рутинные обновления, спорт-победы, сделки)
1 — слегка негативное (критика, регуляторное давление, лёгкий конфликт,
суды без крупных потерь, падения рынков)
2 — явно негативное (скандалы, увольнения, санкции, аварии без массовых
жертв, геополитическая напряжённость, утечки данных)
3 — тяжёлое (смерти, войны, крупные трагедии, природные катастрофы с
жертвами, теракты, системные провалы)
Дополнительная инструкция: «по умолчанию ставь 0, если у новости нет явно негативного угла; 3 — только когда речь о гибели людей или катастрофе крупного масштаба». Это важно — без рубрики LLM начинает «подкручивать» оценки: «ну тут же критика, давай 1, а тут же увольнения, давай 2». Чёткий список дефолтит большинство новостей в 0, что и нужно.
На выходе клампим в int 0..3:
def _clamp_negativity(value) -> int:
try:
return max(0, min(3, int(value)))
except (TypeError, ValueError):
return 0
У пользователя — поле max_negativity (по дефолту 0). В рекомендательном движке появился shared-helper:
def _negativity_cond(user: User):
"""Hide stories whose tone exceeds the user's ceiling.
Default (max_negativity=0) → only positive/neutral news."""
return Story.negativity <= (user.max_negativity or 0)
И он подмешан во все пути доставки: основная лента (next_unseen), персональный auto-broadcast (rank_for_delivery), брейкинг (pending_breaking). Особенно про брейкинг: я специально провожу фильтр и там — потому что «срочное» в реальном мире обычно негативное, и человек с ceiling=0 не должен получать пуш про катастрофу, даже если у него отдельно включены breaking-alerts. Право на «отвернуться от плохого» — оно сильнее правила «если включил срочное, получишь всё».
inline_search и «похожее» — НЕ фильтруются. Это явные действия пользователя; если человек сам набрал в inline @futur_e_news_bot disaster — логично [7] показать.
Тут была тонкость. Дайджест считается один раз для всех (топ-N за период) — иначе на каждого юзера пришлось бы пересчитывать. Если просто отдать всем одинаковые топ-5, но затем выкинуть негативные — у строгого юзера дайджест может оказаться пустым в день, где в топе много тяжёлого.
Решение: овер-фетчить общий пул в 4 раза больше нужного, потом per-user фильтровать и брать первые limit:
pool = await reco.top_recent(hours=hours, limit=limit * 4)
for user in users:
ceiling = user.max_negativity or 0
stories = [st for st in pool if (st.negativity or 0) <= ceiling][:limit]
if not stories:
continue # nothing acceptable for this user → skip the digest entirely
...
Если даже после фильтра ноль — лучше промолчать, чем прислать огрызок. Принцип «не шлём пустоту» в боте без push-токенов часто важнее, чем «не пропустить день».
В настройках появилась зелёная кнопка 🌞 Тональность: только хорошие. По тапу — подменю с 4 опциями:
🌞 Только хорошие
😐 + лёгкий негатив
⚠️ + заметный негатив
💀 Без фильтра (включая тяжёлое)
Текущий уровень подсвечен зелёным. Выбор сохраняется одним тапом и тут же применяется к следующей выдаче. Никаких подтверждений и модалок — мне это всегда казалось важным: настройка должна меняться так же легко, как мнение.
Колонка negativity появилась после того, как в базе уже лежало ~1500 историй с дефолтным 0. Если оставить как есть — пользователи с ceiling=0 будут видеть среди старых новостей и негативные. Поэтому одноразовый скрипт-классификатор:
# scripts/backfill_negativity.py — упрощённо
sem = asyncio.Semaphore(4) # rate-limiter в _chat всё равно сверху
for chunk in chunks(story_ids, 200):
results = await asyncio.gather(*[_score(sem, sid) for sid in chunk])
# ... commit batch
Бежит через те же бесплатные модели OpenRouter (qwen3-next/llama-3.3) с фолбэком на mistral-nemo. ~1500 новостей × один короткий вызов = ~10 минут, ~$0. После прогона распределение в моей базе вышло примерно: 60% — 0, 22% — 1, 13% — 2, 5% — 3. То есть «по-настоящему тяжёлых» новостей всего около 5% потока, но именно они портят общее впечатление [8] от ленты.
Колонки добавляются в init_db идемпотентным ALTER’ом — SQLAlchemy create_all создаёт новые таблицы, но не добавляет колонки в существующие. Поэтому добавил тонкий хелпер:
async def _ensure_column(conn, table: str, column: str, decl: str) -> None:
cols = await conn.exec_driver_sql(f"PRAGMA table_info({table})")
if any(row[1] == column for row in cols.fetchall()):
return
await conn.exec_driver_sql(f"ALTER TABLE {table} ADD COLUMN {column} {decl}")
И в init_db:
await _ensure_column(conn, "stories", "negativity", "INTEGER NOT NULL DEFAULT 0")
await _ensure_column(conn, "users", "max_negativity", "INTEGER NOT NULL DEFAULT 0")
Никакого Alembic ради двух колонок — пока схема меняется редко, это лишняя инфраструктура.
Деплой укладывается в Dockerfile + один fly.toml. SQLite живёт на /data (Fly-volume), бот стартует, opens DB, поднимает планировщик, начинает поллинг. Никаких сервисов, healthchecks, очередей сообщений.
Но по дороге наловил несколько вещей, о которых стоит знать.
При первом деплое получил весёлое OSError: wrong ELF class: ELFCLASS32. Оказалось, в релизе 0.1.6 для arm64 был выложен 32-битный бинарник. Лечится пином версии:
sqlite-vec==0.1.9
В 0.1.9 уже всё ок. Если попадётесь — это типично выглядит как ошибка [9] загрузки расширения SQLite, и легко списать на свой код, а не на upstream.
Стандартный SQLite-ный апсерт INSERT OR REPLACE INTO ... валится на vec0 с UNIQUE-нарушением. У виртуальных таблиц свой движок, и REPLACE-семантика не поддерживается. Решение — DELETE, потом INSERT:
await session.execute(text("DELETE FROM story_vec WHERE rowid = :id"), {"id": story_id})
await session.execute(
text("INSERT INTO story_vec(rowid, embedding) VALUES (:id, :v)"),
{"id": story_id, "v": sqlite_vec.serialize_float32(list(vector))},
)
Не криминал, но в документации это пока не выделено явно.
В SQLAlchemy async-режиме соединение — это AsyncAdapt_aiosqlite_connection, у которого нет enable_load_extension. Чтобы загрузить sqlite-vec, нужно достучаться до «сырого» sqlite3.Connection через два уровня обёрток:
def load_extension(dbapi_conn):
raw = getattr(dbapi_conn, "driver_connection", dbapi_conn) # aiosqlite.Connection
raw = getattr(raw, "_conn", raw) # sqlite3.Connection
raw.enable_load_extension(True)
raw.load_extension(sqlite_vec.loadable_path())
raw.enable_load_extension(False)
raw.execute("PRAGMA busy_timeout=5000")
И вешается это на событие connect SQLAlchemy движка, чтобы выполнялось ровно один раз на новое соединение. Если делать это в обычном async-методе после engine.begin(), не будет работать на других соединениях из пула.
Между минорными версиями fastembed сменил дефолтный пулинг с CLS на mean. Новые эмбеддинги стали несовместимы со старыми, хранившимися в базе. KNN внезапно начал возвращать мусор.
Лечится только одним способом: переэмбедить всю базу новой версией + пересчитать taste_vec всех пользователей как среднее эмбеддингов лайкнутых ими новостей. У меня был одноразовый скрипт reembed.py, который прогнал базу за минуту. Если у вас стоит fastembed в проде — закрепляйте версию явно и проверяйте changelog между апгрейдами.
У современных Telegram-аккаунтов user_id уже превышает int32 (~2.1 млрд). У меня сначала колонка была Integer, потом упало с DataError: out of int32 range. Меняем на BigInteger и больше не вспоминаем.
Очевидно, но всё равно ловил: Telegram пускает только один getUpdates consumer. Если бот запущен локально и параллельно на Fly — оба получают 409 Conflict пачкой в логи. Перед деплоем docker compose down. Перед локальной отладкой — fly machine stop.
Иногда fly deploy падает с deadline_exceeded или handshake EOF при попытке использовать Depot. Решение: fly deploy --local-only --depot=false — собрать образ локально и запушить в Fly registry напрямую. Делает деплой более предсказуемым, особенно если у вас arm64-Mac.
Однажды машина бота оказалась в state: stopped — не разбилась, не упала по OOM, а просто остановилась (видимо, после ручного fly machine stop в прошлой сессии). И никаких хелсчеков нет → автоматического подъёма тоже нет. Урок: или добавлять [checks] секцию в fly.toml с HTTP/TCP-проверкой, или мониторить через внешний uptime-сервис. Я пока что мониторю через свой же бот (/stats показывает, когда был последний пайплайн).
Первая итерация классификатора тональности промптила просто "negativity": 0..3 (how negative this news is). И LLM послушно начинала растягивать шкалу: «ну тут же есть критика — давай поставлю 1». В итоге половина новостей оказывалась негативной даже визуально нейтральная. Лечится явной рубрикой с примерами и инструкцией «по умолчанию 0, 3 — только при жертвах/катастрофе». Распределение тут же выправилось.
Следующие фичи в очереди:
Share-кнопка на каждой карточке через switch_inline_query — самый дешёвый виральный цикл.
Deeplinks на конкретную историю: t.me/futur_e_news_bot?start=story_<id> — чтобы расшаренная ссылка открывала именно эту новость.
Showcase-канал с автопостингом топ-5 за день — публичная витрина и попадание в поиск Telegram.
TTS-аудио для дневной сводки через OpenAI/ElevenLabs — слушать в пробке.
Source weighting в taste_vec: если ты лайкаешь Habr и дизлайкаешь Lenta — Habr должен ранжироваться выше для тебя, не для всех.
Collaborative filtering: «что читают похожие на тебя пользователи» — раз в неделю, поверх taste_vec.
Если зайдёт пост — соберу отдельную статью про рекомендательное ядро (где разберу веса в формуле скоринга, какие сигналы реально влияют на retention и почему собственное velocity-управление вектором работает лучше, чем готовые библиотеки для маленьких ботов).
Бот живой: @futur_e_news_bot [10] — жмёшь /start, выбираешь язык, и через пару тапов лента уже подстраивается. По дефолту включена только дневная сводка (live-лента — выключена, чтобы не спамить), а тональность стоит на «только хорошие». Всё переключается тумблерами в настройках за один тап.
Очень жду фидбэка по трём вещам: качество классификации тональности (заметна ли разница на разных уровнях, не ловит ли система «ложных негативов» — нейтральную новость как мрачную), качество кластеризации (часто ли видны несхлопнувшиеся дубли) и точность ленты на 1-2 неделе (когда taste_vec уже устаканивается).
Если интересно глубже копнуть конкретный кусок (sqlite-vec, рекомендательное ядро, классификатор тональности, OpenRouter chain) — пишите в комменты, отдельным постом разберу.
Автор: RaZe-31cs
Источник [11]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/31166
URLs in this post:
[1] @futur_e_news_bot: https://t.me/futur_e_news_bot?start=habr
[2] реакции: http://www.braintools.ru/article/1549
[3] Память: http://www.braintools.ru/article/4140
[4] sqlite-vec: https://github.com/asg017/sqlite-vec
[5] fastembed: https://github.com/qdrant/fastembed
[6] обучение: http://www.braintools.ru/article/5125
[7] логично: http://www.braintools.ru/article/7640
[8] впечатление: http://www.braintools.ru/article/2012
[9] ошибка: http://www.braintools.ru/article/4192
[10] @futur_e_news_bot: https://t.me/futur_e_news_bot
[11] Источник: https://habr.com/ru/articles/1042690/?utm_campaign=1042690&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.