n4nAI

Building a Go embeddings pipeline with pgvector

Step-by-step guide to building a Go embeddings pipeline with pgvector: call an embeddings API, store vectors in Postgres, and run similarity search.

n4n Team2 min read472 words

Audio narration

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

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 pgvector extension available
  • An embeddings API key (OpenAI or any OpenAI-compatible gateway)
  • psql and curl on your path
  • docker if 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": []string arrays. Batch 32–100 lines per request to cut latency and cost.
  • Index sizing: set lists to rows/1000 (minimum 10) for IVFFlat. After bulk loading, run VACUUM ANALYZE.
  • Pool connections: under concurrent ingestion use pgxpool instead of a single pgx.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.

Tagsgoembeddingspgvectorpostgres

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 →