Build a RAG API in Go with Qdrant
Build a RAG pipeline in Go with vector search, embeddings, and LLM answers.
Introduction
30 Second Summary
AI chatbots are impressive until they confidently make something up. The fix is simple in concept: give the AI access to your actual documents and force it to answer based on what's really there.
In this project, you will build a REST API in Go that implements a complete RAG (Retrieval-Augmented Generation) pipeline from scratch. You'll ingest documents, generate vector embeddings, store them in a vector database, and query them to produce LLM answers grounded in your own data.
What You'll Build
A Go REST API with two endpoints that ingests documents into a Qdrant vector database and answers questions using retrieved context passed to OpenAI's language model.
By the end of this project, you'll have:
- A Go module with clean, modular services for document chunking, embedding generation, vector storage, and LLM-augmented generation.
- Qdrant running locally in Docker with a populated vector collection you can browse in the web dashboard.
- Two working REST endpoints: POST /ingest to add documents and POST /query to ask questions and get grounded answers with source citations.
- Secret Mission: Add metadata filtering so queries can target specific document sources.
Are there any prerequisites?
This project assumes you have Go installed (version 1.22+), VS Code, Docker Desktop, and an OpenAI API key with credits. You should be comfortable writing basic Go code (structs, functions, error handling).
Set Up Qdrant and Scaffold the Project
Your RAG pipeline needs a place to store and search vector embeddings. Qdrant is a purpose-built vector database that handles similarity search efficiently, and it runs locally in Docker with zero configuration.
In this step, you'll launch a Qdrant container, then scaffold a Go module with all the dependencies needed for the rest of the project.
In this step, get ready to:
- Launch a Qdrant container in Docker.
- Initialize a Go module with OpenAI, Qdrant, and dotenv dependencies.
- Create the project file structure and environment configuration.
Pull and run Qdrant in Docker
Qdrant needs to be running before your Go code can connect to it. You'll launch it as a Docker container that exposes two ports: one for the REST API and dashboard, and one for gRPC (which the Go client uses).
- Make sure Docker Desktop is running. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Docker Desktop and press Enter to open it.
- Wait until the Docker engine status shows a green indicator in the bottom-left corner of the Docker Desktop window.
- Open a terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter.
- Pull the Qdrant Docker image by running this command:
docker pull qdrant/qdrant
- Start the Qdrant container by running this command:
docker run -d --name qdrant -p 6333:6333 -p 6334:6334 qdrant/qdrant
What does this command do?
- -d runs the container in the background so you can keep using your terminal.
- --name qdrant gives the container a name you can reference later (to stop or remove it).
- -p 6333:6333 maps port 6333 for the REST API and web dashboard.
- -p 6334:6334 maps port 6334 for gRPC, which the Go client uses to communicate with Qdrant.
- Open your browser and navigate to http://localhost:6333/dashboard.
- You should see the Qdrant Web UI with the Collections tab visible. This confirms Qdrant is running and accessible.
Don't see the dashboard?
- Make sure Docker Desktop is fully started. The engine needs a green status indicator before you can run containers.
- Check that no other process is already using port 6333 or 6334 on your machine.
- Run docker ps in your terminal to confirm the qdrant container is listed and shows a status of "Up".
Help me troubleshoot why my Qdrant Docker container isn't accessible at localhost:6333.
Initialize the Go module and install dependencies
With Qdrant running, you can now scaffold the Go project that will talk to it. You'll install three dependencies: the OpenAI SDK for embeddings and completions, the Qdrant Go client for vector storage, and godotenv for loading environment variables.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir rag-api
cd rag-api
- Initialize a new Go module by running this command:
go mod init rag-api
You should see the message go: creating new go.mod confirming the module was created.
- Install the OpenAI Go SDK by running this command:
go get github.com/openai/openai-go/v3@v3.41.0
- Install the Qdrant Go client by running this command:
go get github.com/qdrant/go-client@v1.18.2
- Install godotenv for loading environment variables by running this command:
go get github.com/joho/godotenv@v1.5.1
What are these dependencies?
- github.com/openai/openai-go/v3 is the official OpenAI Go SDK. You'll use it to generate embeddings and call the LLM for answers.
- github.com/qdrant/go-client is the official Qdrant gRPC client for Go. It handles storing and searching vectors.
- github.com/joho/godotenv loads key-value pairs from a .env file into environment variables so you can keep secrets out of your code.
- Confirm the dependencies were added by running:
cat go.mod
You should see all three packages listed under the require block with their version numbers.
Seeing a network error during go get?
- Make sure you have internet access. The go get command downloads packages from the Go module proxy.
- If you see a timeout, try running the command again. Transient network issues can cause the first attempt to fail.
- Verify your Go installation is working by running go version. You need Go 1.22 or higher.
Help me fix a network or version error when running go get for my RAG API project dependencies.
Create the project structure and environment file
Your RAG pipeline will be organized into service modules. Each stage of the pipeline (chunking, embedding, vector storage, orchestration) gets its own file inside a services directory.
- Create the services directory by running this command:
mkdir services
- Confirm the directory was created by running:
ls
You should see go.mod, go.sum, and services in the output.
- Open VS Code in the current directory by running this command:
code .
If the code command isn't found
Open VS Code manually. Press Cmd+Space (macOS) or the Windows key (Windows), type VS Code, and press Enter. Then click File > Open Folder and select the rag-api folder on your Desktop.
- In the VS Code Explorer sidebar, right-click in the file list and select New File.
- Name the file .env.
- Paste the following content into .env:
OPENAI_API_KEY=your-api-key-here
- Replace your-api-key-here with your actual OpenAI API key.
- Save the file with Cmd+S (macOS) or Ctrl+S (Windows).
Why a .env file?
Your OpenAI API key is a secret credential. Storing it in a .env file keeps it out of your source code.
The godotenv library loads these values into Go's environment variables at startup, so your code can access the key without hardcoding it.
✔️ Awesome, I've got everything!
Great. Double check you've saved your .env file. You're ready to start building the pipeline.
ⓧ I'd like to double check the full code
Here is what your .env file should look like:
OPENAI_API_KEY=your-api-key-here
And here is what your go.mod should look like (the exact indirect dependencies may vary, but the require block should contain these three):
module rag-api
go 1.22
require (
github.com/joho/godotenv v1.5.1
github.com/openai/openai-go/v3 v3.41.0
github.com/qdrant/go-client v1.18.2
)
Your vector database is running and your Go project is ready to go. Next up, you'll build the first stage of the RAG pipeline: document chunking.
Build the Document Chunker
Before an LLM can answer questions grounded in your documents, those documents need to be split into small, manageable pieces. Large documents exceed context windows and dilute relevance.
In this step, you'll build a chunker service that splits text into overlapping segments. The overlap ensures that no important sentence gets cut off at a boundary between two chunks.
In this step, get ready to:
- Create a Chunk struct to represent each text segment with metadata.
- Build a ChunkText function that splits text with configurable size and overlap.
- Verify the chunker produces correct, overlapping chunks.
Define the Chunk struct
Every RAG pipeline starts with a data structure that carries a piece of text alongside metadata about where it came from. This metadata lets the system cite sources when answering questions later.
- Create a new file services/chunker.go and add the package declaration and Chunk struct by pasting this code:
package services
// Chunk represents a single text segment with source metadata
type Chunk struct {
ID string
Text string
Source string
}
What does this struct do?
- ID uniquely identifies each chunk using the source name and an index number.
- Text holds the actual content of the chunk.
- Source tracks which document this chunk came from, so the LLM can cite it in answers.
- Save services/chunker.go.
Build the chunking function
The chunking function takes a large block of text and produces overlapping segments of a fixed size. Overlapping by 100 characters means the end of one chunk repeats at the start of the next, preserving context across boundaries.
- In services/chunker.go, add the ChunkText function below the Chunk struct:
func ChunkText(text string, source string, chunkSize int, overlap int) []Chunk {
var chunks []Chunk
runes := []rune(text)
total := len(runes)
if total == 0 {
return chunks
}
start := 0
chunkIndex := 0
for start < total {
end := start + chunkSize
if end > total {
end = total
}
chunkText := string(runes[start:end])
chunks = append(chunks, Chunk{
ID: fmt.Sprintf("%s-chunk-%d", source, chunkIndex),
Text: chunkText,
Source: source,
})
chunkIndex++
start += chunkSize - overlap
if start >= total {
break
}
}
return chunks
}
What does this function do?
- It converts the input text to a rune slice so multi-byte characters (like emoji or non-Latin scripts) are split correctly.
- The for loop advances by chunkSize - overlap on each iteration. With size 500 and overlap 100, it advances 400 characters each time.
- Each chunk gets an ID like myfile-chunk-0, myfile-chunk-1, etc. This makes chunks traceable back to their source.
The overlap means the last 100 characters of chunk 0 appear again at the start of chunk 1. This prevents information loss at boundaries.
The ChunkText function uses fmt.Sprintf to generate chunk IDs. Go requires each file to declare its own imports, so you need a helper file to make fmt available to the package.
- Create a second file services/chunker_import.go with this content:
package services
import "fmt"
var _ = fmt.Sprintf
Why a separate import file?
In Go, package-level variables and functions are shared across all files in the same package. This helper file ensures the fmt package is imported for use in the chunker logic. The var _ = fmt.Sprintf line prevents the compiler from removing the import as unused.
- Save both services/chunker.go and services/chunker_import.go.
- You won't see a change yet. The next substep uses these files in a test program.
Seeing a compile error about fmt?
If you see an error like undefined: fmt in chunker.go, add import "fmt" at the top of services/chunker.go (below the package services line). Go requires each file to import the packages it directly references.
help me fix the fmt import error in my chunker.go file
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete services/chunker.go:
package services
type Chunk struct {
ID string
Text string
Source string
}
func ChunkText(text string, source string, chunkSize int, overlap int) []Chunk {
var chunks []Chunk
runes := []rune(text)
total := len(runes)
if total == 0 {
return chunks
}
start := 0
chunkIndex := 0
for start < total {
end := start + chunkSize
if end > total {
end = total
}
chunkText := string(runes[start:end])
chunks = append(chunks, Chunk{
ID: fmt.Sprintf("%s-chunk-%d", source, chunkIndex),
Text: chunkText,
Source: source,
})
chunkIndex++
start += chunkSize - overlap
if start >= total {
break
}
}
return chunks
}
And the complete services/chunker_import.go:
package services
import "fmt"
var _ = fmt.Sprintf
Verify the chunker with a test run
Before building the rest of the pipeline, confirm that the chunker produces correctly sized, overlapping segments. You'll write a quick main.go to call ChunkText and print the results.
- Create a file called main.go in the root of your rag-api folder with this test code:
package main
import (
"fmt"
"rag-api/services"
)
func main() {
// Sample text long enough to produce multiple chunks
sampleText := "Go is a statically typed, compiled programming language designed at Google. It is syntactically similar to C, but with memory safety, garbage collection, structural typing, and CSP-style concurrency. The language was designed by Robert Griesemer, Rob Pike, and Ken Thompson. Go was publicly announced in November 2009, and version 1.0 was released in March 2012. Go is widely used in production at Google and many other organizations and open-source projects. The language provides excellent support for concurrent programming through goroutines and channels, making it ideal for building scalable network services and distributed systems that handle thousands of simultaneous connections."
// Chunk with size 500 and overlap 100
chunks := services.ChunkText(sampleText, "go-intro", 500, 100)
fmt.Printf("Total chunks: %d\n\n", len(chunks))
for i, chunk := range chunks {
fmt.Printf("--- Chunk %d ---\n", i)
fmt.Printf("ID: %s\n", chunk.ID)
fmt.Printf("Source: %s\n", chunk.Source)
fmt.Printf("Length: %d characters\n", len([]rune(chunk.Text)))
fmt.Printf("Text: %s\n\n", chunk.Text)
}
}
- Run the test by executing this command in your terminal:
go run .
- You should see output showing 2 chunks. The first chunk is 500 characters long, and the second chunk starts with text that overlaps the end of the first.
What should I see?
Your output will look something like this:
Total chunks: 2
--- Chunk 0 ---
ID: go-intro-chunk-0
Source: go-intro
Length: 500 characters
Text: Go is a statically typed...
--- Chunk 1 ---
ID: go-intro-chunk-1
Source: go-intro
Length: ... characters
Text: ...
The key thing to verify is that the last ~100 characters of Chunk 0's text appear again at the start of Chunk 1's text. That confirms the overlap is working.
Command not found or build errors?
- Make sure you are running go run . from inside the rag-api folder (where go.mod lives).
- If you see an error about missing packages, run go mod tidy to sync your dependencies.
- If you see undefined: fmt in chunker.go, add import "fmt" at the top of that file below the package declaration.
help me fix build errors when running my chunker test
Your chunker is working. This main.go file is temporary. You will replace it in a later step when you build the HTTP server. Next up, you'll generate vector embeddings for these chunks and store them in Qdrant.
Generate Embeddings and Store Vectors
Your chunker splits documents into overlapping pieces, but those text chunks are just strings. To search them by meaning, you need to convert each chunk into a numerical vector that captures its semantic content.
In this step, you will build two services. The first calls the OpenAI API to generate vector embeddings from text. The second stores those vectors in Qdrant so you can search them later by similarity.
In this step, get ready to:
- Create an embedding service that converts text into 1536-dimension vectors using OpenAI.
- Create a Qdrant service that manages collections and stores vectors with metadata.
- Wire both services together and verify vectors appear in the Qdrant dashboard.
Create the embedding service
The embedding service wraps OpenAI's text-embedding-3-small model. It takes a slice of text strings and returns their vector representations as [][]float32.
- Create a new file called services/embedding.go and add the following code:
package services
import (
"context"
"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/option"
)
// EmbeddingService wraps the OpenAI client for generating vector embeddings
type EmbeddingService struct {
client *openai.Client
}
// NewEmbeddingService creates a client authenticated with your API key
func NewEmbeddingService(apiKey string) *EmbeddingService {
client := openai.NewClient(
option.WithAPIKey(apiKey),
)
return &EmbeddingService{client: client}
}
// GenerateEmbeddings converts each text into a 1536-dimension float32 vector
func (s *EmbeddingService) GenerateEmbeddings(ctx context.Context, texts []string) ([][]float32, error) {
var allEmbeddings [][]float32
for _, text := range texts {
// Call OpenAI's embedding endpoint for each chunk
resp, err := s.client.Embeddings.New(ctx, openai.EmbeddingNewParams{
Model: openai.EmbeddingModelTextEmbedding3Small,
Input: openai.EmbeddingNewParamsInputUnion{
OfString: openai.String(text),
},
})
if err != nil {
return nil, err
}
// Convert float64 response to float32 for Qdrant compatibility
embedding := make([]float32, len(resp.Data[0].Embedding))
for i, v := range resp.Data[0].Embedding {
embedding[i] = float32(v)
}
allEmbeddings = append(allEmbeddings, embedding)
}
return allEmbeddings, nil
}
What does this code do?
The EmbeddingService struct holds an authenticated OpenAI client. The constructor uses option.WithAPIKey() from the openai-go SDK to configure credentials.
The GenerateEmbeddings method loops over each text string and calls client.Embeddings.New() with the text-embedding-3-small model. The API returns float64 values, so the method converts them to float32 which is what Qdrant expects.
- Save services/embedding.go.
Why text-embedding-3-small?
At $0.02 per 1M tokens, text-embedding-3-small is extremely cost-effective. It outputs 1536-dimension vectors by default. For a learning project with small documents, the cost is negligible within a $5 credit budget.
Seeing an import error?
If VS Code shows red squiggly lines under the imports, that is normal until you run go mod tidy later in this step. The dependencies were added to go.mod in Step 1, but your editor may need the tidy step to resolve them fully.
help me fix import errors in my Go embedding service
Create the Qdrant service
The Qdrant service manages your vector collection. It connects via gRPC on port 6334, creates a collection configured for cosine similarity, and provides an Upsert method to store chunks alongside their embeddings.
- Create a new file called services/qdrant.go and add the service struct and constructor:
package services
import (
"context"
"fmt"
"github.com/qdrant/go-client/qdrant"
)
// QdrantService manages vector storage and retrieval
type QdrantService struct {
client *qdrant.Client
collectionName string
}
// NewQdrantService connects to Qdrant via gRPC
func NewQdrantService(host string, port int, collectionName string) (*QdrantService, error) {
client, err := qdrant.NewClient(&qdrant.Config{
Host: host,
Port: port,
})
if err != nil {
return nil, fmt.Errorf("failed to create Qdrant client: %w", err)
}
return &QdrantService{
client: client,
collectionName: collectionName,
}, nil
}
// CreateCollection sets up a vector collection with the given dimensions
func (s *QdrantService) CreateCollection(vectorSize uint64) error {
return s.client.CreateCollection(context.Background(), &qdrant.CreateCollection{
CollectionName: s.collectionName,
VectorsConfig: qdrant.NewVectorsConfig(&qdrant.VectorParams{
Size: vectorSize,
Distance: qdrant.Distance_Cosine,
}),
})
}
What does this code do?
The constructor connects to Qdrant at the host and port you specify. Port 6334 is Qdrant's gRPC endpoint, which is faster than the REST API for bulk operations.
The CreateCollection method configures a collection with a vector size of 1536 (matching text-embedding-3-small output) and cosine distance for similarity comparison.
- Now add the Upsert method below CreateCollection in the same file:
// Upsert stores chunks with their embeddings and payload metadata
func (s *QdrantService) Upsert(ctx context.Context, chunks []Chunk, embeddings [][]float32) error {
var points []*qdrant.PointStruct
for i, chunk := range chunks {
// Build a point with ID, vector, and searchable payload
points = append(points, &qdrant.PointStruct{
Id: qdrant.NewIDNum(uint64(i + 1)),
Vectors: qdrant.NewVectors(embeddings[i]...),
Payload: qdrant.NewValueMap(map[string]any{
"text": chunk.Text,
"source": chunk.Source,
"id": chunk.ID,
}),
})
}
// Send all points to Qdrant in one batch
_, err := s.client.Upsert(ctx, &qdrant.UpsertPoints{
CollectionName: s.collectionName,
Points: points,
})
return err
}
What does this code do?
The Upsert method takes your chunks and their matching embeddings, then builds a PointStruct for each one. Each point has a numeric ID, the vector data, and a payload containing the original text, source name, and chunk ID.
The payload is what you will retrieve later when searching. It stores the actual text content alongside the vector so you can pass it to the LLM as context.
- Save services/qdrant.go.
Qdrant connection refused?
Make sure your Qdrant Docker container is still running. Check with docker ps and look for the qdrant/qdrant image. If it is not listed, restart it with docker start qdrant.
help me troubleshoot my Qdrant connection in Go
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the complete services/embedding.go:
package services
import (
"context"
"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/option"
)
type EmbeddingService struct {
client *openai.Client
}
func NewEmbeddingService(apiKey string) *EmbeddingService {
client := openai.NewClient(
option.WithAPIKey(apiKey),
)
return &EmbeddingService{client: client}
}
func (s *EmbeddingService) GenerateEmbeddings(ctx context.Context, texts []string) ([][]float32, error) {
var allEmbeddings [][]float32
for _, text := range texts {
resp, err := s.client.Embeddings.New(ctx, openai.EmbeddingNewParams{
Model: openai.EmbeddingModelTextEmbedding3Small,
Input: openai.EmbeddingNewParamsInputUnion{
OfString: openai.String(text),
},
})
if err != nil {
return nil, err
}
embedding := make([]float32, len(resp.Data[0].Embedding))
for i, v := range resp.Data[0].Embedding {
embedding[i] = float32(v)
}
allEmbeddings = append(allEmbeddings, embedding)
}
return allEmbeddings, nil
}
And the complete services/qdrant.go:
package services
import (
"context"
"fmt"
"github.com/qdrant/go-client/qdrant"
)
type QdrantService struct {
client *qdrant.Client
collectionName string
}
func NewQdrantService(host string, port int, collectionName string) (*QdrantService, error) {
client, err := qdrant.NewClient(&qdrant.Config{
Host: host,
Port: port,
})
if err != nil {
return nil, fmt.Errorf("failed to create Qdrant client: %w", err)
}
return &QdrantService{
client: client,
collectionName: collectionName,
}, nil
}
func (s *QdrantService) CreateCollection(vectorSize uint64) error {
return s.client.CreateCollection(context.Background(), &qdrant.CreateCollection{
CollectionName: s.collectionName,
VectorsConfig: qdrant.NewVectorsConfig(&qdrant.VectorParams{
Size: vectorSize,
Distance: qdrant.Distance_Cosine,
}),
})
}
func (s *QdrantService) Upsert(ctx context.Context, chunks []Chunk, embeddings [][]float32) error {
var points []*qdrant.PointStruct
for i, chunk := range chunks {
points = append(points, &qdrant.PointStruct{
Id: qdrant.NewIDNum(uint64(i + 1)),
Vectors: qdrant.NewVectors(embeddings[i]...),
Payload: qdrant.NewValueMap(map[string]any{
"text": chunk.Text,
"source": chunk.Source,
"id": chunk.ID,
}),
})
}
_, err := s.client.Upsert(ctx, &qdrant.UpsertPoints{
CollectionName: s.collectionName,
Points: points,
})
return err
}
Wire the services together and test ingestion
Now you will connect the chunker, embedding service, and Qdrant service into a single flow. This temporary main.go exercises the full pipeline so you can verify vectors land in Qdrant before building the HTTP layer.
- First, run go mod tidy to download all transitive dependencies:
go mod tidy
- Create a file called main.go in the root of your rag-api folder and add this test code:
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/joho/godotenv"
"rag-api/services"
)
func main() {
// Load the .env file to get your OpenAI API key
if err := godotenv.Load(); err != nil {
log.Fatal("Error loading .env file")
}
apiKey := os.Getenv("OPENAI_API_KEY")
// Create the embedding service
embeddingService := services.NewEmbeddingService(apiKey)
// Connect to Qdrant on gRPC port 6334
qdrantService, err := services.NewQdrantService("localhost", 6334, "documents")
if err != nil {
log.Fatalf("Failed to connect to Qdrant: %v", err)
}
// Create the collection with 1536 dimensions (text-embedding-3-small output size)
if err := qdrantService.CreateCollection(1536); err != nil {
log.Printf("Collection may already exist: %v", err)
}
// Chunk a sample document
sampleText := "Go is a statically typed, compiled programming language designed at Google. It is syntactically similar to C, but with memory safety, garbage collection, structural typing, and CSP-style concurrency. Go was designed to improve programming productivity in an era of multicore processors, networked machines, and large codebases."
chunks := services.ChunkText(sampleText, "golang-intro", 500, 100)
fmt.Printf("Created %d chunks\n", len(chunks))
// Generate embeddings for each chunk
texts := make([]string, len(chunks))
for i, chunk := range chunks {
texts[i] = chunk.Text
}
embeddings, err := embeddingService.GenerateEmbeddings(context.Background(), texts)
if err != nil {
log.Fatalf("Failed to generate embeddings: %v", err)
}
fmt.Printf("Generated %d embeddings of dimension %d\n", len(embeddings), len(embeddings[0]))
// Store vectors in Qdrant
if err := qdrantService.Upsert(context.Background(), chunks, embeddings); err != nil {
log.Fatalf("Failed to upsert: %v", err)
}
fmt.Println("Successfully stored vectors in Qdrant!")
}
What does this code do?
This test program runs the full ingestion pipeline in sequence. It loads your API key, creates both services, chunks a short sample document about Go, generates embeddings via OpenAI, and stores everything in Qdrant.
This main.go is temporary. You will replace it with the HTTP server in the next step.
- Save main.go and run the test by executing:
go run .
- You should see output similar to:
Created 1 chunks
Generated 1 embeddings of dimension 1536
Successfully stored vectors in Qdrant!
- Open your browser and navigate to http://localhost:6333/dashboard.
- Click on the documents collection in the Collections tab.
- You should see your ingested points listed with their payload data (text, source, and id fields).
Seeing an authentication error from OpenAI?
Make sure your .env file contains a valid OPENAI_API_KEY value. The placeholder your-api-key-here must be replaced with your actual key from the OpenAI dashboard.
help me debug OpenAI authentication errors in my Go RAG project
Your embedding and storage layer is working. Next up, you will build the retrieval and generation layer that searches these vectors and uses an LLM to produce grounded answers.
Build the RAG Query Pipeline and HTTP API
You now have documents chunked and stored as vectors in Qdrant. The next challenge is retrieving the right chunks when a user asks a question and feeding them to an LLM so it can produce a grounded answer.
In this step, you will build the retrieval and generation layers of your RAG pipeline. You will add vector search to the Qdrant service, create a RAG orchestration service that ties embedding, search, and LLM completion together, and expose everything through HTTP endpoints you can test with curl.
In this step, get ready to:
- Add a vector similarity search method to the Qdrant service.
- Create the RAG service that orchestrates retrieval and LLM generation.
- Build HTTP endpoints and test the full RAG flow with curl.
Add a Search method to the Qdrant service
Your Qdrant service can store vectors, but it cannot query them yet. You need a method that takes a query vector and finds the most similar stored chunks using cosine distance.
- In services/qdrant.go, scroll to the bottom of the file (below the Upsert method) and add the following Search method:
func (s *QdrantService) Search(ctx context.Context, queryVector []float32, topK uint64, sourceFilter string) ([]map[string]string, error) {
queryParams := &qdrant.QueryPoints{
CollectionName: s.collectionName,
Query: qdrant.NewQuery(queryVector...),
Limit: qdrant.PtrOf(topK),
WithPayload: qdrant.NewWithPayload(true),
}
if sourceFilter != "" {
queryParams.Filter = &qdrant.Filter{
Must: []*qdrant.Condition{
qdrant.NewMatch("source", sourceFilter),
},
}
}
results, err := s.client.Query(ctx, queryParams)
if err != nil {
return nil, err
}
var chunks []map[string]string
for _, point := range results {
chunk := map[string]string{
"text": point.Payload["text"].GetStringValue(),
"source": point.Payload["source"].GetStringValue(),
}
chunks = append(chunks, chunk)
}
return chunks, nil
}
What does this code do?
This method performs a vector similarity search against your Qdrant collection.
- qdrant.NewQuery(queryVector...) wraps the float32 slice into the query format Qdrant expects.
- qdrant.PtrOf(topK) is a helper that creates a pointer to the uint64 value. Qdrant's gRPC-generated structs use pointers for optional fields.
- The optional sourceFilter parameter adds a payload filter so queries can target specific document sources. You will use this in the Secret Mission.
- GetStringValue() extracts the string from Qdrant's typed payload values.
- Save services/qdrant.go.
- Verify the services package compiles by running:
go build ./services
- You should see no output if everything compiled successfully.
Seeing a compile error?
- Check that the method signature matches exactly. The receiver is (s *QdrantService) and the return type is ([]map[string]string, error).
- Make sure you did not accidentally delete the closing brace of the Upsert method above.
- Confirm the qdrant import at the top of the file is still "github.com/qdrant/go-client/qdrant".
Why isn't my Qdrant Search method compiling?
✔️ Awesome, I've got everything!
Great. Double check you have saved services/qdrant.go before moving on.
ⓧ I'd like to double check the full code
package services
import (
"context"
"fmt"
"github.com/qdrant/go-client/qdrant"
)
type QdrantService struct {
client *qdrant.Client
collectionName string
}
func NewQdrantService(host string, port int, collectionName string) (*QdrantService, error) {
client, err := qdrant.NewClient(&qdrant.Config{
Host: host,
Port: port,
})
if err != nil {
return nil, fmt.Errorf("failed to create Qdrant client: %w", err)
}
return &QdrantService{
client: client,
collectionName: collectionName,
}, nil
}
func (s *QdrantService) CreateCollection(vectorSize uint64) error {
return s.client.CreateCollection(context.Background(), &qdrant.CreateCollection{
CollectionName: s.collectionName,
VectorsConfig: qdrant.NewVectorsConfig(&qdrant.VectorParams{
Size: vectorSize,
Distance: qdrant.Distance_Cosine,
}),
})
}
func (s *QdrantService) Upsert(ctx context.Context, chunks []Chunk, embeddings [][]float32) error {
var points []*qdrant.PointStruct
for i, chunk := range chunks {
points = append(points, &qdrant.PointStruct{
Id: qdrant.NewIDNum(uint64(i + 1)),
Vectors: qdrant.NewVectors(embeddings[i]...),
Payload: qdrant.NewValueMap(map[string]any{
"text": chunk.Text,
"source": chunk.Source,
"id": chunk.ID,
}),
})
}
_, err := s.client.Upsert(ctx, &qdrant.UpsertPoints{
CollectionName: s.collectionName,
Points: points,
})
return err
}
func (s *QdrantService) Search(ctx context.Context, queryVector []float32, topK uint64, sourceFilter string) ([]map[string]string, error) {
queryParams := &qdrant.QueryPoints{
CollectionName: s.collectionName,
Query: qdrant.NewQuery(queryVector...),
Limit: qdrant.PtrOf(topK),
WithPayload: qdrant.NewWithPayload(true),
}
if sourceFilter != "" {
queryParams.Filter = &qdrant.Filter{
Must: []*qdrant.Condition{
qdrant.NewMatch("source", sourceFilter),
},
}
}
results, err := s.client.Query(ctx, queryParams)
if err != nil {
return nil, err
}
var chunks []map[string]string
for _, point := range results {
chunk := map[string]string{
"text": point.Payload["text"].GetStringValue(),
"source": point.Payload["source"].GetStringValue(),
}
chunks = append(chunks, chunk)
}
return chunks, nil
}
Create the RAG service
The RAG service is the brain of your pipeline. It orchestrates every stage: chunking documents, generating embeddings, storing vectors, and assembling context for the LLM to produce grounded answers.
- Create a new file called services/rag.go and add the struct definition and constructor:
package services
import (
"context"
"fmt"
"strings"
"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/option"
)
// RAGService orchestrates the full retrieval-augmented generation pipeline
type RAGService struct {
embeddingService *EmbeddingService
qdrantService *QdrantService
llmClient *openai.Client
}
// NewRAGService creates a RAG service with embedding, vector, and LLM clients
func NewRAGService(embeddingService *EmbeddingService, qdrantService *QdrantService, apiKey string) *RAGService {
llmClient := openai.NewClient(
option.WithAPIKey(apiKey),
)
return &RAGService{
embeddingService: embeddingService,
qdrantService: qdrantService,
llmClient: llmClient,
}
}
What does this code do?
The RAGService struct holds references to the embedding service, Qdrant service, and a separate OpenAI client dedicated to LLM chat completions. Keeping embedding and generation as separate clients is a common production pattern that lets you swap models independently.
- Save services/rag.go. You will not see a visible change yet. The Ingest method comes next.
- Below the constructor in services/rag.go, add the Ingest method:
// Ingest chunks a document, generates embeddings, and stores them in Qdrant
func (s *RAGService) Ingest(ctx context.Context, text string, source string) (int, error) {
chunks := ChunkText(text, source, 500, 100)
if len(chunks) == 0 {
return 0, fmt.Errorf("no chunks generated from text")
}
// Extract text from chunks for embedding
texts := make([]string, len(chunks))
for i, chunk := range chunks {
texts[i] = chunk.Text
}
// Generate vector embeddings for all chunks
embeddings, err := s.embeddingService.GenerateEmbeddings(ctx, texts)
if err != nil {
return 0, fmt.Errorf("embedding generation failed: %w", err)
}
// Store chunks and embeddings in Qdrant
if err := s.qdrantService.Upsert(ctx, chunks, embeddings); err != nil {
return 0, fmt.Errorf("vector upsert failed: %w", err)
}
return len(chunks), nil
}
What does this code do?
The Ingest method is the first half of the RAG pipeline. It takes raw text and a source name, then:
- Chunks the text into overlapping 500-character pieces using your ChunkText function.
- Passes all chunk texts to the embedding service to get 1536-dimension vectors.
- Upserts the chunks and vectors into Qdrant with payload metadata.
- Save services/rag.go. The Query method ties the full flow together next.
- Below the Ingest method in services/rag.go, add the Query method:
// Query embeds the question, retrieves relevant chunks, and generates an LLM answer
func (s *RAGService) Query(ctx context.Context, question string, sourceFilter string) (string, []string, error) {
// Embed the user's question into a vector
queryEmbedding, err := s.embeddingService.GenerateEmbeddings(ctx, []string{question})
if err != nil {
return "", nil, fmt.Errorf("query embedding failed: %w", err)
}
// Search Qdrant for the top 3 most similar chunks
results, err := s.qdrantService.Search(ctx, queryEmbedding[0], 3, sourceFilter)
if err != nil {
return "", nil, fmt.Errorf("vector search failed: %w", err)
}
if len(results) == 0 {
return "No relevant documents found.", nil, nil
}
// Assemble context from retrieved chunks
var contextParts []string
var sources []string
for i, result := range results {
contextParts = append(contextParts, fmt.Sprintf("[%d] %s", i+1, result["text"]))
sources = append(sources, result["source"])
}
contextStr := strings.Join(contextParts, "\n\n")
// Build the system prompt with retrieved context
systemPrompt := fmt.Sprintf(`You are a helpful assistant. Answer the user's question based ONLY on the following context. If the context does not contain enough information to answer, say so. Cite the source numbers in your answer.
Context:
%s`, contextStr)
// Call the LLM with the grounded prompt
chatResp, err := s.llmClient.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{
Model: openai.ChatModel("gpt-4.1-nano"),
Messages: []openai.ChatCompletionMessageParamUnion{
openai.SystemMessage(systemPrompt),
openai.UserMessage(question),
},
})
if err != nil {
return "", nil, fmt.Errorf("LLM completion failed: %w", err)
}
return chatResp.Choices[0].Message.Content, sources, nil
}
What does this code do?
The Query method is where retrieval-augmented generation happens. It follows the classic RAG pattern:
- Embed the question into a vector using the same model that embedded the documents.
- Search Qdrant for the top 3 closest vectors (most relevant chunks).
- Assemble those chunks into numbered context blocks within a system prompt.
- Call gpt-4.1-nano with instructions to answer only from context and cite sources.
This grounding technique prevents the LLM from hallucinating. It can only use the information your documents provided.
- Save services/rag.go.
- Verify the services package compiles by running:
go build ./services
- You should see no output if everything compiled successfully.
Seeing an import or undefined error?
- Make sure the package services declaration is at the top of services/rag.go.
- Check that strings is included in the import block alongside context and fmt.
- The ChunkText function lives in the same package (services/chunker.go), so it does not need an import. If you see undefined: ChunkText, make sure chunker.go still exists.
Why is my RAG service not compiling?
✔️ Awesome, I've got everything!
Great. Double check you have saved services/rag.go before moving on.
ⓧ I'd like to double check the full code
package services
import (
"context"
"fmt"
"strings"
"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/option"
)
type RAGService struct {
embeddingService *EmbeddingService
qdrantService *QdrantService
llmClient *openai.Client
}
func NewRAGService(embeddingService *EmbeddingService, qdrantService *QdrantService, apiKey string) *RAGService {
llmClient := openai.NewClient(
option.WithAPIKey(apiKey),
)
return &RAGService{
embeddingService: embeddingService,
qdrantService: qdrantService,
llmClient: llmClient,
}
}
func (s *RAGService) Ingest(ctx context.Context, text string, source string) (int, error) {
chunks := ChunkText(text, source, 500, 100)
if len(chunks) == 0 {
return 0, fmt.Errorf("no chunks generated from text")
}
texts := make([]string, len(chunks))
for i, chunk := range chunks {
texts[i] = chunk.Text
}
embeddings, err := s.embeddingService.GenerateEmbeddings(ctx, texts)
if err != nil {
return 0, fmt.Errorf("embedding generation failed: %w", err)
}
if err := s.qdrantService.Upsert(ctx, chunks, embeddings); err != nil {
return 0, fmt.Errorf("vector upsert failed: %w", err)
}
return len(chunks), nil
}
func (s *RAGService) Query(ctx context.Context, question string, sourceFilter string) (string, []string, error) {
queryEmbedding, err := s.embeddingService.GenerateEmbeddings(ctx, []string{question})
if err != nil {
return "", nil, fmt.Errorf("query embedding failed: %w", err)
}
results, err := s.qdrantService.Search(ctx, queryEmbedding[0], 3, sourceFilter)
if err != nil {
return "", nil, fmt.Errorf("vector search failed: %w", err)
}
if len(results) == 0 {
return "No relevant documents found.", nil, nil
}
var contextParts []string
var sources []string
for i, result := range results {
contextParts = append(contextParts, fmt.Sprintf("[%d] %s", i+1, result["text"]))
sources = append(sources, result["source"])
}
contextStr := strings.Join(contextParts, "\n\n")
systemPrompt := fmt.Sprintf(`You are a helpful assistant. Answer the user's question based ONLY on the following context. If the context does not contain enough information to answer, say so. Cite the source numbers in your answer.
Context:
%s`, contextStr)
chatResp, err := s.llmClient.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{
Model: openai.ChatModel("gpt-4.1-nano"),
Messages: []openai.ChatCompletionMessageParamUnion{
openai.SystemMessage(systemPrompt),
openai.UserMessage(question),
},
})
if err != nil {
return "", nil, fmt.Errorf("LLM completion failed: %w", err)
}
return chatResp.Choices[0].Message.Content, sources, nil
}
Build the HTTP endpoints and test the full flow
Your services are ready. Now you need an HTTP layer that accepts requests, calls the RAG service, and returns JSON responses. Go's standard net/http package is all you need.
- Create a new file called main.go in your project root (the rag-api folder) and add the setup and /ingest endpoint:
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"github.com/joho/godotenv"
"rag-api/services"
)
func main() {
// Load environment variables from .env
if err := godotenv.Load(); err != nil {
log.Println("No .env file found, using environment variables")
}
// Retrieve the OpenAI API key
apiKey := os.Getenv("OPENAI_API_KEY")
if apiKey == "" {
log.Fatal("OPENAI_API_KEY environment variable is required")
}
// Connect to Qdrant on gRPC port
qdrantService, err := services.NewQdrantService("localhost", 6334, "documents")
if err != nil {
log.Fatalf("Failed to connect to Qdrant: %v", err)
}
// Create collection (will log a warning if it already exists)
if err := qdrantService.CreateCollection(1536); err != nil {
log.Printf("Collection may already exist: %v", err)
}
// Wire up the services
embeddingService := services.NewEmbeddingService(apiKey)
ragService := services.NewRAGService(embeddingService, qdrantService, apiKey)
// POST /ingest - accepts document text and source name
http.HandleFunc("/ingest", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Text string `json:"text"`
Source string `json:"source"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
count, err := ragService.Ingest(r.Context(), req.Text, req.Source)
if err != nil {
http.Error(w, fmt.Sprintf("Ingestion failed: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"chunks": count,
})
})
}
What does this code do?
This sets up the application entry point. On startup, it loads your .env file, connects to Qdrant, and wires the three services together.
- godotenv.Load() reads OPENAI_API_KEY from your .env file into the environment.
- The /ingest handler decodes JSON with text and source fields, then calls ragService.Ingest() to chunk, embed, and store the document.
- It returns a JSON response with the number of chunks created.
- Save main.go. The server cannot start yet because main() has no ListenAndServe call. You will add the /query endpoint and server start next.
- In main.go, below the /ingest handler (just before the closing } of main()), add the /query endpoint and the server start:
http.HandleFunc("/query", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Question string `json:"question"`
Source string `json:"source,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
answer, sources, err := ragService.Query(r.Context(), req.Question, req.Source)
if err != nil {
http.Error(w, fmt.Sprintf("Query failed: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"answer": answer,
"sources": sources,
})
})
fmt.Println("RAG API server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
What does this code do?
The /query handler accepts a JSON body with a question field and an optional source filter. It calls ragService.Query() which runs the full RAG pipeline: embed the question, search Qdrant, build context, and call the LLM.
http.ListenAndServe(":8080", nil) starts the HTTP server on port 8080 using Go's default router. The log.Fatal wrapper ensures the program exits with an error message if the server fails to bind.
- Save main.go.
- Start the server by running:
go run .
- You should see RAG API server starting on :8080 printed in your terminal.
Server not starting?
- If you see a compile error about undefined: services.NewRAGService, make sure services/rag.go exists and has package services at the top.
- If you see address already in use, another process is on port 8080. Stop it or change the port.
- Make sure your Qdrant Docker container is still running. The server connects to Qdrant on startup.
Why isn't my Go HTTP server starting?
Now test the full RAG pipeline. Open a second terminal window (keep the server running in the first).
- Ingest a sample document by running:
curl -X POST http://localhost:8080/ingest \
-H "Content-Type: application/json" \
-d '{"text": "Go is a statically typed, compiled language designed at Google. It has garbage collection, structural typing, and CSP-style concurrency. Go was designed by Robert Griesemer, Rob Pike, and Ken Thompson. It was first released in 2009.", "source": "go-intro"}'
- You should see a JSON response like {"chunks":1,"status":"ok"}.
- Query the ingested document by running:
curl -X POST http://localhost:8080/query \
-H "Content-Type: application/json" \
-d '{"question": "Who designed Go?"}'
- You should see a JSON response with an answer field containing the LLM's grounded response citing your document, and a sources array listing go-intro.
Getting an error from curl?
- If you see Connection refused, make sure the server is running in your other terminal.
- If you see Ingestion failed or Query failed, check that your OPENAI_API_KEY in .env is valid and has credits available.
- If you see vector search failed, confirm that your Qdrant container is running with docker ps.
Why is my RAG API returning an error?
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go and your server is still running.
ⓧ I'd like to double check the full code
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"github.com/joho/godotenv"
"rag-api/services"
)
func main() {
if err := godotenv.Load(); err != nil {
log.Println("No .env file found, using environment variables")
}
apiKey := os.Getenv("OPENAI_API_KEY")
if apiKey == "" {
log.Fatal("OPENAI_API_KEY environment variable is required")
}
qdrantService, err := services.NewQdrantService("localhost", 6334, "documents")
if err != nil {
log.Fatalf("Failed to connect to Qdrant: %v", err)
}
if err := qdrantService.CreateCollection(1536); err != nil {
log.Printf("Collection may already exist: %v", err)
}
embeddingService := services.NewEmbeddingService(apiKey)
ragService := services.NewRAGService(embeddingService, qdrantService, apiKey)
http.HandleFunc("/ingest", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Text string `json:"text"`
Source string `json:"source"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
count, err := ragService.Ingest(r.Context(), req.Text, req.Source)
if err != nil {
http.Error(w, fmt.Sprintf("Ingestion failed: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"chunks": count,
})
})
http.HandleFunc("/query", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Question string `json:"question"`
Source string `json:"source,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
answer, sources, err := ragService.Query(r.Context(), req.Question, req.Source)
if err != nil {
http.Error(w, fmt.Sprintf("Query failed: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"answer": answer,
"sources": sources,
})
})
fmt.Println("RAG API server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Your RAG API is fully operational. You have a working pipeline that ingests documents, stores them as vectors, retrieves relevant context, and generates grounded LLM answers. Next up, the Secret Mission will extend your pipeline with metadata filtering for multi-source document stores.
Secret mission
Add Metadata Filtering
Production RAG systems rarely search all documents at once. When you have documents from multiple sources, you need a way to scope queries to just the relevant subset. In this mission, you will prove that your API supports metadata filtering by ingesting documents from two different sources and querying each one in isolation.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Qdrant Docker container running on ports 6333 and 6334.
- rag-api project folder on your Desktop with Go source files and .env credentials.
- OpenAI API tokens consumed (estimated $0.10-$0.50 from your $5 credit, already spent).
Keep everything running
No action needed. Choose this if you're still actively experimenting with your RAG API or plan to ingest more documents.
- Your Qdrant container continues running in Docker with all ingested vectors intact.
- Your rag-api project stays on your Desktop, ready to run with go run main.go.
- No additional OpenAI costs are incurred unless you run new queries or ingest new documents.
Pause - I'll come back to this later
Shut down the running container to free up system resources. Your data persists in the Docker volume so you can resume later.
- Stop the Qdrant container by running:
- Stop your Go server by pressing Ctrl+C in the terminal where it is running.
docker stop qdrant
When you're ready to resume, start the container again with:
docker start qdrant
Your vectors and collection data will still be there.
Delete - I don't want to use this again
Remove all project resources and start fresh. There are no cloud resources to clean up.
- Stop and remove the Qdrant container by running:
docker stop qdrant
docker rm qdrant
- Remove any orphaned Docker volumes by running:
docker volume prune
Docker will ask you to confirm. Type y and press Enter.
- Delete the project folder. On macOS/Linux, run:
rm -rf ~/Desktop/rag-api
- On Windows, run:
rmdir /s /q %USERPROFILE%\Desktop\rag-api
This removes all Go source files, the .env file, and the Go module cache for this project.
That's a Wrap!
That's a Wrap!
Nice work! You just built a complete RAG API in Go from scratch, implementing every stage of the retrieval-augmented generation pipeline by hand.
You've learned how to:
- Build a full RAG pipeline in Go from scratch, implementing document chunking with configurable size and overlap, embedding generation, vector storage, and LLM-augmented generation without any framework dependencies.
- Generate vector embeddings using OpenAI's text-embedding-3-small model and store and query them in Qdrant via its Go gRPC client.
- Assemble retrieval context into a system prompt and generate grounded answers using the official openai-go/v3 SDK with the production pattern for Azure OpenAI compatibility.
- Secret Mission: Added metadata filtering to scope queries by document source, enabling multi-source document retrieval.
Ready to quiz yourself?