# 使用 Go + Ollama 构建本地 RAG 知识问答系统
## 背景介绍
Retrieval-Augmented Generation(检索增强生成)是目前大模型应用最主流的架构之一。简单来说,它的工作原理是先从知识库中检索相关文档,再让大模型基于检索结果生成答案。这样做的好处很明显:大模型不再需要”背”下所有知识,只需要知道”上哪找答案”就行。
但问题来了——要搭建一个完整的 RAG 系统,传统方案通常依赖 OpenAI 的 API。这意味着你得把数据上传到云端,对于处理敏感文档的企业来说,这显然是不可接受的。有没有办法完全本地部署?Ollama 给了我们一个选择。这个工具让在个人电脑上运行开源大模型变得非常简单。配合 Go 语言的高性能和并发特性,我们完全可以构建一个完全离线、私有化的知识问答系统。
这就是本文要解决的问题:从零开始,使用 Go + Ollama 构建一个本地的 RAG 系统。整个系统不需要任何外部 API 调用,所有数据都保存在你自己的电脑上。
## 问题描述
你可能遇到过这些场景:
1. **企业内文档问答**:公司有一大堆内部文档,希望能让 AI 自动回答关于这些文档的问题,但又不想把文档上传到云端
2. **个人知识库**:你整理了大量的笔记和文章,希望用自然语言检索
3. **离线工作环境**:某些场景下没有网络,需要 AI 辅助但只能用本地资源
传统方案的痛点很直接:
– 调用 OpenAI API 要花钱,按 token 计费,大规模使用成本吃不消
– 数据上传云端存在泄露风险,特别是企业机密文档
– 部署麻烦,需要同时跑向量数据库、embedding 服务、LLM 服务等多个组件
本文将展示如何用一套简单的方案解决这些问题:完全本地运行、私有部署、成本为零。
## 详细步骤
### 第一步:安装 Ollama
Ollama 是专门为本地运行大模型开发的工具。安装过程非常简洁:
“`bash
curl -fsSL https://ollama.com/install.sh | sh
“`
Windows 用户可以直接从 GitHub 下载安装包。安装完成后,下载一个适合本地运行的模型。RAG 场景下推荐 llama3:8b 或者 qwen:7b,在性能和资源消耗之间平衡得比较好:
“`bash
ollama pull llama3:8b
“`
电脑有 NVIDIA 显卡的话 Ollama 会自动用 GPU 加速。没有也能跑,就是慢一点。
### 第二步:准备知识库文档
先创建目录,放入待处理的文档:
“`bash
mkdir -p ~/rag-data
“`
支持 TXT、Markdown、PDF 等格式。这里用文本文件演示:
“`bash
echo “Go 语言是 Google 于 2009 年发布的编程语言。
它的主要特点是简洁、高性能和并发原生支持。
Go 语言的并发模型基于 goroutine 和 channel,
使得编写高并发程序变得简单直观。
” > ~/rag-data/go-intro.txt
echo “Ollama 是一个让你在本地运行大语言模型的工具。
它支持多种开源模型,如 Llama、Qwen 等。
使用 Ollama,你可以完全离线使用 AI,
不需要担心数据隐私问题。
” > ~/rag-data/ollama-intro.txt
“`
### 第三步:获取 Embedding
我们需要把文本转换成向量。Ollama 提供了 embedding 功能,直接调 API 就行:
“`bash
curl -X POST http://localhost:11434/api/embeddings \
-d ‘{“prompt”: “测试文本”, “model”: “llama3:8b”}’
“`
返回 embedding 向量说明模型正常工作。
### 第四步:编写 Go 代码
Go 标准库功能已经很强,我们只需要一个轻量的第三方 SDK 来简化 Ollama 调用:
“`bash
mkdir myrag
cd myrag
go mod init myrag
go get github.com/ollama/ollama-api-client
“`
完整代码如下:
“`go
package main
import (
“bufio”
“bytes”
“encoding/json”
“fmt”
“math”
“os”
“path/filepath”
“sort”
“strings”
“time”
“github.com/ollama/ollama-api-client”
)
type Document struct {
Content string
FileName string
}
type SearchResult struct {
Document Document
Score float64
}
type Config struct {
OllamaURL string
ModelName string
DataDir string
TopK int
ChunkSize int
ChunkOverlap int
}
func DefaultConfig() Config {
return Config{
OllamaURL: “http://localhost:11434”,
ModelName: “llama3:8b”,
DataDir: “~/rag-data”,
TopK: 3,
ChunkSize: 512,
ChunkOverlap: 128,
}
}
type RAGSystem struct {
config Config
client *ollama.Client
chunks []Document
}
func NewRAGSystem(cfg Config) (*RAGSystem, error) {
client := ollama.NewClient(cfg.OllamaURL)
rag := &RAGSystem{
config: cfg,
client: client,
}
if err := rag.loadDocuments(); err != nil {
return nil, fmt.Errorf(“加载文档失败: %w”, err)
}
return rag, nil
}
func (r *RAGSystem) loadDocuments() error {
dataDir := r.config.DataDir
if strings.HasPrefix(dataDir, “~/”) {
home := os.Getenv(“HOME”)
dataDir = filepath.Join(home, dataDir[2:])
}
entries, err := os.ReadDir(dataDir)
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) == “.git” {
continue
}
path := filepath.Join(dataDir, entry.Name())
content, err := os.ReadFile(path)
if err != nil {
fmt.Printf(“警告: 读取文件失败 %s: %v\n”, path, err)
continue
}
r.chunks = append(r.chunks, r.splitText(string(content), entry.Name())…)
}
fmt.Printf(“加载了 %d 个文档块\n”, len(r.chunks))
return nil
}
func (r *RAGSystem) splitText(text, filename string) []Document {
var chunks []Document
text = strings.TrimSpace(text)
start := 0
for start < len(text) {
end := start + r.config.ChunkSize
if end > len(text) {
end = len(text)
}
if end < len(text) {
for end > start && text[end] != ‘.’ && text[end] != ‘\n’ {
end–
}
if end <= start {
end = start + r.config.ChunkSize
}
}
chunk := strings.TrimSpace(text[start:end])
if chunk != "" {
chunks = append(chunks, Document{
Content: chunk,
FileName: filename,
})
}
start = end - r.config.ChunkOverlap
if start >= len(text) {
break
}
if start < 0 {
start = 0
}
}
return chunks
}
func (r *RAGSystem) GetEmbedding(text string) ([]float64, error) {
var buf bytes.Buffer
buf.WriteString(text)
req := ollama.EmbeddingRequest{
Model: r.config.ModelName,
Prompt: text,
}
resp, err := r.client.GenerateEmbedding(buf.String(), &req)
if err != nil {
return nil, err
}
return resp.Embedding, nil
}
func cosineSimilarity(a, b []float64) float64 {
if len(a) != len(b) {
return 0
}
var dotProduct, normA, normB float64
for i := range a {
dotProduct += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
if normA == 0 || normB == 0 {
return 0
}
return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB))
}
func (r *RAGSystem) Search(query string, topK int) ([]SearchResult, error) {
queryEmbedding, err := r.GetEmbedding(query)
if err != nil {
return nil, fmt.Errorf("获取查询 embedding 失败: %w", err)
}
type scoredChunk struct {
chunk Document
score float64
}
var scoredChunks [] scoredChunk
batchSize := 10
for i := 0; i < len(r.chunks); i += batchSize {
end := i + batchSize
if end > len(r.chunks) {
end = len(r.chunks)
}
for _, chunk := range r.chunks[i:end] {
chunkEmbedding, err := r.GetEmbedding(chunk.Content)
if err != nil {
continue
}
score := cosineSimilarity(queryEmbedding, chunkEmbedding)
scoredChunks = append(scoredChunks, scoredChunk{
chunk: chunk,
score: score,
})
}
}
sort.Slice(scoredChunks, func(i, j int) bool {
return scoredChunks[i].score > scoredChunks[j].score
})
var results []SearchResult
for i := 0; i < topK && i < len(scoredChunks); i++ {
results = append(results, SearchResult{
Document: scoredChunks[i].chunk,
Score: scoredChunks[i].score,
})
}
return results, nil
}
func (r *RAGSystem) GenerateAnswer(query string, context string) (string, error) {
prompt := fmt.Sprintf(`基于以下参考资料,用中文回答用户的问题。如果无法从参考资料中找到答案,请如实说明。
参考资料:
%s
问题:%s
回答:`, context, query)
req := ollama.GenerateRequest{
Model: r.config.ModelName,
Prompt: prompt,
Stream: false,
}
var buf bytes.Buffer
err := r.client.Generate(&buf, &req)
if err != nil {
return "", err
}
var resp ollama.GenerateResponse
json.Unmarshal(buf.Bytes(), &resp)
return resp.Response, nil
}
func (r *RAGSystem) Query(query string) (string, error) {
fmt.Printf("正在搜索相关文档...\n")
results, err := r.Search(query, r.config.TopK)
if err != nil {
return "", err
}
if len(results) == 0 {
return "未���到���关文档", nil
}
var context strings.Builder
for i, result := range results {
context.WriteString(fmt.Sprintf("[文档 %d] %s\n%s\n\n",
i+1, result.Document.FileName, result.Document.Content))
}
fmt.Printf("找到 %d 个相关文档正在生成答案...\n", len(results))
answer, err := r.GenerateAnswer(query, context.String())
if err != nil {
return "", err
}
return answer, nil
}
func main() {
cfg := DefaultConfig()
for i := 1; i < len(os.Args); i++ {
switch os.Args[i] {
case "-d", "--data":
if i+1 < len(os.Args) {
cfg.DataDir = os.Args[i+1]
i++
}
case "-m", "--model":
if i+1 < len(os.Args) {
cfg.ModelName = os.Args[i+1]
i++
}
}
}
fmt.Println("初始化 RAG 系统...")
rag, err := NewRAGSystem(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "错误: %v\n", err)
os.Exit(1)
}
scanner := bufio.NewScanner(os.Stdin)
fmt.Println("\n=== 本地 RAG 知识问答系统 ===")
fmt.Println("输入问题进行查询,输入 quit 退出")
fmt.Print("> “)
for scanner.Scan() {
query := strings.TrimSpace(scanner.Text())
if query == “” {
continue
}
if query == “quit” || query == “exit” {
break
}
start := time.Now()
answer, err := rag.Query(query)
elapsed := time.Since(start)
if err != nil {
fmt.Fprintf(os.Stderr, “错误: %v\n”, err)
} else {
fmt.Printf(“\n%s\n\n”, answer)
}
fmt.Printf(“(用时: %v)\n”, elapsed)
fmt.Print(“> “)
}
}
“`
### 第五步:运行系统
编译并运行:
“`bash
go build -o myrag .
./myrag -d ~/rag-data
“`
系统会显示加载了多少个文档块,然后就可以输入问题查询了。
## 运行结果
实际运行效果:
“`
$ ./myrag -d ~/rag-data
=== 本地 RAG 知识问答系统 ===
输入问题进行查询,输入 quit 退出
初始化 RAG 系统…
加载了 4 个文档块
> Go 语言有什么特点
正在搜索相关文档…
找到 2 个相关文档正在生成答案…
Go 语言是由 Google 于 2009 年发布的编程语言。它的主要特点包括:
1. 简洁:Go 语言的语法设计简单明了,易于学习和使用
2. 高性能:编译后的程序执行效率高,接近 C/C++
3. 并发原生支持:基于 goroutine 和 goroutine 和 channel,使得编写高并发程序变得简单直观
(用时: 3.2s)
>
“`
系统成功从知识库中检索到相关内容并生成了答案。整个过程完全在本地完成,没有调用任何外部服务。
另一个例子:
“`
> ollama 可以做什么
正在搜索相关文档…
找到 1 个相关文档正在生成答案…
Ollama 是一个让你在本地运行大语言模型的工具。它支持多种开源模型,如 Llama、Qwen 等。使用 Ollama,你可以完全离线使用 AI,不需要担心数据隐私问题。你可以用它来运行各种大模型,进行问答、文本生成等任务。
(用时: 2.8s)
“`
## 总结
我们成功构建了一个完全本地运行的 RAG 知识问答系统。总结一下:
**优势**:
– 完全离线:不依赖任何外部 API,数据始终保存在本地
– 隐私安全:敏感文档不需要上传
– 成本为零:不用付 API 调用费
– 部署简单:只需要 Ollama 加 Go 环境
**限制**:
– 本地硬件影响模型选择和响应速度
– 首次加载文档时 embedding 计算较慢
– 向量检索精度不如商业方案
**后续优化方向**:
– 支持更多文档格式(PDF、Word)
– 增量索引,避免每次重新处理
– 添加图形界面
– 多模型切换
这个系统可以作为企业知识库、个人笔记助手或者离线 AI 工作的基础。感兴趣的读者可以在此基础上继续优化。