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.
- Go installed.
go versionshould 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.
$ 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.
$ 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.
$ 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'$ 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
}$ go build .
# no output means it compiled4 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
}$ go build .
# no output means it compiled5 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)})
}$ go build .
# no output means it compiled6 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
}$ go build .
# no output means it compiled7 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})$ go build .
# no output means it compiled8 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.$ go run .
2026/08/29 15:04:22 listening on :8080$ 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)
}$ go build .
# no output means it compiledWhat 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.