## 背景介绍
企业在日常运营中会产生大量内部文档——技术文档、产品手册、会议纪要、客服 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 文档的完整解析;添加增量索引支持,实时更新知识库;实现多路召回,结合关键词和向量检索;根据不同文档类型采用不同分块策略。
希望这篇文章对你有帮助。