Skip to main content
  1. Articles/

LLM and Go: Retrieval-Augmented Generation (RAG) with OpenAI - Part 1

·2265 words·11 mins· loading · loading ·
Marko Milojevic
Author
Marko Milojevic
Software engineer and architect. Golang and LLM enthusiast. Awful chess player, gym rat, harmonica newbie and cat lover.
LLM and Go - This article is part of a series.
Part 8: This Article

In the previous article we gave Gemini access to tools and let it call them, closing the gap between an LLM and the outside world. This article closes a different gap: getting an LLM to answer questions about data it has never seen.

Ask GPT-4o about a document that was never part of its training data, and it has two options: admit that it does not know, or guess convincingly. Neither answer is acceptable in an application we build and ship. Retrieval-Augmented Generation, RAG, solves this without retraining or fine-tuning anything: we store our own data as vectors, look up the entries closest to a question, and hand only those entries to the LLM as context.

This article is the first of two parts. Here we build the feeder application: a small Go program that reads a set of fairy tales from a Markdown file, generates embeddings for each one using the OpenAI API, and stores them in Postgres with the pgvector extension. The second part covers the retrieval side, querying that data and feeding it back into a chat completion.

How RAG Works
#

Retrieval-Augmented Generation is a pattern that pairs a search step with a generation step: we retrieve relevant text from our own storage, then ask an LLM to generate an answer using that text as context. What this means in practice is that the model’s knowledge stops being the only source of truth — our database becomes one too.

To make retrieval work, we first need a way to compare two pieces of text for similarity. That is what an embedding provides: a fixed-size array of floating-point numbers (1536 of them for OpenAI’s text-embedding-3-small model) that represents the meaning of a piece of text as a point in a high-dimensional space. Texts with similar meaning end up close to each other in that space; texts about unrelated topics end up far apart.

Visualizing 1536 dimensions is not something anyone can do directly, but the same idea holds in three dimensions: each text becomes a point in space, and the closer two points sit, the more similar their meanings.

Text as vectors in embedding space
Text as vectors in embedding space

Postgres and pgvector give us three ways to measure that distance: Euclidean distance (<->), cosine distance (<=>), and inner product (<#>). Cosine distance is the most common choice for text embeddings, because it measures the angle between two vectors rather than their magnitude, which matters more for meaning than for raw scale.

With a way to measure similarity in place, we can describe the full RAG pattern: we embed a piece of text once and store the resulting vector; when a question comes in, we embed the question the same way and search for the stored vectors closest to it; we then pass the matching text, not the question alone, to the LLM and ask it to answer using that text. The model never needs to be retrained — the only thing that changes between applications is which vectors sit in the database.

This is exactly the job pgvector does for Postgres. It adds a vector column type, the distance operators mentioned above, and index types such as IVFFlat and HNSW that keep nearest-neighbor search fast even as a table grows well past what a full table scan could handle.

In practice, we store three things for every entry: the original text, its embedding, and any metadata worth keeping around — a title, a source URL, whatever helps us make sense of the result later. Searching is a normal SQL query: order the table by the distance operator against a query vector, and take the closest few rows with LIMIT.

Setting Up The Project
#

Before writing any Go code, we need a running Postgres instance with the pgvector extension installed. Docker Compose is the fastest way to get one without touching whatever Postgres might already be running on our machine.

services:
  pgvector:
    image: pgvector/pgvector:pg17
    container_name: pgvector-db
    environment:
      POSTGRES_USER: pgvector
      POSTGRES_PASSWORD: supersecretpassword
      POSTGRES_DB: rag
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U pgvector -d rag"]
      interval: 10s
      timeout: 5s
      retries: 10

The pgvector/pgvector:pg17 image is a normal Postgres 17 image with the pgvector extension already compiled in, so there is no separate installation step to run inside the container. The three environment variables set the database user, password, and database name our Go application connects to. Port 5432 is exposed locally so we can reach it from both the application and a database client. The healthcheck runs pg_isready every ten seconds and gives Postgres up to ten retries to come up — that matters if we start the application in the same script as the container, since a connection attempt would otherwise fail against a database still initializing.

With Postgres running, we still need to enable the pgvector extension inside the rag database and create a table to hold our fairy tales.

CREATE EXTENSION IF NOT EXISTS vector;
       
CREATE TABLE fairy_tales (
    id        bigserial PRIMARY KEY,
    text      text NOT NULL,
    embedding vector(1536) NOT NULL,
    metadata  jsonb NOT NULL
);

The text column holds the raw content we embedded — the same string we send back to the LLM once we retrieve it. embedding is a vector(1536) column, sized to match the output of the text-embedding-3-small model we use in the Go application; the dimension has to match exactly, or pgvector rejects the insert. metadata is a jsonb column for anything structured we want to keep alongside the text, such as the fairy tale’s title, without adding a dedicated column for every field we might need later.

RAG Feeder Application
#

The application we build in this article has one job: read a Markdown file containing fairy tales, and store each one in the fairy_tales table with its embedding. We use 50 of the best-known fairy tales as the dataset, each with a title and a short, single-paragraph description. Every fairy tale becomes exactly one row in the table.

The source data lives in a single Markdown file, with each fairy tale as an H2 heading followed by its description:

## Cinderella

Cinderella is a kind young woman forced into servitude by her cruel stepmother 
and two vain stepsisters after her father's death. She spends her days scrubbing 
floors, cooking, and cleaning while sleeping among the cinders of the fireplace, 
which gives her the nickname Cinderella. When the king announces a grand ball to 
help the prince find a bride, Cinderella's stepfamily leaves her behind despite 
her wish to attend. A fairy godmother appears and magically transforms a pumpkin 
into a carriage, mice into horses, and Cinderella's rags into a beautiful gown 
with glass slippers. She warns Cinderella that the magic will end at midnight. 
...

## Snow White and the Seven Dwarfs

...

With the data in place, we can move to the Go application itself. It has three responsibilities: parse the Markdown file into a list of fairy tales, call the OpenAI API to get an embedding for each one, and insert the results into Postgres. We look at each part in turn, starting with the parser.

func parseFairyTales(path string) ([]fairyTale, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, fmt.Errorf("failed to open %s: %w", path, err)
	}
	defer f.Close()

	var tales []fairyTale
	var current *fairyTale

	scanner := bufio.NewScanner(f)
	scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())
		if line == "" {
			continue
		}

		if title, ok := strings.CutPrefix(line, "## "); ok {
			tales = append(tales, fairyTale{
				Title: title,
			})
			current = &tales[len(tales)-1]
			continue
		}

		if current == nil {
			return nil, fmt.Errorf("body line found before any title: %q", line)
		}
		if current.Body != "" {
			return nil, fmt.Errorf("fairy tale %q has more than one body line, which this simple parser doesn't support", current.Title)
		}
		current.Body = line
	}
	if err := scanner.Err(); err != nil {
		return nil, fmt.Errorf("failed to read %s: %w", path, err)
	}

	return tales, nil
}

The parser reads the file line by line with a bufio.Scanner rather than loading the whole file into memory at once — a reasonable habit even for a file this small. Every line starting with ## marks the beginning of a new fairy tale, and the parser appends a new fairyTale entry with that title, keeping a pointer to it as current so the next line can fill in the body. Because the sample data uses exactly one description line per fairy tale, the parser treats a second body line, or a body line with no preceding title, as an error rather than trying to guess how to merge them — a simplification that would need revisiting for a file with multi-paragraph entries.

func fullText(tale fairyTale) string {
	return tale.Title + "\n\n" + tale.Body
}

func embedTales(ctx context.Context, client openai.Client, tales []fairyTale) ([][]float32, error) {
	texts := make([]string, len(tales))
	for i, tale := range tales {
		texts[i] = fullText(tale)
	}

	resp, err := client.Embeddings.New(ctx, openai.EmbeddingNewParams{
		Model: openai.EmbeddingModelTextEmbedding3Small,
		Input: openai.EmbeddingNewParamsInputUnion{
			OfArrayOfStrings: texts,
		},
	})
	if err != nil {
		return nil, fmt.Errorf("embeddings API call failed: %w", err)
	}
	if len(resp.Data) != len(tales) {
		return nil, fmt.Errorf("expected %d embeddings, got %d", len(tales), len(resp.Data))
	}

	vectors := make([][]float32, len(tales))
	for _, d := range resp.Data {
		v := make([]float32, len(d.Embedding))
		for i, f := range d.Embedding {
			v[i] = float32(f)
		}
		vectors[d.Index] = v
	}

	return vectors, nil
}

fullText combines the title and the body into the single string we actually embed, which means the title contributes to the vector as well as the description — useful later if someone searches for a fairy tale by name rather than by plot. embedTales sends every fairy tale in one call to the Embeddings API instead of one call per row, using text-embedding-3-small, the smaller and cheaper of OpenAI’s current embedding models, which is enough for a dataset this size. The response is not guaranteed to preserve the order of the input, so we place each returned embedding at resp.Data[i].Index rather than at position i, and convert the float64 values OpenAI returns into the float32 values pgvector-go expects.

func storeTales(ctx context.Context, conn *pgx.Conn, tales []fairyTale, vectors [][]float32) error {
	tx, err := conn.Begin(ctx)
	if err != nil {
		return fmt.Errorf("failed to begin transaction: %w", err)
	}
	defer tx.Rollback(ctx)

	insertSQL := "INSERT INTO fairy_tales (text, embedding, metadata) VALUES ($1, $2, $3::jsonb)"

	for i, tale := range tales {
		metadata, err := json.Marshal(map[string]string{"title": tale.Title})
		if err != nil {
			return fmt.Errorf("failed to marshal metadata for %q: %w", tale.Title, err)
		}

		_, err = tx.Exec(ctx, insertSQL, fullText(tale), pgvector.NewVector(vectors[i]), string(metadata))
		if err != nil {
			return fmt.Errorf("failed to insert %q: %w", tale.Title, err)
		}

		fmt.Printf("  [stored] %s\n", tale.Title)
	}

	if err := tx.Commit(ctx); err != nil {
		return fmt.Errorf("failed to commit transaction: %w", err)
	}

	return nil
}

storeTales wraps all 50 inserts in a single transaction, so a failure partway through leaves the table untouched instead of half-populated. For each fairy tale, we marshal a small metadata map containing the title into JSON, then insert the text, the embedding wrapped in pgvector.NewVector, and the metadata as a single row. The transaction only commits once every row has been inserted without error.

func main() {
	apiKey := os.Getenv("OPENAI_API_KEY")
	if apiKey == "" {
		log.Fatal("error: OPENAI_API_KEY environment variable is not set")
	}

	connString := os.Getenv("DATABASE_URL")
	if connString == "" {
		log.Fatal("error: DATABASE_URL environment variable is not set")
	}

	ctx := context.Background()

	tales, err := parseFairyTales("examples/rag_gpt/data/fairy_tales.md")
	if err != nil {
		log.Fatalf("error: %v\n", err)
	}
	fmt.Printf("Parsed %d fairy tales from %s\n", len(tales), "examples/rag_gpt/data/fairy_tales.md")

	client := openai.NewClient(option.WithAPIKey(apiKey))

	fmt.Println("Requesting embeddings...")
	vectors, err := embedTales(ctx, client, tales)
	if err != nil {
		log.Fatalf("error: %v\n", err)
	}

	conn, err := pgx.Connect(ctx, connString)
	if err != nil {
		log.Fatalf("error: failed to connect to database: %v\n", err)
	}
	defer conn.Close(ctx)

	if err := pgxvec.RegisterTypes(ctx, conn); err != nil {
		log.Fatalf("error: failed to register pgvector types (is the vector extension installed?): %v\n", err)
	}

	fmt.Println("Storing fairy tales...")
	if err := storeTales(ctx, conn, tales, vectors); err != nil {
		log.Fatalf("error: %v\n", err)
	}

	fmt.Printf("Done. Stored %d fairy tales in %q.\n", len(tales), "fairy_tales")
}

main wires the three pieces together. It reads OPENAI_API_KEY and DATABASE_URL from the environment and fails immediately if either is missing, rather than letting a later API call fail with a less obvious error. After parsing the Markdown file and requesting embeddings, it opens a connection to Postgres with pgx and calls pgxvec.RegisterTypes, which teaches the pgx driver how to encode and decode the vector column type — skipping this step causes the insert to fail as soon as it reaches the embedding argument. From there, storeTales does the rest.

With the Docker Compose file running, the extension and table created, and the two environment variables set, running the application produces output like this:

Parsed 50 fairy tales from examples/rag_gpt/data/fairy_tales.md
Requesting embeddings...
Storing fairy tales...
  [stored] Cinderella
  [stored] Snow White and the Seven Dwarfs
  ...
Done. Stored 50 fairy tales in "fairy_tales".

Once the run finishes, the fairy_tales table holds 50 rows, each with its text, embedding, and metadata:

Fairy tales table in pgvector
Fairy tales table in pgvector

Conclusion
#

We now have a working feeder: a Go application that parses a Markdown file, generates OpenAI embeddings for each fairy tale, and stores the text, the vector, and the metadata in Postgres through pgvector. That gives us the foundation for retrieval (a table of vectors we can search) but no way yet to query it from an application. The next article in this series covers exactly that: turning a question into an embedding, finding the closest fairy tales, and using them as context for a chat completion.

Useful Resources
#

LLM and Go - This article is part of a series.
Part 8: This Article

Related

LLM and Go: Gemini integration via Interactions API

·2880 words·14 mins· loading · loading
The previous article in this series covered the Generate Content API — how to set up a client, let the Chats helper manage conversation history, and call external tools. This article covers the other Gemini interface: the Interactions API. The Interactions API moves conversation state from the client to Google’s servers. You no longer maintain a chat object and let a helper accumulate history locally; instead, you track an interaction ID and pass it back on the next request. That is a meaningful shift for agent-oriented applications — less client-side bookkeeping, but also less transparency into exactly what the server reconstructs on each turn. Understanding the trade-offs between the two APIs is worth doing before choosing which one to build on. Interactions API # Google made the Interactions API generally available recently, positioning it as the unified way to call both plain Gemini models and its specialised agents through a single endpoint. The Generate Content API is stateless by default — every request must carry the full conversation history, whether you build that history yourself or let the Chats helper do it for you locally. The Interactions API inverts this: conversation state lives on Google’s servers, and you reference previous turns by ID rather than resending them. Both APIs give you access to the same underlying models and tool-calling mechanics. The difference is where the orchestration responsibility sits. The table below, first introduced in the Generate Content API article, summarizes the trade-offs: Feature Generate Content API Interactions API Conversation state Client-managed, or via the Chats helper Server-managed via previous_interaction_id History management Manual — resent with every request, unless using Chats Automatic, referenced by ID Tool support Manual function calling Unified tool and agent invocation Background execution No Yes Data retention None — nothing stored server-side 55 days (paid), 1 day (free), unless store=false Control Full Reduced Best for Custom agents, full control, simplicity Long-running interactions, agent orchestration The Generate Content API is the right default when you want to control exactly what the model sees and keep the request/response model explicit. The Interactions API reduces boilerplate and fits well when background execution or server-managed state actually matter for what you are building. In this article we build the same conversational agent we built before — but with the Interactions API driving state management.

LLM and Go: Gemini Integration via Generate Content API

·2874 words·14 mins· loading · loading
This series has so far covered two providers: OpenAI, across Chat Completions and Responses, and Anthropic, across the Messages API and its output parameters. Gemini is the third provider worth knowing in Go, and it comes with its own set of design decisions — a different SDK, a different way of managing conversation history, and, as of recently, two separate APIs doing largely the same job. This article builds the same conversational agent from earlier in the series, this time on top of Google’s Gemini API, using the genai Go SDK. By the end, you will have a working agent that maintains conversation history and can call external tools to answer questions it otherwise could not — and a clear picture of which of Gemini’s two APIs to reach for. A short introduction to Gemini # The path to large language models runs through a decade of incremental progress in deep learning. Early models like word2vec and GloVe learned to embed words into dense vector spaces, capturing semantic relationships between terms. The transformer architecture, introduced by Google in 2017, changed the trajectory of the field — it processes sequences in parallel using attention mechanisms that capture long-range dependencies far more effectively than recurrent networks. This architectural shift made it practical to train models on orders of magnitude more data. GPT-1 in 2018 showed that large-scale unsupervised pre-training followed by fine-tuning could match or beat purpose-built models across a range of language tasks. Understanding what these models actually do removes a lot of the mysticism around them. An LLM is, at its core, a next-token predictor. It takes a sequence of tokens as input and outputs a probability distribution over the vocabulary for the next token. The transformer’s attention mechanism allows every token in the input to attend to every other token, building a rich contextual representation before making that prediction. Training adjusts billions of parameters to minimise prediction error across enormous text corpora. What emerges is a model with broad world knowledge encoded in its weights — not because it was taught facts directly, but because predicting text well requires internalising the structure of the world that produced that text. Gemini is Google DeepMind’s model family, introduced in late 2023 as the first model built jointly by Google Brain and DeepMind after the two research groups merged. Unlike earlier generations of models that had multimodal capabilities bolted on top of a text-only foundation, Gemini was trained natively across text, images, audio, and video from the start, which is part of why it handles mixed-input tasks and tool use without the awkward seams you sometimes see in models where multimodality was added later. For developers, Google exposes Gemini through the Gemini API — and, as of recently, through two distinct interfaces for that API, which is the subject of the next section.

LLM and Go: Investigating Anthropic Messages API

·2276 words·11 mins· loading · loading
In the previous article I covered the fundamentals of Anthropic’s Messages API: setting up a client, maintaining conversation history, and integrating tools. That was enough to build a working conversational agent. This article goes a level deeper — into the API parameters that shape what the model returns and how it thinks. Two parameters stand out as particularly useful in production: output_config.format and output_config.effort. The first gives you control over the structure of the model’s output. The second controls how much the model reasons before responding — which turns out to matter more than you might expect once you start caring about latency and cost. Messages API details # The Messages API endpoint accepts a rich set of parameters. Most have sensible defaults and you will rarely touch them, but understanding what is available saves you from reaching for workarounds that already exist in the API. The table below covers a selection of the current parameters from the API reference: Parameter Type Description model string ID of the model to use messages array Conversation history as an ordered list of messages system string/array System prompt that sets the model’s behaviour, kept separate from messages max_tokens integer Maximum tokens the model may generate — required on every request output_config.format object Constrains the response to a JSON Schema output_config.effort string Reasoning depth: low, medium, high, xhigh, max thinking object Enables and configures extended or adaptive thinking temperature number Sampling temperature from 0 to 1; higher values produce more random output top_p number Alternative to temperature; nucleus sampling probability mass top_k integer Restricts sampling to the top K most likely tokens stop_sequences array Custom sequences at which the API stops generating stream boolean Stream partial responses as server-sent events tools array List of tools the model may call tool_choice object Controls which tool the model calls metadata object Arbitrary metadata about the request, such as an end-user ID In this article we focus on output_config.format and output_config.effort — two parameters with a direct, visible impact on production systems. Information extraction with output_config.format # The format field inside output_config controls how the model structures its output. By default, Claude replies with plain text. Setting output_config.format to a json_schema document constrains the response to conform to that schema — Anthropic calls this structured outputs.