n4nAI

A Go client for OpenAI embeddings in under 50 lines

Hands-on tutorial: build a minimal openai embeddings go client in pure Go with standard library, including batching, error handling, and expected output.

n4n Team3 min read646 words

Audio narration

Coming soon — every post will get a voice note here.

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_KEY in 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 float32 not float64. Embeddings are produced as 32-bit floats; widening them doubles memory for no accuracy gain.
  • Reuse the http.Client across 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/json yields 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-small for 1536 dims or text-embedding-3-large for 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.

Tagsgoembeddingsopenaitutorial

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All embeddings api integration across languages posts →