LLM:会“思考”的 AI
我们可以把 LLM(Large Language Model,大语言模型)想象成是一个读了海量书籍的“数字大脑”,它的训练数据来自互联网上的海量文本,让它具备了理解语言、生成文本、分析逻辑的能力。
假设你想知道 Kubernetes 最新的发行版本,你直接向 DeepSeek 询问:
“Kubernetes 最新发行版本是什么”
图片
不幸的是,由于 LLM 训练数据是静态的,并引入了其所掌握知识的截止日期,它只能告诉你一个过时的版本。
RAG:让 AI 变得更“聪明”
这时,RAG(Retrieval-Augmented Generation,检索增强生成)技术就派上了用场。
我们可以利用 RAG 来提高 LLM 的回答准确性,同时避免“幻觉(Hallucination)”问题。它的工作原理如下:
1、创建知识库
如数据库、文档、网页等在 LLM 原始训练数据之外的数据都称为外部数据,结合向量模型(Embedding)可以将这些外部的文本数据转换为向量数据并将其存储在向量数据库中。这个过程就创建了一个知识库。
2、检索相关信息
同样借助向量模型(Embedding)将用户查询的问题转换为向量表示形式,然后从向量数据库中检索出相关度高的内容。
3、增强 LLM 提示
最后,通过在 LLM 上下文中添加检索到的相关数据来增强用户输入问题或提示,为用户查询生成更加准确的答案。
大致的交互流程如下:
图片
在 DeepSeek 官网中,联网搜索和上传文件,就是 RAG 技术的体现:
图片
可以看到,RAG 能够让 LLM 变得像一个“实时更新的百科全书”,可以随时查找最新答案。
回到技术本身,RAG 说白了就是结合了 LLM 和向量数据库的一种知识问答的技术体系。这个过程会围绕着数据解析、内容分块、数据向量化(Embedding 模型)、结果重排(Rerank 模型)等问题。
接下来,我们逐步探索如何使用 Go 语言开发一个完全本地化的 RAG 知识库问答系统。通过结合 DeepSeek 大语言模型和向量数据库,实现一个可以根据网页内容回答问题的智能问答系统。
准备阶段:Ollama 让大模型在本地运行
Ollama 是一个本地大模型部署工具,它让你可以在自己的电脑或服务器上运行 LLM,不用依赖外部服务。
在 https://ollama.com/ 官网下载并安装 Ollama 后,就会在本地启动一个 Ollama Server 默认监听 11434 端口,往后我们所有的交互都是与该地址通信:
$ curl http://localhost:11434
Ollama is running
我们把所有需要用到的模型都拉取到本地,语言模型选择最小的 deepseek-r1:1.5b ,向量模型选择 nomic-embed-text:latest ,至于重排模型,目前 Ollama 并未支持,我们就不进行结果重排了:
ollama pull deepseek-r1:1.5b
ollama pull nomic-embed-text:latest
准备阶段:部署向量数据库
向量数据库有很多种选择,有 Chroma、Milvus、pgvector、Qdrant 等,我们选择 pgvector ,采用 Docker 部署方式:
docker run -d --name pgvector17 \
-e POSTGRES_USER=pgvector \
-e POSTGRES_PASSWORD=pgvector \
-e POSTGRES_DB=llm-test \
-v pgvector_data:/var/lib/postgresql/data \
-p 5432:5432 \
pgvector/pgvector:pg17
LangChainGo:LLM 应用开发框架 Go 版本
LangChain 是一个非常流行的基于 LLM 开发应用程序的 Python 框架,本文选用 LangChainGo ,即 Go 版本的 LLM 开发框架。
现在正式进入开发环节,整个系统的设计思路就是将非结构化的网页内容转换为结构化的知识,并通过向量检索和大语言模型的结合,实现准确的问答功能。
1、网页内容解析与分块
RAG 系统首先需要获取外部知识,而网页是最常见的知识来源,我们可以使用 goquery 来解析和提取网页的 HTML 内容:
func loadAndSplitWebContent(url string) ([]schema.Document, error) {
// 发送HTTP GET请求获取网页内容
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// 使用goquery解析HTML文档
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil, err
}
var content strings.Builder
// 移除script和style标签,避免抓取无关内容
doc.Find("script,style").Remove()
// 提取body中的所有文本内容
doc.Find("body").Each(func(i int, s *goquery.Selection) {
text := strings.TrimSpace(s.Text())
if text != "" {
content.WriteString(text)
content.WriteString("\n")
}
})
// ......
}
接着使用 textsplitter 对提取到的文本内容进行分块,设置 ChunkSize 块大小(512)和 ChunkOverlap 重叠大小(0),并为每个块添加元数据以后续引用时可以标记来源:
func loadAndSplitWebContent(url string) ([]schema.Document, error) {
// ......
// 将文本分割成多个块,设置块大小为512字符,无重叠
splitter := textsplitter.NewRecursiveCharacter(
textsplitter.WithChunkSize(512),
textsplitter.WithChunkOverlap(0),
)
chunks, err := splitter.SplitText(content.String())
if err != nil {
return nil, err
}
// 为每个文本块创建Document对象,包含元数据
documents := make([]schema.Document, 0)
for i, chunk := range chunks {
documents = append(documents, schema.Document{
PageContent: chunk,
Metadata: map[string]any{
"source": url, // 记录文本来源URL
"chunk": fmt.Sprintf("%d", i), // 记录块的序号
},
})
}
return documents, nil
}
其中块大小代表我们将内容切分为单个块的最大字符数或单词数,而重叠大小代表相邻块之间的重叠字符数或单词数,可以在调试过程不断调整这两个参数来提升 RAG 的表现。
这样,我们就得到了原始的块内容。
比如,以 Kubernetes 的发行版本页面:https://kubernetes.io/zh-cn/releases/ 为例,可以通过该函数解析并切分为 4 个块:
2、向量化与存储
由于文本无法直接比较语义相似度,我们需要对块内容进行文本向量化后存入向量数据库,也就是使用 Ollama 的 nomic-embed-text:latest 向量模型进行文本向量化,首先初始化该向量模型:
const (
// DefaultOllamaServer 默认的Ollama服务器地址
DefaultOllamaServer = "http://localhost:11434"
// DefaultEmbeddingModel 用于生成文本向量的默认模型
DefaultEmbeddingModel = "nomic-embed-text:latest"
)
func initEmbedder() (embeddings.Embedder, error) {
embedModel, err := ollama.New(
ollama.WithServerURL(DefaultOllamaServer),
ollama.WithModel(DefaultEmbeddingModel),
)
if err != nil {
return nil, fmt.Errorf("创建embedding模型失败: %v", err)
}
embedder, err := embeddings.NewEmbedder(embedModel)
if err != nil {
return nil, fmt.Errorf("初始化embedding模型失败: %v", err)
}
return embedder, nil
}
接着配置向量数据库,使用 pgvector 作为向量存储,并将上面初始化好的向量模型绑定到 pgvector 实例中:
const (
// DefaultPGVectorURL PostgreSQL向量数据库的连接URL
DefaultPGVectorURL = "postgres://pgvector:pgvector@localhost:5432/llm-test?sslmode=disable"
)
func initVectorStore(embedder embeddings.Embedder) (vectorstores.VectorStore, error) {
store, err := pgvector.New(
context.Background(),
pgvector.WithConnectionURL(DefaultPGVectorURL),
pgvector.WithEmbedder(embedder), // 绑定向量模型
pgvector.WithCollectionName(uuid.NewString()),
)
if err != nil {
return nil, fmt.Errorf("初始化向量存储失败: %v", err)
}
return &store, nil
}
然后就可以通过 store.AddDocuments 方法批量地将文本向量化后存储到向量数据库中:
func addDocumentsToStore(store vectorstores.VectorStore, allDocs []schema.Document) {
// 设置批处理大小,避免一次处理太多文档
batchSize := 10
totalDocs := len(allDocs)
processedDocs := 0
// 分批处理所有文档
for i := 0; i < totalDocs; i += batchSize {
end := i + batchSize
if end > totalDocs {
end = totalDocs
}
batch := allDocs[i:end]
// 将文档添加到向量存储
_, err := store.AddDocuments(context.Background(), batch)
if err != nil {
fmt.Printf("\n添加文档到向量存储失败: %v\n", err)
continue
}
processedDocs += len(batch)
progress := float64(processedDocs) / float64(totalDocs) * 100
fmt.Printf("\r正在添加文档到向量存储: %.1f%% (%d/%d)", progress, processedDocs, totalDocs)
}
fmt.Printf("\n成功加载 %d 个文档片段到向量存储\n", totalDocs)
}
这一步,我们就得到了一个知识库。如下,所有的块内容都会被向量化存储到数据库中:
3、大语言模型集成
为了可以理解并回答用户的问题,我们开始集成 deepseek-r1:1.5b 模型,和向量模型的初始化类似,也需要对语言模型进行初始化:
const (
// DefaultOllamaServer 默认的Ollama服务器地址
DefaultOllamaServer = "http://localhost:11434"
// DefaultLLMModel 用于生成回答的默认大语言模型
DefaultLLMModel = "deepseek-r1:1.5b"
)
func initLLM() (llms.Model, error) {
llm, err := ollama.New(
ollama.WithServerURL(DefaultOllamaServer),
ollama.WithModel(DefaultLLMModel),
)
if err != nil {
return nil, fmt.Errorf("初始化LLM失败: %v", err)
}
return llm, nil
}
4、获取用户问题并进行语义检索
用户提问后,首先通过 store.SimilaritySearch 方法在向量数据库中查找与用户问题(question)语义相似的文档作为参考信息:
func handleQuestion(store vectorstores.VectorStore, llm llms.Model, question string) {
// 在向量数据库中搜索相关文档
// 参数:最多返回5个结果,相似度阈值0.7
results, err := store.SimilaritySearch(
context.Background(),
question,
5,
vectorstores.WithScoreThreshold(0.7),
)
if err != nil {
fmt.Printf("搜索相关文档失败: %v\n", err)
return
}
if len(results) == 0 {
fmt.Println("\n未找到相关的参考信息,请换个问题试试。")
return
}
// 显示检索到的文档
displaySearchResults(results)
// 将相关文档作为上下文提供给大语言模型并生成问题的回答
generateAnswer(llm, question, results)
}
需要注意的是,该步骤也需要调用向量模型将问题进行向量化。如下,当用户提问后,可以显示检索到的文档,因为我们限定了相似度阈值为 0.7 ,所以只检索到 2 个分块:
图片
5、包装 Prompt 结合参考信息交由大语言模型回答
最后我们只需要设计合适的提示词模板,填充参考信息,调用上面初始化好的 DeepSeek 本地模型就可以回答用户问题了:
func generateAnswer(llm llms.Model, question string, results []schema.Document) {
var references strings.Builder
for i, doc := range results {
score := 1 - doc.Score
references.WriteString(fmt.Sprintf("%d. [相似度:%f] %s\n", i+1, score, doc.PageContent))
}
messages := []llms.MessageContent{
{
// 系统提示,设置助手角色和行为规则
Role: llms.ChatMessageTypeSystem,
Parts: []llms.ContentPart{
llms.TextContent{
Text: fmt.Sprintf(
"你是一个专业的知识库问答助手。以下是基于向量相似度检索到的相关文档:\n\n%s\n"+
"请基于以上参考信息回答用户问题。回答时请注意:\n"+
"1. 优先使用相关度更高的参考信息\n"+
"2. 如果参考信息不足以完整回答问题,请明确指出",
references.String(),
),
},
},
},
{
// 用户问题
Role: llms.ChatMessageTypeHuman,
Parts: []llms.ContentPart{
llms.TextContent{
Text: question,
},
},
},
}
fmt.Printf("生成回答中...\n\n")
_, err := llm.GenerateContent(
context.Background(),
messages,
llms.WithTemperature(0.8), // 设置温度为0.8,增加回答的多样性
llms.WithStreamingFunc(func(ctx context.Context, chunk []byte) error {
fmt.Print(string(chunk))
return nil
}),
)
if err != nil {
fmt.Printf("生成回答失败: %v\n", err)
return
}
fmt.Println()
}
可以看到,现在即使是本地的 deepseek-r1:1.5b 模型,有了 RAG 的加成,也可以正确回答我们的问题:
图片
附上完整代码:https://github.com/togettoyou/rag-demo
至此,我们就实现了一个功能完整的本地知识库问答系统。它几乎包含了 RAG 应用的所有核心要素:
- 文本处理:网页抓取和分块
- 向量化:文本向量化和存储
- 知识检索:相似度搜索
- 答案生成:LLM 回答生成
而在此基础上,还有更多的优化没做:
- 添加更多数据源支持(PDF、Word 等)
- 优化文本分块策略
- 实现结果重排(Rerank 模型)
- 语义检索和语言模型结合的增强处理
最后推荐一些 RAG 领域的开源项目:Dify、FastGPT、QAnything 等,这些都集成了知识库功能,而且基本都对接了各家的语言模型、向量模型、重排模型等,如果是完全本地化,也可以尝试 Page Assist 浏览器插件,可以直接连接本地的 Ollama 实现知识库对话。
本文转载自微信公众号「gopher云原生」,可以通过以下二维码关注。转载本文请联系gopher云原生公众号。