---
title: Workers AI で「関連記事」機能を作り直した
imageUrl: ./cover.webp
publishedOn: 2026-09-10
lastModifiedOn: 2026-09-10
---

よくあるブログサイトの構成では、記事を読み終えると、末尾に「関連記事」が並ぶ。
本サイトにも「関連記事」があるが、異なる記事に同じ 6 件が出てくることがしばしばあった。
「関連記事」は、共通するタグが多い順に並べ、同数なら公開日の新しい順にする仕様であった。
タグのみを考慮するので、当然、本文の内容は一文字も考慮しない。

たとえば、次の 4 つは全部 2010 年の記事で、付いているタグは「日記」だけだ。
新年の挨拶も、やることのメモも、読み終わると同じ 6 件を同じ順で勧められる。
どの記事とどの記事が近いかを、「日記」の 2 文字だけで決めていた。

- [Happy New Year 2010！](/articles/new-year-2010)
- [８月中にやること](/articles/todo-for-august-2010)
- [プログラマさんに100の質問](/articles/hundred-questions-for-programmers)
- [立派なものを頂いた](/articles/prizes-from-the-it-contest)

![「８月中にやること」と「立派なものを頂いた」の記事ページから、題名と末尾の関連記事を切り出して並べたもの。どちらも関連記事に、洗濯表示、Hack U 2016、KVI-70B、技科大写真、CODE THANKS FESTIVAL 2014、Linux 班の 6 件が同じ順で並んでいる](./related-notes-by-tags.webp)

理想を言えば、「[洗濯表示は守ったほうが良い。特に乾燥機。](/articles/the-dryer-shrank-my-kando-jackets)」を読み終えた読者には、同じ読み味のエッセイを勧めたい。
ところが 2 番目に出ていたのは「[Windows タブレット KEIAN KVI-70B に Arch Linux をインストールする](/articles/install-arch-linux-on-kvi-70b)」だった。
生活のエッセイを読み終えた読者に、次は OS を入れてもらう。
ちょっと滑稽だ。

関連記事が本文を一文字も見ていないなら、本文を見せればよい。
そこで、本文の特徴を数値の列にした埋め込みベクトルを作り、向きの近さを表すコサイン類似度で並べるようにした。
Workers AI で使えるモデルを 4 つ比べたので、この記事にはその過程と結果をメモしておく。

## タグを適切に付けることは難しい

関連記事が正しく動作するタグには、2 つの条件がある。

- 2 記事以上に付いていること。1 記事だけでは、そのタグで他の記事を推薦することができない。
- そのタグを共有する記事同士が、同じ話題であること。乾燥機の話から Arch Linux のインストールまで含む「日記」では広すぎる。

この条件を満たすタグを、粒度が適切なタグと定義しよう。
本サイトには 55 記事と 19 種類のタグがある。
「日記」は粗すぎ、1 記事にしか付いていない 9 種類は細かすぎる。
粒度が適切なタグは残る 9 種類で、自分で付けたタグの半分は粒度が適切ではなかった。

しかも、粒度が適切かどうかは他の記事との関係で決まる。
「乾燥機」の記事に付けた「生活」は今のところ細かすぎるが、生活の話をもう 1 記事書けば粒度が適切なタグになる。
同じタグでも、記事が増えれば判定が動く。
書くたびに、過去のタグ付けまで見直す仕事が付いてきてしまうのだ。

冒頭に挙げた 4 記事についていたタグ「日記」は、粒度が適切でないタグの好例である。
共通するタグがどの記事とも 1 個止まりの記事は 55 記事のうち 15 記事あり、その関連記事は公開日の新しい順に並ぶ。
この 4 つも全部公開日が 2010 年のため、「日記」の新しいほうから 6 件に入らず、関連記事が完全に同じ顔ぶれになってしまった。
つまり関連記事の欄で、新着記事を紹介していた。

逆に、「[カフェでも風呂でも、DevPod でシームレスな Claude Code 体験を](/articles/claude-code-anywhere-with-devpod)」には 5 つもタグを付けた。
開発環境に使った道具の名前を並べたのだが、どのタグもこの記事にしか付いていないものだ。
結果、当然関連記事は 0 件。
丹精込めてタグを 5 つ付けても、関連記事に寄与しないのである。

「乾燥機」の記事では、粗すぎるタグと細かすぎるタグが重なってしまった。
現時点では細かすぎるタグ「生活」を共有する記事が無いので、残った粗すぎるタグ「日記」だけで「Arch Linux」の記事が上がってきてしまっている。

すなわち、粒度が適切なタグを付け続けるには、他の記事全部と、この先書く記事まで見えている必要があるのだ。
そもそも、タグで関連記事を出すのは扱いにくいのではないだろうか。

## Workers AI に本文を読ませる

「関連記事」機能を実装するとして、教科書的な候補の筆頭は tf-idf だろう。
自分も学部時代、研究室に配属された直後の研修で実装した覚えがある。
この手法は、その記事に何度も出てきて、他の記事にはあまり出ない語ほど、その記事らしい語だと見なすものだ。
そういった語をどれだけ共有しているかで、記事同士の近さを測る。

tf-idf の利点の一つに、「どちらにも『TopCoder』が出てくるから近い」のように、共通する語で理由が説明可能であることが挙げられる。
一方で欠点もある。
「[CODE THANKS FESTIVAL 2014 に行ってきた](/articles/code-thanks-festival-2014)」の記事には、「競技プログラミング」という語は出てくるものの、「TopCoder」は出てこない。
「[情オリのため、久しぶりの C 言語](/articles/back-to-c-for-joi)」はその逆である。
どちらも競技プログラミングの記事だが、語の一致で測る tf-idf は「競技プログラミング」と「TopCoder」を同じ話題として拾えない。

語が一致しない話題もつなぎたいので、説明可能性と引き換えに、今回は埋め込みベクトルを試すことにした。
文章の意味を数百から数千次元のベクトルで表したもので、意味が近い文章ほどベクトルの向きが近くなるようになっている。
うまく行けば、語が違っても「競技プログラミング」と「TopCoder」はつながるはずである。

本サイトが動いている Cloudflare には Workers AI というサービスがあり、ここで埋め込みベクトルも計算できる。
2026 年 9 月時点の Text Embeddings は 7 モデル。
名前に `en` が入る英語向けの 3 つを外して、残りの 4 つを試してみることにした。
日本語向けの `plamo-embedding-1b` と、多言語対応の `bge-m3`、`qwen3-embedding-0.6b`、`embeddinggemma-300m` である。
以下では bge、qwen3、plamo、gemma と略す。

長い本文を渡してベクトルを保存するので、入力の上限と次元数、料金を比べた。

| モデル | 次元 | 入力の上限 (モデルカード)[^model-cards] | 入力の上限 (Cloudflare)[^cf-bge-m3] | 料金 (入力 100 万トークン) |
| --- | --- | --- | --- | --- |
| bge-m3 | 1024 | 8,192 トークン | 60,000 トークン | $0.012 |
| qwen3-embedding-0.6b | 1024 | 32K トークン | 8,192 トークン | $0.012 |
| plamo-embedding-1b | 2048 | 4,096 トークン | 記載なし | $0.019 |
| embeddinggemma-300m | 768 | 2,048 トークン | 記載なし | 料金表に無い (beta) |

bge と qwen3 は、モデルカードと Cloudflare の一覧で入力の上限が食い違う。
どちらを信じるかで、本文をどこで区切るかが変わってしまう。

## Workers AI の入力の上限を実測する

Workers AI が実際にどこまで読むかを確かめるため、最長の DevPod の記事 (7,229 字) を繰り返して実験用の文章を作った。
1,000 字から 64,000 字まで長くしながら渡し、応答を確かめる。

bge は 24,000 字で「Sequence too long: 11128 > 8192」と返して止まった。
残る 3 モデルは 64,000 字でもベクトルを返すが、途中から文章を長くしても値が変わらない。
入力の上限を超えた分を捨てているのだろう。
本文を読まない関連記事を直すために、本文を読ませたつもりになっていた。

ただし、値が変わらなくなる境目は、実験に使った文章しだいで動く。
上限の単位は字数ではなくトークン数で、DevPod の記事は半分が ASCII だった。
Hugging Face から qwen3 の tokenizer を取って 55 記事を数えると、1 トークンあたり 1.39〜2.13 字になる。
いちばん密なのは JOI 予選の失敗記の 1.39 字で、8,192 トークンに掛けると約 11,400 字にしかならない。
日本語の本文を読ませる実験に、半分が ASCII の文章を使っていた。

そこで、JOI 予選の失敗記を繰り返した文章で、500 字刻みに測り直した。
後半まで読ませるため、実験では長い記事を何個かのチャンクに分け、チャンクごとのベクトルを平均して 1 つにする。
チャンクの長さは、密な日本語で測った上限の範囲の小さいほうを 0.85 倍して、千字単位で四捨五入した。
qwen3 なら 11,500 × 0.85 = 9,775 字で、10,000 字になる。

| モデル | DevPod (ASCII 半分) | JOI 予選 (密な日本語) | 上限を超えたとき | チャンクの長さ |
| --- | --- | --- | --- | --- |
| bge-m3 | 24,000 字でエラー | 13,500〜14,000 字でエラー | エラーを返す | 11,000 字 |
| qwen3-embedding-0.6b | 16,000〜24,000 字 | 11,500〜12,000 字 | 黙って切り捨てる | 10,000 字 |
| plamo-embedding-1b | 8,000〜10,000 字 | 6,000〜6,500 字 | 黙って切り捨てる | 5,000 字 |
| embeddinggemma-300m | 5,000〜6,000 字 | 3,500〜4,000 字 | 黙って切り捨てる | 3,000 字 |

密な日本語では、どのモデルも ASCII 半分の文章より 2 割から 4 割短い字数で上限に達した。
qwen3 の境目は 11,500〜12,000 字で、tokenizer から見積もった約 11,400 字と合う。
bge が 13,500 字まで読めるのは、tokenizer が違い、同じ日本語を qwen3 より少ないトークンに切るからだろう。
bge では Cloudflare の一覧の 60,000 トークンが外れ、qwen3 ではモデルカードの 32K トークンが外れていた。
チャンクに分けたのは plamo が 3 記事、gemma が 8 記事で、bge と qwen3 は 55 記事を分けずに読めた。

## 全記事の平均ベクトルを引いてから記事を比べる

55 記事の埋め込みベクトルがそろったので、さっそくコサイン類似度で並べてみた。
すると、多くの記事でやたら「CODE THANKS FESTIVAL 2014 に行ってきた」が関連記事に入ってきてしまった。
モデルを問わずこの傾向があったが、たとえば qwen3 では、開いた記事の半分でこれが発生していた。
タグをやめても関連記事の常連が発生してしまったのだ。

調べてみると、「CODE THANKS FESTIVAL」の記事は全記事の平均ベクトルに最も近いことがわかった。
イベント参加記で話題が広く、平均に近い向きになったのだろう。
全体が同じ向きに寄っていると、その向きに近い記事はあちこちで上位に入ってしまう。

そこで、各ベクトルから全記事の平均ベクトルを引いて、正規化し直した。
以下、これを中心化と呼ぶ。
全体に共通する成分を引いてから、記事同士の差を比べる。
中心化すると、「CODE THANKS FESTIVAL」の記事が関連記事に入る回数は、他の記事と変わらなくなった。
他の 3 モデルも同じだったので、この先はすべて中心化後のベクトルで比較する。

中心化には代償がある。
記事を足すたびに平均が動き、既存の記事同士の類似度も変わるので、毎回全ペアを計算し直す必要があるのだ。

55 記事なら、組み合わせは 55 × 54 ÷ 2 = 1,485 通り。
両方向で持っても 2,970 行なので、ベクトルデータベースを使わず総当たりで足りる。
1,000 記事では 999,000 行になるが、週に 1 記事書いても 18 年先だ。
もっともその頃には、1 記事増やしても、平均ベクトルはほとんど変わらないだろう。
この問題の対策は、18 年後に改めて考えることにした。

## 4 モデルの関連記事を比べる

### 記事同士の類似度をヒートマップで比べる

中心化した類似度を、55 × 55 のヒートマップにした。
縦横に記事を並べ、近いペアほど赤く、遠いペアほど青く塗る。
色の 0 は、モデルごとの全ペアの平均に置いた。
並びはタグごとにまとめ、4 枚でそろえた。
同じタグの記事が本当に似ていれば、対角線上に四角いブロックが浮かぶ。

![中心化した 4 モデルの 55 × 55 の類似度行列を 2 × 2 に並べたもの。並びは 4 枚とも同じで、試験、ハードウェア、競技プログラミングの対角には 4 枚ともブロックが浮かび、日記の区画には何も出ない。qwen3 と gemma はブロックの内と外がくっきり分かれ、bge-m3 は白い升が混じってぼやける](./similarity-heatmaps-by-model.webp)

試験、ハードウェア、競技プログラミングの 3 ブロックは、4 枚全部に出た。
本文だけを渡しても、この話題のまとまりは拾えるらしい。
一方、日記の区画には何も出てこない。
この区画にある冒頭の 4 つと技科大写真と乾燥機の計 6 記事を、4 モデルそろって持て余している。
日記は「日記」という話題を扱っているわけではないのでこうなるのだろう。

違いも見える。
qwen3 と gemma はブロックの内外がくっきり分かれ、bge はぼやけ、plamo はその中間だった。
ただ、図の印象だけでは採否を決めにくい。
タグとどれくらい合ったか、数でも確かめることにした。

### 関連記事が人手で付けたタグと合うかを測る

4 モデルが出す関連記事の採点には、自分が付けていたタグを物差しに使う。
存在意義の問われる「日記」タグと、1 記事にしか付いていないタグは外した。
残りのタグを共有するペアが、共有しないペアより近くなるなら、本文から話題を拾えていると考える。

まず、タグを共有するペアと共有しないペアを 1 つずつ選び、共有するペアのほうが近い確率を測った。
この評価指標を AUC と呼ぶ。
AUC は、でたらめなら 0.5 に収束する。
もう 1 つは、上位 6 件のうち元の記事とタグを共有する枠の割合で、この評価指標をタグ共有率と呼ぶ。
55 記事に 6 件ずつなので、数える枠は 330 個だ。
タグを共有するペアは全 1,485 通りのうち 264 通りなので、でたらめに 6 件選んだときのタグ共有率は平均で 264 ÷ 1,485 ≒ 17.8% になる。

| モデル | タグ共有率 (日記以外) | AUC |
| --- | --- | --- |
| でたらめ | 17.8% | 0.5 |
| bge-m3 | 47.6% | 0.775 |
| qwen3-embedding-0.6b | 53.0% | 0.811 |
| plamo-embedding-1b | 53.0% | 0.813 |
| embeddinggemma-300m | **54.5%** | **0.820** |

数字では gemma が最良だった。
qwen3 は plamo とほぼ並び、bge は両方の指標で届かない。
記事を選び直して採点を繰り返しても、bge が他の 3 つに追いつくことはほとんど無い。
一方、gemma と qwen3 の差は 4 回に 1 回ひっくり返る。
図でくっきり見えた qwen3 と gemma のうち、採点では gemma が前に出た。
自分のタグ付けを再現する競技なら、ここで表彰して終わることができる。

### 勧める記事とその順序を目で確かめる

実際に出てくる関連記事は、モデルごとにけっこう違う。
上位 1 件が 4 モデルで一致したのは、55 記事のうち 20 記事だった。
各記事の上位 6 件を比べると、4 モデルすべてに入った候補は平均 2.53 件だった。
ヒートマップで同じブロックが見えても、意外と中身は異なるものだ。

それでも、「CODE THANKS FESTIVAL」の関連記事の 1 位は、4 つとも「情オリのため、久しぶりの C 言語」だった。
「立派なものを頂いた」の関連記事の 1 位も、4 つとも「応用情報合否発表！！」で一致する。
タグ版では関連記事がなかった音楽の「[素敵なまゆげ](/articles/suteki-na-mayuge)」にも、候補が付いた。
上位 2 件はどれも、小学生の頃に描いたキャラクターの「[サルークファミリー](/articles/meet-the-saruku-family)」と、クラスメイトから募ったイラストの「[キャラクター製作研究所](/articles/characters-my-classmates-drew)」だった。
天涯孤独かと思われたまゆげにも、ようやく友達ができた。

迷うのは「プログラマさんに100の質問」だ。
上位 6 件のうち、4 モデルすべてに共通する候補は 0 件だった。
qwen3 は TopCoder と自作プログラムの話を 6 件中 5 件並べる。
bge は 1 位に「乾燥機」の記事を置き、「乾燥機」の記事の 2 位には「100 の質問」の記事を置いた。
この相互推薦は、どちらを読み返しても理由を見つけられない。

「乾燥機」の記事には、plamo が「[なぜ AI 全盛の時代に、技術ブログをリニューアルしたのか](/articles/back-from-times)」を 1 位に、gemma が 2 位に置く。
生活の話を読んだあとに別のエッセイへ進むなら、自分にはこの並びも納得できる。
ただし plamo は「100 の質問」の記事にリニューアルの話や情報処理技術者試験まで混ぜるので、最終候補は qwen3 と gemma に絞られた。

## qwen3-embedding-0.6b を本番に載せる

最終候補を見比べて悩んだ結果、長い本文をそのまま渡すことができる qwen3 を採用することにした。
qwen3 の入力の上限は 8,192 トークンで、55 記事を 1 つも切らずに読める。
1024 次元で、料金は入力 100 万トークンあたり $0.012。

gemma は数値が最良でも、入力の上限が 2,048 トークンと最も短い。
`wrangler ai models list` では beta の印が付き、料金表にも載っていなかった。

ところで、この記事は 55 記事のどれよりも長く、本番でチャンクに分ける 10,000 字まで、1 割も余裕が無い。
皮肉なことに、この記事が qwen3 の限界に挑戦することになっていた。

## 本番の関連記事がどう変わったかを確かめる

さて、冒頭の 4 つの記事だが、この記事が公開された 2026 年 9 月 10 日時点の本番では全部違う関連記事になった。
「８月中にやること」と「立派なものを頂いた」を開くと、この並びに変わった。

![「８月中にやること」と「立派なものを頂いた」の記事ページから、題名と末尾の関連記事を切り出して並べたもの。6 件のうち重なるのは 3 件で、互いに相手も含んでいる。「８月中にやること」の先頭は基本情報技術者試験と TopCoder の Practice 計画、「立派なものを頂いた」の先頭は応用情報の合否発表と基本情報の合格報告になっている](./related-notes-by-qwen3.webp)

「８月中にやること」からは、基本情報技術者試験と TopCoder の Practice 計画へ進める。
「立派なものを頂いた」の 1 位は応用情報の合否発表だ。
それぞれの 6 件のうち重なるのは 3 件で、互いに相手も含んでいる。
日記を行き来しても同じ候補しか見えない状態は直った。

「プログラマさんに100の質問」の 1 位には、共通タグが 0 の「TopCoder 初挑戦！」が来た。
「競技プログラミング」と「TopCoder」で語が食い違っていた「CODE THANKS FESTIVAL」の記事と「情オリ」の記事も、1 位でつながった。
ただし、「100 の質問」の記事自身はどの記事の上位 6 件にも入っていない。
行き先はあるが、来る道が無い。

「乾燥機」の記事の 1 位は「[スマホのジャック(4極)にヘッドフォン(3極)とマイク(2極)を接続する](/articles/splitting-a-4-pole-headset-jack)」だ。
冒頭で望んだ、生活のエッセイへ案内する並びには届かなかった。
そりゃそうだ。生活エッセイが他に 1 記事も無いからである。
冒頭での問いの立て方を間違えたような気がしないでもないが、何も問題はない。
これから書いていく予定だ。

## 関連記事にタグを使わなくなった今、タグをどうするか

関連記事の算出に埋め込みベクトルを使うようになったことで、
タグに残る仕事は、一覧の絞り込みだけだった。
「日記」で絞ると 34 記事、「Talos Linux」で絞ると 1 記事。
もちろん現時点で妥当についているタグが無いわけではないが、これも今後の記事によって妥当性を失っていく可能性がある。

そこで、思い切ってタグを書くのをやめた。
Markdown frontmatter から `tags` を消し、一覧の絞り込みもタグの表示も廃止した。
これからはタグに気を揉むことなく、面白い記事をどんどん書いていけるはずだ。 (言ったな？)

余談だが、Workers AI で使えるモデル一覧を見ると、2026 年 9 月時点でタスクの種別は 10 もある。

- Text Generation: 文章の生成。
- Text-to-Image: テキストから画像を作る。
- Text Embeddings: この記事で使った、テキストのベクトル化。
- Automatic Speech Recognition: 音声の文字起こし。
- Text-to-Speech: テキストから読み上げ音声を作る。
- Text Classification: 感情などのラベルをテキストに付ける。
- Image-to-Text: 画像の説明文を作ったり、画像内の文字を読み取ったりする。
- Translation: 別の言語に翻訳する。
- Dumb Pipe: (ざっと調べたけどよくわからなかった)
- Image Classification: 画像にラベルを付ける。

上記の通り Text-to-Image もあるので、記事のカバー画像に使える絵が出るかは試してみたい。
また記事の中身を書く前に、本サイトの機能を作り込み始めそうだ。

[^model-cards]: Hugging Face にある各モデルのページ。
    <https://huggingface.co/BAAI/bge-m3>
    <https://huggingface.co/Qwen/Qwen3-Embedding-0.6B>
    <https://huggingface.co/pfnet/plamo-embedding-1b>
    <https://huggingface.co/google/embeddinggemma-300m>
    2026 年 9 月 7 日の時点で、BAAI/bge-m3 は 8192 トークン、Qwen/Qwen3-Embedding-0.6B は 32K トークンと書いてある。
    pfnet/plamo-embedding-1b は 4096 トークン、google/embeddinggemma-300m は 2048 トークンである。

[^cf-bge-m3]: Cloudflare Docs "Workers AI > Models > bge-m3" の Model Info
    <https://developers.cloudflare.com/workers-ai/models/bge-m3/>
    2026 年 9 月 7 日の時点で、Context Window は 60,000 tokens と書いてある。
    同じ欄は qwen3-embedding-0.6b のページでは 8,192 tokens で、plamo-embedding-1b と embeddinggemma-300m のページには無い。
