A reliable go embeddings pipeline pgvector setup lets you store and query semantic vectors next to your relational data without standing up a separate vector database. This tutorial builds a small, production-shaped service that calls an OpenAI-compatible embeddings endpoint, writes vectors into Postgres, and runs cosine similarity search.
Prerequisites
- Go 1.21 or newer
- Postgres 14+ with the
pgvectorextension available - An embeddings API key (OpenAI or any OpenAI-compatible gateway)
psqlandcurlon your pathdockerif you need a quick local Postgres with pgvector
If you don’t have a Postgres instance, run the official image:
docker run -e POSTGRES_PASSWORD=secret -p 5432:5432 pgvector/pgvector:pg16
Install pgvector and create the schema
Connect and enable the extension, then define a table sized to your model’s dimensions. OpenAI’s text-embedding-3-small returns 1536 floats.
createdb embeddings_demo
psql embeddings_demo -c "CREATE EXTENSION IF NOT EXISTS vector;"
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
content TEXT NOT NULL,
embedding VECTOR(1536)
);
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
The IVFFlat index accelerates cosine distance queries once the table grows past a few thousand rows. For a tiny demo it is optional but harmless.
Scaffold the Go module
mkdir embedpipe && cd embedpipe
go mod init embedpipe
go get github.com/jackc/pgx/v5
We use pgx/v5 for Postgres and the standard library for HTTP. No ORM, no magic.
Call the embeddings API
The endpoint is OpenAI-compatible: POST /v1/embeddings with a JSON body. The code below is the core of the go embeddings pipeline pgvector ingestion path.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
type embedRequest struct {
Input string `json:"input"`
Model string `json:"model"`
}
type embedResponse struct {
Data []struct {
Embedding []float32 `json:"embedding"`
} `json:"data"`
}
func getEmbedding(text string) ([]float32, error) {
apiKey := os.Getenv("EMBED_API_KEY")
baseURL := os.Getenv("EMBED_BASE_URL")
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
reqBody, _ := json.Marshal(embedRequest{Input: text, Model: "text-embedding-3-small"})
req, err := http.NewRequest("POST", baseURL+"/embeddings", bytes.NewReader(reqBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("embeddings api error: %s", body)
}
var er embedResponse
if err := json.NewDecoder(resp.Body).Decode(&er); err != nil {
return nil, err
}
if len(er.Data) == 0 {
return nil, fmt.Errorf("no embedding returned")
}
return er.Data[0].Embedding, nil
}
If you want a single OpenAI-compatible endpoint with automatic fallback when a provider is rate-limited, n4n.ai exposes one that fronts 240+ models and honors client cache-control hints—swap EMBED_BASE_URL to that endpoint and the rest of the go embeddings pipeline pgvector code stays identical.
Always verify the returned slice length matches your table’s VECTOR(n) dimension before insert:
if len(emb) != 1536 {
return fmt.Errorf("expected 1536 dims, got %d", len(emb))
}
Store vectors in pgvector
pgvector accepts a vector literal as a bracketed, comma-separated string. We format it and cast to vector in the SQL.
import (
"context"
"strings"
"github.com/jackc/pgx/v5"
)
func joinFloats(f []float32) string {
parts := make([]string, len(f))
for i, v := range f {
parts[i] = fmt.Sprintf("%f", v)
}
return strings.Join(parts, ",")
}
func storeDocument(conn *pgx.Conn, content string, emb []float32) error {
vec := "[" + joinFloats(emb) + "]"
_, err := conn.Exec(context.Background(),
"INSERT INTO documents (content, embedding) VALUES ($1, $2::vector)",
content, vec)
return err
}
Checkpoint after inserting one row:
psql embeddings_demo -c "SELECT count(*) FROM documents;"
Expected:
count
-------
1
(1 row)
Query similar documents
Cosine distance in pgvector is the <=> operator. Distance 0 means identical, 2 means opposite.
func search(conn *pgx.Conn, emb []float32, limit int) ([]string, error) {
vec := "[" + joinFloats(emb) + "]"
rows, err := conn.Query(context.Background(),
`SELECT content FROM documents
ORDER BY embedding <=> $1
LIMIT $2`, vec, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
Seed two contrasting documents:
storeDocument(conn, "Postgres handles relational and vector data together", emb1)
storeDocument(conn, "Kubernetes orchestrates containers at scale", emb2)
similar, _ := search(conn, embQuery, 1)
fmt.Println(similar)
If embQuery is an embedding of “storing vectors in Postgres”, expected output is:
[Postgres handles relational and vector data together]
You can also inspect distances directly in psql:
SELECT content, embedding <=> '[0.01,0.02,...]' AS dist
FROM documents ORDER BY dist LIMIT 3;
Build the ingestion worker
A minimal main.go that reads lines from stdin, embeds, and stores them:
func main() {
ctx := context.Background()
conn, err := pgx.Connect(ctx, os.Getenv("DATABASE_URL"))
if err != nil {
panic(err)
}
defer conn.Close(ctx)
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
emb, err := getEmbedding(line)
if err != nil {
fmt.Fprintln(os.Stderr, "embed error:", err)
continue
}
if err := storeDocument(conn, line, emb); err != nil {
fmt.Fprintln(os.Stderr, "store error:", err)
}
}
}
Run it:
export EMBED_API_KEY=sk-...
export DATABASE_URL="postgres://postgres:secret@localhost:5432/embeddings_demo"
echo -e "Vector search in Postgres is simple\nDocker containers package apps" | go run main.go
Tuning the go embeddings pipeline pgvector deployment
- Batch inputs: the embeddings API accepts
"input": []stringarrays. Batch 32–100 lines per request to cut latency and cost. - Index sizing: set
liststorows/1000(minimum 10) for IVFFlat. After bulk loading, runVACUUM ANALYZE. - Pool connections: under concurrent ingestion use
pgxpoolinstead of a singlepgx.Conn. - Normalization: if you normalize embeddings to unit length, cosine and inner-product distance are equivalent; pgvector also provides
<#>(negative inner product) for that case. - Dimension drift: changing models changes dimensions. Keep the
VECTOR(n)size in a migration and fail fast on length mismatch.
The go embeddings pipeline pgvector pattern keeps vector data colocated with your metadata, uses one backup strategy, and avoids a second datastore to operate. Once the table and index are in place, the Go code above is the entire surface area needed to embed, store, and search.