🎯

ベクトル検索入門 実装して仕組みを理解する

に公開

Fusic Advent Calendar 2025 24日目の記事です。
昨日は @C-Yoshizumiさんの「AIがコードを書いてくれるからこそ”書く力”が欲しい」でした。

はじめに

LLMやRAGの普及でベクトル検索を業務で使ったり聞いたりすることはあるけれど、中で何が起きているのか理解している人はまだ少ないのではないでしょうか。
僕もその一人で、ちょっと今更感はありますが、裏側の仕組みを理解するためにベクトル検索の基礎を学びつつ、Goで最小限のベクトルDBを実装しながら最後に検索させてみたいと思います。

概要

ベクトル検索は、文章・画像・音声などを「意味を表す数値の並び(ベクトル)」に変換し、クエリのベクトルに近いデータを探す検索方法です。キーワードの完全一致ではなく、ベクトル同士の距離(例:コサイン類似度)で類似度を測るため、「意味が近い」「雰囲気が似ている」といった検索ができます。

近年はLLMの発展に伴い、RAGなどで外部知識を取り込む用途が増えたことで、ベクトル検索の重要性も高まっています。

それと合わせて議論されることが多いのが、ベクトルデータベースです。ベクトルとメタデータを保存し、ベクトル検索を高速に実行できるよう最適化されたデータベースで、類似文書検索、レコメンド、RAG(検索拡張生成)の文脈取得などを実用化させることができます。

従来の検索の限界

従来のキーワード検索では、例えば以下のように、「犬」で検索すると「犬」が含まれる文章がヒットしていました。
しかし、ベクトル検索においては「犬」で検索すると「ワンちゃん」「ペット」「柴犬」といった、意味的に近い文章もヒットさせることができます。これをセマンティック検索と呼んだりします。

クエリ 従来の検索 ベクトル検索
「犬」で検索 「犬」を含む文書のみ 「ワンちゃん」「ペット」「柴犬」なども見つかる
「悲しい映画」で検索 「悲しい」AND「映画」 「泣ける」「感動」「切ない」映画も見つかる

「意味的に近い」とは何なのか

LLMの学習(LLMに限らず)では、学習が進むにつれて、意味的に近くなるようにモデルの特徴空間が出来上がっていきます。例えば、「犬」と「ワンちゃん」は近くに、「猫」と「ニャンコ」は近くに配置され、逆に「犬」と「猫」は遠くに配置されます。なので、「意味的に近い」というのはその言葉の通りで、私たち人間が理解する「意味的」に近いという意味です。LLMモデルの認識能力が高いというのは、言い換えれば、意味的な近さを理解する能力が高いというイメージです。
実際には、セマンティック検索で行われる「意味的な近さ」の解釈は、「犬」や「猫」といった自然言語レベルで行われるのではなく、LLMが扱うベクトル形式(埋め込み:Embedding)の方が適しています。

セマンティック検索では大まかに2つの役割に分割できます。

  1. 入力を学習済みのLLMモデルに与え、埋め込み(Embedding)を生成する部分
  2. 生成された埋め込み(Embedding)で検索して類似度の高いものを検索結果として返す部分

なので、「クエリに対して意味的に近いものを検索する」と一色単に考えるのではなく、意味的な解釈をLLMが担ってくれて、検索はベクトルDBが担ってくれる、と考える方がスッキリするかなと思います。ベクトルDBが本質的には担うのは類似度を計算して返す部分だけで、こうしてみるとやっていることは非常にシンプルであることが理解できます。

ということで本記事では、意味的な解釈は外部のLLMモデル(OpenAI Embeddingモデル)、ベクトルDBはGoでスクラッチ実装することでそのシンプルさを伝えようと思います。

ベクトル検索

意味的な近さを理解してベクトルに変換する部分をLLMに任せてしまうとすれば、ベクトル検索にとって重要な機構はそう難しくはありません。

  • 検索の尺度はどう測るのか?
  • 検索の精度をどう上げるのか?

コサイン類似度

検索の尺度はどのように測るのでしょうか?
これはベクトル同士の類似度で解決できます。ベクトル間の類似度を測る方法はいくつかありますが、最もポピュラーなのがコサイン類似度です。ベクトルAとBがある時以下のように計算します。
やっていることは簡単で、分子がベクトル同士の内積(ベクトルの同じ位置の要素同士をかけて全て足し合わせること)で、分母がL2ノルム(ベクトルの大きさのこと)を計算します。

※ コサイン類似度の他に、L2距離(ユークリッド距離)、L1距離(マンハッタン距離)、リンフ距離(チェビシェフ距離)、ドット積、ハミング距離などがあります。

\cos(\theta) = \frac{A \cdot B}{||A|| \cdot ||B||}
  • 1: 完全に同じ方向(最も類似)
  • 0: 直交(無関係)
  • -1: 正反対の方向

Goの実装では以下のように書くことができます。

Goでの実装

// コサイン類似度の計算
func CosineSimilarity(a, b []float64) float64 {
        // aとb同じサイズのベクトルのみ計算可能
	if len(a) != len(b) || len(a) == 0 {
		return 0
	}

	dot := dotProduct(a, b)
	normA := magnitude(a)
	normB := magnitude(b)

	// ゼロベクトルの場合
	if normA == 0 || normB == 0 {
		return 0
	}
        // 上記数式の部分
	return dot / (normA * normB)
}


// dotProduct は2つのベクトルのドット積(内積)を計算する
func dotProduct(a, b []float64) float64 {
	var sum float64
	for i := range a {
		sum += a[i] * b[i]
	}
	return sum
}

// magnitude はベクトルの大きさ(L2ノルム)を計算する
func magnitude(v []float64) float64 {
	var sum float64
	for _, val := range v {
		sum += val * val
	}
	return math.Sqrt(sum)
}

コサイン類似度を使うメリットとしては以下が挙げられます。

  • ベクトルの「大きさ」ではなく「方向」で比較
  • 文章の長さに影響されにくい
  • -1〜1の範囲で正規化されているので扱いやすい

検索アルゴリズム

コサイン類似度などで2つのベクトル間の類似度を計算できることがわかりました。
しかし、ここまでの内容で実際の検索の場面をイメージしてみると、

  • 私たちが検索したいキーワードを入力(「猫」や「犬」など)
  • LLMが推論してベクトル(埋め込み)を出力して、1つのベクトルが手に入る
  • そのベクトルを使ってベクトルDBに保存してあるもう1つのベクトルと類似度を算出する。

これではまだ検索として機能していないことがわかります。検索対象は1つではないからです。実際には、検索したいキーワード:ベクトルDB内の1:Nの検索が必要で、ベクトルDB内に蓄積された大量のベクトルとの比較が必要で、さらにそれも効率良く行わないとユーザー体験を損ねてしまいます。
そこで、様々な効率的な検索アルゴリズムが考案され、大きく下の2種類に分類できます。

  • FLAT(総当たり)検索/線形検索
    • 全データを1つずつ比較して最近傍を見つける
    • 計算量: O(nd): データ数n × 次元数d
    • 遅いが100%最近傍が見つかる
    • 小規模データ向け
  • 近似最近傍(ANN)
    • 様々なアルゴリズムがある(HNSW, IVF, PQなど)
    • データ構造を工夫して選択範囲を絞る
    • 計算量: O(log n) 〜 O(√n)
    • 早いが90%-99%程度で近似的に見つかる
    • 大規模データ向け

HNSW (Hierarchical Navigable Small World)

しかし、実際にはほとんどのケースで ANN の HNSW が採用されています。

サービス 対応アルゴリズム 特徴
Pinecone HNSW (独自最適化) マネージドで最も人気
Weaviate HNSW オープンソース
Qdrant HNSW Rust製、高速
Milvus HNSW, IVF, IVFPQ, Annoy, DiskANN 多機能
Chroma HNSW (hnswlib) LangChain人気
pgvector IVFFlat, HNSW PostgreSQL拡張
Elasticsearch HNSW 8.0から対応
OpenSearch HNSW, IVF AWS版ES
Faiss (Meta) IVF, IVFPQ, HNSW, PQ ライブラリ
Vespa (Yahoo) HNSW 大規模向け

ここまで採用される理由はその効率性・高速性にあります。

HNSWとは、先頭ベクトルから順に比較していく全探索とは異なり、近いベクトル同士をつないだ階層グラフをたどって、最近傍を高速に探すことができる類似検索アルゴリズムです。

下の図のように上位層でざっくり移動、最下層へ移動するごとに詳細な位置に移動することで目的の最近傍ベクトルを探し出します。全探索やランダムに見ていくよりずっと効率が良いことは容易に想像できると思います。

画像は原著論文より引用:https://arxiv.org/pdf/1603.09320.pdf

HNSW以外にも様々なアルゴリズムがあります。興味があれば調べてみてください。

Top-K

比較して類似度を計算した後はどうでしょうか。
計算された全て類似度を返してもユーザーは困ってしまうので、類似度が高かったトップK件だけ返すようにしましょう、というのが、Top-Kの考え方です。検索アルゴリズムと併用して用いられることが多いです。

Top-Kとは、「最も類似した上位K件」を返す方法で、

例えば、Top-3で「犬」と検索すると、返されるのは

  • 「dog」 コサイン類似度: 0.99
  • 「ワンちゃん」 コサイン類似度: 0.93
  • 「柴犬」 コサイン類似度: 0.85

の類似度が高い上位3件のみです。

以下が検索部分のGoの実装例です。
小規模・検証目的なら、検索アルゴリズムはFLAT(総当たり)検索/線形検索で十分です。ベクトルDB内のrecords全件をループし、最終的に線形探索しながら Top-K個の検索結果を取得しています。

※ コード上では効率化のために同時にmin-heapアルゴリズムを使っています。

Goでの実装
import (
    // container/heap 標準パッケージを使用
    // https://pkg.go.dev/container/heap
	"container/heap"
)

// LinearIndex は線形探索によるインデックス実装
// シンプルだがO(n)の計算量
type LinearIndex struct {
	records map[string]*Record
	mu      sync.RWMutex
}

// 線形探索で全レコードとの類似度を計算し、ヒープでTop-Kを取得
func (idx *LinearIndex) Search(query []float64, topK int) []SearchResult {
	idx.mu.RLock()
	defer idx.mu.RUnlock()

	if topK <= 0 || len(idx.records) == 0 {
		return nil
	}

	// Top-K(スコアの高い順にK件)を「走査しながら」維持するためにMin-Heapを使う。
	// - ヒープの先頭はTop-K集合の中で最小スコア(= 足切りライン)になる
	// - 新しい候補が足切りラインより大きい時だけ入れ替えればよい
	//   -> 判定はO(1)、入れ替えはO(log K)で、全体はO(N log K)
	h := &resultHeap{}
	heap.Init(h)

	for _, record := range idx.records {
		// コサイン類似度を計算
		score := CosineSimilarity(query, record.Vector)

		if h.Len() < topK {
			heap.Push(h, searchItem{
				id:       record.ID,
				score:    score,
				metadata: record.Metadata,
			})
		} else if score > (*h)[0].score {
			// 現在の最小スコアより大きい場合のみ置き換え
			heap.Pop(h)
			heap.Push(h, searchItem{
				id:       record.ID,
				score:    score,
				metadata: record.Metadata,
			})
		}
	}
    
	// ヒープから結果を取り出し(スコア降順にソート)
	results := make([]SearchResult, h.Len())
	for i := len(results) - 1; i >= 0; i-- {
		item := heap.Pop(h).(searchItem)
		results[i] = SearchResult{
			ID:       item.id,
			Score:    item.score,
			Metadata: item.metadata,
		}
	}

	return results
}

Goで実装・検索デモ

ここまででベクトル検索としての最低限の機能が揃ったので、今度は実際にOpenAIのembeddingモデルを活用してベクトルDBの実装、データの登録 → 検索まで行ってみます。

GoでOpenAIのembeddingを取得する実装はこちらのライブラリを使わせていただきました。
https://github.com/sashabaranov/go-openai/tree/master

まずは検索を行うためのベクトルDBを用意する必要があるため、必要最小限の機能だけ実装したサンプルを用意しました。以下が本記事のリポジトリです。

https://github.com/ta-ke-inf/go-vector-db-scratch

OpenAIのEmbeddingモデルでベクトルデータを取得する実装

https://platform.openai.com/account/api-keys からAPIキーを発行できます。
そのAPIキーを使ってテキストを与えて〜ベクトルを取得するためのOpenAIEmbedder構造体を定義しました。
データ登録用に、複数のテキストを一括でベクトル化・取得できるEmbedメソッド、検索時用に単一のテキストをベクトル化・取得できるEmbedOneメソッドが定義してあります。

OpenAIEmbedderの実装は以下です。

Goでの実装
package embedding

import (
	"context"
	"fmt"

	openai "github.com/sashabaranov/go-openai"
)

type Embedder interface {
	Embed(ctx context.Context, texts []string) ([][]float64, error)
}

type OpenAIEmbedder struct {
	client *openai.Client
	model  openai.EmbeddingModel
}

func NewOpenAIEmbedder(apiKey string, model openai.EmbeddingModel) *OpenAIEmbedder {
	if model == "" {
		model = openai.SmallEmbedding3
	}
	return &OpenAIEmbedder{
		client: openai.NewClient(apiKey),
		model:  model,
	}
}

func (e *OpenAIEmbedder) Embed(ctx context.Context, texts []string) ([][]float64, error) {
	if len(texts) == 0 {
		return nil, nil
	}

	resp, err := e.client.CreateEmbeddings(ctx, openai.EmbeddingRequest{
		Input: texts,
		Model: e.model,
	})
	if err != nil {
		return nil, fmt.Errorf("failed to create embeddings: %w", err)
	}

	results := make([][]float64, len(resp.Data))
	for i, emb := range resp.Data {
		vec := make([]float64, len(emb.Embedding))
		for j, v := range emb.Embedding {
			vec[j] = float64(v)
		}
		results[i] = vec
	}

	return results, nil
}

func (e *OpenAIEmbedder) EmbedOne(ctx context.Context, text string) ([]float64, error) {
	vecs, err := e.Embed(ctx, []string{text})
	if err != nil {
		return nil, err
	}
	if len(vecs) == 0 {
		return nil, fmt.Errorf("no embedding returned")
	}
	return vecs[0], nil
}

func (e *OpenAIEmbedder) Model() string {
	return string(e.model)
}


データベースとして最小限機能を実装

最低限、ベクトルDBへのデータの登録と検索ができれば今回は良しです。
VectorDB構造体でデータを持ち、以下3つの最小限のメソッドを事前に実装しました。Searchには以前紹介した検索アルゴリズム(線形探索、コサイン類似度、Top-K)が実装されて検索結果返してくれます。

  • Upsert: レコードの追加
  • Search: 検索(線形探索、コサイン類似度、Top-K)
  • Save: jsonに保存
使用例
// VectorDB初期化
db := vectordb.New()

// レコードの追加
db.Upsert(
    vectordb.Record{
        ID:       "doc1",
        Vector:   []float64{0.1, 0.2, 0.3},
        Metadata: map[string]any{"title": "Document 1"},
    },
    vectordb.Record{
        ID:       "doc2",
        Vector:   []float64{0.2, 0.3, 0.4},
        Metadata: map[string]any{"title": "Document 2"},
    },
    vectordb.Record{
        ID:       "doc3",
        Vector:   []float64{0.4, 0.8, 0.4},
        Metadata: map[string]any{"title": "Document 3"},
    },
)

// 検索(Top-3)
results, _ := db.Search(queryVector, 3)
for _, r := range results {
    fmt.Printf("%s: %.4f\n", r.ID, r.Score)
}

// jsonに保存
db.Save("data.json")

データ登録〜検索してみる

ここまでの実装を用いて、以下の流れを試してみます。

  1. データ登録
    • 事前にサンプルをOpenAI Embeddingモデルでベクトル化
    • これらのベクトルをベクトルDBに登録
    • 以下のデータを登録
	// サンプルドキュメント(料理メニュー)
	documents := []string{
		"濃厚な豚骨スープと細麺が特徴の博多ラーメン",
		"新鮮なマグロやサーモンを使った握り寿司の盛り合わせ",
		"スパイシーなチキンカレーとモチモチのナンのセット",
		"もちもちの生パスタに濃厚トマトソースを絡めたボロネーゼ",
		"ジューシーな和牛ハンバーグステーキ、デミグラスソース添え",
		"野菜たっぷりのヘルシーなアボカドサラダボウル",
		"サクサク衣の海老天ぷら定食、ご飯と味噌汁付き",
		"本格四川風の痺れる辛さの麻婆豆腐",
	}
  1. 検索
    • クエリをOpenAI Embeddingモデルでベクトル化
    • ベクトルDBに対して検索
    • 以下のデータで検索
	// クエリで類似検索
	queries := []string{
		"あっさりした和食が食べたい",
		"ガッツリ肉料理が食べたい",
		"辛いものが食べたい気分",
		"ダイエット中でもOKなメニュー",
	}
main.goの実装
package main

import (
	"context"
	"fmt"
	"log"
	"os"

	"go-vector-db/embedding"
	"go-vector-db/vectordb"
)

func main() {
	apiKey := os.Getenv("OPENAI_API_KEY")
	if apiKey == "" {
		log.Fatal("OPENAI_API_KEY environment variable is not set")
	}

	ctx := context.Background()

	// OpenAI Embedder を初期化(text-embedding-3-small を使用)
	embedder := embedding.NewOpenAIEmbedder(apiKey, "")
	fmt.Printf("Using model: %s\n\n", embedder.Model())

	// サンプルドキュメント(料理メニュー)
	documents := []string{
		"濃厚な豚骨スープと細麺が特徴の博多ラーメン",
		"新鮮なマグロやサーモンを使った握り寿司の盛り合わせ",
		"スパイシーなチキンカレーとモチモチのナンのセット",
		"もちもちの生パスタに濃厚トマトソースを絡めたボロネーゼ",
		"ジューシーな和牛ハンバーグステーキ、デミグラスソース添え",
		"野菜たっぷりのヘルシーなアボカドサラダボウル",
		"サクサク衣の海老天ぷら定食、ご飯と味噌汁付き",
		"本格四川風の痺れる辛さの麻婆豆腐",
	}

	// ドキュメントをベクトル化
	vectors, err := embedder.Embed(ctx, documents)
	if err != nil {
		log.Fatalf("Failed to embed documents: %v", err)
	}

	// VectorDB に保存
	db := vectordb.New()
	for i, vec := range vectors {
		err := db.Upsert(vectordb.Record{
			ID:     fmt.Sprintf("doc-%d", i),
			Vector: vec,
			Metadata: map[string]any{
				"text":  documents[i],
				"index": i,
			},
		})
		if err != nil {
			log.Fatalf("Failed to upsert record: %v", err)
		}
	}
	fmt.Printf("VectorDB に %d 件保存しました\n\n", db.Size())

	// クエリで類似検索
	queries := []string{
		"あっさりした和食が食べたい",
		"ガッツリ肉料理が食べたい",
		"辛いものが食べたい気分",
		"ダイエット中でもOKなメニュー",
	}

	for _, query := range queries {
		fmt.Printf("=== クエリ: %s ===\n", query)

		// クエリをベクトル化
		queryVec, err := embedder.EmbedOne(ctx, query)
		if err != nil {
			log.Fatalf("Failed to embed query: %v", err)
		}

		// Top-3 検索
		results, err := db.Search(queryVec, 3)
		if err != nil {
			log.Fatalf("Search failed: %v", err)
		}

		// 結果表示
		for rank, r := range results {
			text := r.Metadata["text"].(string)
			fmt.Printf("  %d. [Score: %.4f] %s\n", rank+1, r.Score, text)
		}
		fmt.Println()
	}

	// Jsonに保存
	savePath := "data/embeddings.json"
	if err := db.Save(savePath); err != nil {
		log.Printf("Warning: Failed to save DB: %v", err)
	}
}

検索結果

以下のように、それぞれのクエリに対してコサイン類似度のスコアとTop-3件の検索結果が返されました。「ガッツリ肉料理が食べたい」というクエリに対しては2つ目のハンバーグステーキを返すのが期待値だと思いますが、全体的に意味が近いデータを返すことができていることがわかります。今回は、text-embedding-3-smallの小さめのモデルで、検索対象のテキストが短く抽象度が高すぎるせいかモデルが正確に理解することはできていないようです。
これに、HNSWを導入したり、良いモデルに置き換えたり、ベクトルDBに質の高いデータを貯めることでみなさんが普段使っているベクトル検索の技術になっていきます。

go run ./cmd/embedding_demo/main.go
Using model: text-embedding-3-small

VectorDB に 8 件保存しました

=== クエリ: あっさりした和食が食べたい ===
  1. [Score: 0.4184] サクサク衣の海老天ぷら定食、ご飯と味噌汁付き
  2. [Score: 0.4002] スパイシーなチキンカレーとモチモチのナンのセット
  3. [Score: 0.3699] 野菜たっぷりのヘルシーなアボカドサラダボウル

=== クエリ: ガッツリ肉料理が食べたい ===
  1. [Score: 0.4328] サクサク衣の海老天ぷら定食、ご飯と味噌汁付き
  2. [Score: 0.4080] ジューシーな和牛ハンバーグステーキ、デミグラスソース添え
  3. [Score: 0.3978] 新鮮なマグロやサーモンを使った握り寿司の盛り合わせ

=== クエリ: 辛いものが食べたい気分 ===
  1. [Score: 0.4332] 本格四川風の痺れる辛さの麻婆豆腐
  2. [Score: 0.3884] サクサク衣の海老天ぷら定食、ご飯と味噌汁付き
  3. [Score: 0.3250] スパイシーなチキンカレーとモチモチのナンのセット

=== クエリ: ダイエット中でもOKなメニュー ===
  1. [Score: 0.3749] 野菜たっぷりのヘルシーなアボカドサラダボウル
  2. [Score: 0.3301] スパイシーなチキンカレーとモチモチのナンのセット
  3. [Score: 0.3249] サクサク衣の海老天ぷら定食、ご飯と味噌汁付き

まとめ

今回はベクトル検索の基礎を実際のコード追いながら見ていくことで、RAGなどの裏側にある技術が想像よりシンプルに実現できることが理解できたと思います。
複雑に見える技術も、今回のように最小限の機能をスクラッチで実装してみたり、あるいは誰かが作ってくれたライブラリを活用したりして、どのように実装できるか考えてみると結構面白いです。

Fusic 技術ブログ

Discussion