Skip to content

Answering Questions from Your Own Data

This recipe shows how a Go program answers a question from data held in PostgreSQL, using the pgEdge Go LLM Library. The technique is known as retrieval-augmented generation (RAG): the program embeds the question, retrieves the rows whose embeddings lie closest to it with pgvector, and passes those rows to a chat model as the only context from which to answer. The example is written in Go only, because the library is a Go package.

The example program stores five facts about a fictional product, asks how long the warranty lasts, and prints the model's answer followed by the ids of the two facts that it retrieved. The wording of the answer varies from model to model, but the output has the following shape:

Question: How long is the warranty on the Zephyr Kettle?
Answer: Three years.
Sources: 1, 2

Preparing the Ingredients

The example needs a PostgreSQL server on which the pgvector extension is available, and a provider for chat and embeddings. By default, the example uses a local Ollama server with the qwen2.5:1.5b chat model and the nomic-embed-text embedding model; the following commands pull both models:

ollama pull qwen2.5:1.5b
ollama pull nomic-embed-text

The example uses the pgEdge Go LLM Library for the model requests and pgx to talk to PostgreSQL. The following code shows the dependency declaration from the example project:

// The pgEdge Go LLM Library provides one client for many providers.
require github.com/pgEdge/pgedge-go-llm-lib v0.6.0

// pgx is a pure Go driver and toolkit for PostgreSQL.
require github.com/jackc/pgx/v5 v5.12.0

require (
    github.com/jackc/pgpassfile v1.0.0 // indirect
    github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
    golang.org/x/text v0.41.0 // indirect
)

Importing the Libraries

The program imports pgx and the library. The blank import of the llm/all package registers every provider that the library supports, so the program can pick a provider by name at run time. The following code shows the imports:

import (
    "context"
    "fmt"
    "net"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"

    "github.com/jackc/pgx/v5"
    "github.com/pgEdge/pgedge-go-llm-lib/llm"
    _ "github.com/pgEdge/pgedge-go-llm-lib/llm/all"
)

Configuring the Program

The program reads its settings from environment variables, and treats an empty variable as unset. The five PG* variables describe the database connection exactly as in the first recipe. The following table describes the other variables that the program reads:

Variable Default Description
LLM_PROVIDER ollama The provider for chat requests.
LLM_MODEL qwen2.5:1.5b The chat model.
LLM_API_KEY None The API key, if one is needed.
LLM_BASE_URL The provider's own Overrides the provider endpoint.
EMBED_PROVIDER ollama The provider for embeddings.
EMBED_MODEL nomic-embed-text The embedding model.

The following code shows how the program reads the variables:

// envOr returns the value of an environment variable, or a fallback if
// the variable is unset or empty.
func envOr(name, fallback string) string {
    if value := os.Getenv(name); value != "" {
        return value
    }
    return fallback
}

// config holds the settings that the program reads from the environment.
type config struct {
    llmProvider   string
    llmModel      string
    embedProvider string
    embedModel    string
    apiKey        string
    baseURL       string
    connString    string
}

// loadConfig reads the LLM_*, EMBED_*, and PG* environment variables. An
// empty variable counts as unset.
func loadConfig() config {
    u := url.URL{
        Scheme: "postgres",
        Host:   net.JoinHostPort(envOr("PGHOST", "localhost"), envOr("PGPORT", "5432")),
        Path:   "/" + envOr("PGDATABASE", "postgres"),
    }
    if password := os.Getenv("PGPASSWORD"); password != "" {
        u.User = url.UserPassword(envOr("PGUSER", "postgres"), password)
    } else {
        u.User = url.User(envOr("PGUSER", "postgres"))
    }

    return config{
        llmProvider:   envOr("LLM_PROVIDER", "ollama"),
        llmModel:      envOr("LLM_MODEL", "qwen2.5:1.5b"),
        embedProvider: envOr("EMBED_PROVIDER", "ollama"),
        embedModel:    envOr("EMBED_MODEL", "nomic-embed-text"),
        apiKey:        os.Getenv("LLM_API_KEY"),
        baseURL:       os.Getenv("LLM_BASE_URL"),
        connString:    u.String(),
    }
}

Creating the Clients

The library represents every provider as an llm.Client, which llm.NewClient builds from a provider name and an llm.Options value. The program builds two clients, because the chat provider and the embedding provider can differ. The chat client caps the answer at 150 tokens and sets the temperature to 0, which keeps the answer short and as repeatable as the model allows. The API key and the base URL apply to the embedding client only when both clients use the same provider; otherwise the library uses the embedding provider's own endpoint and reads the key from the provider's own environment variable. The following code builds both clients:

// retry keeps the wait short when a provider is unreachable.
var retry = llm.RetryConfig{
    MaxRetries:     2,
    InitialBackoff: 200 * time.Millisecond,
    MaxBackoff:     time.Second,
}

// newClients builds one client for chat and one for embeddings. The API
// key and base URL apply to the chat client, and to the embedding client
// only when both use the same provider; otherwise the library uses the
// embedding provider's own defaults and reads its key from the provider's
// own environment variable.
func newClients(cfg config) (chat, embed llm.Client, err error) {
    chat, err = llm.NewClient(cfg.llmProvider, llm.Options{
        Model:   cfg.llmModel,
        APIKey:  cfg.apiKey,
        BaseURL: cfg.baseURL,
        // Keep the answer short, which also keeps the request quick.
        MaxTokens:   llm.Int(150),
        Temperature: llm.Float(0),
        Retry:       retry,
    })
    if err != nil {
        return nil, nil, fmt.Errorf("creating the chat client failed: %w", err)
    }

    embedOpts := llm.Options{Model: cfg.embedModel, Retry: retry}
    if cfg.embedProvider == cfg.llmProvider {
        embedOpts.APIKey = cfg.apiKey
        embedOpts.BaseURL = cfg.baseURL
    }
    embed, err = llm.NewClient(cfg.embedProvider, embedOpts)
    if err != nil {
        return nil, nil, fmt.Errorf("creating the embedding client failed: %w", err)
    }
    return chat, embed, nil
}

Creating the Embeddings

The EmbedBatch method sends several texts in one call and returns one vector per text, in the same order. Each vector is a slice of float64 values. The program also converts a vector to the text form that pgvector reads, such as [0.1,0.2,0.3]; passing the text with a ::vector cast means that the program needs no extra driver for the vector type. The following code shows both steps:

// embedTexts asks the provider for one embedding vector per text, returned
// in the same order as the texts.
func embedTexts(ctx context.Context, client llm.Client, texts []string) ([][]float64, error) {
    vectors, err := client.EmbedBatch(ctx, texts)
    if err != nil {
        return nil, fmt.Errorf("embedding failed: %w", err)
    }
    if len(vectors) != len(texts) || len(vectors[0]) == 0 {
        return nil, fmt.Errorf("the provider returned an unexpected number of vectors")
    }
    return vectors, nil
}

// vectorLiteral formats an embedding as the text form of a pgvector value,
// such as [0.1,0.2,0.3].
func vectorLiteral(vector []float64) string {
    parts := make([]string, len(vector))
    for i, x := range vector {
        parts[i] = strconv.FormatFloat(x, 'g', -1, 32)
    }
    return "[" + strings.Join(parts, ",") + "]"
}

Storing the Facts

A pgvector column has a fixed number of dimensions, and that number depends on the embedding model. The program therefore takes the size from the length of the first vector, rather than hard-coding it. The table is TEMPORARY, so it disappears when the connection closes and the example can run repeatedly. The following code creates the table and inserts the facts:

// storeFacts creates a temporary table whose vector column matches the
// size of the embeddings, and inserts one row per fact. The id of a row is
// its position in the list, starting at 1.
func storeFacts(ctx context.Context, conn *pgx.Conn, facts []string, vectors [][]float64) error {
    if _, err := conn.Exec(ctx, "CREATE EXTENSION IF NOT EXISTS vector"); err != nil {
        return fmt.Errorf("creating the extension failed: %w", err)
    }

    // The dimension count is an integer taken from the embedding, so it is
    // safe to format into the statement; SQL cannot take it as a parameter.
    create := fmt.Sprintf(
        "CREATE TEMPORARY TABLE facts (id int PRIMARY KEY, body text, embedding vector(%d))",
        len(vectors[0]))
    if _, err := conn.Exec(ctx, create); err != nil {
        return fmt.Errorf("creating the table failed: %w", err)
    }

    for i, fact := range facts {
        _, err := conn.Exec(ctx,
            "INSERT INTO facts (id, body, embedding) VALUES ($1, $2, $3::vector)",
            i+1, fact, vectorLiteral(vectors[i]))
        if err != nil {
            return fmt.Errorf("insert failed: %w", err)
        }
    }
    return nil
}

Retrieving the Nearest Facts

The <=> operator returns the cosine distance between two vectors, and ordering by that distance puts the closest facts first. The program asks for the two nearest facts, which is enough context for a short answer without burying the relevant fact in noise. The following code runs the search:

// fact is one row returned by the search.
type fact struct {
    id   int
    body string
}

// retrieve returns the facts nearest to the question, closest first, using
// the cosine distance operator (<=>).
func retrieve(ctx context.Context, conn *pgx.Conn, question []float64, limit int) ([]fact, error) {
    rows, err := conn.Query(ctx,
        "SELECT id, body FROM facts ORDER BY embedding <=> $1::vector LIMIT $2",
        vectorLiteral(question), limit)
    if err != nil {
        return nil, fmt.Errorf("search failed: %w", err)
    }
    defer rows.Close()

    var found []fact
    for rows.Next() {
        var f fact
        if err := rows.Scan(&f.id, &f.body); err != nil {
            return nil, fmt.Errorf("reading a row failed: %w", err)
        }
        found = append(found, f)
    }
    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf("search failed: %w", err)
    }
    return found, nil
}

Asking the Model

The program builds a prompt that lists the retrieved facts as context, followed by the question, and sends it in an llm.ChatRequest. The system prompt tells the model to answer from the context alone and to admit when the context does not contain the answer; this instruction is what keeps the model from inventing details. The response holds a list of content blocks, so the program joins the text blocks into one reply. The following code sends the request:

// answer asks the chat model the question, giving it the retrieved facts
// as context and telling it to use nothing else.
func answer(ctx context.Context, client llm.Client, question string, retrieved []fact) (string, error) {
    var prompt strings.Builder
    prompt.WriteString("Context:\n")
    for _, f := range retrieved {
        fmt.Fprintf(&prompt, "- %s\n", f.body)
    }
    fmt.Fprintf(&prompt, "\nQuestion: %s", question)

    resp, err := client.Chat(ctx, llm.ChatRequest{
        SystemPrompt: "Answer the question in one short sentence, using only " +
            "the context. If the context does not contain the answer, say " +
            "that you do not know.",
        Messages: []llm.Message{llm.UserText(prompt.String())},
    })
    if err != nil {
        return "", fmt.Errorf("chat request failed: %w", err)
    }

    // A response is a list of content blocks; join the text blocks.
    var text strings.Builder
    for _, block := range resp.Content {
        if block.Type == llm.BlockText {
            text.WriteString(block.Text)
        }
    }
    // Put the answer on one line, whatever whitespace the model used.
    reply := strings.Join(strings.Fields(text.String()), " ")
    if reply == "" {
        return "", fmt.Errorf("the model returned no text")
    }
    return reply, nil
}

Handling Errors and Closing the Connection

The run function embeds the facts and the question in a single batch before it opens a database connection, so a provider failure leaves nothing behind. The deferred call closes the connection, which also drops the temporary table. The main function reports any error on stderr and exits with a non-zero status. The following code shows both functions:

// run embeds the facts and the question, retrieves the two nearest facts,
// and prints the model's answer with the ids of the facts it was given.
func run(ctx context.Context) error {
    cfg := loadConfig()

    chat, embed, err := newClients(cfg)
    if err != nil {
        return err
    }

    facts := []string{
        "The Zephyr Kettle comes with a three-year warranty that covers manufacturing defects.",
        "The Zephyr Kettle holds 1.7 litres of water.",
        "The Zephyr Kettle is available in matte black, white, and copper.",
        "The Zephyr Kettle boils a full load in about three minutes.",
        "Descale the Zephyr Kettle once a month with citric acid.",
    }
    question := "How long is the warranty on the Zephyr Kettle?"

    inputs := make([]string, 0, len(facts)+1)
    inputs = append(inputs, facts...)
    inputs = append(inputs, question)

    // Embed the facts and the question in one batch, before touching the
    // database, so a provider failure never leaves a half-filled table.
    vectors, err := embedTexts(ctx, embed, inputs)
    if err != nil {
        return err
    }

    conn, err := pgx.Connect(ctx, cfg.connString)
    if err != nil {
        return fmt.Errorf("connection failed: %w", err)
    }
    // The deferred call closes the connection, which also drops the
    // temporary table.
    defer conn.Close(ctx)

    if err := storeFacts(ctx, conn, facts, vectors[:len(facts)]); err != nil {
        return err
    }

    found, err := retrieve(ctx, conn, vectors[len(facts)], 2)
    if err != nil {
        return err
    }

    reply, err := answer(ctx, chat, question, found)
    if err != nil {
        return err
    }

    ids := make([]string, len(found))
    for i, f := range found {
        ids[i] = strconv.Itoa(f.id)
    }
    fmt.Printf("Question: %s\n", question)
    fmt.Printf("Answer: %s\n", reply)
    fmt.Printf("Sources: %s\n", strings.Join(ids, ", "))
    return nil
}

// main reports any error returned by run() and exits with a non-zero
// status. Keeping the work in run() means that deferred cleanup always
// happens before the program exits.
func main() {
    if err := run(context.Background()); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
}

Running the Example

The following commands run the example against a local PostgreSQL server and a local Ollama server:

export PGHOST=localhost PGUSER=postgres
cd recipes/go_llm_lib/rag_answer/go
go run .

The Sources: line lists the ids of the facts that the model saw, which lets a reader check the answer against the data.

Viewing the Complete Program

The following code shows the complete program:

// Ellie's Postgres Cookbook
// Answering a question from your own data: retrieve the most relevant rows
// with pgvector and pass them to a chat model, using the pgEdge Go LLM
// Library and pgx.
//
// Copyright (c) 2026, pgEdge, Inc.
// This code is released under the PostgreSQL License.

package main

import (
    "context"
    "fmt"
    "net"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"

    "github.com/jackc/pgx/v5"
    "github.com/pgEdge/pgedge-go-llm-lib/llm"
    _ "github.com/pgEdge/pgedge-go-llm-lib/llm/all"
)


// envOr returns the value of an environment variable, or a fallback if
// the variable is unset or empty.
func envOr(name, fallback string) string {
    if value := os.Getenv(name); value != "" {
        return value
    }
    return fallback
}

// config holds the settings that the program reads from the environment.
type config struct {
    llmProvider   string
    llmModel      string
    embedProvider string
    embedModel    string
    apiKey        string
    baseURL       string
    connString    string
}

// loadConfig reads the LLM_*, EMBED_*, and PG* environment variables. An
// empty variable counts as unset.
func loadConfig() config {
    u := url.URL{
        Scheme: "postgres",
        Host:   net.JoinHostPort(envOr("PGHOST", "localhost"), envOr("PGPORT", "5432")),
        Path:   "/" + envOr("PGDATABASE", "postgres"),
    }
    if password := os.Getenv("PGPASSWORD"); password != "" {
        u.User = url.UserPassword(envOr("PGUSER", "postgres"), password)
    } else {
        u.User = url.User(envOr("PGUSER", "postgres"))
    }

    return config{
        llmProvider:   envOr("LLM_PROVIDER", "ollama"),
        llmModel:      envOr("LLM_MODEL", "qwen2.5:1.5b"),
        embedProvider: envOr("EMBED_PROVIDER", "ollama"),
        embedModel:    envOr("EMBED_MODEL", "nomic-embed-text"),
        apiKey:        os.Getenv("LLM_API_KEY"),
        baseURL:       os.Getenv("LLM_BASE_URL"),
        connString:    u.String(),
    }
}


// retry keeps the wait short when a provider is unreachable.
var retry = llm.RetryConfig{
    MaxRetries:     2,
    InitialBackoff: 200 * time.Millisecond,
    MaxBackoff:     time.Second,
}

// newClients builds one client for chat and one for embeddings. The API
// key and base URL apply to the chat client, and to the embedding client
// only when both use the same provider; otherwise the library uses the
// embedding provider's own defaults and reads its key from the provider's
// own environment variable.
func newClients(cfg config) (chat, embed llm.Client, err error) {
    chat, err = llm.NewClient(cfg.llmProvider, llm.Options{
        Model:   cfg.llmModel,
        APIKey:  cfg.apiKey,
        BaseURL: cfg.baseURL,
        // Keep the answer short, which also keeps the request quick.
        MaxTokens:   llm.Int(150),
        Temperature: llm.Float(0),
        Retry:       retry,
    })
    if err != nil {
        return nil, nil, fmt.Errorf("creating the chat client failed: %w", err)
    }

    embedOpts := llm.Options{Model: cfg.embedModel, Retry: retry}
    if cfg.embedProvider == cfg.llmProvider {
        embedOpts.APIKey = cfg.apiKey
        embedOpts.BaseURL = cfg.baseURL
    }
    embed, err = llm.NewClient(cfg.embedProvider, embedOpts)
    if err != nil {
        return nil, nil, fmt.Errorf("creating the embedding client failed: %w", err)
    }
    return chat, embed, nil
}


// embedTexts asks the provider for one embedding vector per text, returned
// in the same order as the texts.
func embedTexts(ctx context.Context, client llm.Client, texts []string) ([][]float64, error) {
    vectors, err := client.EmbedBatch(ctx, texts)
    if err != nil {
        return nil, fmt.Errorf("embedding failed: %w", err)
    }
    if len(vectors) != len(texts) || len(vectors[0]) == 0 {
        return nil, fmt.Errorf("the provider returned an unexpected number of vectors")
    }
    return vectors, nil
}

// vectorLiteral formats an embedding as the text form of a pgvector value,
// such as [0.1,0.2,0.3].
func vectorLiteral(vector []float64) string {
    parts := make([]string, len(vector))
    for i, x := range vector {
        parts[i] = strconv.FormatFloat(x, 'g', -1, 32)
    }
    return "[" + strings.Join(parts, ",") + "]"
}


// storeFacts creates a temporary table whose vector column matches the
// size of the embeddings, and inserts one row per fact. The id of a row is
// its position in the list, starting at 1.
func storeFacts(ctx context.Context, conn *pgx.Conn, facts []string, vectors [][]float64) error {
    if _, err := conn.Exec(ctx, "CREATE EXTENSION IF NOT EXISTS vector"); err != nil {
        return fmt.Errorf("creating the extension failed: %w", err)
    }

    // The dimension count is an integer taken from the embedding, so it is
    // safe to format into the statement; SQL cannot take it as a parameter.
    create := fmt.Sprintf(
        "CREATE TEMPORARY TABLE facts (id int PRIMARY KEY, body text, embedding vector(%d))",
        len(vectors[0]))
    if _, err := conn.Exec(ctx, create); err != nil {
        return fmt.Errorf("creating the table failed: %w", err)
    }

    for i, fact := range facts {
        _, err := conn.Exec(ctx,
            "INSERT INTO facts (id, body, embedding) VALUES ($1, $2, $3::vector)",
            i+1, fact, vectorLiteral(vectors[i]))
        if err != nil {
            return fmt.Errorf("insert failed: %w", err)
        }
    }
    return nil
}


// fact is one row returned by the search.
type fact struct {
    id   int
    body string
}

// retrieve returns the facts nearest to the question, closest first, using
// the cosine distance operator (<=>).
func retrieve(ctx context.Context, conn *pgx.Conn, question []float64, limit int) ([]fact, error) {
    rows, err := conn.Query(ctx,
        "SELECT id, body FROM facts ORDER BY embedding <=> $1::vector LIMIT $2",
        vectorLiteral(question), limit)
    if err != nil {
        return nil, fmt.Errorf("search failed: %w", err)
    }
    defer rows.Close()

    var found []fact
    for rows.Next() {
        var f fact
        if err := rows.Scan(&f.id, &f.body); err != nil {
            return nil, fmt.Errorf("reading a row failed: %w", err)
        }
        found = append(found, f)
    }
    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf("search failed: %w", err)
    }
    return found, nil
}


// answer asks the chat model the question, giving it the retrieved facts
// as context and telling it to use nothing else.
func answer(ctx context.Context, client llm.Client, question string, retrieved []fact) (string, error) {
    var prompt strings.Builder
    prompt.WriteString("Context:\n")
    for _, f := range retrieved {
        fmt.Fprintf(&prompt, "- %s\n", f.body)
    }
    fmt.Fprintf(&prompt, "\nQuestion: %s", question)

    resp, err := client.Chat(ctx, llm.ChatRequest{
        SystemPrompt: "Answer the question in one short sentence, using only " +
            "the context. If the context does not contain the answer, say " +
            "that you do not know.",
        Messages: []llm.Message{llm.UserText(prompt.String())},
    })
    if err != nil {
        return "", fmt.Errorf("chat request failed: %w", err)
    }

    // A response is a list of content blocks; join the text blocks.
    var text strings.Builder
    for _, block := range resp.Content {
        if block.Type == llm.BlockText {
            text.WriteString(block.Text)
        }
    }
    // Put the answer on one line, whatever whitespace the model used.
    reply := strings.Join(strings.Fields(text.String()), " ")
    if reply == "" {
        return "", fmt.Errorf("the model returned no text")
    }
    return reply, nil
}


// run embeds the facts and the question, retrieves the two nearest facts,
// and prints the model's answer with the ids of the facts it was given.
func run(ctx context.Context) error {
    cfg := loadConfig()

    chat, embed, err := newClients(cfg)
    if err != nil {
        return err
    }

    facts := []string{
        "The Zephyr Kettle comes with a three-year warranty that covers manufacturing defects.",
        "The Zephyr Kettle holds 1.7 litres of water.",
        "The Zephyr Kettle is available in matte black, white, and copper.",
        "The Zephyr Kettle boils a full load in about three minutes.",
        "Descale the Zephyr Kettle once a month with citric acid.",
    }
    question := "How long is the warranty on the Zephyr Kettle?"

    inputs := make([]string, 0, len(facts)+1)
    inputs = append(inputs, facts...)
    inputs = append(inputs, question)

    // Embed the facts and the question in one batch, before touching the
    // database, so a provider failure never leaves a half-filled table.
    vectors, err := embedTexts(ctx, embed, inputs)
    if err != nil {
        return err
    }

    conn, err := pgx.Connect(ctx, cfg.connString)
    if err != nil {
        return fmt.Errorf("connection failed: %w", err)
    }
    // The deferred call closes the connection, which also drops the
    // temporary table.
    defer conn.Close(ctx)

    if err := storeFacts(ctx, conn, facts, vectors[:len(facts)]); err != nil {
        return err
    }

    found, err := retrieve(ctx, conn, vectors[len(facts)], 2)
    if err != nil {
        return err
    }

    reply, err := answer(ctx, chat, question, found)
    if err != nil {
        return err
    }

    ids := make([]string, len(found))
    for i, f := range found {
        ids[i] = strconv.Itoa(f.id)
    }
    fmt.Printf("Question: %s\n", question)
    fmt.Printf("Answer: %s\n", reply)
    fmt.Printf("Sources: %s\n", strings.Join(ids, ", "))
    return nil
}

// main reports any error returned by run() and exits with a non-zero
// status. Keeping the work in run() means that deferred cleanup always
// happens before the program exits.
func main() {
    if err := run(context.Background()); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
}

Next Steps

The following resources provide more information about the topics in this recipe: