記事を書き終えて公開ボタンに手をかけた瞬間、「これ、前にも似たようなことを書いた気がする」と手が止まった経験はないでしょうか。私自身、個人開発で複数の技術ブログを回していると、コーパスが数百本を超えたあたりから、この既視感が頻繁に起きるようになりました。
やっかいなのは、タイトルも slug も違うのに、検索エンジンから見ると「ほぼ同じ問いに答える2本」になっているケースです。これは SEO の世界でキーワードカニバリゼーション(共食い)と呼ばれ、両方の記事の掲載順位が中途半端に下がる原因になります。人間の目視レビューでは、600本のうちどれと近いのかを毎回照合しきれません。
そこで私が実際に組んだのが、公開前に本文の意味ベクトルを既存記事群と突き合わせ、近すぎる記事があれば差し戻す「類似ゲート」です。画像の近重複を弾く仕組みは画像の近重複ゲートを作る記事 で扱いましたが、今回はその文章版にあたります。gemini-embedding-2 の意味埋め込みを使い、閾値の決め方と誤検知の潰し方まで、運用で効いた順にお伝えします。
なぜ「キーワード一致」や「タイトル比較」では取りこぼすのか
最初に試したのは、タイトルと description の文字列一致でした。ところがこれは、ほとんど役に立ちませんでした。「Gemini API のコストを削る」と「Gemini の課金を月末に膨らませない設計」は、共通する単語がほぼないのに、読者の検索意図はほぼ同じです。逆に「Gemini でコストを削る」と「Gemini で画像を圧縮する」は「コスト」「Gemini」を共有しますが、まったく別の記事です。
表層の単語では、意味の距離を測れません。ここが埋め込み(embedding)の出番になります。埋め込みは文章を高次元のベクトルに変換し、意味が近い文章ほどベクトル空間上で近い位置に配置します。単語が一つも重ならなくても、「同じことを別の言い方で書いている」記事を、コサイン類似度という一つの数値で捉えられるようになります。
私の場合、この違いが如実に出たのは運用系の記事群でした。表現を変えながら似た運用ノウハウを書いていた3本が、文字列一致ではまったく引っかからず、埋め込みでは相互に 0.9 前後という高い類似度で並んだのです。
何を埋め込むか — 本文全体は逆効果になる
素朴に本文全文を1本のベクトルにすると、精度がむしろ落ちます。長い記事はコード例や余談で薄まり、核心のトピックがベクトルの中で埋もれるためです。試行錯誤の末、私が落ち着いたのは次の構成です。
タイトル
description(記事の主旨を160字で凝縮した一文)
すべての H2・H3 見出し(記事の骨格そのもの)
この3要素を連結した「トピック要約テキスト」を埋め込みます。見出しは著者が意識的に付けた記事の設計図なので、本文の枝葉より意味の芯をよく表します。コード例や引用は意図的に外します。
import re
from pathlib import Path
def build_topic_text (mdx_path: Path) -> str :
"""MDX からトピック要約テキスト(タイトル+description+見出し)を組み立てる"""
text = mdx_path.read_text( encoding = "utf-8" )
m = re.match( r " ^ --- \n (. *? ) \n --- \n (. * )$ " , text, re. DOTALL )
front, body = m.group( 1 ), m.group( 2 )
def fm (key: str ) -> str :
mm = re.search( rf '^ { key } :\s*"?(.*?)"?\s*$' , front, re. MULTILINE )
return mm.group( 1 ) if mm else ""
# コードブロックを除去してから見出しを抽出する
body_no_code = re.sub( r "``` . *? ```" , "" , body, flags = re. DOTALL )
headings = re.findall( r " ^ # {2,3} \s + (. * )$ " , body_no_code, re. MULTILINE )
parts = [fm( "title" ), fm( "description" )] + headings
return " \n " .join(p for p in parts if p)
出力は「タイトル+主旨+全見出し」を改行で並べた1本のテキストになります。これを次のステップでベクトル化します。
gemini-embedding-2 でベクトル化する
埋め込みの生成には google-genai SDK を使います。ポイントは task_type に SEMANTIC_SIMILARITY を指定することです。これを省くと汎用の埋め込みになり、「文章同士の近さ」を測る用途では精度が一段落ちます。次元数は Matryoshka 表現学習に対応しているので、保存容量とのトレードオフで切り詰められます。私は 768 次元に落として運用しています。
from google import genai
from google.genai import types
import numpy as np
client = genai.Client() # GEMINI_API_KEY を環境変数から読む
def embed (text: str ) -> np.ndarray:
resp = client.models.embed_content(
model = "gemini-embedding-2" ,
contents = text,
config = types.EmbedContentConfig(
task_type = "SEMANTIC_SIMILARITY" ,
output_dimensionality = 768 , # 3072→768 に切り詰めて保存量を1/4に
),
)
v = np.array(resp.embeddings[ 0 ].values, dtype = np.float32)
# 次元を切り詰めた場合は L2 正規化を明示的にかけ直す必要がある
return v / np.linalg.norm(v)
次元を切り詰めたときに正規化をかけ直す一手を忘れると、コサイン類似度がわずかにずれます。ここは公式ドキュメントでも見落としやすい注意点で、私は正規化を入れる前後で閾値がずれてしばらく悩みました。次元削減の考え方そのものはMatryoshka による次元削減とベクトルDBコストの記事 に詳しく書いています。
既存コーパスをインデックス化する
670本規模でも、768次元 float32 なら1本あたり約3KB、全体で2MB程度に収まります。専用のベクトルDBを立てるまでもなく、SQLite にベクトルを BLOB として保存すれば十分です。埋め込みAPIの呼び出しは、無料枠でも数百本なら一気に処理できます。
import sqlite3
def build_index (articles_dir: Path, db_path: str = "topics.db" ) -> None :
conn = sqlite3.connect(db_path)
conn.execute( """CREATE TABLE IF NOT EXISTS topics(
slug TEXT PRIMARY KEY, vec BLOB)""" )
for mdx in articles_dir.glob( "**/*.mdx" ):
slug = mdx.stem
cur = conn.execute( "SELECT 1 FROM topics WHERE slug=?" , (slug,))
if cur.fetchone():
continue # 既にある記事は再計算しない(増分インデックス)
vec = embed(build_topic_text(mdx))
conn.execute( "INSERT INTO topics(slug, vec) VALUES(?, ?)" ,
(slug, vec.tobytes()))
conn.commit()
print ( f "indexed: { slug } " )
conn.close()
SELECT 1 で既存 slug をスキップしている点が、地味ですが運用では効きます。記事が増えるたびに全件を再埋め込みしていては、API コストも時間も無駄になります。差分だけを足す増分インデックスにしておくと、毎日の投稿パイプラインに組み込んでも負荷が一定に保てます。
公開前ゲート — コサイン類似度の上位を出す
新しいドラフトを埋め込み、既存の全ベクトルとのコサイン類似度を計算して、上位数件を出します。670本との総当たりでも、正規化済みベクトルなら行列積1回で終わり、数ミリ秒です。
def check_draft (draft_mdx: Path, db_path: str = "topics.db" , top_k: int = 5 ):
conn = sqlite3.connect(db_path)
rows = conn.execute( "SELECT slug, vec FROM topics" ).fetchall()
slugs = [r[ 0 ] for r in rows]
mat = np.vstack([np.frombuffer(r[ 1 ], dtype = np.float32) for r in rows])
q = embed(build_topic_text(draft_mdx))
sims = mat @ q # 全ベクトル正規化済みなので内積 = コサイン類似度
order = np.argsort( - sims)[:top_k]
print ( f "draft: { draft_mdx.stem } " )
for i in order:
verdict = "🛑差し戻し" if sims[i] >= 0.92 else \
"⚠️要判断" if sims[i] >= 0.88 else "✅許容"
print ( f " { sims[i] :.3f } { verdict } { slugs[i] } " )
return sims[order[ 0 ]], slugs[order[ 0 ]]
出力はたとえば次のようになります。
draft: gemini-embedding-article-topic-cannibalization-prepublish-gate
0.905 ⚠️要判断 gemini-embedding2-image-dedup-prepublish-gate
0.837 ✅許容 gemini-embeddings-semantic-search-production
0.812 ✅許容 gemini-embedding-app-reviews-semantic-clustering-priority-design
トップが 0.90 前後で「要判断」に入るのは想定どおりです。姉妹記事(画像版)とはアプローチが近いので、意味も近く出ます。ここで人間、あるいは次に述べる Gemini の判定に渡します。
閾値をどう決めたか
閾値は理屈より実測で決めるのが確実です。私は既存コーパス内で相互の類似度を全ペア計算し、実際に共食いしていた既知のペアと、別物だと確信できるペアの分布を眺めて線を引きました。670本規模で落ち着いた基準が以下です。
コサイン類似度 判定 対応
0.92 以上 差し戻し ほぼ同一トピック。統合するか、片方の角度を根本から変える
0.88〜0.92 要判断 境界域。Gemini か著者が「補完か重複か」を判断する
0.88 未満 許容 関連はするが独立した記事として公開してよい
この数値はあくまで私のコーパスと文体での実測値です。文章のトーンが揃っているサイトほど全体の類似度が底上げされるので、他のサイトにそのまま持ち込むと厳しすぎたり緩すぎたりします。導入する場合は、自分の既知の共食いペアで一度キャリブレーションすることを強く推奨します。
境界域だけ Gemini に判定させる
0.88〜0.92 の境界域は、コサイン類似度だけでは白黒つきません。「同じ主題を別角度から深掘りした補完記事」なのか「実質同じことを言い換えただけの重複」なのかは、意味の微妙な差を読む必要があります。ここだけ Gemini に判断させると、目視レビューの負担が大きく減ります。
def adjudicate (draft_mdx: Path, rival_mdx: Path) -> dict :
prompt = f """次の2記事は、検索面で共食い(カニバリ)を起こすほど
主題が重複していますか。読者の検索意図が実質同じなら "duplicate"、
関連するが独立した価値があるなら "complementary" と判定してください。
# 記事A
{ build_topic_text(draft_mdx) }
# 記事B
{ build_topic_text(rival_mdx) } """
resp = client.models.generate_content(
model = "gemini-3.5-flash" ,
contents = prompt,
config = types.GenerateContentConfig(
response_mime_type = "application/json" ,
response_schema = {
"type" : "object" ,
"properties" : {
"verdict" : { "type" : "string" ,
"enum" : [ "duplicate" , "complementary" ]},
"reason" : { "type" : "string" },
"suggested_angle" : { "type" : "string" },
},
"required" : [ "verdict" , "reason" ],
},
),
)
import json
return json.loads(resp.text)
構造化出力で verdict を列挙型に固定しているので、判定結果をそのままパイプラインの分岐条件に使えます。complementary と返ってきたら、suggested_angle(差別化すべき角度)をドラフトの冒頭にメモとして残し、重複しないよう本文を寄せていきます。構造化出力そのもののつまずきどころは構造化出力の意味検索を本番運用する記事 側でも触れています。
軽量な gemini-3.5-flash で十分な精度が出ます。判定に回るのは境界域の数件だけなので、コストはほぼ無視できる範囲に収まります。私の運用では、1日の新規記事に対して Gemini 判定が走るのは平均1件未満です。
投稿パイプラインへの組み込みで気づいたこと
自動投稿のパイプラインにこのゲートを差し込んでみて、いくつか本番ならではの落とし穴に当たりました。
一つ目は、下書き段階では見出しがまだ埋まっていないことです。見出しに強く依存する設計なので、H2 が2〜3個しかない初稿だと類似度が不安定になります。私は「本文が一通り書き上がった段階で1回だけゲートを通す」運用に落ち着きました。
二つ目は、削除した記事のベクトルが残り続けることです。410 で消した記事のベクトルがインデックスに残っていると、実在しない記事との共食いを警告してきます。インデックス更新時に、実ファイルが消えた slug の行も掃除する処理を必ず入れます。
三つ目は、閾値が固定ではないという点です。コーパスが育つほど、どこかしらに近い記事が存在する確率が上がります。私は四半期に一度、既知の判定結果と突き合わせて閾値を見直すようにしています。この地道な調整が、誤検知で書き手の手を止めない鍵になりました。
まず試すなら
手元の記事群から30本ほどを選び、build_index でインデックスを作って、既存記事同士の類似度を眺めてみてください。自分のコーパスで「これは共食いだ」と直感する既知ペアが、どの数値に落ちるかを確かめる。そこから逆算して自分の閾値を引く——この最初のキャリブレーションが、ゲート全体の精度を決めます。
Dolice Labs のように記事が増え続ける前提でメディアを個人で運用していると、新しく書く力と同じくらい、過去の自分と衝突しない仕組みが効いてきます。共食いを機械が先に見つけてくれるだけで、書くことそのものに集中できる時間が確実に増えました。同じように記事数と格闘している方の助けになればうれしいです。