Semantic search in Go
What you’re building
Section titled “What you’re building”A search over eight handwritten notes. You ask “where did I put my keys?” and it finds:
The spare set for the front door is in the kitchen drawer.
No “keys” in that note. No “key” either. A LIKE '%key%' query would miss it entirely and hand you the note about a 4mm hex key instead.
Making the right note win is what this guide is about.
Before you start
Section titled “Before you start”go versionPart 1 needs any recent Go. Part 2 uses the official OpenAI SDK, which currently requires Go 1.25 or newer — worth checking before you get there. Grab a build from go.dev/dl if you need one.
Part 1: the mechanics, with no API key
Section titled “Part 1: the mechanics, with no API key”Step 1: Understand the one idea
Section titled “Step 1: Understand the one idea”An embedding is a list of numbers standing in for a piece of text. Text that means similar things gets similar numbers. That’s the whole concept.
Once meaning is numbers, “find something similar” is arithmetic, and computers are very good at arithmetic.
For Part 1 we write those numbers by hand, across five made-up categories:
[ where things tools food health admin ] [ are kept & DIY & money ]
"spare set … kitchen drawer" [ 0.95, 0.20, 0.05, 0.00, 0.10 ]"standing desk … 4mm hex key" [ 0.15, 0.95, 0.00, 0.05, 0.00 ]"pizza dough: 500g flour…" [ 0.05, 0.05, 0.95, 0.10, 0.00 ]The first note scores high on where things are kept. The hex key note scores high on tools. Ask about keys and the first wins, because you were asking where something is, not about DIY.
Step 2: Write the file
Section titled “Step 2: Write the file”mkdir notes-search && cd notes-searchgo mod init notessearchThen main.go:
package main
import ( "fmt" "math" "sort")
// A note, and the numbers standing in for its meaning.type Note struct { Text string Vector []float64}
// A question, and what we reckon it's "about".type Search struct { Question string Vector []float64}
// Five made-up categories: where things are kept, tools & DIY,// food, health, admin & money.var notes = []Note{ {"The spare set for the front door is in the kitchen drawer.", []float64{0.95, 0.20, 0.05, 0.00, 0.10}}, {"The dog needs his flea treatment on the 3rd.", []float64{0.05, 0.00, 0.10, 0.70, 0.35}}, {"Mum's birthday is in April, she likes tulips.", []float64{0.05, 0.00, 0.15, 0.00, 0.60}}, {"Renew the car insurance before it lapses.", []float64{0.00, 0.05, 0.00, 0.00, 0.95}}, {"The standing desk needs a 4mm hex key to assemble.", []float64{0.15, 0.95, 0.00, 0.05, 0.00}}, {"Pizza dough: 500g flour, 300ml water, rest overnight.", []float64{0.05, 0.05, 0.95, 0.10, 0.00}}, {"The wifi password is taped under the router.", []float64{0.65, 0.10, 0.00, 0.00, 0.20}}, {"Physio said ice the knee for twenty minutes.", []float64{0.00, 0.00, 0.00, 0.95, 0.05}},}
var searches = []Search{ {"where did I put my keys?", []float64{0.90, 0.30, 0.00, 0.00, 0.05}}, {"what do I cook on Friday?", []float64{0.05, 0.05, 0.95, 0.05, 0.00}}, {"my knee is hurting", []float64{0.00, 0.00, 0.00, 0.95, 0.05}},}
func main() { for _, search := range searches { fmt.Printf("\nQ: %s\n", search.Question)
type match struct { text string score float64 } matches := make([]match, len(notes)) for i, note := range notes { matches[i] = match{note.Text, cosineSimilarity(note.Vector, search.Vector)} }
sort.Slice(matches, func(i, j int) bool { return matches[i].score > matches[j].score })
for _, m := range matches[:3] { fmt.Printf(" %.3f %s\n", m.score, m.text) } }}
// How similar are two vectors? Returns roughly -1 (opposite) to 1 (identical).// It compares the direction the vectors point, ignoring their length.func cosineSimilarity(a, b []float64) float64 { var dot, lengthA, lengthB float64
for i := range a { dot += a[i] * b[i] // multiply matching positions, add them up lengthA += a[i] * a[i] // Pythagoras, part one lengthB += b[i] * b[i] // Pythagoras, part two }
return dot / (math.Sqrt(lengthA) * math.Sqrt(lengthB))}Step 3: Run it
Section titled “Step 3: Run it”go run .Q: where did I put my keys? 0.991 The spare set for the front door is in the kitchen drawer. 0.957 The wifi password is taped under the router. 0.459 The standing desk needs a 4mm hex key to assemble.
Q: what do I cook on Friday? 0.999 Pizza dough: 500g flour, 300ml water, rest overnight. 0.245 Mum's birthday is in April, she likes tulips. 0.176 The dog needs his flea treatment on the 3rd.
Q: my knee is hurting 1.000 Physio said ice the knee for twenty minutes. 0.907 The dog needs his flea treatment on the 3rd. 0.104 Pizza dough: 500g flour, 300ml water, rest overnight.That output is real: this sample is compiled and run before publishing, so what you see above is what the program actually printed.
That’s a semantic search. Standard library only, one loop of arithmetic, no dependencies at all.
Look at the first result. The front-door note scores 0.991; the hex key note — the only one that literally contains “key” — scores 0.459 and lands third. Keyword search would rank those two in precisely the opposite order.
Worth a look at the third search too. “My knee is hurting” finds the physio note, then the dog’s flea treatment. Both are health-shaped, so they sit near each other. That’s a real characteristic of the technique rather than a bug: “related” and “relevant” aren’t the same thing, which is why reranking exists.
Part 2: real embeddings
Section titled “Part 2: real embeddings”Part 1 works, but we wrote the numbers. Nobody hand-scores ten thousand documents across five invented axes, so a real model has to do it. Same maths, one function swapped.
Step 1: Add the SDK
Section titled “Step 1: Add the SDK”go get github.com/openai/openai-go/v3That’s the official OpenAI library for Go. It needs Go 1.25 or newer.
Step 2: Get an API key
Section titled “Step 2: Get an API key”Create one at platform.openai.com/api-keys and copy it immediately, since it’s shown once. New accounts need a payment method on file before the API responds at all, though embeddings cost very little.
export OPENAI_API_KEY="sk-..."Step 3: Replace main.go
Section titled “Step 3: Replace main.go”package main
import ( "context" "fmt" "log" "math" "sort"
"github.com/openai/openai-go/v3")
var notes = []string{ "The spare set for the front door is in the kitchen drawer.", "The dog needs his flea treatment on the 3rd.", "Mum's birthday is in April, she likes tulips.", "Renew the car insurance before it lapses.", "The standing desk needs a 4mm hex key to assemble.", "Pizza dough: 500g flour, 300ml water, rest overnight.", "The wifi password is taped under the router.", "Physio said ice the knee for twenty minutes.",}
var client = openai.NewClient() // reads OPENAI_API_KEY from the environment
func main() { ctx := context.Background()
// Embed all eight notes ONCE, before any searching happens. noteVectors, err := embed(ctx, notes) if err != nil { log.Fatal(err) }
for _, question := range []string{ "where did I put my keys?", "what do I cook on Friday?", "my knee is hurting", } { fmt.Printf("\nQ: %s\n", question)
// Only the question gets embedded at search time. queryVectors, err := embed(ctx, []string{question}) if err != nil { log.Fatal(err) }
type match struct { text string score float64 } matches := make([]match, len(notes)) for i, note := range notes { matches[i] = match{note, dot(noteVectors[i], queryVectors[0])} }
sort.Slice(matches, func(i, j int) bool { return matches[i].score > matches[j].score })
for _, m := range matches[:3] { fmt.Printf(" %.3f %s\n", m.score, m.text) } }}
// The only function that changed from Part 1. One request for the whole batch.func embed(ctx context.Context, texts []string) ([][]float64, error) { response, err := client.Embeddings.New(ctx, openai.EmbeddingNewParams{ Model: openai.EmbeddingModelTextEmbedding3Small, Input: openai.EmbeddingNewParamsInputUnion{OfArrayOfStrings: texts}, }) if err != nil { return nil, err }
vectors := make([][]float64, len(response.Data)) for i, item := range response.Data { vectors[i] = normalize(item.Embedding) } return vectors, nil}
// Scale to length 1, so a dot product gives cosine similarity directly.func normalize(vector []float64) []float64 { var sumOfSquares float64 for _, v := range vector { sumOfSquares += v * v } length := math.Sqrt(sumOfSquares)
unit := make([]float64, len(vector)) for i, v := range vector { unit[i] = v / length } return unit}
// Cosine similarity, given both vectors are already length 1.func dot(a, b []float64) float64 { var total float64 for i := range a { total += a[i] * b[i] } return total}Step 4: Run it
Section titled “Step 4: Run it”go run .You’ll see the same winner, chosen by a real model rather than by hand. Exact scores depend on the model version, so they’re not reproduced here — the C# guide explains why they land so much lower than Part 1’s, and why you should never hard-code a threshold against them.
Understanding the code
Section titled “Understanding the code”-
openai.NewClient()Reads
OPENAI_API_KEYfor you. Passoption.WithAPIKey(...)if you need it explicit, or a custom base URL for a proxy. Build one client and share it — it holds a connection pool. -
embed(ctx, notes)runs once, before the loopThe most important line in the file. Embedding documents is the expensive part, so it happens once, up front; only the query is embedded per search.
The classic mistake is moving this inside the loop, or inside an HTTP handler, which re-embeds every document on every request. Fine with eight notes, ruinous with real data.
-
OfArrayOfStringsThe SDK models
inputas a union, because the API accepts a string, an array of strings, or pre-tokenised input. SettingOfArrayOfStringssends the whole batch in one request; results come back in the order you sent them. -
openai.EmbeddingModelTextEmbedding3SmallA typed constant rather than a magic string, so a typo is a compile error instead of an HTTP 400 at runtime.
-
item.Embeddingis[]float64Go’s SDK gives you plain
float64slices, no wrapper to unpack. Note that the API returns 32-bit floats, so if memory matters at scale you can convert to[]float32and halve your storage for no real loss of quality. -
normalize— and why Part 2 usesdotinstead ofcosineSimilarityLook at Part 1’s
cosineSimilarity: it divides the dot product by both vectors’ lengths. If both already have length 1, you are dividing by1 × 1. So we normalise once at embedding time, and every later comparison becomes plain multiply-and-add. The full explanation.
When it goes wrong
Section titled “When it goes wrong”go: module requires Go 1.25 or later
The SDK is ahead of your toolchain. Upgrade Go, or stay on Part 1, which needs nothing.
401 Incorrect API key provided
The variable isn’t reaching the process. export applies only to the shell you typed it in, and editors often cache the environment from when they launched.
429 on your very first call
Usually means billing isn’t set up rather than genuine rate limiting. Check your billing settings.
Every result scores about the same Either your query is far shorter or vaguer than your documents, or — the silent one — you embedded notes with one model and the query with another. Vectors from different models live in unrelated coordinate spaces, and comparing them produces confident nonsense with no error.
Where to go from here
Section titled “Where to go from here”References
Section titled “References”- openai-go on GitHub — official SDK source and current API
- openai-go on pkg.go.dev — generated reference for every type
- OpenAI — Embeddings guide — models, dimensions and token limits
- go.dev/dl — Go toolchain downloads, if you need 1.25
- Effective Go — the idioms this sample tries to follow
- pgvector — where these vectors usually end up in production