4つのサイトで日本語と英語の記事を必ずペアで公開しています。片方だけ存在すると言語切替で 404 になるため、日英は常に同数です。手元の1サイトでいま JA 725 本・EN 725 本。つまり「意味が同じで言語だけが違う文書」が 725 組、正解つきで揃っている状態です。
この対を使って、公開前の重複検知を作ろうとしました。新しく書いた日本語の下書きを埋め込み、既存記事の中から近いものを引く。日英どちらの既存記事も同じインデックスに入れておけば、言語をまたいだ重複も拾えるはずでした。
引いてみると、上位10件が全部日本語記事でした。私自身、最初はインデックスの作り方を間違えたのだと思いました。しかも、その日本語の下書きには必ず対応する英語版が存在しているにもかかわらず、その対訳は上位に出てきません。内容が完全に一致する相手が、内容の遠い同言語の記事に負けています。
task_type は揃えていました。正規化もしていました。原因はもっと手前にありました。
コサイン類似度は「何語で書かれているか」も測っている
埋め込みモデルは、渡した文をベクトルにします。そのベクトルには意味だけが入っていてほしいのですが、実際には書かれた言語という属性も一緒に載ります。
多言語モデルは、確かに言語をまたいで意味を近づけるように学習されています。ただ「まったく同じ向きに揃える」ようには学習されていません。結果として、埋め込み空間の中で日本語の文はゆるやかに一箇所に集まり、英語の文は別の一箇所に集まります。
| 比較対象 | 意味の近さ | 言語の一致 | 実際の類似度 |
|---|---|---|---|
| 日本語の下書き ↔ その英語対訳 | ほぼ同一 | 不一致 | 中 |
| 日本語の下書き ↔ 別テーマの日本語記事 | 遠い | 一致 | 中〜高 |
言語の一致が稼ぐ下駄が、意味の差を埋めてしまう。上位10件が日本語で占められていたのは、モデルが間違えたからではありませんでした。私が「意味だけを測っているつもりで、言語も一緒に測っていた」だけです。
この成分をどう扱うかは用途で変わります。同一言語内の検索しかしないなら、言語成分は全ベクトルに等しく載るので順位に影響しません。困るのは、日本語と英語を同じインデックスに混ぜて引く瞬間だけです。私はそこを踏みました。
対訳ペアは、そのまま評価データセットになる
先に測り方を用意します。対訳ペアの良いところは、正解のラベル付けが要らないことです。gemini-3-multitool-agent-guide.mdx の日本語版から引いたとき、正解は同じ slug の英語版だと最初から決まっています。
100 組ほどあれば傾向は見えます。次のコードは、日英ペアを埋め込んで JA→EN の再現率と MRR を出す最小の計測台です。
import os, json, math, random
from google import genai
from google.genai import types
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
MODEL = "gemini-embedding-2"
def embed(texts, task_type):
"""テキスト群を埋め込む。API 側の上限に合わせて分割する。"""
out = []
for i in range(0, len(texts), 32):
chunk = texts[i:i + 32]
res = client.models.embed_content(
model=MODEL,
contents=chunk,
config=types.EmbedContentConfig(task_type=task_type),
)
out.extend([e.values for e in res.embeddings])
return out
def l2_normalize(v):
n = math.sqrt(sum(x * x for x in v))
return [x / n for x in v] if n else v
def cosine(a, b):
return sum(x * y for x, y in zip(a, b))
# pairs: [{"slug": "...", "ja": "日本語本文の冒頭2000字", "en": "English body ..."}, ...]
pairs = json.load(open("pairs.json"))
random.seed(42)
pairs = random.sample(pairs, min(100, len(pairs)))
ja_vecs = [l2_normalize(v) for v in embed([p["ja"] for p in pairs], "RETRIEVAL_QUERY")]
en_vecs = [l2_normalize(v) for v in embed([p["en"] for p in pairs], "RETRIEVAL_DOCUMENT")]
def evaluate(queries, docs):
"""queries[i] の正解は docs[i]。順位を集計する。"""
hits = {1: 0, 5: 0, 10: 0}
rr_sum = 0.0
for i, q in enumerate(queries):
scored = sorted(
((cosine(q, d), j) for j, d in enumerate(docs)),
reverse=True,
)
rank = next(r for r, (_, j) in enumerate(scored, start=1) if j == i)
for k in hits:
if rank <= k:
hits[k] += 1
rr_sum += 1.0 / rank
n = len(queries)
return {
"recall@1": hits[1] / n,
"recall@5": hits[5] / n,
"recall@10": hits[10] / n,
"MRR": rr_sum / n,
}
print("baseline:", evaluate(ja_vecs, en_vecs))この時点では、母集団に英語文書しか入っていません。言語の下駄は全候補に等しくかかるので、ここでの再現率は素直に出ます。私の手元では recall@1 が 0.9 前後、つまり 90% ほどの下書きで対訳が1位に来ていました。
問題は次です。候補に日本語記事も混ぜます。
# 日本語記事も同じインデックスに入れる(自分自身は候補から除く)
ja_docs = [l2_normalize(v) for v in embed([p["ja"] for p in pairs], "RETRIEVAL_DOCUMENT")]
def evaluate_mixed(queries, en_docs, ja_docs):
hits = {1: 0, 5: 0, 10: 0}
for i, q in enumerate(queries):
cands = [(cosine(q, d), ("en", j)) for j, d in enumerate(en_docs)]
cands += [(cosine(q, d), ("ja", j)) for j, d in enumerate(ja_docs) if j != i]
cands.sort(reverse=True)
rank = next(r for r, (_, key) in enumerate(cands, start=1) if key == ("en", i))
for k in hits:
if rank <= k:
hits[k] += 1
n = len(queries)
return {f"recall@{k}": v / n for k, v in hits.items()}
print("mixed:", evaluate_mixed(ja_vecs, en_vecs, ja_docs))日本語候補を混ぜた途端に recall@1 が崩れました。混ぜる前と後の差が、そのまま言語成分の大きさです。単一言語のベンチマークで問題なく見えていたインデックスが、多言語にした瞬間に別物になる。この落差を数字にできたことが、この計測台の一番の収穫でした。