使用 Go + Ollama 构建本地 RAG 知识问答系统

# 使用 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 工作的基础。感兴趣的读者可以在此基础上继续优化。

暂无评论

发送评论 编辑评论


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