PidokuInfra

Embeddings and Vector Search

Expert Advanced 1h Difficulty 4/5 Topic 07 of 09

Prerequisites 02, V.03, IV.06

The idea in one minute#

An embedding is a vector — a few hundred to a few thousand float32s — produced by a model so that texts with similar meaning land close together. Search by meaning then becomes geometry: embed the query, find the stored vectors nearest to it.

“Nearest” over a million vectors has three practical answers. Exact search compares the query with everything: simple, perfectly accurate, and fast enough for far more data than people expect. Quantization stores each number in fewer bits to cut memory. Approximate indexes (IVF, HNSW) look at only a small part of the data and accept missing a few true neighbours — measured as recall — in exchange for a large speed-up.

An analogy#

Finding the books most like the one in your hand. Exact search: compare it with every book in the library. A partitioned index: the library is arranged by subject, so you walk to the two or three most relevant sections and compare only there — much faster, though occasionally the best match was shelved in a section you skipped.

A picture#

flowchart TB
  DOC["Documents"] --> CH["Chunk"] --> EM["Embedding model"] --> ST[("Vectors: one flat []float32<br/>N x dim, unit length")]
  Q["Query text"] --> EQ["Embed the query"]
  EQ --> SRCH{"Search"}
  ST --> SRCH
  SRCH -->|"exact"| EX["dot product with all N<br/>recall 100%"]
  SRCH -->|"quantized"| QN["int8 codes: 4x less memory,<br/>re-score a shortlist in float"]
  SRCH -->|"IVF"| IV["nearest partitions only:<br/>N / nlist x nprobe comparisons"]
  SRCH -->|"HNSW"| HN["walk a neighbour graph:<br/>about log N steps"]
  EX --> TOP["top-k IDs and scores"]
  QN --> TOP
  IV --> TOP
  HN --> TOP
  TOP --> USE["retrieve chunks, add to the prompt (RAG)"]
  class DOC,Q,USE neutral
  class CH,EM,EQ compute
  class ST memory
  class SRCH queue
  class EX,QN,IV,HN compute
  class TOP memory

How it really works#

Similarity#

MeasureFormulaNotes
Dot productΣ aᵢbᵢCheapest
Cosinea·b / (‖a‖‖b‖)Ignores length; the usual choice for text
Euclidean‖a − b‖For unit vectors it ranks identically to cosine

Normalize every vector to unit length once, at insertion. Cosine similarity is then just a dot product — the unrolled kernel from V.02.

Scan all N vectors, keep the best k. The cost is N × dim multiply-adds per query, walking memory sequentially — the best case for the cache (V.03) — and it parallelizes trivially by splitting the range across goroutines. On one core, pure Go manages a few gigabytes of vectors per second; a hundred thousand 768-dimensional vectors is tens of milliseconds, and with SIMD and all cores exact search stays practical into the millions.

For the top-k, a small sorted array beats a heap when k is small; each worker keeps its own and the results are merged at the end (no shared state, no lock).

Reach for an approximate index only when exact search is measurably too slow. It is easier to operate, has no tuning parameters, supports filters trivially, and is the ground truth you need anyway to measure an approximate index’s recall.

Storage layout#

Everything from II.04 and V.03 applies, at scale:

  • One flat []float32 of N × dim: no per-vector allocation, no pointers for the garbage collector, sequential scans.
  • IDs and metadata in parallel slices indexed by position (struct of arrays).
  • For data larger than memory, memory-map the file and let the OS page it.

Quantization#

SchemeBytes per dimensionTypical recall after re-scoring
float324100%
float162~100%
int8 (scalar quantization)198–100%
Binary (sign bit)1/8Good only as a first pass
Product quantization~1/8–1/32Depends on re-scoring

The standard recipe is two passes: scan the compact codes to get a shortlist several times larger than k, then re-score the shortlist with the full-precision vectors. Memory to read falls 4× or more. Whether it is also faster depends on SIMD — in scalar Go an int8 dot product is about as fast as a float32 one; with vector instructions it is several times faster.

Approximate indexes#

IVF (inverted file). Cluster the vectors into nlist partitions (k-means). At query time, compare with the centroids, then search only the nprobe closest partitions. Work drops from N to about N × nprobe / nlist. nprobe is the dial between speed and recall.

HNSW (hierarchical navigable small world). Build a graph linking each vector to its near neighbours, in layers from sparse to dense. Search starts at the top, greedily walks toward the query, and refines in the bottom layer. About log N hops, very high recall, and the default in most vector databases. The costs: more memory (the graph), slow builds, awkward deletes — and it is a graph of pointers, exactly the structure III.04 and V.03 warn about. Good implementations store neighbours as integer IDs in flat arrays.

ExactIVFHNSW
Query costNN·nprobe/nlist~log N
Recall100%TunableTunable, usually highest
Extra memoryNoneSmallSubstantial
BuildNoneClusteringSlow
Updates and deletesTrivialEasyHard
Filtering by metadataTrivialWorkableHard to do well

Always measure recall against exact search on your own data and queries. An index is a trade, and the published numbers are for someone else’s data.

Retrieval-augmented generation, in one paragraph#

Split documents into chunks; embed and store them. For a question: embed it, retrieve the top few chunks, and place them in the prompt with an instruction to answer from them. Quality depends more on chunking, on combining vector search with keyword search, and on re-ranking the candidates than on which index you chose. Retrieved text is untrusted input (lesson 06).

Getting embeddings from Go#

Call an embedding endpoint (/v1/embeddings on OpenAI-compatible servers) — batch many texts per request and run several requests concurrently under a limit (IV.06) — or run a small embedding model in-process through an ONNX Runtime binding. Embedding is a throughput workload: batch aggressively.

Code#

Go
// vsearch.go — exact, quantized and partitioned (IVF) nearest-neighbour search, with recall.
package main

import (
	"fmt"
	"math"
	"math/rand"
	"runtime"
	"sort"
	"sync"
	"time"
)

const (
	N   = 50000 // vectors
	Dim = 128
	K   = 10 // neighbours wanted
)

type Hit struct {
	ID    int32
	Score float32
}

// topK keeps the K best hits in a small sorted slice: for small K this beats a heap.
type topK []Hit

func (t *topK) push(id int32, score float32) {
	h := *t
	if len(h) == K && score <= h[K-1].Score {
		return
	}
	if len(h) < K {
		h = append(h, Hit{})
	}
	i := len(h) - 1
	for ; i > 0 && h[i-1].Score < score; i-- {
		h[i] = h[i-1]
	}
	h[i] = Hit{id, score}
	*t = h
}

func dot(a, b []float32) float32 {
	b = b[:len(a)]
	var s0, s1, s2, s3 float32
	for i := 0; i+4 <= len(a); i += 4 {
		s0 += a[i] * b[i]
		s1 += a[i+1] * b[i+1]
		s2 += a[i+2] * b[i+2]
		s3 += a[i+3] * b[i+3]
	}
	return s0 + s1 + s2 + s3
}

func dotInt8(a, b []int8) int32 {
	b = b[:len(a)]
	var s0, s1, s2, s3 int32
	for i := 0; i+4 <= len(a); i += 4 {
		s0 += int32(a[i]) * int32(b[i])
		s1 += int32(a[i+1]) * int32(b[i+1])
		s2 += int32(a[i+2]) * int32(b[i+2])
		s3 += int32(a[i+3]) * int32(b[i+3])
	}
	return s0 + s1 + s2 + s3
}

// quantize maps a component of a unit vector to 8 bits. Components of a 128-D unit vector are
// small (around 0.09), so they are scaled up first and clamped.
func quantize(x float32) int8 {
	return int8(max(-127, min(127, math.Round(float64(x)*400))))
}

func normalize(v []float32) {
	var ss float64
	for _, x := range v {
		ss += float64(x) * float64(x)
	}
	inv := float32(1 / math.Sqrt(ss))
	for i := range v {
		v[i] *= inv
	}
}

// Index stores every vector in ONE flat slice: contiguous, pointer-free, cache-friendly.
type Index struct {
	vecs  []float32 // N x Dim, unit length, so dot product = cosine similarity
	q8    []int8    // the same vectors at 8 bits per value
	cents []float32 // IVF: nlist centroids
	lists [][]int32 // IVF: vector IDs per centroid
}

func (ix *Index) vec(i int) []float32 { return ix.vecs[i*Dim : (i+1)*Dim] }

// Exact scans everything, splitting the range across goroutines.
func (ix *Index) Exact(q []float32, workers int) []Hit {
	parts := make([]topK, workers)
	var wg sync.WaitGroup
	chunk := (N + workers - 1) / workers
	for w := 0; w < workers; w++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			t := make(topK, 0, K)
			for i := w * chunk; i < min((w+1)*chunk, N); i++ {
				t.push(int32(i), dot(q, ix.vec(i)))
			}
			parts[w] = t // each goroutine writes its own slot
		}()
	}
	wg.Wait()
	merged := make(topK, 0, K)
	for _, p := range parts {
		for _, h := range p {
			merged.push(h.ID, h.Score)
		}
	}
	return merged
}

// Quantized scans int8 codes (4x less memory to read), then re-scores the best with float32.
func (ix *Index) Quantized(q []float32) []Hit {
	q8 := make([]int8, Dim)
	for i, x := range q {
		q8[i] = quantize(x)
	}
	// Pass 1: approximate scores for everything; keep a shortlist of 4K candidates.
	const shortlist = 4 * K
	ids := make([]int32, 0, shortlist)
	scores := make([]int32, 0, shortlist)
	for i := 0; i < N; i++ {
		s := dotInt8(q8, ix.q8[i*Dim:(i+1)*Dim])
		if len(ids) == shortlist && s <= scores[shortlist-1] {
			continue
		}
		if len(ids) < shortlist {
			ids, scores = append(ids, 0), append(scores, 0)
		}
		j := len(ids) - 1
		for ; j > 0 && scores[j-1] < s; j-- {
			ids[j], scores[j] = ids[j-1], scores[j-1]
		}
		ids[j], scores[j] = int32(i), s
	}
	// Pass 2: exact scores for the shortlist only.
	t := make(topK, 0, K)
	for _, id := range ids {
		t.push(id, dot(q, ix.vec(int(id))))
	}
	return t
}

// IVF looks only in the nprobe partitions whose centroids are closest to the query.
func (ix *Index) IVF(q []float32, nprobe int) []Hit {
	nlist := len(ix.lists)
	order := make([]Hit, nlist)
	for c := 0; c < nlist; c++ {
		order[c] = Hit{int32(c), dot(q, ix.cents[c*Dim:(c+1)*Dim])}
	}
	sort.Slice(order, func(a, b int) bool { return order[a].Score > order[b].Score })
	t := make(topK, 0, K)
	for _, c := range order[:nprobe] {
		for _, id := range ix.lists[c.ID] {
			t.push(id, dot(q, ix.vec(int(id))))
		}
	}
	return t
}

func recall(got, want []Hit) float64 {
	in := map[int32]bool{}
	for _, h := range want {
		in[h.ID] = true
	}
	n := 0
	for _, h := range got {
		if in[h.ID] {
			n++
		}
	}
	return float64(n) / float64(len(want))
}

func main() {
	rng := rand.New(rand.NewSource(3))
	// Synthetic "embeddings": 200 topics, each vector is its topic plus noise.
	const topics = 200
	centers := make([]float32, topics*Dim)
	for i := range centers {
		centers[i] = float32(rng.NormFloat64())
	}
	ix := &Index{vecs: make([]float32, N*Dim), q8: make([]int8, N*Dim)}
	for i := 0; i < N; i++ {
		c := rng.Intn(topics)
		v := ix.vec(i)
		for d := range v {
			v[d] = centers[c*Dim+d] + float32(rng.NormFloat64())*0.6
		}
		normalize(v)
		for d, x := range v {
			ix.q8[i*Dim+d] = quantize(x)
		}
	}
	// IVF build: sample centroids, assign every vector to its nearest one.
	const nlist = 224 // about sqrt(N)
	ix.cents = make([]float32, nlist*Dim)
	for c := 0; c < nlist; c++ {
		copy(ix.cents[c*Dim:(c+1)*Dim], ix.vec(rng.Intn(N)))
	}
	ix.lists = make([][]int32, nlist)
	for i := 0; i < N; i++ {
		best, bestS := 0, float32(-2)
		for c := 0; c < nlist; c++ {
			if s := dot(ix.vec(i), ix.cents[c*Dim:(c+1)*Dim]); s > bestS {
				best, bestS = c, s
			}
		}
		ix.lists[best] = append(ix.lists[best], int32(i))
	}

	queries := make([][]float32, 50)
	for i := range queries {
		queries[i] = append([]float32{}, ix.vec(rng.Intn(N))...)
		for d := range queries[i] {
			queries[i][d] += float32(rng.NormFloat64()) * 0.05
		}
		normalize(queries[i])
	}
	truth := make([][]Hit, len(queries))
	for i, q := range queries {
		truth[i] = ix.Exact(q, 1)
	}

	fmt.Printf("%d vectors x %d dims = %d MB as float32, %d MB as int8\n\n", N, Dim, N*Dim*4>>20, N*Dim>>20)
	fmt.Println("method                         ms/query   recall@10")
	run := func(name string, f func(q []float32) []Hit) {
		start := time.Now()
		r := 0.0
		for i, q := range queries {
			r += recall(f(q), truth[i])
		}
		fmt.Printf("%-28s %9.2f   %8.1f%%\n", name,
			float64(time.Since(start).Microseconds())/1000/float64(len(queries)), 100*r/float64(len(queries)))
	}
	workers := runtime.GOMAXPROCS(0)
	run("exact scan, 1 goroutine", func(q []float32) []Hit { return ix.Exact(q, 1) })
	run(fmt.Sprintf("exact scan, %d goroutines", workers), func(q []float32) []Hit { return ix.Exact(q, workers) })
	run("int8 scan + float re-score", ix.Quantized)
	for _, np := range []int{1, 4, 16, 64} {
		run(fmt.Sprintf("IVF, nprobe=%d of %d lists", np, nlist), func(q []float32) []Hit { return ix.IVF(q, np) })
	}
}

Remember this#

  • Normalize once; cosine similarity is then a dot product over one flat slice.
  • Exact search is simple, parallel, cache-friendly and often sufficient. It is also your ground truth.
  • Quantize to shrink memory; re-score a shortlist at full precision.
  • IVF and HNSW trade recall for speed. Measure recall on your own data.

Try it#

  1. Run vsearch.go. What nprobe gives 95% recall, and how much faster than exact is it?
  2. Replace the sampled centroids with three iterations of k-means. How does IVF recall change at the same nprobe?
  3. Add a metadata filter (say, only vectors whose ID is even) to each method. Which methods lose recall, and why?

Check yourself#

  1. Why normalize vectors when storing them?
  2. What does recall measure, and how do you compute it?
  3. Why is exact search a good default before adding an index?

↑↓ navigate↵ openesc close