埋め込みとベクトル検索(RAG の土台)
実装:
llm/embed// 実行:go test ./llm/embed/
LLM の内部表現は意味のベクトルで、近い意味の文は近いベクトルになる。この性質を使うのが意味検索だ。文をベクトル化して蓄え、クエリのベクトルとコサイン類似度が高いものを返す。キーワードの一致でなく意味の近さで引けるので、RAG や推薦の土台になる。この章では平均プーリングでの文ベクトル化・コサイン類似度・総当たり検索を実装し、件数が増えると総当たりが破綻して近似最近傍が要る理由まで確かめる。
この章で作るもの
Gemini や Claude の長コンテキストは強力だが、社内文書やマニュアル全体を毎回窓に入れるのは高くつく。かわりに使われるのが RAG(retrieval-augmented generation)で、質問に関係する断片だけを検索してコンテキストに足す。その検索の芯がこの章の意味検索だ。
芯にあるのは、Transformer が作る内部表現が「意味のベクトル」だという性質だ。似た意味の文は空間内で近い位置に来る。だから「猫」で検索して「子猫」がヒットする。キーワードが一致しなくても、意味が近ければ引ける。転置インデックス の全文検索が単語の一致で引いたのに対し、こちらは意味の近さで引く。
意味空間(概念図)
↑
kitten ● cat クエリ「猫」に近い順:
dog ● cat(1.00)→ kitten(0.99)→ dog(0.93)→ car(0.10)
キーワード一致でなく、向きの近さで並ぶ
────────────→ car ●順に見ていく。
- 文をベクトルにする: トークン埋め込みを平均して 1 本の文ベクトルにする(プーリング)
- 近さはコサイン類似度: 向きの一致で測る。長さに依存しないので文の長短に頑健
- 総当たりは件数に線形: 全ベクトルと比較する。billion 件では破綻し、近似最近傍(ANN)が要る
① 文をベクトルにする: プーリング
モデルが出すのはトークンごとの埋め込み列だ。これを 1 本の文ベクトルにまとめる必要がある。最も素朴なのが平均プーリングで、全トークンの埋め込みを平均する:
// MeanPool はトークン埋め込みの列を平均して 1 本の文ベクトルにする。
// 最も素朴なプーリング。実物は attention 重み付き平均や [CLS] トークンも使う。
func MeanPool(tokens [][]float64) []float64 {
if len(tokens) == 0 {
return nil
}
dim := len(tokens[0])
out := make([]float64, dim)
for _, t := range tokens {
for i := range out {
out[i] += t[i]
}
}
for i := range out {
out[i] /= float64(len(tokens))
}
return out
}
// Cosine はコサイン類似度 a·b / (|a||b|)。向きだけを見て長さに依存しない。
// 同じ向き 1、直交 0、逆向き -1。ゼロベクトルは 0 を返す。
func Cosine(a, b []float64) float64 {
dot, na, nb := 0.0, 0.0, 0.0
for i := range a {
dot += a[i] * b[i]
na += a[i] * a[i]
nb += b[i] * b[i]
}
if na == 0 || nb == 0 {
return 0
}
return dot / (math.Sqrt(na) * math.Sqrt(nb))
}
// Normalize は長さ 1 のベクトルにする。正規化済みベクトルどうしの内積は
// コサイン類似度に等しくなるので、大量検索では前もって正規化しておくと速い。
func Normalize(v []float64) []float64 {
n := 0.0
for _, x := range v {
n += x * x
}
out := make([]float64, len(v))
if n == 0 {
return out
}
inv := 1 / math.Sqrt(n)
for i, x := range v {
out[i] = x * inv
}
return out
}Cosine が近さの指標だ。2 つのベクトルの内積を長さで割るので、向きだけを見て長さに依存しない。同じ向きなら 1、直交なら 0、逆向きなら -1。文の長短でベクトルの大きさが変わっても、向きが同じなら類似度は高いままになる。テストでは同方向・直交・逆向きの 3 ケースと、ゼロベクトルが NaN でなく 0 を返すことを固定した。
Normalize は長さ 1 にそろえる操作で、正規化済みベクトルどうしの内積はコサイン類似度そのものになる。大量検索では前もって正規化しておけば、検索時は割り算なしの内積だけで済む。
実物のプーリングはもう少し凝る。BERT 系は文頭の [CLS] トークンの埋め込みを文ベクトルに使い、専用の埋め込みモデル(Sentence-BERT など)は「似た文を近づける」目標で追加学習する。平均プーリングはその最も単純な形だ。
② ベクトルを蓄えて検索する
文ベクトルを蓄え、クエリに近い順で返すストアを作る。総当たりで全件とのコサイン類似度を計算し、上位を返すだけだ:
// Hit は検索結果の 1 件(キーと類似度スコア)。
type Hit struct {
Key string
Score float64
}
// entry は蓄えた 1 件の埋め込み。
type entry struct {
key string
vec []float64
}
// Store は埋め込みの集まり。総当たりでコサイン類似度の上位を返す。
type Store struct {
items []entry
}
// NewStore は空のストアを作る。
func NewStore() *Store { return &Store{} }
// Add はキーと埋め込みを 1 件追加する。
func (s *Store) Add(key string, vec []float64) {
s.items = append(s.items, entry{key: key, vec: vec})
}
// Search はクエリに最も近い上位 k 件をコサイン類似度の降順で返す。
func (s *Store) Search(query []float64, k int) []Hit {
hits, _ := s.SearchCounted(query, k)
return hits
}
// SearchCounted は Search に加え、比較したベクトル数(= 総当たりの計算量)を返す。
// 蓄積件数にそのまま比例することを見せるための計測点。
func (s *Store) SearchCounted(query []float64, k int) ([]Hit, int) {
hits := make([]Hit, len(s.items))
for i, e := range s.items {
hits[i] = Hit{Key: e.key, Score: Cosine(query, e.vec)}
}
sort.SliceStable(hits, func(a, b int) bool { return hits[a].Score > hits[b].Score })
if k > len(hits) {
k = len(hits)
}
if k < 0 {
k = 0
}
return hits[:k], len(s.items)
}テストで固定したのは順位だ。「猫」のクエリに対し、cat 自身が最上位(類似度 1.0)、次に意味の近い kitten、無関係な car は最下位になる。キーワードは一切一致していないのに、意味の近さで正しく並ぶ。これが意味検索の効きどころで、転置インデックス の単語一致では引けなかった「言い換え」や「同義」を拾える。
③ なぜ近似最近傍が要るか
SearchCounted は比較したベクトル数を返す。この数が、総当たり検索の弱点をそのまま映す。
総当たりは蓄積件数にそのまま比例する。1000 件なら 1000 回の比較で済むが、実サービスのベクトル数は数百万から数十億になる。1 クエリごとに 10 億ベクトルと比較するのは、レイテンシでも費用でも成立しない。キャパシティ見積もりと同じで、件数の桁が構成そのものを決める。
そこで実物は近似最近傍探索(ANN)を使う。厳密な最近傍を諦め、「ほぼ最も近い」を高速に返す。代表的な HNSW は、ベクトルを多層のグラフに配置して、粗い層から近い方向へたどり降りることで、全件を見ずに近傍へ到達する。スキップリスト の多層で探索を対数にする発想や、コンシステントハッシュ の空間分割と同じ、「全部見ない」ための構造だ。総当たりの O(n) を実質 O(log n) に落とす。
動かす
下のデモは 2 つの見方を用意した。「意味検索」は 2 次元に配置した単語ベクトルに対しクエリを動かし、コサイン類似度で並ぶ順位が変わる様子を見る。「総当たりの限界」は蓄積件数を増やしたとき、総当たりの比較回数が線形に膨らみ、ANN が必要になる規模を確かめられる。
クエリ「猫っぽく」に最も近いのは cat(類似度 1.00)。キーワードは一致していないのに、ベクトルの向きの近さで意味的に並ぶ。これが同義・言い換えを拾える理由
文をベクトルにすると、意味の近さが向きの近さ(コサイン類似度)で測れる。キーワード一致でなく 意味で引けるので RAG の検索に使う。ただし総当たりは件数に線形で billion 件では破綻するため、 実物は近似最近傍(HNSW など)で全件を見ずに近傍へたどり着く。
設計の観点
- RAG の全体像: 文書をチャンクに切って埋め込み、ベクトル DB に蓄える(索引時)。クエリを埋め込んで近い断片を引き、プロンプトに足して LLM に渡す(検索時)。この章はその検索の芯
- チャンク設計が効く: 長すぎると 1 ベクトルに意味が混ざり、短すぎるとコンテキストが切れる。段落単位やオーバーラップ付き分割など、切り方が検索品質を左右する
- コサイン vs 内積 vs ユークリッド: 正規化すればコサインと内積は一致。埋め込みモデルがどの指標で学習されたかに合わせるのが原則
- ANN の精度と速度: HNSW や IVF はパラメータで再現率と速度を交換する。「厳密に最も近い 1 件」が要る用途では総当たり、「ほぼ近い上位」で足りる用途では ANN
- ハイブリッド検索: 意味検索は言い換えに強いが固有名詞や型番の完全一致に弱い。転置インデックス のキーワード検索と組み合わせる(ハイブリッド)のが実務の定番
対照と実例
| 要素 | 素朴な形(この章) | 実物 |
|---|---|---|
| 文ベクトル化 | 平均プーリング | [CLS] / Sentence-BERT 等の専用モデル |
| 類似度 | コサイン | コサイン / 内積(正規化して同一) |
| 検索 | 総当たり O(n) | ANN(HNSW / IVF)O(log n) |
| 保存 | メモリ配列 | ベクトル DB(Faiss / pgvector / 専用) |
裏どり:
- Sentence-BERT(2019): Reimers & Gurevych。文の意味比較に特化した埋め込みモデル。平均プーリングを超える文ベクトルの作り方
- HNSW(2016): Malkov & Yashunin。多層グラフの ANN。多くのベクトル DB の既定アルゴリズム
- Faiss: Meta のベクトル検索ライブラリ。IVF や積量子化(量子化の応用)で大規模検索を高速化
- RAG(2020): Lewis et al.。検索した文書で生成を補強する枠組みの原典
簡略化したこと
- 埋め込みモデルなし: ベクトルは外から与える前提。生成は専用エンコーダの仕事で、ここは検索側に絞った
- ANN 未実装: 総当たりのみ。HNSW などは解説で、「なぜ要るか」を件数で示すに留めた
- プーリングは平均のみ: [CLS] や重み付き平均は本文で言及
- 永続化・更新なし: メモリ配列。実物のベクトル DB の索引更新や削除は扱わない
参考資料
- Reimers & Gurevych, Sentence-BERT(2019)
- Malkov & Yashunin, HNSW(2016)
- Lewis et al., Retrieval-Augmented Generation(2020)
- 実装: llm/embed