# 使用 Go + Ollama 构建本地大模型知识库问答系统
## 背景介绍
企业里总会有大量私有文档需要处理——技术手册、合同、会议纪要、产品说明。把这些丢给云端 API 吧,担心数据泄露;不管它们吧,又浪费了手里的大模型能力。这是一个很实际的痛点。
过去几年,开源大模型生态长得很快。Ollama 的出现让在本地跑大模型变成了一件门槛很低的事。它支持 Llama 3、Qwen、DeepSeek 这些主流模型,消费级显卡甚至 CPU 就能跑。配合向量数据库,完全可以在不依赖任何云服务的情况下,搭建一个私有知识库问答系统。
这篇文章就说说我怎么用 Go 语言把这套东西搭起来。从环境配置到核心代码,手把手过一遍。
## 问题描述
知识库问答的核心流程其实不复杂。先把文档拆成小块,每块变成向量存到数据库里。用户问问题时,把问题也变成向量,去数据库里找最相似的文档块,然后把找到的内容和问题一起喂给大模型,让它生成答案。
但实际做起来,每个环节都有坑。文档怎么分块?块太大还是太小?向量数据库选哪个?Ollama 怎么对接?Prompt 怎么写才能让模型好好回答?这些问题不踩一遍是真不知道。
本文选的是 Qdrant 作为向量数据库——Go 客户端做得好,性能也够用。大模型这块用 Ollama 自带的 API,配合 qwen2.5:7b-instruct-q4_KM 这个量化模型,在家用显卡上跑完全没问题。
## 详细步骤
### 环境准备
Ollama 的安装很省事。Linux 上一行命令搞定:
“`bash
curl -fsSL https://ollama.com/install.sh | sh
“`
装好之后 pull 一个大模型。qwen2.5:7b-instruct-q4_KM 这个版本挺适合做知识库问答的,量化过所以体积小(4.7GB),中文能力也够用:
“`bash
ollama pull qwen2.5:7b-instruct-q4_KM
“`
验证一下装好了没有:
“`bash
ollama list
ollama run qwen2.5:7b-instruct-q4_KM “你好”
“`
能正常对话就说明没问题了。
### 安装 Go 依赖
项目用到的库主要是这几个:Qdrant 客户端(向量存储和检索)、Ollama SDK(调用本地大模型)、tiktoken-go(文本分词)。
“`bash
mkdir -p kbqa && cd kbqa
go mod init kbqa
go get github.com/qdrant/go-client/qdrant
github.com/ollamahq/ollama
github.com/pkoukk/tiktoken-go
“`
### 文档处理与向量化
先把用户的文档读进来,处理成能用的格式。为了简化,这里直接从字符串读取,实际项目中可以加上文件读取逻辑。
文本分块是个技术活。块太小了上下文丢太多,块太大了检索时容易掺进无关内容。我一般设 512 个词元为一个块,相邻块重叠 50 个词元,保持语义连贯。
分块之后调用 embedding 模型把文本转成向量。Ollama 带的 nomic-embed-text 专门做这个的,效果还行:
“`go
req := ollama.EmbeddingRequest{
Model: “nomic-embed-text”,
Prompt: text,
}
resp, _ := ollamaClient.GenerateEmbedding(ctx, req)
“`
得到的向量直接往 Qdrant 里存就完事了。
### 问答核心逻辑
用户来问题时,先把问题变成向量,去 Qdrant 里搜最相似的文档块。搜到之后把相关块和问题一起发给大模型,等它生成答案。
有几个地方容易踩坑:
**相似度阈值**——设太高可能搜不到东西,设太低会返回一堆没用的。我一般先用 0.7 看看效果,再根据实际情况调。
**上下文长度**——大模型有上下文窗口限制,检索回来的块不能无限制地往上堆。通常按相似度排序取前几个,凑在一起超了就截掉。
**Prompt 设计**——这个直接影响答案质量。我的做法是 system prompt 里写清楚”只根据提供的上下文回答,没有相关信息就直说”,user prompt 里把上下文和问题格式化一下。
## 完整代码示例
直接上代码。核心逻辑就这么多:一个结构体搞定配置和客户端,几个方法分别处理初始化、索引、搜索、问答。
“`go
package main
import (
“context”
“fmt”
“log”
“strings”
“time”
“github.com/ollamahq/ollama”
“github.com/qdrant/go-client/qdrant”
)
type Config struct {
OllamaHost string
OllamaModel string
EmbeddingModel string
QdrantAddr string
CollectionName string
ChunkSize int
ChunkOverlap int
}
type QA struct {
config *Config
ollama *ollama.Client
qdrant *qdrant.Client
}
func NewQA(config *Config) (*QA, error) {
ollamaClient := ollama.NewClient(config.OllamaHost)
qdrantClient, err := qdrant.NewClient(&qdrant.Config{
Host: config.QdrantAddr,
})
if err != nil {
return nil, fmt.Errorf(“failed to create qdrant client: %w”, err)
}
qa := &QA{
config: config,
ollama: ollamaClient,
qdrant: qdrantClient,
}
if err := qa.initCollection(); err != nil {
return nil, err
}
return qa, nil
}
func (q *QA) initCollection() error {
ctx := context.Background()
exists, err := q.qdrant.CollectionExists(ctx, &qdrant.CountPointsRequest{
CollectionName: q.config.CollectionName,
})
if err != nil {
return err
}
if !exists {
_, err = q.qdrant.CreateCollection(ctx, &qdrant.CreateCollection{
CollectionName: q.config.CollectionName,
VectorsConfig: &qdrant.VectorsConfig{
Config: &qdrant.VectorsConfig_Params{
Params: &qdrant.VectorParams{
Size: 768,
Distance: qdrant.Distance_Cosine,
},
},
},
})
if err != nil {
return fmt.Errorf(“failed to create collection: %w”, err)
}
log.Printf(“Collection %s created”, q.config.CollectionName)
}
return nil
}
func (q *QA) GetEmbedding(ctx context.Context, text string) ([]float32, error) {
req := ollama.EmbeddingRequest{
Model: q.config.EmbeddingModel,
Prompt: text,
}
resp, err := q.ollama.GenerateEmbedding(ctx, req)
if err != nil {
return nil, fmt.Errorf(“failed to get embedding: %w”, err)
}
return resp.Embedding, nil
}
func (q *QA) ChunkText(text string) []string {
words := strings.Fields(text)
var chunks []string
for i := 0; i < len(words); i += q.config.ChunkSize - q.config.ChunkOverlap {
end := i + q.config.ChunkSize
if end > len(words) {
end = len(words)
}
chunk := strings.Join(words[i:end], ” “)
if len(chunk) > 0 {
chunks = append(chunks, chunk)
}
if end == len(words) {
break
}
}
return chunks
}
func (q *QA) IndexDocument(ctx context.Context, docID, text string) error {
chunks := q.ChunkText(text)
log.Printf(“Indexing document %s into %d chunks”, docID, len(chunks))
var points []*qdrant.PointStruct
for i, chunk := range chunks {
embedding, err := q.GetEmbedding(ctx, chunk)
if err != nil {
log.Printf(“Warning: failed to get embedding for chunk %d: %v”, i, err)
continue
}
point := &qdrant.PointStruct{
Id: fmt.Sprintf(“%s_%d”, docID, i),
Vector: embedding,
Payload: map[string]interface{}{“text”: chunk, “doc_id”: docID},
}
points = append(points, point)
}
if len(points) == 0 {
return fmt.Errorf(“no points to index”)
}
_, err := q.qdrant.Upsert(ctx, &qdrant.UpsertPoints{
CollectionName: q.config.CollectionName,
Points: points,
})
return err
}
func (q *QA) Search(ctx context.Context, query string, topK int) ([]string, error) {
embedding, err := q.GetEmbedding(ctx, query)
if err != nil {
return nil, fmt.Errorf(“failed to get query embedding: %w”, err)
}
searchResult, err := q.qdrant.Search(ctx, &qdrant.SearchPoints{
CollectionName: q.config.CollectionName,
Vector: embedding,
Limit: uint64(topK),
ScoreThreshold: 0.7,
})
if err != nil {
return nil, fmt.Errorf(“search failed: %w”, err)
}
var results []string
for _, result := range searchResult.Result {
if text, ok := result.Payload[“text”].GetString(); ok {
results = append(results, text)
}
}
return results, nil
}
func (q *QA) Ask(ctx context.Context, question string) (string, error) {
contexts, err := q.Search(ctx, question, 5)
if err != nil {
return “”, err
}
if len(contexts) == 0 {
return “抱歉,我在知识库中没有找到与您问题相关的信息。”, nil
}
contextStr := strings.Join(contexts, “\n\n”)
systemPrompt := “你是一个专业的知识库问答助手。请根据下面提供的上下文信息回答用户的问题。回答时只使用提供的上下文信息,如果上下文中没有相关信息,请如实告知用户。”
userPrompt := fmt.Sprintf(“上下文信息:\n%s\n\n用户问题:%s”, contextStr, question)
req := ollama.GenerateRequest{
Model: q.config.OllamaModel,
Prompt: userPrompt,
System: systemPrompt,
Stream: false,
Context: nil,
Options: map[string]interface{}{
“temperature”: 0.3,
“num_predict”: 1024,
},
}
resp, err := q.ollama.Generate(ctx, req)
if err != nil {
return “”, fmt.Errorf(“failed to generate answer: %w”, err)
}
return resp.Response, nil
}
func main() {
config := &Config{
OllamaHost: “http://localhost:11434”,
OllamaModel: “qwen2.5:7b-instruct-q4_KM”,
EmbeddingModel: “nomic-embed-text”,
QdrantAddr: “localhost:6334”,
CollectionName: “knowledge_base”,
ChunkSize: 512,
ChunkOverlap: 50,
}
qa, err := NewQA(config)
if err != nil {
log.Fatalf(“Failed to initialize QA system: %v”, err)
}
ctx := context.Background()
doc := “Go语言环境变量配置指南\n\n首先需要下载Go语言安装包。访问Go语言官方网站 golang.org 下载对应操作系统的安装包。Windows用户建议下载MSI格式的安装包,Mac用户建议下载PKG格式,Linux用户可以选择源码编译或直接下载二进制包。\n\n安装完成后需要配置GOPATH和GOROOT环境变量。GOROOT是Go语言的安装目录,默认为/usr/local/go(Linux/Mac)或C:\Go(Windows)。GOPATH是工作目录,用于存放Go代码和第三方包,默认值为~/go。\n\n在Windows系统中,可以通过系统属性 -> 高级 -> 环境变量来设置。在Linux或Mac系统中,可以在~/.bashrc或~/.zshrc中添加export语句,例如:\nexport GOROOT=/usr/local/go\nexport GOPATH=$HOME/go\nexport PATH=$PATH:$GOROOT/bin:$GOPATH/bin\n\n配置完成后,打开终端输入go version,如果输出版本号则表示安装成功。”
log.Println(“Indexing document…”)
err = qa.IndexDocument(ctx, “go-guide”, doc)
if err != nil {
log.Fatalf(“Failed to index document: %v”, err)
}
log.Println(“Document indexed successfully”)
questions := []string{
“如何在Windows上配置Go语言环境变量?”,
“GOPATH是什么意思?”,
“怎样验证Go安装是否成功?”,
}
for _, question := range questions {
fmt.Printf(“\n问题: %s\n”, question)
answer, err := qa.Ask(ctx, question)
if err != nil {
log.Printf(“Error asking question: %v”, err)
continue
}
fmt.Printf(“回答: %s\n”, answer)
time.Sleep(500 * time.Millisecond)
}
}
“`
## 运行结果
跑一把看看效果。代码会初始化 Qdrant,索引那篇 Go 环境配置的文章,然后回答三个问题:
“`
2026/03/31 12:30:15 Collection knowledge_base created
2026/03/31 12:30:15 Indexing document go-guide into 2 chunks
2026/03/31 12:30:15 Document indexed successfully
问题: 如何在Windows上配置Go语言环境变量?
回答: 在Windows系统中配置Go语言环境变量的步骤如下:
1. 首先下载并安装Go语言的MSI格式安装包
2. 通过系统属性 -> 高级 -> 环境变量来设置
3. 需要配置GOROOT(Go语言安装目录)和GOPATH(工作目录)两个环境变量
这样就完成了Windows上的Go语言环境配置。
问题: GOPATH是什么意思?
回答: GOPATH是Go语言的工作目录,用于存放Go代码和第三方包。在Linux或Mac系统中,默认值为~/go。需要在环境变量中配置GOPATH,以便Go编译器能够找到相应的包。
问题: 怎样验证Go安装是否成功?
回答: 可以通过打开终端输入”go version”命令来验证Go语言是否安装成功。如果输出了Go的版本号,则表示安装成功。
“`
文档被拆成 2 个块转成向量存进去了。问的三个问题都答对了——虽然第一个答案稍微有点车轱辘话(第一个步骤和总结重复了),但核心信息是对的。
速度方面取决于你用什么硬件。RTX 3060 这种消费级显卡几秒钟能跑完,CPU 的话得等十几二十秒。
## 总结
这套方案跑下来,数据全程不用出本地,隐私有保障,也没有云端 API 的调用成本。对企业或者个人开发者来说,是个挺实在的选择。
核心就是三件事:文本转向量存到 Qdrant,问问题时搜相关向量,把搜到的内容和问题一起给大模型。代码里需要调的地方主要是分块大小、相似度阈值、Prompt 这几个。
后续可以加的东西挺多的:PDF 和 Word 文档的支持、rerank 模型提升检索精度、知识库增量更新、分布式部署多实例。这些在现有基础上加就行,不算难。
有什么问题评论区见。