使用 Go + Ollama 构建本地大模型知识库问答系统

# 使用 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 模型提升检索精度、知识库增量更新、分布式部署多实例。这些在现有基础上加就行,不算难。

有什么问题评论区见。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇