情報が多すぎて読む前に疲れるので、AIで整理するRSSアプリ「Sifto」を作った

技術

はじめに

エンジニアをやっていると、情報収集は仕事の一部というより、ほとんど生活習慣になる。

技術ブログ、企業の発表、OSSのリリースノート、ニュースサイト、個人ブログ。
RSSリーダーを開けば、新しい記事はいくらでも流れてくる。新しいものを追いかけるのは嫌いではないし、むしろ好きなほうだ。知らない技術や、新しい考え方や、誰かの設計の癖に触れるのは、この仕事の楽しさの一つでもある。

ただ、しばらくそうやって情報を取り続けていると、だんだん別の種類の疲れが溜まってくる。

記事を読むことに疲れるのではない。
何を読むかを判断することに疲れる。

RSSリーダーは便利だ。
だが、便利すぎる。

フィードを追加すればするほど流入量は増え、未読件数は簡単に数百件を超える。忙しい日が続けば、あっという間に手がつけられない量になる。未読の数字を見るだけでげんなりして、そのまま閉じる。
「後で読む」に入れた記事は安心材料にはなるが、現実にはかなりの割合でそのまま沈む。

つまり、情報を集める仕組みはある。
でも、それを消化する仕組みが弱い。

一方で、最近はAIを使った要約サービスもかなり増えてきた。
記事を短くまとめてくれるのは確かに便利だし、情報量が多すぎる現代では理にかなっているようにも見える。

けれど、実際に使ってみると今度は別の不満が出る。

要約だけを読んでいると、その記事が本当に重要なのかどうかが分からない。
要約は読める。だが、原文に戻るべき記事なのか、そこで止めてよい記事なのか、その判断材料が薄い。AIが「こういう記事です」と言ってくれても、「で、これは自分がちゃんと読む価値があるのか?」という問いには、案外答えてくれない。

RSSリーダーは、情報を集めることには強い。
AI要約アプリは、情報を短くすることには強い。

でもその間にある、

大量の情報の中から、今日ちゃんと読むべきものを見つける

という行為は、意外と放置されている。

その隙間を埋めるものが欲しくて、僕は Sifto というアプリを作った。

Siftoは何をするアプリなのか

Siftoは、登録したRSSフィードや単発URLを継続的に収集し、本文抽出、事実抽出、要約、スコアリングを行って、Digestメールやブリーフィング画面で消化しやすく見せるパーソナル情報収集サービスだ。

ここでわざわざ「RSSリーダー」ではなく「情報収集サービス」と書いているのには理由がある。
Siftoは記事を一覧で並べるだけのアプリではないし、逆にAI要約をひたすら生成するだけのアプリでもない。

僕がやりたかったのはもっと中間にある。

大量に流れ込んでくる情報を、人が判断しやすい形に前処理すること。
Siftoの中心にあるのはこの一点だ。

ユーザーはRSSフィード、単発URL、あるいはOPMLやInoreader連携を通じてソースを登録する。RSSは10分ごとに自動取得され、新規記事が収集される。収集された記事はそこで終わらず、非同期で三段階の処理を通る。

まず本文抽出。
次に事実抽出。
最後に要約とスコアリング。

その結果は、記事一覧、インラインリーダー、クイックトリアージ、トピックパルス、ブリーフィング、Digestメールといった複数の導線で使われる。

この構造を一言で言えば、Siftoは

情報の入口を広く持ちつつ、出口を「今日読む価値があるものを短時間で把握できる形」に寄せるアプリ

だ。

つまり、情報収集のツールではあるけれど、本当に最適化したかったのは「収集」ではなく、その後の

選別、理解、優先順位付け、再訪

のほうだった。

なぜ要約の前に「事実抽出」を入れたのか

Siftoを作る上で、一番大きな設計判断はここだった。

普通に考えるなら、記事本文をAIに投げて、そのまま要約してもらえばいい。
実際、世の中のAI要約ツールの多くはそういう構造になっている。

つまり、

記事本文 → 要約

という一段構成だ。

最初は僕も、その方向から入った。
実装としても単純だし、動くものを作るだけならそれで十分に見える。

でも実際に試していくと、この構造は思ったより扱いにくかった。

まず、要約の品質がモデルに強く依存する。
同じ記事を投げても、モデルによってかなり書きぶりが変わるし、同じモデルでも温度やプロンプト次第でニュアンスがぶれる。
もちろんそれ自体は生成AIの性質なので驚くことではないのだけれど、情報整理アプリとして見ると、この揺れはちょっと困る。

さらに問題だったのは、要約だけをUIに出すと、その要約の根拠が見えないことだ。
AIが「こういう内容です」と書いても、それが本文中のどの事実に基づいているのかが見えない。ユーザー側からすると、便利ではあるが、少しブラックボックス感がある。

そこで僕は、要約の前に事実抽出を入れることにした。

処理の流れはこうなる。

記事本文 → 事実抽出 → 要約生成

事実抽出では、記事本文の中から客観的なポイントを短く取り出す。
たとえば「新製品を発表した」「来月発売予定」「新しいAPIが追加された」といった、本文に明示されている事実を列挙する。

その事実リストを元にして、最後に要約を生成する。

この構造にしたことで、いくつか大きな利点が出た。

第一に、要約が安定しやすくなった。
本文の全体をそのまま相手にするより、ある程度構造化された事実の集合から要約するほうが、モデルの出力が暴れにくい。

第二に、UIで「要約」と「事実」を分けて見せられるようになった。
これはかなり大きい。Siftoでは記事詳細やインラインリーダーの文脈で、要約だけでなく事実も確認できる。要するに、AIの結論だけでなく、その手前の材料も見せられる。

第三に、再処理しやすい。
モデルは今後も変わる。半年後にはもっと良い要約モデルが出ているかもしれない。そのとき、事実抽出結果が残っていれば、本文からすべてやり直さなくても要約だけ再生成できる。

この発想は、Sifto全体にかなり効いている。
AIを一発で全部やらせるのではなく、中間成果物を残しながらパイプライン化する。
これをやると、運用もUIもだいぶ筋が良くなる。

Siftoの体験は「読む」ではなく「判断する」ことを最適化している

SiftoのUIを見てもらうと分かるが、機能の切り方が一般的なRSSリーダーと少し違う。

ホームにはブリーフィングがある。
記事一覧とは別に、クイックトリアージがある。
記事詳細はインラインリーダーでオーバーレイ表示できる。
トピックパルスでは、トピックの人気推移をヒートマップで見られる。
Digestは毎朝まとまって届く。

これらは、ただ「便利そうだから積んだ」というものではない。
全部、情報を読む前の判断コストを減らすために置いている。

ブリーフィングは、直近24時間のハイライトやストリークを見せることで、「いま何が大事そうか」を短時間で把握するための画面だ。
クイックトリアージは、記事をきちんと読む前に「読む・あとで読む・スキップ」を高速に決めるための機能だ。
トピックパルスは、個別記事ではなく話題の流れそのものを見るための機能だ。
Digestメールは、その日の終わりや翌朝に「前日に何があったか」を再整理して届けるためのものだ。

つまりSiftoは、読みやすい記事ビューを作ること以上に、読む前と読んだ後の導線を強く意識している。

インプットがしんどくなる理由は、文章を読むのが遅いからではない。
ほとんどの場合は、読む対象を決めるのに疲れるからだ。
僕はそこを減らしたかった。

システムアーキテクチャ

ここからは実装の話に入る。

Siftoはモノレポ構成で、web、api、worker、db/migrations を一つのリポジトリに持っている。

全体像としては、ブラウザやPWAからNext.jsのフロントエンドにアクセスし、認証済みリクエストはGo APIに届く。Go APIはDBやキャッシュにアクセスしつつ、必要に応じてPython Workerに重い処理を委譲する。
その一方で、RSS取得や記事処理、Digest生成、ブリーフィングスナップショット生成といった非同期処理はInngest Cloudを司令塔として動かしている。

フロントエンドは Next.js 16 + React 19 + Tailwind CSS v4、デプロイ先は Vercel。
APIサーバーは Go 1.24 + chi、デプロイ先は Fly.io。
本文抽出とLLM処理は Python FastAPI + trafilatura、これも Fly.io。
データベースは PostgreSQL、キャッシュはローカルではRedis、本番では Upstash Redis。
非同期ジョブとcronは Inngest Cloud。
認証は Clerk、メール送信は Resend、Push通知は OneSignal、監視は Sentry だ。

こう並べると、いかにも今っぽいSaaSの寄せ集めに見えるかもしれない。
でも実際に重要なのは、サービス名ではなく責務の分離のされ方だ。

Siftoでは最初から、ユーザーが待つべきでない処理をUIの世界から追い出している。
本文抽出も、要約も、Digest本文生成も、全部重い。遅い。失敗する。外部APIにも依存する。
それをHTTPリクエストの中でやると、UXは簡単に崩れる。

だからアーキテクチャの中心にあるのは、「AIをどう使うか」ではなく、

AIをどこで使わないか、どこまで同期に持ち込まないか

という判断だった。

Webフロントエンド

WebはNext.jsで実装している。
構造を見ると、(main) 配下にブリーフィング、トリアージ、パルス、items、sources、digests、settings、llm-usage、debug があり、機能ごとにかなり明確に分かれている。

ここで意識しているのは、UIを単なる「記事の一覧画面」に寄せすぎないことだ。

たとえば triage/ が独立した画面としてあるのは、記事を読むことより、記事を素早く仕分けることを一つの機能として扱いたかったからだ。
inline-reader.tsx も同じで、一覧やトリアージの文脈を壊さずに、記事の要約・事実・原文に入れる。
pulse/ は、個別記事ではなくトピックの盛り上がり方を見るための画面だ。
llm-usage/ が画面として存在するのも、AIの使用量やコストを単なる裏側の数字ではなく、ユーザー体験の一部として扱いたかったからだ。

日本語・英語の2言語対応を I18nProvider と辞書で持ち、PWAインストール促進も入れている。
これも、日常的に使うアプリとして成立させたかったからだ。

Go APIの役割

APIはGoで実装している。
ルーターはchi。
内部は handler、repository、service、middleware、inngest などに分かれている。

この構造自体は王道だが、Siftoではかなり理にかなっている。
というのも、Go APIの役目は「重いことを頑張ること」ではなく、アプリケーションの境界として振る舞うことだからだ。

APIは、記事一覧、ソース管理、トピック、ブリーフィング、Digest、LLM使用量、設定などを扱う。
さらにClerk Bearer token認証を受け、内部エンドポイントには X-Internal-Secret を要求する。
Digestの手動生成や送信、embeddingのバックフィル、タイトル翻訳のバックフィル、Push通知テストなど、運用系の入口も持っている。

ここで重要なのは、Go APIが「全部を背負い込む巨大サーバー」になっていないことだ。
LLM処理や本文抽出は自分でやらず、Python WorkerへHTTPで処理を投げる。
つまりGo APIは、データと認証とアプリケーションロジックの境界を担うレイヤーに徹している。

この切り方にしたのは、AIアプリで一番避けたかったのが、APIサーバーの中でそのままLLMを叩いて、HTTPレスポンス時間とAIの気分を直結させることだったからだ。

Python Workerの役割

WorkerはFastAPIで実装していて、Siftoの情報加工エンジンそのものになっている。

ルーターを見ると、本文抽出、事実抽出、要約・スコアリング、タイトル翻訳、Digestメール、クラスタドラフト生成、フィード推薦、フィードシード提案まで、かなり多くの責務を持っている。
要するに「AI処理を置く場所」ではなく、情報を整形・再編集する専門プロセスとして設計している。

本文抽出には trafilatura を使っている。
これはかなり大事だ。記事ページのHTMLは、本文よりノイズの方が多いことすらある。広告、ヘッダ、関連記事、コメント欄、フッターをそのままLLMに食わせると、要約の質以前に入力が壊れる。
Siftoがまず本文抽出を独立ステップとして持ち、それをPython Workerに任せているのは、AI以前に前処理をちゃんと整備したかったからでもある。

さらにWorkerには claude_service.py、gemini_service.py、model_router.py がある。
ここでやりたかったのは、単一ベンダー固定ではなく、モデル名ベースでClaudeとGeminiを振り分ける抽象化を持つことだった。

この時点で、ただAI APIを呼んでいるだけのアプリとはかなり違う。
後からモデルの切り替えや用途別の最適化がしやすい構造にしてある。

記事処理パイプライン

Siftoの中心は、やはり記事処理パイプラインにある。

まずユーザーがソースを登録する。RSS URLでも、サイトURLでもよく、フィード自動検出にも対応している。OPMLインポートやInoreader連携もある。
次に、Inngestのcronが10分ごとに有効なRSSソースを取得し、新規URLだけを items に status=’new’ でINSERTする。
その後 item/created イベントが発火し、アイテムは三段階の非同期処理を通る。

  • Step 1 が本文抽出。
    ここで trafilatura による抽出を行い、ステータスは fetched になる。
  • Step 2 が事実抽出。
    ClaudeまたはGeminiを使って事実リストを生成し、facts_extracted になる。
  • Step 3 が要約・スコアリング。
    ここでもClaudeまたはGeminiを使い、要約とスコアを生成し、summarized になる。

さらにOpenAI APIキーが設定されている場合だけ、embedding生成も走る。

この設計で重要なのは、処理を段階ごとに失敗可能なものとして扱っていることだ。

アイテムの状態遷移も new → fetched → facts_extracted → summarized と素直で、どこで失敗したかが見える。
失敗した場合は failed になり、個別リトライも一括リトライも用意している。

AI処理は失敗する。
ここを「失敗しない前提」で組むと、すぐに死ぬ。
僕は最初からそこを前提にしたかった。

しかも item_facts と item_summaries が別テーブルで保存される。
だから中間成果物はその場限りで消えず、UIでも使えるし、後からの再処理にも使える。

Digestとブリーフィングは「その場で作らない」

Siftoの特徴は、記事を処理して終わりではないことだ。

毎朝6:00 JSTには、前日公開分の summarized 記事を元にDigestが生成される。
digests と digest_items に保存し、さらにクラスタドラフトもLLMで生成し、必要に応じてメール送信する。

メールも生成と送信を分けている。
digest/created イベントを起点に、本文生成、件名生成、ResendによるHTMLメール送信へ進み、最後に digests.sent_at を更新する。

ここで意識していたのは、Digestを「作ること」と「送ること」を別の責務として扱うことだった。
生成だけしたいモードもあるし、送信失敗だけ後からやり直したいこともある。
だからここはちゃんと分けた。

ブリーフィングも同じで、ホーム画面を開いた瞬間に重い集計を走らせるのではなく、Inngestジョブでスナップショットを事前生成している。
当日のハイライト、クラスタ、ストリーク情報を先に計算し、保存しておく。

これは単なるキャッシュではなく、ユーザーが開く前に見せる情報を編集しておくという発想だ。
Siftoが「表示の瞬間に頑張る」アプリではなく、「触る前に仕込む」アプリになっているのは、このブリーフィング設計にも表れている。

Inngestを使った時間軸の設計

SiftoでInngestを使っているのは、cronが欲しいからだけではない。
本質は、処理の時間軸をアプリの外に切り出していることにある。

fetch-rss が10分ごとに走る。
process-item は item/created を起点に三段階処理を進める。
embed-item は必要時のみ走る。
generate-digest は毎朝用に動く。
generate-digest-cluster-drafts、compose-digest-copy、send-digest がその後につながる。
generate-briefing-snapshots も定期で回る。

この構造でやりたかったのは、ユーザーのクリックと内部処理を完全に切り離すことだ。

ユーザーが記事一覧を開いたからといって、今まさに要約処理が始まるわけではない。
ユーザーがDigest一覧を見たからといって、その場でメール本文を生成するわけでもない。
僕は、ユーザーの操作に追従して頑張るのではなく、ユーザーが来る前に必要な状態を作っておく方向で組みたかった。

AIアプリは、どうしても「その場でLLMを呼んで賢く見せる」方向に寄りがちだ。
でも日常的に使うサービスとしては、それだと不安定になる。
SiftoがInngestを軸にしているのは、賢さより先に、処理の安定性と体験の軽さを取りに行っているからだ。

データベーススキーマ

Siftoでは、記事そのものだけでなく、記事がどう処理され、どう読まれ、どう再編集されたかまで含めてデータとして持つようにしている。

users と user_identities があり、認証の外部IDと内部 user_id を分離している。
sources がソース本体、items が記事、item_facts と item_summaries が中間成果物と最終成果物。
item_reads、item_laters、item_feedbacks が読む行為や嗜好を記録し、item_embeddings が関連記事検索を支える。
digests、digest_items、digest_cluster_drafts がDigestの構造を持ち、briefing_snapshots と reading_streaks が日次の体験を支える。
source_health_snapshots でソースの健全性を見て、llm_usage_logs と budget_alert_logs でAIコストを観測する。
そして user_settings がAPIキー、予算、モデル選択、Digest設定、Inoreader連携状態まで持っている。

ここで意識していたのは、Siftoを記事だけを保存するアプリにしないことだった。

持ちたかったのは、

  • 記事がどう処理されたか
  • その結果どう再編集されたか
  • ユーザーがどう反応したか
  • どれだけAIコストがかかったか

まで含めた状態そのものだ。

AIアプリは、生成結果だけ持って満足しがちだ。
でも実際には、使用量、失敗、再試行、フィードバック、予算、配信状態まで観測できないと、継続的に改善できない。
だからそこも最初からスキーマに織り込んだ。

LLM設計は「モデルを使う」ではなく「モデルを選ばせる」方向にある

SiftoのLLM設計で重視しているのは、モデルを用途別に分けられるようにすることだ。

user_settings を見ると、facts_model、summary_model、digest_model、digest_cluster_model、source_suggestion_model、embedding_model があり、ユーザーが設定画面から用途別に選べるようにしてある。

こうしたのは、事実抽出とDigest生成では求めるものが違うからだ。

事実抽出は、できれば安くて速くて安定してほしい。
要約は、その次に品質が欲しい。
Digestやクラスタドラフトは、さらに文章としてのまとまりや読みやすさが欲しい。
Embeddingはまた別物だ。

ここを「全部同じモデルでやる」で押し切るのではなく、最初から用途ごとに分ける。
しかもWorker側には model_router.py があり、モデル名に応じてClaudeとGeminiに振り分ける。
つまり、LLM provider abstraction がコードとして成立している。

単に複数ベンダー対応している、というだけではない。
タスクごとのモデル戦略を持てるという意味で、SiftoのLLM設計はかなり実用寄りだ。

ユーザー別APIキー方式

Siftoでは、Anthropic、OpenAI、Google、Groq、DeepSheekのAPIキーをサーバー共通で持たず、ユーザーごとに設定画面から登録する方式を取っている。
キーは USER_SECRET_ENCRYPTION_KEY で暗号化して user_settings に保存している。

この設計で意識しているのは、個人開発としての現実を外さないことだ。

AIアプリは、機能を増やすのは簡単だが、運用コストが後から効いてくる。
ユーザー数が増え、処理量が増え、モデルが少しでも重くなると、推論コストは無視できなくなる。
運営側が全部持つ設計にすると、アプリが伸びるほど財布が死ぬ。

僕はそこを避けたかった。

でもこの設計の良さは、単にコスト回避だけではない。
ユーザー側から見ても、自分の好みや予算に合わせてモデルを選べる。Claudeを使いたい人もいればGeminiを使いたい人もいるし、embeddingだけOpenAIを使いたい人もいる。
さらに月額予算やアラート閾値まで持てるので、AIを「魔法」ではなく、使うとお金がかかる資源として扱っている。

この視点はかなり大事だと思っている。
AI機能を足すのは簡単だが、長く運用するには「どれだけ使っているか」を見ないといけない。
Siftoは llm_usage_logs や budget_alert_logs まで含めて、そこをちゃんと設計している。

本番構成と運用の考え方

本番構成は、WebがVercel、APIとWorkerがFly.io、ジョブがInngest Cloud、メールがResend、PushがOneSignal、認証がClerk、監視がSentry、DB migrationはGitHub Actionsから自動という構成になっている。

ここで意識していたのは、Fly.ioをなんとなく使うのではなく、なぜそれを選ぶのかに理由を持たせることだった。

Inngestが10分ごとにcronでサービスを叩く以上、コールドスタート遅延は無視しにくい。
Fly.ioのMachine auto-stop/startは数百msで起動し、さらに .internal でAPI→Workerを低レイテンシに呼べる。
つまり、定期実行されるバックエンドとしての性質を見て選んでいる。

デプロイも main ブランチへのpushで、まずDB migration、次にAPI、次にWorker、WebはVercelのGitHub連携で自動、という流れだ。
DB migrationはDBに対してTailscale経由で実行する。

個人開発では、ここはかなり雑にしがちなところだ。
ローカルから手でmigrationして、デプロイは気合いで、みたいになりやすい。
でもSiftoはそのへんを最初から仕組みに寄せている。
この地味な部分が、あとで効く。

なぜこのアプリが単なる「AIを載せたRSSリーダー」で終わっていないのか

ここまで実装してきて、自分の中ではSiftoの本質はかなりはっきりしている。

これは単にRSSを集めてAIで要約するアプリではない。
本質は、

情報の流入から、理解、優先順位付け、再編集、再訪までを一つのパイプラインとして設計していること

にある。

  • 本文抽出を独立させる。
  • 事実抽出を中間成果物として保存する。
  • 要約とスコアを別に持つ。
  • Digest生成と送信を分ける。
  • ブリーフィングを事前生成する。
  • embeddingをオプション化する。
  • モデルを用途別に切り替える。
  • 使用量と予算を観測する。
  • 認証外部IDと内部IDを分ける。

どれも一つ一つは派手ではない。
だが、こういう地味な判断の積み重ねでしか、AIアプリは「動くおもちゃ」から「継続して使えるサービス」にならない。

モデルは変わる。
ベンダーも変わる。
半年後には、今のモデル選択は古くなっているかもしれない。

でも、

同期処理と非同期処理の分離
中間成果物を残す設計
タスク単位のモデル切り替え
usageと予算の観測
生成前提ではなく再処理前提の構造

こういった部分は簡単には古びない。

Siftoを作っていて面白かったのは、結局AIそのものよりも、AIをちゃんとアプリケーションに落とし込む設計のほうだった。

おわりに

Siftoは、RSSリーダーでも、単なるAI要約アプリでもない。

大量に流れてくる情報の中から、「今日読む価値があるものは何か」を見つけやすくするためのアプリとして作った。
情報を集めることはもう十分にできる時代だからこそ、その後の

仕分け、理解、優先順位付け、再訪

を助けるツールのほうが大事になると思っている。

情報が多すぎる時代に必要なのは、もっと大量に集める仕組みではなく、人が判断しやすい形に整える仕組みなのかもしれない。
Siftoは、少なくとも自分にとっては、そのための道具として作っている。

もし同じように「読む前に疲れる」という感覚がある人がいれば、こういう方向の情報アプリはもっとあっていいと思う。

リポジトリ

Sifto のコードは GitHub で公開している。

GitHub - enjoydarts/sifto: ユーザーが登録したRSSフィードおよび単発URLを自動収集し、本文抽出・事実抽出・要約を行い、毎朝Digestメールとして配信するサービス。
ユーザーが登録したRSSフィードおよび単発URLを自動収集し、本文抽出・事実抽出・要約を行い、毎朝Digestメールとして配信するサービス。 - enjoydarts/sifto

Next.js / Go / Python Worker のモノレポ構成で、記事処理パイプラインや Digest 生成、LLM モデル切り替えまわりの実装も含めて置いている。気になったら見てもらえると嬉しい。

コメント

タイトルとURLをコピーしました