コンテンツへスキップ

好きに書いて、技術記事だけ届く仕組みを作った

好きに書いて、技術記事だけ届く仕組みを作った のアイキャッチ
Contents

はじめに

技術ブログを探すのは意外と難しいです。Google検索で過去記事を遡ったり、まとめ記事を探したり。ゼロから良質な技術ブログを見つけるのはなかなか骨が折れます。

この課題に対して、すでにいくつかの先行サービスがあります。

  • tech-blog-rss-feed - 企業テックブログのRSSをまとめたプロジェクト
  • diff.blog - 技術ブログの更新を追えるサービス(海外向け)

tech-blog-rss-feedは非常に便利ですが、企業ブログが中心。diff.blog は海外向けで、日本の個人ブログには対応していません。僕には一つ欲しい機能がありました。個人ブログから技術記事だけを吸い出して読みたいというニーズです。

企業テックブログは基本的に技術記事しか投稿されませんが、個人ブログは違います。技術記事、日記、旅行記、ガジェットレビュー...様々なコンテンツが混在しています。RSSで購読すると「今日は〇〇を食べました」みたいな記事も流れてきます。

ただ、僕はそういった記事をすべて排除したいわけではありません。日記や雑記も読み物として面白いですし、その人の人となりが見えてくる。でも、技術情報を効率よくキャッチアップしたいときには、技術記事だけをフィルタリングして見たい。

この「全部は消したくないけど、技術記事だけ見たいときもある」という着想から生まれたのがtailfです。名前の由来は tail -f、ファイルの変更をリアルタイムで追うUnixコマンドから取っています。

個人ブログには技術記事だけでなく雑記や日記も混在しています。tailfではこの問題を解決するために、embeddingによる技術記事の分類FTS5による検索を組み合わせています。

この記事では、なぜ2つの技術を併用しているのか、そしてなぜ検索にセマンティック検索ではなくFTS5を採用したのかを解説します。


tailfとは

tailfは日本の技術ブログを集約するサービスです。tech-blog-rss-feeddiff.blogから着想を得て、個人ブログだけでなく企業テックブログにも対応しています。

ブログを書いている人へ:

  • 自分のブログをRSSで登録するだけ
  • 技術記事は自動でピックアップされる
  • 雑記も自由に書ける。技術記事だけが読者に届く

技術記事を読みたい人へ:

  • 個人ブログの技術記事だけを効率よくキャッチアップ
  • 企業テックブログも公式で順次追加中
  • キーワードで検索も可能

ホーム | tail -ftailf.pavegy.workers.dev


分類と検索は別の問題

まず前提として、「分類」と「検索」は異なる問題です。

問題求められること適した技術
分類「この記事は技術記事か?」を判定embedding(意味的な理解)
検索「Reactを含む記事を探す」FTS(キーワードマッチ)

「全部embeddingでやればいいのでは?」と思うかもしれません。実際、検索もベクトル検索(セマンティック検索)で実現できます。しかし、tailfではあえてFTS5を採用しました。その理由は後述します。


技術記事スコアリング:BGE-M3 embedding

なぜembeddingを使うのか

技術記事かどうかの判定は、単純なキーワードマッチでは難しいです。

  • 「Reactで状態管理を実装した」→ 技術記事
  • 「React製のアプリを使ってみた感想」→ ガジェットレビュー寄り
  • 「Reactエンジニアとして転職した話」→ キャリア記事

どれも「React」を含みますが、技術記事度は異なります。文脈や意味の理解が欠かせません。

アンカー句との比較

tailfでは、Cloudflare Workers AIのBGE-M3モデルを使ってembeddingを生成し、「技術記事らしいフレーズ」と「非技術記事らしいフレーズ」との類似度を比較しています。

アンカー句の定義
// 技術記事のアンカー句
const TECH_ANCHOR_PHRASES = [
'React TypeScript フロントエンド開発 コンポーネント実装',
'Python 機械学習 データ分析 モデル構築',
'AWS インフラ構築 Terraform IaC デプロイ自動化',
'Docker Kubernetes コンテナ オーケストレーション',
'LLM プロンプトエンジニアリング RAG ベクトル検索',
// ...
];
// 非技術記事のアンカー句
const NON_TECH_ANCHOR_PHRASES = [
'転職 キャリア 年収 面接対策 就職活動',
'日記 振り返り 感想 ポエム 雑記',
'ガジェット レビュー 買ってよかった デスクツアー',
// ...
];

入力テキストのembeddingを生成し、各アンカー句とのコサイン類似度を計算します。技術記事アンカーとの最大類似度から、非技術記事アンカーとの類似度をペナルティとして引きます。

スコア計算
const maxTechSim = maxSimilarity(anchors.tech);
const maxNonTechSim = maxSimilarity(anchors.nonTech);
// スコア = 技術類似度 - 非技術ペナルティ(正規化)
const rawScore = maxTechSim - maxNonTechSim * 0.5;
const normalizedScore = Math.max(0, Math.min(1, (rawScore + 0.3) / 0.8));

キーワードベースとのハイブリッド

Workers AIが利用できない場合のフォールバックとして、キーワードベースのスコアリングも実装しています。

キーワードベースのフォールバック
// 高信頼キーワード(重み0.3、最大3マッチ)
const HIGH_WEIGHT_KEYWORDS = [
'javascript',
'typescript',
'python',
'rust',
'react',
'vue',
'next.js',
'kubernetes',
'docker',
// ...
];
// 中信頼キーワード(重み0.15)
const MEDIUM_WEIGHT_KEYWORDS = [
'プログラミング',
'エンジニア',
'実装',
'アーキテクチャ',
// ...
];
const score =
countMatches(text, HIGH_WEIGHT_KEYWORDS, 3) * 0.3 +
countMatches(text, MEDIUM_WEIGHT_KEYWORDS, 4) * 0.15 +
countMatches(text, LOW_WEIGHT_KEYWORDS, 5) * 0.05;

これにより、AIが使えない環境でも最低限の分類が可能になります。


検索にFTS5を選んだ理由

ここからが本題です。分類にはembeddingを使っているのに、なぜ検索にはセマンティック検索(ベクトル検索)ではなくFTS5を採用したのでしょうか。

1. コストとシンプルさ

tailfはCloudflare D1(SQLite)をデータベースとして使っています。D1にはFTS5が組み込まれており、追加コストなしで全文検索が使えます。

一方、ベクトル検索を行うにはCloudflare Vectorizeなどの別サービスが必要になります。小規模なサービスでは、この追加の複雑さとコストは避けたいところです。

FTS5テーブルの作成
-- D1組み込み、追加コストなし
CREATE VIRTUAL TABLE posts_fts USING fts5(
title,
summary,
content='posts',
content_rowid='rowid',
tokenize='trigram'
);

2. カーソルページネーションとの相性

これが最も重要な理由です。

セマンティック検索は「類似度スコア順」で結果を返します。しかし、tailfでは検索結果を「公開日順」で表示したいです。つまりリランク(並べ替え)が必要になります。

# ベクトル検索の結果(類似度順)
[スコア0.95の記事, スコア0.90の記事, スコア0.85の記事...]
# でも表示は公開日順
→ リランクが必要

問題は、リランク後のカーソルページネーションです。

tailfではカーソルベースのページネーションを採用しています。「この日時より前の記事を20件取得」という形式です。しかし、ベクトル検索でリランクすると:

  1. 1ページ目を取得(ベクトル検索 → リランク → 上位20件)
  2. 2ページ目を取得するとき、同じベクトル検索結果が得られる保証がない
  3. 新しい記事が追加されると、類似度の順位が変動する
  4. カーソルの一貫性が崩壊

FTS5ならこの問題は発生しません。

FTS5での検索クエリ
SELECT p.id FROM posts p
JOIN posts_fts ON p.rowid = posts_fts.rowid
WHERE posts_fts MATCH 'React'
AND p.published_at < :cursor -- カーソル条件
ORDER BY p.published_at DESC -- 公開日順
LIMIT 20

絞り込み(MATCH)と並び順(ORDER BY)が分離されているため、カーソルは published_at ベースで安定して動作します。

3. 日本語対応

FTS5のデフォルトトークナイザーはスペース区切りを前提としているため、日本語では機能しません。tailfでは trigram トークナイザーを使用しています。

tokenize='trigram'

trigramは3文字単位でテキストを分割します。「プログラミング」なら「プロ」「ログ」「グラ」「ラミ」「ミン」「ング」のようにインデックス化されます。

これにより、日本語でも部分一致検索が可能になります。ただし、3文字未満のクエリではtrigramが機能しないため、LIKEにフォールバックしています。

検索クエリの分岐
const FTS5_MIN_QUERY_LENGTH = 3;
if (q.length >= FTS5_MIN_QUERY_LENGTH) {
// FTS5検索
const ftsResult = await db.all(sql`
SELECT p.id FROM posts p
JOIN posts_fts ON p.rowid = posts_fts.rowid
WHERE posts_fts MATCH ${q}
ORDER BY p.published_at DESC
LIMIT ${limit + 1}
`);
} else {
// LIKEフォールバック
const result = await db.query.posts.findMany({
where: or(like(posts.title, `%${q}%`), like(posts.summary, `%${q}%`)),
// ...
});
}

4. YAGNI:要望が出たら考える

セマンティック検索が有効なのは「曖昧なクエリ」です。

  • 「状態管理について」→ Redux, Zustand, Jotaiなど関連記事を横断的に取得
  • 「パフォーマンス改善」→ 様々な最適化手法の記事を取得

しかし、tailfの検索では、ユーザーは「React」「Next.js」「Kubernetes」など明確なキーワードで検索します。曖昧なクエリでの検索需要は今のところありません。

存在しない需要のために複雑さを増やす必要はありません。要望が出たら、その時に検討すればいいと考えています。


実装詳細:FTS5のセットアップ

参考までに、FTS5の実装詳細を載せておきます。

外部コンテンツテーブル

FTS5を「外部コンテンツテーブル」として設定することで、元の posts テーブルとデータを同期できます。

外部コンテンツテーブルの設定
CREATE VIRTUAL TABLE posts_fts USING fts5(
title,
summary,
content='posts', -- 参照元テーブル
content_rowid='rowid', -- 紐付けるID
tokenize='trigram'
);

トリガーによる自動同期

INSERT/UPDATE/DELETE時に自動でFTSテーブルを更新するトリガーを設定します。

同期用トリガー
-- INSERT時
CREATE TRIGGER posts_fts_ai AFTER INSERT ON posts BEGIN
INSERT INTO posts_fts(rowid, title, summary)
VALUES (new.rowid, new.title, COALESCE(new.summary, ''));
END;
-- UPDATE時
CREATE TRIGGER posts_fts_au AFTER UPDATE ON posts BEGIN
INSERT INTO posts_fts(posts_fts, rowid, title, summary)
VALUES('delete', old.rowid, old.title, COALESCE(old.summary, ''));
INSERT INTO posts_fts(rowid, title, summary)
VALUES (new.rowid, new.title, COALESCE(new.summary, ''));
END;
-- DELETE時
CREATE TRIGGER posts_fts_ad AFTER DELETE ON posts BEGIN
INSERT INTO posts_fts(posts_fts, rowid, title, summary)
VALUES('delete', old.rowid, old.title, COALESCE(old.summary, ''));
END;

まとめ

tailfでは、分類にはembedding、検索にはFTS5を採用しています。

用途技術理由
分類BGE-M3 embedding意味的な理解が必要
検索FTS5 (trigram)コスト、ページネーション、シンプルさ

「全部AIでやる」が正解とは限りません。適材適所で技術を選ぶことで、シンプルで保守しやすいシステムになります。

セマンティック検索が必要になったら、その時に考えればいいのです。


tailfを使ってみてください。 ブログを書いている人は自分のRSSを登録するだけ。技術記事を読みたい人はそのまま使えます。

ホーム | tail -ftailf.pavegy.workers.dev


今後の展望

現在、「後で読む」サービスのTuckとの連携を検討しています。tailfで発見した記事をワンクリックでTuckに保存できるようになる予定です。

両サービスともCloudflare Workers + Hono + D1という同じ技術スタックで構築しているため、統合はスムーズに進められそうです。進捗があれば別途記事にします。

それでは、またね。


関連記事

参考リンク

X Facebook B! Hatena