Building an openai embeddings go client doesn’t require pulling a large vendor SDK. With nothing but net/http and encoding/json you can stand up a working client in under 50 lines of Go, capable of embedding single strings or batches for semantic search, deduplication, or clustering.
Prerequisites
- Go 1.21 or newer installed and on your PATH.
- An OpenAI API key (set as
OPENAI_API_KEYin the environment). Any OpenAI-compatible endpoint works too. - Basic familiarity with Go modules, structs, and interfaces.
Initialize a module so the code below compiles cleanly:
mkdir goembed && cd goembed
go mod init example.com/goembed
The request contract
OpenAI’s embeddings API exposes POST /v1/embeddings. The minimal request body is:
{
"model": "text-embedding-3-small",
"input": "The quick brown fox jumps over the lazy dog"
}
The response includes a data array, each element with an embedding float slice, plus a usage object. We’ll mirror only what we need.
Define the types
Keep the structs tight. We ignore fields we don’t consume.
type embedRequest struct {
Model string `json:"model"`
Input []string `json:"input"`
}
type embedResponse struct {
Data []struct {
Embedding []float32 `json:"embedding"`
} `json:"data"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
} `json:"usage"`
}
Using []string for Input lets us send batches without changing the shape; the API accepts a string or array of strings.
The client in under 50 lines
Here is the full client. Counting the struct, constructor, and method, it’s 42 lines.
package embed
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
type Client struct {
baseURL string
apiKey string
http *http.Client
}
func NewClient(baseURL, apiKey string) *Client {
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
return &Client{baseURL: baseURL, apiKey: apiKey, http: http.DefaultClient}
}
func (c *Client) Embed(model string, inputs []string) ([][]float32, int, error) {
body, err := json.Marshal(embedRequest{Model: model, Input: inputs})
if err != nil {
return nil, 0, fmt.Errorf("marshal: %w", err)
}
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/embeddings", bytes.NewReader(body))
if err != nil {
return nil, 0, fmt.Errorf("newreq: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := c.http.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("do: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, 0, fmt.Errorf("status %d", resp.StatusCode)
}
var er embedResponse
if err := json.NewDecoder(resp.Body).Decode(&er); err != nil {
return nil, 0, fmt.Errorf("decode: %w", err)
}
out := make([][]float32, len(er.Data))
for i, d := range er.Data {
out[i] = d.Embedding
}
return out, er.Usage.PromptTokens, nil
}
That’s the entire openai embeddings go client. No retries, no middleware, but it compiles and runs.
Use it from main
Create main.go in the module root:
package main
import (
"fmt"
"os"
"example.com/goembed/embed"
)
func main() {
apiKey := os.Getenv("OPENAI_API_KEY")
if apiKey == "" {
fmt.Fprintln(os.Stderr, "OPENAI_API_KEY not set")
os.Exit(1)
}
client := embed.NewClient("", apiKey)
vecs, tokens, err := client.Embed("text-embedding-3-small", []string{
"Go is a statically typed language.",
"Python is dynamically typed.",
})
if err != nil {
fmt.Fprintln(os.Stderr, "embed error:", err)
os.Exit(1)
}
fmt.Printf("embedded %d vectors, %d prompt tokens\n", len(vecs), tokens)
fmt.Printf("dimensions: %d\n", len(vecs[0]))
fmt.Printf("first 3 values of vec0: %.4f %.4f %.4f\n", vecs[0][0], vecs[0][1], vecs[0][2])
}
Run it:
export OPENAI_API_KEY=sk-...
go run .
Expected output (values will differ):
embedded 2 vectors, 8 prompt tokens
dimensions: 1536
first 3 values of vec0: 0.0123 -0.0045 0.0789
You now have a working openai embeddings go client in roughly 70 lines including the demo.
Batch size and error handling
The API accepts up to a certain number of inputs per call (often 2048). For production, chunk larger slices:
func chunk(s []string, size int) [][]string {
var out [][]string
for i := 0; i < len(s); i += size {
end := i + size
if end > len(s) {
end = len(s)
}
out = append(out, s[i:end])
}
return out
}
Wrap the client call in a loop, accumulate vectors, and surface the first error. If you get a 429, back off and retry; the minimal client above returns the status code so you can branch on it.
Request parameters beyond the basics
Newer embedding models accept an optional dimensions field to truncate the vector size. Extend embedRequest if you need it:
type embedRequest struct {
Model string `json:"model"`
Input []string `json:"input"`
Dimensions int `json:"dimensions,omitempty"`
}
Setting Dimensions: 512 on text-embedding-3-small returns 512 floats instead of 1536, trading some recall for storage savings.
Pointing at a compatible gateway
The client is agnostic to the backend. If you set the base URL to an OpenAI-compatible gateway, the same code works unchanged. For example, n4n.ai exposes one OpenAI-compatible endpoint that addresses 240+ models and provides automatic fallback when a provider is rate-limited or degraded. Swap the constructor:
client := embed.NewClient("https://api.n4n.ai/v1", apiKey)
Your openai embeddings go client now routes through that gateway, and you can send provider cache-control hints via extra headers if needed. The response still carries usage.prompt_tokens for per-token metering.
Adding cosine similarity
Embeddings are useless without a distance metric. Drop this in:
import "math"
func cosine(a, b []float32) float32 {
var dot, na, nb float32
for i := range a {
dot += a[i] * b[i]
na += a[i] * a[i]
nb += b[i] * b[i]
}
return dot / (float32(math.Sqrt(float64(na))) * float32(math.Sqrt(float64(nb))))
}
Then:
sim := cosine(vecs[0], vecs[1])
fmt.Printf("cosine similarity: %.4f\n", sim)
Expected output for unrelated sentences is a small number near 0; for near-duplicates it approaches 1.
Testing without an API key
Use httptest to mock the endpoint in unit tests:
func TestEmbed(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":[{"embedding":[0.1,0.2]}],"usage":{"prompt_tokens":1}}`))
}))
defer srv.Close()
c := embed.NewClient(srv.URL, "fake")
v, tok, err := c.Embed("model", []string{"hi"})
if err != nil || tok != 1 || len(v[0]) != 2 {
t.Fatal("unexpected", err, tok, v)
}
}
This validates your openai embeddings go client without burning quota.
Performance notes
- Use
float32notfloat64. Embeddings are produced as 32-bit floats; widening them doubles memory for no accuracy gain. - Reuse the
http.Clientacross calls. The default client has connection pooling; creating one per request will leak goroutines. - For high throughput, send batches of 100–500 strings per call. Round-trip latency dominates small calls.
Common pitfalls
- Forgetting
Content-Type: application/jsonyields a 400. - Using
[]interface{}for input forces messy type assertions; stick to[]string. - Mixing model names between requests breaks A/B comparisons.
- Ignoring non-200 statuses and decoding anyway produces zero-length slices and silent failures.
Why not use the official SDK?
The official openai Go package is fine, but it pulls in extra dependencies and abstracts the HTTP layer. When you only need embeddings, a 50-line client is easier to audit, debug, and constrain. You control timeouts, retries, and header propagation. In a constrained service, that matters.
Final checklist
- Set
OPENAI_API_KEY(or equivalent). - Use
text-embedding-3-smallfor 1536 dims ortext-embedding-3-largefor 3072. - Batch inputs to reduce round trips.
- Handle non-200 responses explicitly.
- Swap base URL if you need multi-provider fallback.
That’s a complete, minimal openai embeddings go client you can extend without fighting a framework.