言葉が数字になるまでで、言葉が意味空間の座標に変換される話を書きました。ただ「ベクトル」という言葉は、読んだだけではどうにも掴みどころがありません。
実物は、手元で取り出せます。 公開モデルを1つ落としてくれば、ノートパソコンで動きます。この記事は、実際に取り出した数字をそのまま並べたものです。
同じ道具を使って測った記事を、いくつも書いています(テーマ『手元で測る』にまとめてあります)。この記事は、その入口にあたります。
ベクトル・トークンといった言葉は『LLMと日本語の用語集』に一言ずつまとめてあるので、引っかかったらそちらへ。
準備
Node.jsがインストールされていれば、これだけです。
mkdir vec-demo && cd vec-demo
npm install @huggingface/transformers
初回の実行時に、モデルのダウンロードが走ります(約450MB)。2回目以降はキャッシュから読むので、すぐ動きます。GPUは要りません。
落としてくるモデルの名前は paraphrase-multilingual-MiniLM-L12-v2 です。長い名前ですが、やることは一つだけ——語や文を渡すと、384個の数字の組を返す。 それだけです。100以上の言語をまとめて学習しているので、日本語も扱えます。以下では MiniLM と短く呼びます。
一つ、大事な区別があります。これは文章を書くLLMではありません。 ChatGPTやClaudeのように文章を作る道具とは別物で、「この文とこの文は近いか」を判定するためだけにあります。文書検索や、自分の資料で専門特化のエージェントを作るで書いたRAGで、関係のありそうな箇所を探す工程に使われる側です。
動かすもの
demo.mjs として保存して node demo.mjs で動きます。
import { pipeline } from '@huggingface/transformers'
const ex = await pipeline('feature-extraction', 'Xenova/paraphrase-multilingual-MiniLM-L12-v2')
const emb = async w => Array.from((await ex(w, { pooling: 'mean', normalize: true })).data)
const cos = (a, b) => a.reduce((s, x, i) => s + x * b[i], 0)
const words = ['人間', 'ヒト', 'チンパンジー', 'ゴリラ', 'イヌ', 'マグロ', '机', '自由']
const raw = []
for (const w of words) raw.push(await emb(w))
console.log(`次元数: ${raw[0].length}`)
console.log(`「人間」の先頭6次元: ${raw[0].slice(0, 6).map(x => x.toFixed(4)).join(', ')} ...`)
// 全体の平均を引いてから、長さを1に揃え直す
const d = raw[0].length
const mean = new Array(d).fill(0)
for (const v of raw) for (let i = 0; i < d; i++) mean[i] += v[i] / raw.length
const vecs = raw.map(v => {
const u = v.map((x, i) => x - mean[i])
const n = Math.hypot(...u)
return u.map(x => x / n)
})
for (const target of ['人間', 'チンパンジー']) {
const i = words.indexOf(target)
const near = words
.map((w, j) => [w, cos(vecs[i], vecs[j])])
.filter((_, j) => j !== i)
.sort((a, b) => b[1] - a[1])
.map(([w, s]) => `${w}(${s.toFixed(2)})`)
console.log(`【${target}】 ${near.join(' > ')}`)
}
出力です。
次元数: 384
「人間」の先頭6次元: 0.0034, 0.0345, -0.0091, -0.0069, -0.0638, -0.0783 ...
【人間】 ヒト(0.86) > マグロ(0.03) > 机(-0.06) > イヌ(-0.12) > 自由(-0.14) > チンパンジー(-0.45) > ゴリラ(-0.47)
【チンパンジー】 ゴリラ(0.60) > イヌ(-0.09) > 自由(-0.29) > 机(-0.30) > マグロ(-0.42) > 人間(-0.45) > ヒト(-0.48)
1つめ:数字そのものには、意味がない
「人間」は384個の数字でした。この個数を次元数といい、モデルごとに決められた設計値です。384はこのモデルの値で、他のモデルなら別の数になります。
先頭の6個を見ても、何の手がかりもありません。1次元目が何を表しているか、という問いには答えがありません。
意味は、1つ1つの数字ではなく並び全体にあり、しかも他の語との位置関係としてしか取り出せません。だから、比べる相手が要ります。
比べ方——コサイン類似度
上の出力に並んでいる数字が、その「比べた結果」です。コサイン類似度といいます。名前は難しいのですが、中身は単純です。
384個の数字は、384次元の空間に引いた矢印だと思ってください。次元が多すぎて絵には描けませんが、考え方は紙の上の矢印と同じです。コサイン類似度は、2本の矢印がどれだけ同じ方向を向いているかを表します。矢印の長さは見ません。向きだけです。
| 2本の矢印 | 角度 | 値 |
|---|---|---|
| 同じ方向 | 0度 | 1 |
| 直角 | 90度 | 0 |
| 正反対 | 180度 | −1 |
右の列は、その角度のコサインそのものです。cos 0° = 1、cos 90° = 0、cos 180° = −1。名前はここから来ています。角度を、扱いやすい数字に置き換えただけのものです。
なぜ長さを見ないのか。 矢印の長さは、その語がどれだけ文章に登場するかといった、意味とは関係のない事情で変わります。 意味を担っているのは向きのほうなので、先に長さを1に揃えてしまいます。スクリプトの normalize: true がその処理です。
長さが揃っているおかげで、計算はこれだけで済みます。
const cos = (a, b) => a.reduce((s, x, i) => s + x * b[i], 0)
同じ位置の数字どうしを掛けて、全部足しているだけです。384組を掛けて足す。それで向きの一致度が出ます。
2つめ:生の数字は、そのままでは読めない
スクリプトの真ん中に「全体の平均を引く」という処理があります。これを外すと、結果はこうなります。
【人間】 ヒト(0.97) > マグロ(0.75) > イヌ(0.67) > 机(0.63) > 自由(0.55) > チンパンジー(0.50) > ゴリラ(0.47)
何と何を比べても0.4〜0.9に収まってしまい、差が読めません。 これは意味の話ではなく、ベクトルが空間の狭い一角に固まっているために起きます。全部が同じ方向を向いているので、どれとどれも「そこそこ似ている」ように見える。
全体の平均を引くと、その共通成分が消えて、差だけが残ります。 上の処理はそのためのものです。似ていないものはマイナスになります。
3つめ:人間は、類人猿の隣にいない
本題です。チンパンジーとゴリラは0.60でぴったり近い。ところが人間は、どちらからも−0.45前後で、はっきり遠い。 人間のほうに近いのは、マグロや机のほうでした。
生物学的な分類は、ここには含まれていません。 含まれているのは文章の中での使われ方の近さだけです。「人間」という語は「人間とは何か」「人間関係」のような文脈で使われ、「ゴリラ」は生態や動物園の文脈で使われる。別の場所で使われる語は、別の場所に置かれます。
ただし、ここまでは推測です。 もっともらしい説明ではありますが、この記事の中では確かめていません。
そこで別途、先に予言を立てて測り直しました——クジラのほうが、人間より哺乳類だった。推測どおりだった部分と、外れた部分の両方が載っています。
英語で同じことをやっても、結果は変わりませんでした。
【human】 freedom(0.02) > desk(-0.10) > dog(-0.14) > gorilla(-0.22) > chimpanzee(-0.26)
【chimpanzee】 gorilla(0.38) > dog(-0.16) > desk(-0.19) > human(-0.26) > freedom(-0.27)
humanのいちばん近くにいるのは freedom です。 日本語固有の癖ではなく、そういう作りだということになります。
4つめ:足し算引き算が効く
意味空間という言い方が誇張でないことは、演算で確かめられます。単語のベクトルを足し引きしてから、いちばん近い語を探します。
const u = V['王'].map((x, i) => x - V['男'][i] + V['女'][i])
結果です。
王 - 男 + 女 → 女王(0.84) > 王女(0.80) > 女(0.76)
父 - 男 + 女 → 母(0.82) > 女(0.72) > 父(0.64)
王子 - 男 + 女 → 王女(0.79) > 女王(0.75) > 女(0.69)
「男から女への向き」が、空間の中で一定の方向として存在しています。 王からその方向に進むと女王に着く。父から進むと母に着く。教えられた対応表ではなく、位置関係として持っていることが、ここで見えます。
但し書き
- ここで使ったのは384次元の小さな公開モデルで、Claudeではありません。 Anthropicはモデルの中身を公開していないので、同じことはできません
- 単語1つを与えていますが、このモデルは本来文を扱うものです。単語単体は本来の用途から外れた使い方で、結果もその分ぶれます
- 語の選び方で結果は動きます。8語の中での相対的な近さを見ている、という点は外さないでください
まとめ
- 意味のベクトルは、公開モデルを落とせば手元で実際に取り出せる。GPUは要らない
- 中身は数字の並びで、1つ1つには意味がない。意味は他の語との位置関係としてしか現れない。個数(次元数)はモデルごとの設計値で、384個はこのモデルの場合
- 生のコサイン類似度は読めない。 全部が0.4〜0.9に張り付くので、全体の平均を引いてから見る
- 人間はチンパンジーやゴリラから遠い。 意味空間にあるのは生物学的分類ではなく、文章中での使われ方の近さ——ただしこれは本記事では推測にとどまり、別記事で測り直している
- 足し引きが成り立つ。「王 − 男 + 女」は女王に着く。日本語でも再現する
「ベクトル」という言葉が曖昧に感じられるのは当然で、実物も曖昧です。 1つ1つの数字は説明できません。それでも、並べて比べると構造が見える。 そこだけが確かなところです。
なお、この記事で使った語のうち「イヌ」の扱いには別の問題があります。それは「犬」と「いぬ」は別の語として扱われているで扱います。