Skip to content

Go App — PG-Wire End-to-End (Vector + Hybrid Search)

Go App — PG-Wire End-to-End (Vector + Hybrid Search)

Available since: v3.6.0 (compression, SQLite import) — vector wire fully stable v3.13.0 Stack: github.com/jackc/pgx/v5 against the Nano binary’s PostgreSQL wire endpoint HeliosDB build: default — no feature flag required


UVP

There is no in-process Go embedding API for HeliosDB Nano — the engine is Rust. You don’t need one. The Nano binary speaks the PostgreSQL v3 wire protocol natively, so every battle-tested Go driver (pgx, lib/pq, database/sql, GORM, ent) connects with zero adapters. This walkthrough builds a small Go service that connects, runs CRUD, then uses pgx’s prepared statements to push and query 384-dim vectors — same data path your existing Postgres-backed Go code already uses. No CGo. No FFI. Just pgx.


Prerequisites

Terminal window
mkdir helios-go && cd helios-go
go mod init example.com/helios-go
go get github.com/jackc/pgx/v5@latest

Start a Nano server:

Terminal window
heliosdb-nano start \
--memory \
--auth scram-sha-256 \
--password s3cret

1. Connect

main.go
package main
import (
"context"
"fmt"
"log"
"github.com/jackc/pgx/v5"
)
const dsn = "postgresql://postgres:s3cret@127.0.0.1:5432/postgres"
func main() {
ctx := context.Background()
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
log.Fatal(err)
}
defer conn.Close(ctx)
var version string
if err := conn.QueryRow(ctx, "SELECT version()").Scan(&version); err != nil {
log.Fatal(err)
}
fmt.Println(version)
// PostgreSQL 16.0 (compatible) -- HeliosDB Nano 3.19.1
}

Nano implements the v3 startup handshake, SCRAM-SHA-256, the simple-Q protocol, and the extended-Q (parse/bind/describe/execute) protocol. pgx negotiates extended-Q automatically.


2. CRUD Schema

func setupSchema(ctx context.Context, conn *pgx.Conn) error {
_, err := conn.Exec(ctx, `
CREATE TABLE IF NOT EXISTS products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
category TEXT,
price NUMERIC(10, 2),
in_stock BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT now()
)`)
return err
}

3. Basic CRUD with pgx

type Product struct {
ID int
Name string
Category string
Price float64
}
func insertProduct(ctx context.Context, conn *pgx.Conn, p Product) (int, error) {
var id int
err := conn.QueryRow(ctx,
`INSERT INTO products (name, category, price)
VALUES ($1, $2, $3)
RETURNING id`,
p.Name, p.Category, p.Price,
).Scan(&id)
return id, err
}
func listProducts(ctx context.Context, conn *pgx.Conn) ([]Product, error) {
rows, err := conn.Query(ctx,
`SELECT id, name, category, price FROM products ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Product
for rows.Next() {
var p Product
if err := rows.Scan(&p.ID, &p.Name, &p.Category, &p.Price); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
func updatePrice(ctx context.Context, conn *pgx.Conn, id int, price float64) error {
_, err := conn.Exec(ctx,
`UPDATE products SET price = $1 WHERE id = $2`, price, id)
return err
}

$1, $2 are PostgreSQL’s positional parameters; pgx binds them in the extended-Q protocol so the values never get string-interpolated into SQL.


4. Transactions

func transferStock(ctx context.Context, conn *pgx.Conn, fromID, toID int, qty int) error {
tx, err := conn.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx,
`UPDATE products SET in_stock = false WHERE id = $1`, fromID); err != nil {
return err
}
if _, err := tx.Exec(ctx,
`UPDATE products SET in_stock = true WHERE id = $1`, toID); err != nil {
return err
}
return tx.Commit(ctx)
}

Rollback after Commit is a no-op — idiomatic Go pattern works as expected.


5. Vector Schema

func setupVectorSchema(ctx context.Context, conn *pgx.Conn) error {
_, err := conn.Exec(ctx, `
CREATE TABLE IF NOT EXISTS docs (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
body TEXT,
embedding VECTOR(384)
);
CREATE INDEX IF NOT EXISTS docs_embedding_idx
ON docs USING hnsw (embedding vector_cosine_ops);
`)
return err
}

6. Insert and Query Vectors via pgx

pgx doesn’t ship a pgx.Vector type out of the box, but the VECTOR(n) type accepts the standard Postgres array literal '[v1,v2,...]' over the wire. We marshal a []float32 to that text encoding and pgx forwards it as-is:

import (
"fmt"
"strconv"
"strings"
)
// vecLit converts a float slice into the Postgres VECTOR literal form.
func vecLit(v []float32) string {
parts := make([]string, len(v))
for i, f := range v {
parts[i] = strconv.FormatFloat(float64(f), 'f', 6, 32)
}
return "[" + strings.Join(parts, ",") + "]"
}
func insertDoc(ctx context.Context, conn *pgx.Conn, title, body string, vec []float32) error {
_, err := conn.Exec(ctx,
`INSERT INTO docs (title, body, embedding) VALUES ($1, $2, $3::vector)`,
title, body, vecLit(vec),
)
return err
}

The $3::vector cast tells the planner to coerce the text literal into a VECTOR(384) value.

type SearchHit struct {
ID int
Title string
Distance float64
}
func vectorSearch(ctx context.Context, conn *pgx.Conn, query []float32, k int) ([]SearchHit, error) {
rows, err := conn.Query(ctx,
`SELECT id, title, embedding <=> $1::vector AS distance
FROM docs
ORDER BY distance
LIMIT $2`,
vecLit(query), k,
)
if err != nil {
return nil, err
}
defer rows.Close()
var hits []SearchHit
for rows.Next() {
var h SearchHit
if err := rows.Scan(&h.ID, &h.Title, &h.Distance); err != nil {
return nil, err
}
hits = append(hits, h)
}
return hits, rows.Err()
}

Distance operators (covered in VECTOR_SEARCH_TUTORIAL):

OperatorDistanceUse case
<=>CosineNormalized text/sentence embeddings
<->L2Image / unnormalized vectors
<#>Negative inner productPre-normalized, speed-critical

7. Hybrid Search (BM25 + Vector)

Same query you’d write in Python or psql; pgx scans the result the same way:

func hybridSearch(ctx context.Context, conn *pgx.Conn, qVec []float32, qText string, alpha float64, k int) ([]SearchHit, error) {
rows, err := conn.Query(ctx, `
SELECT id, title,
$1::float * (1.0 - (embedding <=> $2::vector))
+ (1.0 - $1::float) * ts_rank_cd(to_tsvector(body), plainto_tsquery($3))
AS score
FROM docs
WHERE to_tsvector(body) @@ plainto_tsquery($3)
OR embedding <=> $2::vector < 0.5
ORDER BY score DESC
LIMIT $4`,
alpha, vecLit(qVec), qText, k,
)
if err != nil {
return nil, err
}
defer rows.Close()
var hits []SearchHit
for rows.Next() {
var h SearchHit
if err := rows.Scan(&h.ID, &h.Title, &h.Distance); err != nil {
return nil, err
}
hits = append(hits, h)
}
return hits, rows.Err()
}

8. Putting It All Together

cmd/main.go
package main
import (
"context"
"fmt"
"log"
"math/rand"
"github.com/jackc/pgx/v5"
)
func main() {
ctx := context.Background()
conn, err := pgx.Connect(ctx, "postgresql://postgres:s3cret@127.0.0.1:5432/postgres")
if err != nil {
log.Fatal(err)
}
defer conn.Close(ctx)
if err := setupVectorSchema(ctx, conn); err != nil {
log.Fatal(err)
}
// Insert a few synthetic vectors
for i := 0; i < 5; i++ {
v := make([]float32, 384)
for j := range v {
v[j] = rand.Float32()
}
if err := insertDoc(ctx, conn,
fmt.Sprintf("Doc %d", i+1),
fmt.Sprintf("Body for doc %d", i+1), v); err != nil {
log.Fatal(err)
}
}
// Search with a fresh random vector
q := make([]float32, 384)
for j := range q {
q[j] = rand.Float32()
}
hits, err := vectorSearch(ctx, conn, q, 3)
if err != nil {
log.Fatal(err)
}
for _, h := range hits {
fmt.Printf("dist=%.4f id=%d %s\n", h.Distance, h.ID, h.Title)
}
}
Terminal window
go run ./cmd
# dist=0.4823 id=2 Doc 2
# dist=0.4901 id=4 Doc 4
# dist=0.5012 id=1 Doc 1

9. Connection Pooling for Servers

For HTTP services, swap pgx.Connect for pgxpool:

import "github.com/jackc/pgx/v5/pgxpool"
pool, err := pgxpool.New(ctx, dsn)
// pool.Acquire(ctx) -> *pgxpool.Conn, do work, defer Release()

pgxpool handles SCRAM negotiation lazily; first acquire pays the auth cost, subsequent acquires are free.


Troubleshooting

SymptomCauseFix
connection refusedNano not started, or wrong portConfirm heliosdb-nano start ... is running and listening on 5432
unsupported authentication methodOld pgx version doesn’t support SCRAMUpgrade pgx to v5+; or start Nano with --auth md5
cannot convert string to vectorForgot $N::vector castAdd the cast — pgx sends a text literal, server needs to coerce
unknown OID warning in logspgx queries pg_type at startupHarmless; pg_type returns the standard set
Slow ANN searchHNSW index missing or wrong opclassCheck EXPLAIN; ensure vector_cosine_ops matches <=>

Where Next