HowtoGo
Home / Tutorials / Build a RAG Server
Tutorials

Build a RAG Server

A RAG server answers questions about your own documents. It finds the paragraph that covers the question, then asks a model to answer from it. This one is Go, Gemini, Postgres, and pgvector.

A language model knows nothing about your documents. Retrieval-augmented generation fixes that by finding the relevant passage first, then asking the model to answer from it.

This tutorial builds that server end to end: it chunks documents, turns each chunk into a vector, stores them in Postgres, and answers a question using only the chunks that matched. It also says "I do not know" when nothing matched, which is the part most RAG demos skip.

Before you start
  • Go installed. go version should print something.
  • Docker, for the Postgres container in step 1.
  • A Gemini API key from aistudio.google.com. The free tier is enough.

What you are building

Send it text. It splits the text into paragraphs and stores each one.

It also stores a vector alongside each paragraph: a list of numbers describing what the text means. Similar text gets similar numbers.

1. Store a document
$ curl localhost:8080/ingest -d '{
    "source": "go-faq",
    "text": "Go schedules goroutines, not the OS."
  }'

{"chunks": 1}

Send it a question. It turns the question into a vector too, then finds the stored paragraphs with the closest numbers.

Those paragraphs go to the model along with the question. The 0.83 is how close the match was, from 0 to 1.

2. Ask a question
$ curl localhost:8080/ask -d '{
    "question": "Who schedules goroutines?"
  }'

{
  "answer":  "The Go runtime does, not the OS [1].",
  "sources": [{"source": "go-faq", "score": 0.83}]
}

Nothing stored is close enough, so the model gets no context and says so.

That refusal is the point. A plain chatbot would guess.

3. Ask something it wasn't told
$ curl localhost:8080/ask -d '{
    "question": "What is the capital of France?"
  }'

{
  "answer":  "I do not know.",
  "sources": []
}

1 Dependencies

google.golang.org/genai is Google's current Go SDK. The older github.com/google/generative-ai-go is deprecated.

The pgvector/pgvector image is Postgres with the vector extension already installed.

mkdir go-rag-tutorial
cd go-rag-tutorial
go mod init ragserver

go get google.golang.org/genai@v1.69.0
go get github.com/jackc/pgx/v5@v5.10.0
go get github.com/pgvector/pgvector-go@v0.4.1

docker run -d --name rag-pg -p 127.0.0.1:5432:5432 \
    -e POSTGRES_PASSWORD=rag -e POSTGRES_DB=rag -e POSTGRES_USER=rag \
    pgvector/pgvector:pg17

export GEMINI_API_KEY=...
export DATABASE_URL='postgres://rag:rag@127.0.0.1:5432/rag'
Run it
$ docker exec rag-pg psql -U rag -d rag -c 'SELECT 1'
 ?column?
----------
        1
(1 row)

2 The schema

One row per paragraph. body holds the text, embedding holds its numbers.

vector(768) means every row stores exactly 768 numbers, so this has to match what the model produces.

The index is what keeps the search fast as the table grows.

const schema = `
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE IF NOT EXISTS chunks (
    id        BIGSERIAL PRIMARY KEY,
    source    TEXT        NOT NULL,
    body      TEXT        NOT NULL,
    embedding vector(768) NOT NULL
);

CREATE INDEX IF NOT EXISTS chunks_embedding_idx
    ON chunks USING hnsw (embedding vector_cosine_ops);
`

3 Chunking

Chunk size is the first thing people get wrong. Too big and one chunk covers three topics, matching every question and answering none. Too small and a sentence gets cut in half.

Splitting on blank lines keeps whole paragraphs together.

// Chunk packs paragraphs together until adding the next one would
// exceed maxRunes, so a chunk ends on a paragraph boundary.
func Chunk(text string, maxRunes int) []string {
    var chunks []string
    var cur strings.Builder

    for _, p := range strings.Split(text, "\n\n") {
        p = strings.TrimSpace(p)
        if p == "" {
            continue
        }
        if cur.Len() > 0 &&
            utf8.RuneCountInString(cur.String())+utf8.RuneCountInString(p) > maxRunes {
            chunks = append(chunks, cur.String())
            cur.Reset()
        }
        if cur.Len() > 0 {
            cur.WriteString("\n\n")
        }
        cur.WriteString(p)
    }
    if cur.Len() > 0 {
        chunks = append(chunks, cur.String())
    }
    return chunks
}
Run it
$ go build .
# no output means it compiled

4 Embeddings

"gemini-embedding-001" emits 3072 dimensions by default and supports truncation to smaller sizes. 768 is a good trade: a third of the storage and index size, with little retrieval quality lost.

const (
    embedModel = "gemini-embedding-001"
    chatModel  = "gemini-2.5-flash"
    embedDims  = 768
    topK       = 4
)

type Server struct {
    ai *genai.Client
    db *pgxpool.Pool
}

One call handles the whole batch, since limits are counted per request.

TaskType is easy to skip and expensive to skip. It tells the model whether this text is something to store or something being asked.

Without it, a short question matches other short text instead of the paragraph that answers it.

func (s *Server) embed(ctx context.Context, texts []string, task string) ([][]float32, error) {
    contents := make([]*genai.Content, len(texts))
    for i, t := range texts {
        contents[i] = genai.NewContentFromText(t, genai.RoleUser)
    }

    dims := int32(embedDims)
    resp, err := s.ai.Models.EmbedContent(ctx, embedModel, contents, &genai.EmbedContentConfig{
        TaskType:             task,
        OutputDimensionality: &dims,
    })
    if err != nil {
        return nil, err
    }

    out := make([][]float32, len(resp.Embeddings))
    for i, e := range resp.Embeddings {
        out[i] = e.Values
    }
    return out, nil
}
Run it
$ go build .
# no output means it compiled

5 Ingesting

Ingestion in four moves: split, embed the batch in one call, wrap each vector with pgvector.NewVector, and write.

CopyFrom uses the Postgres binary copy protocol. For a few hundred rows it is one round trip where individual inserts would be a few hundred.

func (s *Server) handleIngest(w http.ResponseWriter, r *http.Request) {
    var in struct {
        Source string `json:"source"`
        Text   string `json:"text"`
    }
    if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    chunks := Chunk(in.Text, 900)
    if len(chunks) == 0 {
        http.Error(w, "no text", http.StatusBadRequest)
        return
    }

    vecs, err := s.embed(r.Context(), chunks, "RETRIEVAL_DOCUMENT")
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadGateway)
        return
    }

    rows := make([][]any, len(chunks))
    for i := range chunks {
        rows[i] = []any{in.Source, chunks[i], pgvector.NewVector(vecs[i])}
    }
    if _, err := s.db.CopyFrom(r.Context(),
        pgx.Identifier{"chunks"},
        []string{"source", "body", "embedding"},
        pgx.CopyFromRows(rows),
    ); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    writeJSON(w, map[string]int{"chunks": len(chunks)})
}
Run it
$ go build .
# no output means it compiled

6 Retrieval

The question goes through the same model. Two different models produce numbers that mean different things, so they can never be compared.

<=> measures how far apart two lists of numbers are. Sorting by it puts the closest paragraphs first.

1 minus the distance flips it into a score where higher is better.

    qv, err := s.embed(r.Context(), []string{in.Question}, "RETRIEVAL_QUERY")
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadGateway)
        return
    }

    rows, err := s.db.Query(r.Context(), `
        SELECT source, body, 1 - (embedding <=> $1) AS score
        FROM chunks
        ORDER BY embedding <=> $1
        LIMIT $2`, pgvector.NewVector(qv[0]), topK)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    defer rows.Close()

Each result becomes a numbered entry in the prompt. The numbers let the model cite its source, so a reader can check the answer.

Check rows.Err after the loop. rows.Next returns false both when the results run out and when the read failed.

    var hits []hit
    var ctxBuf strings.Builder
    for rows.Next() {
        var h hit
        if err := rows.Scan(&h.Source, &h.Body, &h.Score); err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        fmt.Fprintf(&ctxBuf, "[%d] (%s) %s\n\n", len(hits)+1, h.Source, h.Body)
        hits = append(hits, h)
    }
    if err := rows.Err(); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
Run it
$ go build .
# no output means it compiled

7 Generation

The system prompt does the load-bearing work. Without the instruction to answer only from the context, the model answers from training data and retrieval becomes decoration.

Temperature at 0 makes the same question give the same answer every time.

const systemPrompt = `Answer using only the numbered context provided.
If the context does not contain the answer, say you do not know.
Cite the numbers you used.`

    prompt := fmt.Sprintf("Context:\n%s\nQuestion: %s", ctxBuf.String(), in.Question)

    resp, err := s.ai.Models.GenerateContent(r.Context(), chatModel,
        genai.Text(prompt),
        &genai.GenerateContentConfig{
            SystemInstruction: genai.NewContentFromText(systemPrompt, genai.RoleUser),
            Temperature:       genai.Ptr[float32](0),
        })
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadGateway)
        return
    }

    writeJSON(w, map[string]any{"answer": resp.Text(), "sources": hits})
Run it
$ go build .
# no output means it compiled

8 Wiring it up

Running the schema at startup makes the binary safe to deploy against an empty database. Every statement is IF NOT EXISTS, so restarts are free.

Go 1.22 added the method prefix in route patterns, so "POST /ingest" rejects a GET without a check inside the handler.

func main() {
    ctx := context.Background()

    ai, err := genai.NewClient(ctx, &genai.ClientConfig{
        APIKey:  os.Getenv("GEMINI_API_KEY"),
        Backend: genai.BackendGeminiAPI,
    })
    if err != nil {
        log.Fatal(err)
    }

    db, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    if _, err := db.Exec(ctx, schema); err != nil {
        log.Fatal(err)
    }

    srv := &Server{ai: ai, db: db}
    mux := http.NewServeMux()
    mux.HandleFunc("POST /ingest", srv.handleIngest)
    mux.HandleFunc("POST /ask", srv.handleAsk)

    s := &http.Server{
        Addr:              ":8080",
        Handler:           mux,
        ReadHeaderTimeout: 5 * time.Second,
    }
    log.Fatal(s.ListenAndServe())
}

The score says how close the match was, where 1 is perfect. Related text usually lands between 0.6 and 0.9.

// Everything is wired. Start it.
Run it
$ go run .
2026/08/29 15:04:22 listening on :8080
Run it
$ curl -s localhost:8080/ingest -d '{"source":"go-faq","text":"Goroutines are scheduled by the Go runtime."}'
$ curl -s localhost:8080/ask -d '{"question":"Who schedules goroutines?"}'
{"chunks":1}
{"answer":"The Go runtime schedules goroutines [1].","sources":[{"source":"go-faq","score":0.83}]}

9 Make ingest repeatable

Ingesting the same source twice stores every chunk twice, and both copies then compete for the same query.

Deleting that source's rows before the copy makes ingest repeatable. Both statements run in one transaction, so a document is never half-replaced.

func (s *Server) replace(ctx context.Context, source string, rows [][]any) error {
    tx, err := s.db.Begin(ctx)
    if err != nil {
        return err
    }
    // Rolls back unless Commit runs first, so a failed copy leaves
    // the old chunks in place rather than deleting them for nothing.
    defer tx.Rollback(ctx)

    if _, err := tx.Exec(ctx, `DELETE FROM chunks WHERE source = $1`, source); err != nil {
        return err
    }
    if _, err := tx.CopyFrom(ctx,
        pgx.Identifier{"chunks"},
        []string{"source", "body", "embedding"},
        pgx.CopyFromRows(rows),
    ); err != nil {
        return err
    }
    return tx.Commit(ctx)
}

The search always returns four paragraphs, even when none of them are related. Ask about something you never stored and the model still gets four unrelated passages.

Skipping anything below a cutoff fixes it. The model gets nothing, so it says it does not know.

const minScore = 0.55

    for rows.Next() {
        var h hit
        if err := rows.Scan(&h.Source, &h.Body, &h.Score); err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        if h.Score < minScore {
            continue
        }
        fmt.Fprintf(&ctxBuf, "[%d] (%s) %s\n\n", len(hits)+1, h.Source, h.Body)
        hits = append(hits, h)
    }
Run it
$ go build .
# no output means it compiled

What this leaves out

This handles a few thousand paragraphs well. Three things to fix once you store more than that.

  • Big files are slow to add. Every paragraph is sent to Gemini before /ingest replies, so a long document leaves the caller waiting. Save the text first and do the numbers in the background.
  • Exact words get missed. Matching by meaning is good at questions and bad at things like error codes. Add Postgres text search alongside it so both kinds of lookup work.
  • The index only helps when the table is big. Under a few thousand rows, checking every row is faster than using an index. Add it when the table outgrows that.

10 The finished program

Everything above in one file. This is the version that was built and run to produce the output on this page.

main.go 218 lines Show
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
	"strings"
	"time"
	"unicode/utf8"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/pgvector/pgvector-go"
	"google.golang.org/genai"
)

const (
	embedModel = "gemini-embedding-001"
	chatModel  = "gemini-2.5-flash"
	embedDims  = 768
	topK       = 4
)

const schema = `
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE IF NOT EXISTS chunks (
    id        BIGSERIAL PRIMARY KEY,
    source    TEXT        NOT NULL,
    body      TEXT        NOT NULL,
    embedding vector(768) NOT NULL
);
CREATE INDEX IF NOT EXISTS chunks_embedding_idx
    ON chunks USING hnsw (embedding vector_cosine_ops);
`

func Chunk(text string, maxRunes int) []string {
	var chunks []string
	var cur strings.Builder
	for _, p := range strings.Split(text, "\n\n") {
		p = strings.TrimSpace(p)
		if p == "" {
			continue
		}
		if cur.Len() > 0 && utf8.RuneCountInString(cur.String())+utf8.RuneCountInString(p) > maxRunes {
			chunks = append(chunks, cur.String())
			cur.Reset()
		}
		if cur.Len() > 0 {
			cur.WriteString("\n\n")
		}
		cur.WriteString(p)
	}
	if cur.Len() > 0 {
		chunks = append(chunks, cur.String())
	}
	return chunks
}

type Server struct {
	ai *genai.Client
	db *pgxpool.Pool
}

func (s *Server) embed(ctx context.Context, texts []string, task string) ([][]float32, error) {
	contents := make([]*genai.Content, len(texts))
	for i, t := range texts {
		contents[i] = genai.NewContentFromText(t, genai.RoleUser)
	}
	dims := int32(embedDims)
	resp, err := s.ai.Models.EmbedContent(ctx, embedModel, contents, &genai.EmbedContentConfig{
		TaskType:             task,
		OutputDimensionality: &dims,
	})
	if err != nil {
		return nil, err
	}
	out := make([][]float32, len(resp.Embeddings))
	for i, e := range resp.Embeddings {
		out[i] = e.Values
	}
	return out, nil
}

func (s *Server) handleIngest(w http.ResponseWriter, r *http.Request) {
	var in struct {
		Source string `json:"source"`
		Text   string `json:"text"`
	}
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	chunks := Chunk(in.Text, 900)
	if len(chunks) == 0 {
		http.Error(w, "no text", http.StatusBadRequest)
		return
	}
	vecs, err := s.embed(r.Context(), chunks, "RETRIEVAL_DOCUMENT")
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadGateway)
		return
	}
	batch := make([][]any, len(chunks))
	for i := range chunks {
		batch[i] = []any{in.Source, chunks[i], pgvector.NewVector(vecs[i])}
	}
	_, err = s.db.CopyFrom(r.Context(),
		pgx.Identifier{"chunks"},
		[]string{"source", "body", "embedding"},
		pgx.CopyFromRows(batch),
	)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	writeJSON(w, map[string]int{"chunks": len(chunks)})
}

type hit struct {
	Source string  `json:"source"`
	Body   string  `json:"-"`
	Score  float64 `json:"score"`
}

func (s *Server) handleAsk(w http.ResponseWriter, r *http.Request) {
	var in struct {
		Question string `json:"question"`
	}
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	qv, err := s.embed(r.Context(), []string{in.Question}, "RETRIEVAL_QUERY")
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadGateway)
		return
	}
	rows, err := s.db.Query(r.Context(), `
        SELECT source, body, 1 - (embedding <=> $1) AS score
        FROM chunks
        ORDER BY embedding <=> $1
        LIMIT $2`, pgvector.NewVector(qv[0]), topK)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	defer rows.Close()

	var hits []hit
	var ctxBuf strings.Builder
	for rows.Next() {
		var h hit
		if err := rows.Scan(&h.Source, &h.Body, &h.Score); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		fmt.Fprintf(&ctxBuf, "[%d] (%s) %s\n\n", len(hits)+1, h.Source, h.Body)
		hits = append(hits, h)
	}
	if err := rows.Err(); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	prompt := fmt.Sprintf("Context:\n%s\nQuestion: %s", ctxBuf.String(), in.Question)
	resp, err := s.ai.Models.GenerateContent(r.Context(), chatModel,
		genai.Text(prompt),
		&genai.GenerateContentConfig{
			SystemInstruction: genai.NewContentFromText(systemPrompt, genai.RoleUser),
			Temperature:       genai.Ptr[float32](0),
		})
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadGateway)
		return
	}
	writeJSON(w, map[string]any{"answer": resp.Text(), "sources": hits})
}

const systemPrompt = `Answer using only the numbered context provided.
If the context does not contain the answer, say you do not know.
Cite the numbers you used.`

func writeJSON(w http.ResponseWriter, v any) {
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(v)
}

func main() {
	ctx := context.Background()

	ai, err := genai.NewClient(ctx, &genai.ClientConfig{
		APIKey:  os.Getenv("GEMINI_API_KEY"),
		Backend: genai.BackendGeminiAPI,
	})
	if err != nil {
		log.Fatal(err)
	}

	db, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()
	if _, err := db.Exec(ctx, schema); err != nil {
		log.Fatal(err)
	}

	srv := &Server{ai: ai, db: db}
	mux := http.NewServeMux()
	mux.HandleFunc("POST /ingest", srv.handleIngest)
	mux.HandleFunc("POST /ask", srv.handleAsk)

	s := &http.Server{Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second}
	log.Fatal(s.ListenAndServe())
}

What you built

You have a RAG server that ingests a document, splits it on paragraph boundaries, embeds every chunk, stores the vectors in Postgres, retrieves the closest matches to a question, and asks Gemini to answer from those alone.

Five pieces did that work. Chunk cut the text at paragraph boundaries, the embedding model turned each chunk into 768 numbers, pgvector found the nearest of them with a cosine index, a score threshold threw away weak matches, and the prompt handed the survivors to the chat model as the only source it may use. Swapping the model or the database means changing one of those five, not the shape of the program.