Build

Retrieval-augmented generation (RAG) in Go, with the standard library

Split documents into chunks, embed them, search in memory, and answer from what the search found.

Updated

A model knows what was in its training data, and nothing about your service’s documentation, your runbooks, or last week’s design notes. When the answer to a question is in documents like those, the program has to find the right passages and put them in front of the model.

The search is usually by meaning rather than by keyword. An embeddings model turns each passage into a vector, a list of numbers, placed so that passages about similar things have similar vectors; the question is turned into a vector the same way, and the closest passages win. Everything on this page is standard-library Go: the embeddings come from an HTTP API, and the search is a loop.

Run it first

The program is rag in the companion code. It answers questions about the documentation of shortlink, a small URL shortener made up for the purpose: 25 Markdown files, about 53 KB. From the repository’s code directory, with no API key:

terminal
$ go run ./rag

Without a key, it replays a recorded run: the replies of xiaomi/mimo-v2.6-pro for the answers and of voyageai/voyage-4-lite for the embeddings, recorded on October 19, 2026. The chunking and the search run for real. It asks 16 questions three ways, and this page builds the first, rag; the others are the subject of RAG or agentic search?.

go run ./rag
index: 140 chunks, $0.00028 to embed

approach  correct  calls  tokens in      out       cost
rag         16/16     32       8714     3685    $0.0064
rag-tool    16/16     72      25834     6103    $0.0128
files       16/16     54      43201     6098    $0.0151

With OPENROUTER_API_KEY set, ask your own question:

terminal
$ go run ./rag -ask rag "How do I make redirects permanent?"

There are four pieces: split the documents into chunks, embed every chunk, search the chunks for each question, and answer from what the search found.

Split the documents into chunks

A whole document is the wrong unit to search. A long page about deployment matches many questions a little, and sending all of it spends tokens on paragraphs that don’t matter. A sentence is too small: it loses the context that says what it is about. Documentation is written in sections, so the sections are the chunks here:

rag/retrieve.go
// Chunk is one piece of one document: the unit that is embedded,
// searched, and handed to the model.
type Chunk struct {
	Doc     string `json:"doc"`     // the file it came from
	Heading string `json:"heading"` // the nearest heading above it
	Text    string `json:"text"`
}

// chunkDocs splits every Markdown file under docs into chunks, one per
// section: the text under each heading, with long sections cut at
// paragraph breaks so no chunk passes maxChunk bytes.
func chunkDocs(docs fs.FS, maxChunk int) ([]Chunk, error) {
	var out []Chunk
	err := fs.WalkDir(docs, ".", func(p string, d fs.DirEntry,
		err error) error {
		if err != nil || d.IsDir() || !strings.HasSuffix(p, ".md") {
			return err
		}
		data, err := fs.ReadFile(docs, p)
		if err != nil {
			return fmt.Errorf("read %s: %w", p, err)
		}
		heading, body := "", ""
		flush := func() {
			for _, part := range splitParas(body, maxChunk) {
				out = append(out, Chunk{Doc: p, Heading: heading, Text: part})
			}
			body = ""
		}
		for line := range strings.SplitSeq(string(data), "\n") {
			if h, ok := strings.CutPrefix(line, "#"); ok {
				flush()
				heading = strings.TrimSpace(strings.TrimLeft(h, "#"))
				continue
			}
			body += line + "\n"
		}
		flush()
		return nil
	})
	if err != nil {
		return nil, fmt.Errorf("chunk docs: %w", err)
	}
	return out, nil
}

Each chunk is the text under one heading, cut at paragraph breaks when a section runs past 700 bytes. The 25 files make 140 chunks.

A chunk carries its document’s path and its heading. “Set trusted_proxies” means little on its own; under “Behind a reverse proxy” in docs/deploy.md, it answers a question. So the text that is embedded, and later shown to the model, starts with both:

output
[docs/deploy.md: Behind a reverse proxy]
When shortlink runs behind a reverse proxy such as nginx, every request
appears to come from the proxy's address, so every user shares one rate
limit. ...

The headings also let the model cite where each fact came from, which the prompt below asks for.

Embed the chunks

OpenRouter serves embeddings models from the same API, with the same key, as the chat models. The request is a model and a list of texts, and the reply holds one vector per text:

rag/retrieve.go
// Embed turns texts into vectors with an embeddings model, through
// the same API and key as the chat calls.
func (c *Client) Embed(ctx context.Context, model string,
	texts []string) ([][]float64, error) {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
	defer cancel()
	body, err := json.Marshal(map[string]any{
		"model": model, "input": texts, "dimensions": embedDims,
	})
	if err != nil {
		return nil, fmt.Errorf("encode embeddings request: %w", err)
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost,
		c.BaseURL+"/embeddings", bytes.NewReader(body))
	if err != nil {
		return nil, fmt.Errorf("build embeddings request: %w", err)
	}
	req.Header.Set("Authorization", "Bearer "+c.APIKey)
	req.Header.Set("Content-Type", "application/json")
	resp, err := c.httpClient().Do(req)
	if err != nil {
		return nil, fmt.Errorf("send embeddings request: %w", err)
	}
	defer resp.Body.Close()
	return c.readEmbeddings(resp, len(texts))
}

All 140 chunks go in one request, which cost $0.00028 with voyageai/voyage-4-lite. Embedding costs little next to the answers: the 16 questions’ answers cost about 20 times as much as building the index.

The request asks for 256 numbers per vector, through the dimensions field; this model makes 1,024 by default. Shorter vectors take a quarter of the storage, and a quarter of the arithmetic in every comparison. The questions must be embedded by the same model, with the same number of dimensions, as the chunks, or the vectors can’t be compared.

Search in memory

To compare two vectors, the program uses cosine similarity: it measures the angle between them and ignores their length. It is 1 for vectors that point the same way and near 0 for unrelated ones. The search computes it against every chunk and keeps the best:

rag/retrieve.go
// Index holds every chunk with its vector, and finds the chunks
// closest to a question by cosine similarity: an in-memory vector
// search, which for a few thousand chunks needs no database.
type Index struct {
	Chunks  []Chunk
	Vectors [][]float64
}

// Hit is one search result.
type Hit struct {
	Chunk
	Score float64 // cosine similarity, from -1 to 1
}

// Search returns the k chunks most similar to the question's vector,
// best first.
func (ix *Index) Search(q []float64, k int) []Hit {
	hits := make([]Hit, len(ix.Chunks))
	for i, v := range ix.Vectors {
		hits[i] = Hit{ix.Chunks[i], cosine(q, v)}
	}
	slices.SortFunc(hits, func(a, b Hit) int {
		return cmp.Compare(b.Score, a.Score)
	})
	return hits[:min(k, len(hits))]
}

func cosine(a, b []float64) float64 {
	var dot, na, nb float64
	for i := range min(len(a), len(b)) {
		dot += a[i] * b[i]
		na += a[i] * a[i]
		nb += b[i] * b[i]
	}
	if na == 0 || nb == 0 {
		return 0
	}
	return dot / (math.Sqrt(na) * math.Sqrt(nb))
}

Comparing the question with every chunk is a brute-force search, and for a few thousand chunks it needs no database. Larger sets keep their vectors in a store with a vector index, which finds close vectors without comparing every one; the rest of the program stays the same.

Answer from the passages

The question is embedded, the four closest chunks are found, and one chat call gets the passages and the question:

rag/answer.go
const ragPrompt = `Answer the question using only the passages from
shortlink's documentation that come with it. Name the document each
fact comes from. If the passages don't answer the question, say that
the documentation doesn't say.`

// askRAG is retrieval-augmented generation in one call: embed the
// question, find the closest chunks, and send them with the question.
func askRAG(ctx context.Context, c *Client, ix *Index, embedModel,
	question string) (string, error) {
	qv, err := c.Embed(ctx, embedModel, []string{question})
	if err != nil {
		return "", err
	}
	var passages []string
	for _, h := range ix.Search(qv[0], topK) {
		passages = append(passages, h.passage())
	}
	msgs := []Message{
		{Role: "system", Content: ragPrompt},
		{Role: "user", Content: "Passages:\n\n" +
			strings.Join(passages, "\n\n") + "\n\nQuestion: " + question},
	}
	reply, err := c.Chat(ctx, msgs, nil)
	if err != nil {
		return "", err
	}
	if reply.FinishReason == "length" {
		return "", errCutOff
	}
	return reply.Message.Content, nil
}

The system prompt makes three rules: answer only from the passages, name the document each fact comes from, and say so when the passages don’t answer the question. The last rule matters as much as the first two. Search always returns four passages, whether or not any of them is relevant, and a model given passages tends to answer from them. The 16th question asks whether shortlink can make QR codes, which no document mentions; rag answered:

output
The documentation doesn't say. None of the provided passages mention
QR codes ...

16 of 16 questions answered right, for $0.0064 in all

The cost includes embedding each question. One run on one model is a single sample: it shows that the pipeline works on these documents, not a rate you can expect elsewhere.

Task
16 questions about shortlink’s 25 documents, from a single setting’s default to a two-step upgrade and one question the documents don’t answer, graded by the phrases a right answer must contain
Models
xiaomi/mimo-v2.6-pro for the answers, voyageai/voyage-4-lite for the embeddings, through OpenRouter
Runs
one run of the 16 questions; 32 API calls, two per question, after one call to build the index
Date
October 2026

Measured with the companion codeThe comparison with agentic search

Retrieval as a tool

The same search can be a tool instead, so that a model in an agent loop decides when to search and what to search for:

rag/answer.go
const toolPrompt = `You answer questions about shortlink from its
documentation. Use search_docs to find passages; if the first results
don't answer the question, search again with other words. Answer only
from what you found, name the documents you used, and if the
documentation doesn't answer the question, say so.`

// searchTool is retrieval as a tool: the model decides when to search
// and what to search for, and can search again.
func searchTool(c *Client, ix *Index, embedModel string) Tool {
	return Tool{
		Name: "search_docs",
		Description: "Search shortlink's documentation. Returns the " +
			"passages closest in meaning to the query, best first.",
		Parameters: []byte(`{"type": "object", "properties": {"query":
			{"type": "string"}}, "required": ["query"]}`),
		Run: func(ctx context.Context, args string) (string, error) {
			var a struct {
				Query string `json:"query"`
			}
			if err := json.Unmarshal([]byte(args), &a); err != nil {
				return "", fmt.Errorf("arguments: %w", err)
			}
			qv, err := c.Embed(ctx, embedModel, []string{a.Query})
			if err != nil {
				return "", err
			}
			var out []string
			for _, h := range ix.Search(qv[0], topK) {
				out = append(out, h.passage())
			}
			return strings.Join(out, "\n\n"), nil
		},
	}
}

With the tool, the model can search again with other words when the first results miss, and on these questions it searched more than once on 13 of 16. That costs calls and tokens; RAG or agentic search? compares it with one-call RAG and with an agent that reads the files itself.

What to watch

Retrieval can miss. When the right chunk isn’t among the four returned, the model can’t answer, and with the prompt above it says it can’t. Check retrieval on its own: for each test question, note which document holds the answer, and count how often a chunk from it comes back. That tells you whether a wrong answer came from the search or from the model, which an eval of the answers alone can’t.

The index goes stale. Vectors describe the documents as they were when they were embedded. Rebuild the index when the documents change, or embed only the files that changed.

Retrieved text is untrusted input. The passages come from documents, and anyone who can edit a document can write instructions into it. Treat them as data in the prompt, the way prompt injection describes for any tool result.

Chunk size is a trade-off. Larger chunks carry more context and spend more tokens on each answer; smaller ones are more precise and lose context. Splitting by heading suits documentation; for text without headings, split by paragraph and keep a little overlap between neighbors.

Try it

  1. Run go run ./rag and read testdata/run.json for one question: the embedding request, the four passages in the chat request, and the answer.
  2. With a key, ask a question whose answer spans two documents, such as which setting turns webhooks on and which metric counts failed deliveries, and see whether four chunks are enough.
  3. Change topK in answer.go to 2, record a run with -record, and compare the scores and the cost.
  4. Change embedDims to 1,024, and compare the size of the recording and the answers.