Skip to content

Semantic search in Go

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.

Terminal window
go version

Part 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.


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.

Terminal window
mkdir notes-search && cd notes-search
go mod init notessearch

Then main.go:

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))
}
Terminal window
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 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.

Terminal window
go get github.com/openai/openai-go/v3

That’s the official OpenAI library for Go. It needs Go 1.25 or newer.

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.

Terminal window
export OPENAI_API_KEY="sk-..."
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
}
Terminal window
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.

  1. openai.NewClient()

    Reads OPENAI_API_KEY for you. Pass option.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.

  2. embed(ctx, notes) runs once, before the loop

    The 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.

  3. OfArrayOfStrings

    The SDK models input as a union, because the API accepts a string, an array of strings, or pre-tokenised input. Setting OfArrayOfStrings sends the whole batch in one request; results come back in the order you sent them.

  4. openai.EmbeddingModelTextEmbedding3Small

    A typed constant rather than a magic string, so a typo is a compile error instead of an HTTP 400 at runtime.

  5. item.Embedding is []float64

    Go’s SDK gives you plain float64 slices, no wrapper to unpack. Note that the API returns 32-bit floats, so if memory matters at scale you can convert to []float32 and halve your storage for no real loss of quality.

  6. normalize — and why Part 2 uses dot instead of cosineSimilarity

    Look at Part 1’s cosineSimilarity: it divides the dot product by both vectors’ lengths. If both already have length 1, you are dividing by 1 × 1. So we normalise once at embedding time, and every later comparison becomes plain multiply-and-add. The full explanation.

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.