ARTICLE

Workers AI で「関連記事」機能を作り直した

article

One reaction at a time. Picking another replaces it.

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

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

理想を言えば、「洗濯表示は守ったほうが良い。特に乾燥機。」を読み終えた読者には、同じ読み味のエッセイを勧めたい。ところが 2 番目に出ていたのは「Windows タブレット KEIAN KVI-70B に Arch Linux をインストールする」だった。生活のエッセイを読み終えた読者に、次は 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 体験を」には 5 つもタグを付けた。開発環境に使った道具の名前を並べたのだが、どのタグもこの記事にしか付いていないものだ。結果、当然関連記事は 0 件。丹精込めてタグを 5 つ付けても、関連記事に寄与しないのである。

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

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

Workers AI に本文を読ませる

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

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

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

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

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

モデル次元入力の上限 (モデルカード)1入力の上限 (Cloudflare)2料金 (入力 100 万トークン)
bge-m310248,192 トークン60,000 トークン$0.012
qwen3-embedding-0.6b102432K トークン8,192 トークン$0.012
plamo-embedding-1b20484,096 トークン記載なし$0.019
embeddinggemma-300m7682,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-m324,000 字でエラー13,500〜14,000 字でエラーエラーを返す11,000 字
qwen3-embedding-0.6b16,000〜24,000 字11,500〜12,000 字黙って切り捨てる10,000 字
plamo-embedding-1b8,000〜10,000 字6,000〜6,500 字黙って切り捨てる5,000 字
embeddinggemma-300m5,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 枚でそろえた。同じタグの記事が本当に似ていれば、対角線上に四角いブロックが浮かぶ。

試験、ハードウェア、競技プログラミングの 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-m347.6%0.775
qwen3-embedding-0.6b53.0%0.811
plamo-embedding-1b53.0%0.813
embeddinggemma-300m54.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 つとも「応用情報合否発表!!」で一致する。タグ版では関連記事がなかった音楽の「素敵なまゆげ」にも、候補が付いた。上位 2 件はどれも、小学生の頃に描いたキャラクターの「サルークファミリー」と、クラスメイトから募ったイラストの「キャラクター製作研究所」だった。天涯孤独かと思われたまゆげにも、ようやく友達ができた。

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

「乾燥機」の記事には、plamo が「なぜ AI 全盛の時代に、技術ブログをリニューアルしたのか」を 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 日時点の本番では全部違う関連記事になった。「8月中にやること」と「立派なものを頂いた」を開くと、この並びに変わった。

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

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

「乾燥機」の記事の 1 位は「スマホのジャック(4極)にヘッドフォン(3極)とマイク(2極)を接続する」だ。冒頭で望んだ、生活のエッセイへ案内する並びには届かなかった。そりゃそうだ。生活エッセイが他に 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 もあるので、記事のカバー画像に使える絵が出るかは試してみたい。また記事の中身を書く前に、本サイトの機能を作り込み始めそうだ。

Footnotes

  1. 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 トークンである。

  2. 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 のページには無い。

One reaction at a time. Picking another replaces it.

Related articles