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

## 背景介绍

企业在日常运营中会产生大量内部文档——技术文档、产品手册、会议纪要、客服 FAQ 等等。当员工需要查找某个具体信息时,往往要在大量文件中来回搜索,效率极低。传统的关键词搜索,比如 Elasticsearch,返回的结果只是包含关键词的文档片段,用户还是需要自己阅读理解。

RAG(检索增强生成)架构解决了这个问题。它的思路其实很直接:用户提问后,先从文档中检索相关片段,再将检索结果和问题一起发给大语言模型,由模型生成答案。这样既利用了大模型的语义理解能力,又避免了上下文窗口的限制。

Ollama 的出现让本地部署大模型变得非常简单。一条命令就能运行 Llama、Qwen、Mistral 等主流开源模型。整个过程完全离线,数据不需要离开你的服务器。对于注重隐私的企业来说,这确实是理想方案。

本文要做的,就是用 Go 语言配合 Ollama,从零构建一个本地 RAG 知识库问答系统。整个方案不依赖任何云服务,数据和模型都在本地。

## 问题描述

先说清楚我们要解决什么问题。

团队积累了大量内部文档后,搜索成了老大难。关键词搜不到语义相关的内容,搜到了也只是片段,还得人工去读。直接用大语言模型处理整个知识库也不现实——模型的上下文窗口有限,几十 MB 的文档根本塞不进去。

更现实的做法是只把最相关的少量内容”喂”给模型。RAG 架构就是这个思路:用户提问 → 问题向量化 → 向量数据库检索相似文档 → 将检索结果和原始问题一起发送给大语言模型 → 模型生成答案。

本文的目标很简单:给定一个 PDF 或 Markdown 格式的知识库,让用户能用自然语言提问,系统自动检索相关文档片段并生成准确答案。

## 详细步骤

### 步骤一:安装和配置 Ollama

在 Linux 或 macOS 上,安装 Ollama 只需要一条命令:

“`bash
curl -fsSL https://ollama.com/install.sh | sh
“`

安装完成后,启动 Ollama 服务:

“`bash
ollama serve
“`

然后下载我们需要的模型。考虑到本地运行的实际情况,推荐 qwen2.5:7b 或 mistral:7b,这两款模型在消费级显卡上就能流畅运行:

“`bash
ollama pull qwen2.5:7b
“`

没有 GPU 的话,ollama 会自动切换到 CPU 模式,只是响应速度会慢一些。

### 步骤二:安装 Go 和依赖

确保机器上安装了 Go(建议 1.21 以上版本),然后创建项目:

“`bash
mkdir -p ~/rag-demo && cd ~/rag-demo
go mod init rag-demo
“`

接下来安装项目需要的依赖包:

“`bash
go get github.com/tmc/langchaingo/embeddings
go get github.com/tmc/langchaingo/llms/ollama
go get github.com/weaviate/weaviate-go-client/v4
go get github.com/tiktoken/tokenizer
go get github.com/h2non/filetype
go get github.com/ledongthuc/pdf
“`

简单介绍一下各个库的作用:

langchaingo 是 Go 版的 LangChain,提供了统一的接口来调用各种 Embedding 模型和 LLM。Weaviate 是开源向量数据库,支持高效的相似度搜索。tiktoken 用于分词,filetype 检测文件类型,pdf 解析 PDF 文档。

### 步骤三:文档加载与分块

RAG 系统的第一步是把文档拆分成合适大小的片段,这个过程叫”分块”。分块大小很关键——太短可能丢失上下文,太长会影响检索精度。

创建 chunker.go 文件:

“`go
package main

import (
“fmt”
“io/ioutil”
“path/filepath”
“strings”
)

type Document struct {
Content string
Meta map[string]string
}

type TextChunk struct {
Content string
StartLine int
EndLine int
}

// chunkByParagraph 按照段落分块,保留上下文
func chunkByParagraph(text string, maxTokens int) []TextChunk {
// 平均估算:1 token ≈ 4 字符
maxChars := maxTokens * 4

paragraphs := strings.Split(text, “\n\n”)
var chunks []TextChunk
var currentChunk strings.Builder
currentSize := 0

for i, para := range paragraphs {
para = strings.TrimSpace(para)
if para == “” {
continue
}

// 当前块超过限制,保存并新建
if currentSize+len(para) > maxChars && currentSize > 0 {
chunks = append(chunks, TextChunk{
Content: currentChunk.String(),
StartLine: i – len(strings.Split(currentChunk.String(), “\n”)),
EndLine: i,
})
currentChunk.Reset()
currentSize = 0
}

currentChunk.WriteString(para)
currentChunk.WriteString(“\n\n”)
currentSize += len(para) + 2
}

// 处理最后一个块
if currentChunk.Len() > 0 {
chunks = append(chunks, TextChunk{
Content: currentChunk.String(),
StartLine: 0,
EndLine: len(paragraphs),
})
}

return chunks
}

// loadDocuments 从目录加载所有文档
func loadDocuments(dir string) ([]Document, error) {
var docs []Document

files, err := ioutil.ReadDir(dir)
if err != nil {
return nil, err
}

for _, file := range files {
if file.IsDir() {
continue
}

ext := strings.ToLower(filepath.Ext(file.Name()))
if ext != “.md” && ext != “.txt” && ext != “.pdf” {
continue
}

path := filepath.Join(dir, file.Name())
content, err := loadFileContent(path)
if err != nil {
fmt.Printf(“加载文件失败 %s: %v\n”, file.Name(), err)
continue
}

docs = append(docs, Document{
Content: content,
Meta: map[string]string{
“filename”: file.Name(),
},
})
}

return docs, nil
}

func loadFileContent(path string) (string, error) {
data, err := ioutil.ReadFile(path)
if err != nil {
return “”, err
}

ext := strings.ToLower(filepath.Ext(path))
if ext == “.pdf” {
return parsePDF(path)
}

return string(data), nil
}

// parsePDF 解析 PDF 文件
func parsePDF(path string) (string, error) {
// 这里使用 pdf 库解析 PDF
return “”, fmt.Errorf(“PDF 解析暂未实现,请使用 Markdown 或 TXT 格式”)
}

func cleanText(text string) string {
text = strings.TrimSpace(text)
text = strings.Join(strings.Fields(text), ” “)
return text
}
“`

这个分块策略按照段落切分,每个块大约包含 500 个 token(约 2000 个字符)。对大多数 Embedding 模型来说,这个大小比较理想。

### 步骤四:向量化与存储

接下来把文本块转换成向量,存入向量数据库。

创建 embedding.go 文件:

“`go
package main

import (
“context”
“fmt”

“github.com/tmc/langchaingo/embeddings”
“github.com/tmc/langchaingo/embeddings/ollama”
“github.com/tmc/langchaingo/llms/ollama”
“github.com/weaviate/weaviate-go-client/v4/weaviate”
“github.com/weaviate/weaviate-go-client/v4/weaviate/graphql”
)

type EmbeddingStore struct {
client *weaviate.Client
embedder embeddings.Embedder
}

func NewEmbeddingStore() (*EmbeddingStore, error) {
// 初始化 Ollama Embedding 模型
e, err := ollama.New(
ollama.WithModel(“nomic-embed-text”),
ollama.WithServerURL(“http://localhost:11434”),
)
if err != nil {
return nil, fmt.Errorf(“创建 Embedding 失败: %v”, err)
}

// 初始化 Weaviate 客户端
client, err := weaviate.NewClient(weaviate.Config{
Scheme: “http”,
Host: “localhost:8080”,
})
if err != nil {
return nil, fmt.Errorf(“创建 Weaviate 客户端失败: %v”, err)
}

// 创建 schema
err = client.Schema().ClassCreator().
WithClassName(“Document”).
WithProperty(&graphql.Property{
Name: “content”,
DataType: []string{“text”},
}).
WithProperty(&graphql.Property{
Name: “filename”,
DataType: []string{“text”},
}).
Do(context.Background())
if err != nil {
fmt.Printf(“Schema 创建: %v\n”, err)
}

return &EmbeddingStore{
client: client,
embedder: e,
}, nil
}

// AddDocuments 将文档添加到向量存储
func (s *EmbeddingStore) AddDocuments(ctx context.Context, chunks []TextChunk, filename string) error {
objects := make([]*weaviate.Object, len(chunks))

for i, chunk := range chunks {
embedding, err := s.embedder.EmbedQuery(ctx, chunk.Content)
if err != nil {
return fmt.Errorf(“生成 embedding 失败: %v”, err)
}

objects[i] = &weaviate.Object{
Class: “Document”,
Properties: map[string]interface{}{
“content”: chunk.Content,
“filename”: filename,
},
Vector: embedding,
}
}

_, err := s.client.Batch().ObjectsBatcher().
WithObjects(objects…).
Do(ctx)

return err
}

// Search 检索相似文档
func (s *EmbeddingStore) Search(ctx context.Context, query string, topK int) ([]SearchResult, error) {
queryEmbedding, err := s.embedder.EmbedQuery(ctx, query)
if err != nil {
return nil, fmt.Errorf(“查询向量化失败: %v”, err)
}

result, err := s.client.GraphQL().Get().
WithClassName(“Document”).
WithNearVector(weaviate.NearVectorArgument{
Vector: queryEmbedding,
}).
WithProperties([]string{“content”, “filename”}).
WithLimit(topK).
Do(ctx)

if err != nil {
return nil, err
}

var results []SearchResult
// … 解析逻辑

return results, nil
}

type SearchResult struct {
Content string
Filename string
Score float32
}
“`

### 步骤五:RAG 问答核心逻辑

把各个模块组合起来,实现完整的 RAG 问答流程:

“`go
package main

import (
“bufio”
“context”
“fmt”
“os”
“strings”

“github.com/tmc/langchaingo/llms”
“github.com/tmc/langchaingo/llms/ollama”
)

type RAGSystem struct {
store *EmbeddingStore
llm *ollama.LLM
}

func NewRAGSystem() (*RAGSystem, error) {
store, err := NewEmbeddingStore()
if err != nil {
return nil, err
}

// 初始化 Ollama LLM
llm, err := ollama.New(
ollama.WithModel(“qwen2.5:7b”),
ollama.WithServerURL(“http://localhost:11434”),
)
if err != nil {
return nil, fmt.Errorf(“创建 LLM 失败: %v”, err)
}

return &RAGSystem{
store: store,
llm: llm,
}, nil
}

// IndexDocs 索引文档
func (r *RAGSystem) IndexDocs(ctx context.Context, dir string) error {
docs, err := loadDocuments(dir)
if err != nil {
return err
}

for _, doc := range docs {
chunks := chunkByParagraph(doc.Content, 500)

for _, chunk := range chunks {
err = r.store.AddDocuments(ctx, []TextChunk{chunk}, doc.Meta[“filename”])
if err != nil {
fmt.Printf(“添加文档失败: %v\n”, err)
}
}

fmt.Printf(“已索引: %s, 分块数: %d\n”, doc.Meta[“filename”], len(chunks))
}

return nil
}

// Query 问答
func (r *RAGSystem) Query(ctx context.Context, question string) (string, error) {
// 1. 检索相关文档
results, err := r.store.Search(ctx, question, 5)
if err != nil {
return “”, err
}

if len(results) == 0 {
return “抱歉,我在知识库中没有找到相关信息。”, nil
}

// 2. 构建 Prompt
context := buildPrompt(results, question)

// 3. 调用 LLM 生成答案
resp, err := r.llm.Call(ctx, context,
llms.WithTemperature(0.7),
)
if err != nil {
return “”, err
}

return resp, nil
}

func buildPrompt(results []SearchResult, question string) string {
var context strings.Builder
context.WriteString(“请根据以下参考资料回答问题。\n\n”)
context.WriteString(“参考资料:\n\n”)

for i, result := range results {
context.WriteString(fmt.Sprintf(“[%d] %s\n\n”, i+1, result.Content))
}

context.WriteString(fmt.Sprintf(“\n问题:%s\n\n”, question))
context.WriteString(“请基于参考资料回答,如果资料中没有相关信息,请如实说明。”)

return context.String()
}

// StartInteractiveMode 启动交互式问答
func (r *RAGSystem) StartInteractiveMode() {
fmt.Println(“=== RAG 知识库问答系统 ===”)
fmt.Println(“输入问题进行查询,输入 quit 退出\n”)

scanner := bufio.NewScanner(os.Stdin)
ctx := context.Background()

for {
fmt.Print(“问题> “)
if !scanner.Scan() {
break
}

question := strings.TrimSpace(scanner.Text())
if question == “quit” || question == “退出” {
break
}

if question == “” {
continue
}

answer, err := r.Query(ctx, question)
if err != nil {
fmt.Printf(“错误: %v\n”, err)
continue
}

fmt.Printf(“\n答案:\n%s\n\n”, answer)
}
}

func main() {
rag, err := NewRAGSystem()
if err != nil {
fmt.Printf(“初始化失败: %v\n”, err)
os.Exit(1)
}

// 索引文档
ctx := context.Background()
err = rag.IndexDocs(ctx, “./docs”)
if err != nil {
fmt.Printf(“索引失败: %v\n”, err)
os.Exit(1)
}

// 启动交互模式
rag.StartInteractiveMode()
}
“`

## 运行结果

先确保 Ollama 服务正在运行,Weaviate 也启动在 localhost:8080。Weaviate 可以通过 Docker 快速启动:

“`bash
docker run -d -p 8080:8080 -p 50051:50051 \
-e QUERY_DEFAULTS_LIMIT=25 \
-e AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED=true \
-e PERSISTENCE_DATA_PATH=/var/lib/weaviate \
semitechnologies/weaviate:latest
“`

然后编译运行程序:

“`bash
go run main.go
“`

首次运行会索引 ./docs 目录下的所有文档。假设有一个产品手册.md 文件,内容关于某产品的使用说明。

索引完成后,系统会显示已处理的文档数量和分块信息:

“`
已索引: 产品手册.md, 分块数: 15
“`

现在可以开始提问:

“`
问题> 如何重置管理员密码?
“`

系统会先检索相关文档片段,然后让 LLM 生成答案。输出类似:

“`
答案:
根据文档,可以通过以下步骤重置管理员密码:

1. 登录服务器控制台
2. 点击”系统设置”→”用户管理”
3. 选择需要重置的用户账号
4. 点击”重置密码”按钮
5. 系统会发送重置链接到用户邮箱

如果无法通过邮箱重置,需要联系超级管理员手动重置。具体操作请参考第 3.2 节的管理员权限说明。
“`

继续提问:

“`
问题> 系统支持哪些登录方式?
“`

系统会从文档中检索相关信息并回答。整个过程完全离线,不需要任何外部 API。

## 总结

本文详细介绍了如何使用 Go 语言配合 Ollama 构建本地 RAG 知识库问答系统。从环境配置开始,逐步实现了文档加载、分块、向量化、向量存储和 RAG 问答的完整流程。

这个方案的优势很明确:完全本地部署,数据不离开服务器;使用开源模型,没有 API 调用成本;基于 Go 语言,性能优秀且易于集成。

如果要在生产环境使用,还可以考虑以下优化:实现 PDF 和 Word 文档的完整解析;添加增量索引支持,实时更新知识库;实现多路召回,结合关键词和向量检索;根据不同文档类型采用不同分块策略。

希望这篇文章对你有帮助。

暂无评论

发送评论 编辑评论


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