從 LLM 到 RAG:探索基于 DeepSeek 開發本地知識庫應用
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云原生公眾號。