Skip to main content
  1. Articles/

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

·2335 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 9: This Article

In the previous article we built a feeder application: it reads fairy tales from a Markdown file, generates an OpenAI embedding for each one, and stores the text, the vector, and a small metadata blob in Postgres through pgvector. That gave us a table full of vectors, but no way to query it from a running application — the data sat there, searchable only by hand, through a database client.

This article closes that gap. We build a small conversational agent that searches the fairy tale library through a tool and answers using only what that tool returns, not whatever GPT happens to remember about fairy tales from its training data. The model decides on its own when a search is needed, based on what the user actually asks.

Setting Up The Project
#

Everything here builds on top of the previous article, so the same Docker Compose setup (a Postgres 17 container with the pgvector extension baked in) needs to be running, with the fairy_tales table already populated by the feeder. If that step has not happened yet, the first article walks through the Docker Compose file, the table schema, and the feeder application that fills it with fifty fairy tales.

Assuming the feeder already ran, the table looks like this:

Fairy tales table in pgvector
Fairy tales table in pgvector

The data is in place, but the goal for this article is different from a one-off SQL query: we want an AI agent to decide, on its own, when a search is needed and what to search for. Running a hardcoded query against every user message would not give the model that choice. That means giving the agent a tool — a function with a name, a description, and a parameter schema — that it can choose to call.

The tool itself is allowed to call the OpenAI API. The agent calls GPT to decide it needs information, and the tool it invokes then calls the Embeddings API on its own to turn that request into a vector before it ever touches Postgres. Nothing forces every OpenAI call in an application through the same layer: a tool is just a Go function, and that function can talk to whatever it needs to get its job done.

Tool For Retrieval
#

Search starts with turning the user’s natural-language request into the same kind of vector the feeder stored for every fairy tale. That is a single call to the Embeddings API, wrapped in its own function:

func embedQuery(ctx context.Context, query string) ([]float32, error) {
	resp, err := openaiClient.Embeddings.New(ctx, openai.EmbeddingNewParams{
		Model: openai.EmbeddingModelTextEmbedding3Small,
		Input: openai.EmbeddingNewParamsInputUnion{OfString: openai.String(query)},
	})
	if err != nil {
		return nil, fmt.Errorf("embeddings API call failed: %w", err)
	}
	if len(resp.Data) != 1 {
		return nil, fmt.Errorf("expected 1 embedding, got %d", len(resp.Data))
	}

	raw := resp.Data[0].Embedding
	vector := make([]float32, len(raw))
	for i, f := range raw {
		vector[i] = float32(f)
	}
	return vector, nil
}

embedQuery mirrors the embedding call from the feeder application, with one difference: it sends a single string rather than a batch, using OfString instead of OfArrayOfStrings on EmbeddingNewParamsInputUnion. The rest is the same conversion we already needed in the feeder — OpenAI returns float64 values, and pgvector-go expects float32, so the function converts every element before returning the vector. Using the same text-embedding-3-small model here as during ingestion is not optional: two embeddings only end up close together in vector space if they came from the same model, so the query and the stored data have to be embedded the same way.

With a way to embed the query, the next function runs the actual similarity search against Postgres:

var dbConn *pgx.Conn

type searchMatch struct {
	Title    string  `json:"title"`
	Text     string  `json:"text"`
	Distance float64 `json:"distance"`
}

func searchFairyTale(ctx context.Context, description string) ([]searchMatch, error) {
	vector, err := embedQuery(ctx, description)
	if err != nil {
		return nil, err
	}

	rows, err := dbConn.Query(ctx,
		"SELECT text, metadata, embedding <=> $1 AS distance FROM fairy_tales ORDER BY distance LIMIT $2",
		pgvector.NewVector(vector),
		3,
	)
	if err != nil {
		return nil, fmt.Errorf("search query failed: %w", err)
	}
	defer rows.Close()

	var matches []searchMatch
	for rows.Next() {
		var text string
		var metadata []byte
		var distance float64
		if err := rows.Scan(&text, &metadata, &distance); err != nil {
			return nil, fmt.Errorf("failed to scan row: %w", err)
		}

		var meta struct {
			Title string `json:"title"`
		}
		if err := json.Unmarshal(metadata, &meta); err != nil {
			return nil, fmt.Errorf("failed to parse metadata: %w", err)
		}

		matches = append(matches, searchMatch{
			Title:    meta.Title,
			Text:     text,
			Distance: distance,
		})
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("failed to read search results: %w", err)
	}

	return matches, nil
}

Unlike the feeder, which passed its *pgx.Conn around as an explicit parameter, this application keeps the connection as a package-level variable, dbConn. That is a deliberate trade-off: the tool call chain runs several layers below main, and threading a connection through every function signature just to reach searchFairyTale would add noise without adding safety in a program this size.

searchFairyTale embeds the incoming description with embedQuery, then runs a single SQL query that orders the whole fairy_tales table by cosine distance (<=>) against that vector and takes the three closest rows with LIMIT. For every row, the metadata column (stored as jsonb) gets unmarshaled into an anonymous struct just far enough to pull out the title, and the result is collected into a searchMatch that carries the title, the full text, and the distance score back to the caller. Returning the distance alongside the text matters: it gives the model a signal for how confident the match actually is, rather than presenting three results as if they were equally relevant.

Retrieval is now a plain Go function, so the next step is describing it to the model as a tool, the same way tool calling with the Chat Completions API was introduced for a time-lookup tool earlier in this series:

var searchToolDefinition = openai.ChatCompletionToolParam{
	Type: "function",
	Function: openai.FunctionDefinitionParam{
		Name: "search_fairy_tale",
		Description: openai.String(
			"Search the fairy tale library for tales matching a natural-language description " +
				"of their plot, characters, or theme. Returns the most similar tales with their full text.",
		),
		Parameters: openai.FunctionParameters{
			"type": "object",
			"properties": map[string]interface{}{
				"description": map[string]interface{}{
					"type":        "string",
					"description": "A description of the fairy tale to look for, e.g. plot points, characters, or setting.",
				},
			},
			"required":             []string{"description"},
			"additionalProperties": false,
		},
	},
}

func callTool(ctx context.Context, call openai.ChatCompletionMessageToolCall) (string, error) {
	switch call.Function.Name {
	case "search_fairy_tale":
		var args searchArgs
		if err := json.Unmarshal([]byte(call.Function.Arguments), &args); err != nil {
			return "", fmt.Errorf("failed to parse tool arguments: %w", err)
		}

		matches, err := searchFairyTale(ctx, args.Description)
		if err != nil {
			errMsg, _ := json.Marshal(map[string]string{"error": err.Error()})
			return string(errMsg), nil
		}

		out, err := json.Marshal(matches)
		if err != nil {
			return "", fmt.Errorf("failed to marshal tool result: %w", err)
		}
		return string(out), nil

	default:
		return "", fmt.Errorf("unknown tool: %q", call.Function.Name)
	}
}

searchToolDefinition follows the same shape as any other tool definition: a Name the model uses when it wants to call the function, a Description the model reads to decide whether this tool is the right one for the current message, and a Parameters block that requires a single description string. That description field is what the model fills in with its own summary of what the user is looking for — not the user’s raw message, but the model’s interpretation of it, which tends to search better than the literal wording ever could.

callTool is the same router pattern used for the time tool earlier in the series: it switches on the function name the model requested, unmarshals the JSON arguments into searchArgs, and calls searchFairyTale with the description the model provided. A failed search gets marshaled into a JSON error payload and returned without an error value, so the model sees what went wrong and can respond to the user instead of crashing the loop. A successful search gets marshaled into JSON directly (titles, text, and distances included) and handed back as the tool result.

AI Agent
#

The agent loop itself needs no new ideas beyond what tool calling with the Chat Completions API already covered — it is the same request-check-execute-repeat cycle, wired to the search tool instead of a time lookup:

var history []openai.ChatCompletionMessageParamUnion
var openaiClient openai.Client

func talkToAgent(ctx context.Context) (string, error) {
	for {
		resp, err := openaiClient.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{
			Model:    openai.ChatModelGPT4oMini,
			Messages: history,
			Tools: []openai.ChatCompletionToolParam{
				searchToolDefinition,
			},
		})
		if err != nil {
			return "", fmt.Errorf("API call failed: %w", err)
		}

		choice := resp.Choices[0]
		history = append(history, choice.Message.ToParam())

		if len(choice.Message.ToolCalls) == 0 {
			return choice.Message.Content, nil
		}

		for _, call := range choice.Message.ToolCalls {
			fmt.Printf("  [tool] %s(%s)\n", call.Function.Name, call.Function.Arguments)

			result, err := callTool(ctx, call)
			if err != nil {
				return "", err
			}

			history = append(history, openai.ToolMessage(result, call.ID))
		}
	}
}

talkToAgent sends the full conversation history plus the searchToolDefinition on every request. When the model responds with no tool calls, its message is the final answer and the loop returns it directly. When it does request a call — search_fairy_tale, in this application’s case — the loop executes it through callTool, appends the JSON result to the history as a tool message tied to the call’s ID, and fires another request so the model can turn that result into an answer. ChatModelGPT4oMini is used here rather than a larger model, since tool-routing and grounded summarization are well within what a smaller model handles reliably.

The last piece wires the database connection, the OpenAI client, and the system prompt together, then runs the read loop:

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()

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

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

	openaiClient = openai.NewClient(option.WithAPIKey(apiKey))

	history = append(history, openai.SystemMessage(
		"You are a fairy tale librarian. "+
			"When the user describes a fairy tale by its plot, characters, or theme, "+
			"always use the search_fairy_tale tool to find it in the library. "+
			"Never rely on your own memory of fairy tales; base your answer only on what the tool returns. "+
			"If nothing relevant is found, say so.",
	))

	scanner := bufio.NewScanner(os.Stdin)
	fmt.Println("Fairy tale librarian ready. Describe a fairy tale, or type 'exit' to quit.")
	fmt.Println()

	for {
		fmt.Print("Human: ")
		if !scanner.Scan() {
			break
		}

		input := strings.TrimSpace(scanner.Text())
		if input == "" {
			continue
		}
		if strings.EqualFold(input, "exit") {
			fmt.Println("Bye.")
			break
		}

		history = append(history, openai.UserMessage(input))

		answer, err := talkToAgent(ctx)
		if err != nil {
			log.Fatalf("error: %v\n", err)
		}

		fmt.Printf("Agent: %s\n\n", answer)
	}
}

main reads OPENAI_API_KEY and DATABASE_URL from the environment and fails immediately if either is missing, connects to Postgres with pgx, and calls pgxvec.RegisterTypes so the driver knows how to encode and decode the vector column — the same registration step the feeder needed before it could insert embeddings. The system prompt is where the retrieval behavior actually gets enforced: it tells the model to always call search_fairy_tale when a user describes a plot, characters, or theme, and explicitly forbids answering from its own memory of fairy tales. Without that instruction, GPT-4o mini already knows plenty about Cinderella and would happily answer without ever touching the database — which defeats the purpose of building a RAG application in the first place. From there, the loop reads a line, appends it to history as a user message, and prints whatever talkToAgent returns.

Running the application, with the container up and the table populated, produces this exchange:

Fairy tale librarian ready. Describe a fairy tale, or type 'exit' to quit.

Human: Do you have an access to the internet?
Agent: No, I don't have access to the internet. I can only provide information based on the fairy tale library I have access to. If you're looking for a specific fairy tale, please describe it, and I'll try to find it for you!

Human: Can you tell me which fairy tale mentions a girl that cleans house for her stepmother and stepsisters?
  [tool] search_fairy_tale({"description":"a girl that cleans house for her stepmother and stepsisters"})
Agent: The fairy tale that mentions a girl who cleans house for her stepmother and stepsisters is **Cinderella**. 

In this story, 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 living among the cinders of the fireplace, which gives her the nickname Cinderella. The tale goes on to describe her magical transformation and eventual escape from her life of servitude.

If you would like to know more about Cinderella or any other tales, feel free to ask!

The first question triggers no tool call at all — the model answers directly, since nothing about internet access requires searching the fairy tale library. The second question is where the system prompt earns its keep: the model rewrites the user’s question into a search description, calls search_fairy_tale, and only then answers, using text that traces back to a specific row in the fairy_tales table, not to whatever GPT already knew about Cinderella.

Conclusion
#

Across these two articles, we built the full RAG loop end to end: a feeder that embeds and stores text, and an agent that embeds a question, searches for the closest matches, and answers from what it retrieves instead of from memory. Cosine distance and a LIMIT clause did most of the actual work — the harder part was giving the model a tool it could decide to call on its own, and a system prompt strict enough to keep it from skipping that step. The same pattern extends past fairy tales to any dataset that changes faster than a model’s training data.

Useful Resources
#

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

Related

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

·2265 words·11 mins· loading · loading
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 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.

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.