從 LLM 到 RAG:探索基于 DeepSeek 開(kāi)發(fā)本地知識(shí)庫(kù)應(yīng)用
LLM:會(huì)“思考”的 AI
我們可以把 LLM(Large Language Model,大語(yǔ)言模型)想象成是一個(gè)讀了海量書(shū)籍的“數(shù)字大腦”,它的訓(xùn)練數(shù)據(jù)來(lái)自互聯(lián)網(wǎng)上的海量文本,讓它具備了理解語(yǔ)言、生成文本、分析邏輯的能力。
假設(shè)你想知道 Kubernetes 最新的發(fā)行版本,你直接向 DeepSeek 詢問(wèn):
“Kubernetes 最新發(fā)行版本是什么”
圖片
不幸的是,由于 LLM 訓(xùn)練數(shù)據(jù)是靜態(tài)的,并引入了其所掌握知識(shí)的截止日期,它只能告訴你一個(gè)過(guò)時(shí)的版本。
RAG:讓 AI 變得更“聰明”
這時(shí),RAG(Retrieval-Augmented Generation,檢索增強(qiáng)生成)技術(shù)就派上了用場(chǎng)。
我們可以利用 RAG 來(lái)提高 LLM 的回答準(zhǔn)確性,同時(shí)避免“幻覺(jué)(Hallucination)”問(wèn)題。它的工作原理如下:
1、創(chuàng)建知識(shí)庫(kù)
如數(shù)據(jù)庫(kù)、文檔、網(wǎng)頁(yè)等在 LLM 原始訓(xùn)練數(shù)據(jù)之外的數(shù)據(jù)都稱為外部數(shù)據(jù),結(jié)合向量模型(Embedding)可以將這些外部的文本數(shù)據(jù)轉(zhuǎn)換為向量數(shù)據(jù)并將其存儲(chǔ)在向量數(shù)據(jù)庫(kù)中。這個(gè)過(guò)程就創(chuàng)建了一個(gè)知識(shí)庫(kù)。
2、檢索相關(guān)信息
同樣借助向量模型(Embedding)將用戶查詢的問(wèn)題轉(zhuǎn)換為向量表示形式,然后從向量數(shù)據(jù)庫(kù)中檢索出相關(guān)度高的內(nèi)容。
3、增強(qiáng) LLM 提示
最后,通過(guò)在 LLM 上下文中添加檢索到的相關(guān)數(shù)據(jù)來(lái)增強(qiáng)用戶輸入問(wèn)題或提示,為用戶查詢生成更加準(zhǔn)確的答案。
大致的交互流程如下:
圖片
在 DeepSeek 官網(wǎng)中,聯(lián)網(wǎng)搜索和上傳文件,就是 RAG 技術(shù)的體現(xiàn):
圖片
可以看到,RAG 能夠讓 LLM 變得像一個(gè)“實(shí)時(shí)更新的百科全書(shū)”,可以隨時(shí)查找最新答案。
回到技術(shù)本身,RAG 說(shuō)白了就是結(jié)合了 LLM 和向量數(shù)據(jù)庫(kù)的一種知識(shí)問(wèn)答的技術(shù)體系。這個(gè)過(guò)程會(huì)圍繞著數(shù)據(jù)解析、內(nèi)容分塊、數(shù)據(jù)向量化(Embedding 模型)、結(jié)果重排(Rerank 模型)等問(wèn)題。
接下來(lái),我們逐步探索如何使用 Go 語(yǔ)言開(kāi)發(fā)一個(gè)完全本地化的 RAG 知識(shí)庫(kù)問(wèn)答系統(tǒng)。通過(guò)結(jié)合 DeepSeek 大語(yǔ)言模型和向量數(shù)據(jù)庫(kù),實(shí)現(xiàn)一個(gè)可以根據(jù)網(wǎng)頁(yè)內(nèi)容回答問(wèn)題的智能問(wèn)答系統(tǒng)。
準(zhǔn)備階段:Ollama 讓大模型在本地運(yùn)行
Ollama 是一個(gè)本地大模型部署工具,它讓你可以在自己的電腦或服務(wù)器上運(yùn)行 LLM,不用依賴外部服務(wù)。
在 https://ollama.com/ 官網(wǎng)下載并安裝 Ollama 后,就會(huì)在本地啟動(dòng)一個(gè) Ollama Server 默認(rèn)監(jiān)聽(tīng) 11434 端口,往后我們所有的交互都是與該地址通信:
$ curl http://localhost:11434
Ollama is running
我們把所有需要用到的模型都拉取到本地,語(yǔ)言模型選擇最小的 deepseek-r1:1.5b ,向量模型選擇 nomic-embed-text:latest ,至于重排模型,目前 Ollama 并未支持,我們就不進(jìn)行結(jié)果重排了:
ollama pull deepseek-r1:1.5b
ollama pull nomic-embed-text:latest
準(zhǔn)備階段:部署向量數(shù)據(jù)庫(kù)
向量數(shù)據(jù)庫(kù)有很多種選擇,有 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 應(yīng)用開(kāi)發(fā)框架 Go 版本
LangChain 是一個(gè)非常流行的基于 LLM 開(kāi)發(fā)應(yīng)用程序的 Python 框架,本文選用 LangChainGo ,即 Go 版本的 LLM 開(kāi)發(fā)框架。
現(xiàn)在正式進(jìn)入開(kāi)發(fā)環(huán)節(jié),整個(gè)系統(tǒng)的設(shè)計(jì)思路就是將非結(jié)構(gòu)化的網(wǎng)頁(yè)內(nèi)容轉(zhuǎn)換為結(jié)構(gòu)化的知識(shí),并通過(guò)向量檢索和大語(yǔ)言模型的結(jié)合,實(shí)現(xiàn)準(zhǔn)確的問(wèn)答功能。
1、網(wǎng)頁(yè)內(nèi)容解析與分塊
RAG 系統(tǒng)首先需要獲取外部知識(shí),而網(wǎng)頁(yè)是最常見(jiàn)的知識(shí)來(lái)源,我們可以使用 goquery 來(lái)解析和提取網(wǎng)頁(yè)的 HTML 內(nèi)容:
func loadAndSplitWebContent(url string) ([]schema.Document, error) {
// 發(fā)送HTTP GET請(qǐng)求獲取網(wǎng)頁(yè)內(nèi)容
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標(biāo)簽,避免抓取無(wú)關(guān)內(nèi)容
doc.Find("script,style").Remove()
// 提取body中的所有文本內(nèi)容
doc.Find("body").Each(func(i int, s *goquery.Selection) {
text := strings.TrimSpace(s.Text())
if text != "" {
content.WriteString(text)
content.WriteString("\n")
}
})
// ......
}
接著使用 textsplitter 對(duì)提取到的文本內(nèi)容進(jìn)行分塊,設(shè)置 ChunkSize 塊大?。?12)和 ChunkOverlap 重疊大小(0),并為每個(gè)塊添加元數(shù)據(jù)以后續(xù)引用時(shí)可以標(biāo)記來(lái)源:
func loadAndSplitWebContent(url string) ([]schema.Document, error) {
// ......
// 將文本分割成多個(gè)塊,設(shè)置塊大小為512字符,無(wú)重疊
splitter := textsplitter.NewRecursiveCharacter(
textsplitter.WithChunkSize(512),
textsplitter.WithChunkOverlap(0),
)
chunks, err := splitter.SplitText(content.String())
if err != nil {
return nil, err
}
// 為每個(gè)文本塊創(chuàng)建Document對(duì)象,包含元數(shù)據(jù)
documents := make([]schema.Document, 0)
for i, chunk := range chunks {
documents = append(documents, schema.Document{
PageContent: chunk,
Metadata: map[string]any{
"source": url, // 記錄文本來(lái)源URL
"chunk": fmt.Sprintf("%d", i), // 記錄塊的序號(hào)
},
})
}
return documents, nil
}
其中塊大小代表我們將內(nèi)容切分為單個(gè)塊的最大字符數(shù)或單詞數(shù),而重疊大小代表相鄰塊之間的重疊字符數(shù)或單詞數(shù),可以在調(diào)試過(guò)程不斷調(diào)整這兩個(gè)參數(shù)來(lái)提升 RAG 的表現(xiàn)。
這樣,我們就得到了原始的塊內(nèi)容。
比如,以 Kubernetes 的發(fā)行版本頁(yè)面:https://kubernetes.io/zh-cn/releases/ 為例,可以通過(guò)該函數(shù)解析并切分為 4 個(gè)塊:
2、向量化與存儲(chǔ)
由于文本無(wú)法直接比較語(yǔ)義相似度,我們需要對(duì)塊內(nèi)容進(jìn)行文本向量化后存入向量數(shù)據(jù)庫(kù),也就是使用 Ollama 的 nomic-embed-text:latest 向量模型進(jìn)行文本向量化,首先初始化該向量模型:
const (
// DefaultOllamaServer 默認(rèn)的Ollama服務(wù)器地址
DefaultOllamaServer = "http://localhost:11434"
// DefaultEmbeddingModel 用于生成文本向量的默認(rèn)模型
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("創(chuàng)建embedding模型失敗: %v", err)
}
embedder, err := embeddings.NewEmbedder(embedModel)
if err != nil {
return nil, fmt.Errorf("初始化embedding模型失敗: %v", err)
}
return embedder, nil
}
接著配置向量數(shù)據(jù)庫(kù),使用 pgvector 作為向量存儲(chǔ),并將上面初始化好的向量模型綁定到 pgvector 實(shí)例中:
const (
// DefaultPGVectorURL PostgreSQL向量數(shù)據(jù)庫(kù)的連接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("初始化向量存儲(chǔ)失敗: %v", err)
}
return &store, nil
}
然后就可以通過(guò) store.AddDocuments 方法批量地將文本向量化后存儲(chǔ)到向量數(shù)據(jù)庫(kù)中:
func addDocumentsToStore(store vectorstores.VectorStore, allDocs []schema.Document) {
// 設(shè)置批處理大小,避免一次處理太多文檔
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]
// 將文檔添加到向量存儲(chǔ)
_, err := store.AddDocuments(context.Background(), batch)
if err != nil {
fmt.Printf("\n添加文檔到向量存儲(chǔ)失敗: %v\n", err)
continue
}
processedDocs += len(batch)
progress := float64(processedDocs) / float64(totalDocs) * 100
fmt.Printf("\r正在添加文檔到向量存儲(chǔ): %.1f%% (%d/%d)", progress, processedDocs, totalDocs)
}
fmt.Printf("\n成功加載 %d 個(gè)文檔片段到向量存儲(chǔ)\n", totalDocs)
}
這一步,我們就得到了一個(gè)知識(shí)庫(kù)。如下,所有的塊內(nèi)容都會(huì)被向量化存儲(chǔ)到數(shù)據(jù)庫(kù)中:
3、大語(yǔ)言模型集成
為了可以理解并回答用戶的問(wèn)題,我們開(kāi)始集成 deepseek-r1:1.5b 模型,和向量模型的初始化類似,也需要對(duì)語(yǔ)言模型進(jìn)行初始化:
const (
// DefaultOllamaServer 默認(rèn)的Ollama服務(wù)器地址
DefaultOllamaServer = "http://localhost:11434"
// DefaultLLMModel 用于生成回答的默認(rèn)大語(yǔ)言模型
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、獲取用戶問(wèn)題并進(jìn)行語(yǔ)義檢索
用戶提問(wèn)后,首先通過(guò) store.SimilaritySearch 方法在向量數(shù)據(jù)庫(kù)中查找與用戶問(wèn)題(question)語(yǔ)義相似的文檔作為參考信息:
func handleQuestion(store vectorstores.VectorStore, llm llms.Model, question string) {
// 在向量數(shù)據(jù)庫(kù)中搜索相關(guān)文檔
// 參數(shù):最多返回5個(gè)結(jié)果,相似度閾值0.7
results, err := store.SimilaritySearch(
context.Background(),
question,
5,
vectorstores.WithScoreThreshold(0.7),
)
if err != nil {
fmt.Printf("搜索相關(guān)文檔失敗: %v\n", err)
return
}
if len(results) == 0 {
fmt.Println("\n未找到相關(guān)的參考信息,請(qǐng)換個(gè)問(wèn)題試試。")
return
}
// 顯示檢索到的文檔
displaySearchResults(results)
// 將相關(guān)文檔作為上下文提供給大語(yǔ)言模型并生成問(wèn)題的回答
generateAnswer(llm, question, results)
}
需要注意的是,該步驟也需要調(diào)用向量模型將問(wèn)題進(jìn)行向量化。如下,當(dāng)用戶提問(wèn)后,可以顯示檢索到的文檔,因?yàn)槲覀兿薅讼嗨贫乳撝禐?0.7 ,所以只檢索到 2 個(gè)分塊:
圖片
5、包裝 Prompt 結(jié)合參考信息交由大語(yǔ)言模型回答
最后我們只需要設(shè)計(jì)合適的提示詞模板,填充參考信息,調(diào)用上面初始化好的 DeepSeek 本地模型就可以回答用戶問(wèn)題了:
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{
{
// 系統(tǒng)提示,設(shè)置助手角色和行為規(guī)則
Role: llms.ChatMessageTypeSystem,
Parts: []llms.ContentPart{
llms.TextContent{
Text: fmt.Sprintf(
"你是一個(gè)專業(yè)的知識(shí)庫(kù)問(wèn)答助手。以下是基于向量相似度檢索到的相關(guān)文檔:\n\n%s\n"+
"請(qǐng)基于以上參考信息回答用戶問(wèn)題?;卮饡r(shí)請(qǐng)注意:\n"+
"1. 優(yōu)先使用相關(guān)度更高的參考信息\n"+
"2. 如果參考信息不足以完整回答問(wèn)題,請(qǐng)明確指出",
references.String(),
),
},
},
},
{
// 用戶問(wèn)題
Role: llms.ChatMessageTypeHuman,
Parts: []llms.ContentPart{
llms.TextContent{
Text: question,
},
},
},
}
fmt.Printf("生成回答中...\n\n")
_, err := llm.GenerateContent(
context.Background(),
messages,
llms.WithTemperature(0.8), // 設(shè)置溫度為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()
}
可以看到,現(xiàn)在即使是本地的 deepseek-r1:1.5b 模型,有了 RAG 的加成,也可以正確回答我們的問(wèn)題:
圖片
附上完整代碼:https://github.com/togettoyou/rag-demo
至此,我們就實(shí)現(xiàn)了一個(gè)功能完整的本地知識(shí)庫(kù)問(wèn)答系統(tǒng)。它幾乎包含了 RAG 應(yīng)用的所有核心要素:
- 文本處理:網(wǎng)頁(yè)抓取和分塊
- 向量化:文本向量化和存儲(chǔ)
- 知識(shí)檢索:相似度搜索
- 答案生成:LLM 回答生成
而在此基礎(chǔ)上,還有更多的優(yōu)化沒(méi)做:
- 添加更多數(shù)據(jù)源支持(PDF、Word 等)
- 優(yōu)化文本分塊策略
- 實(shí)現(xiàn)結(jié)果重排(Rerank 模型)
- 語(yǔ)義檢索和語(yǔ)言模型結(jié)合的增強(qiáng)處理
最后推薦一些 RAG 領(lǐng)域的開(kāi)源項(xiàng)目:Dify、FastGPT、QAnything 等,這些都集成了知識(shí)庫(kù)功能,而且基本都對(duì)接了各家的語(yǔ)言模型、向量模型、重排模型等,如果是完全本地化,也可以嘗試 Page Assist 瀏覽器插件,可以直接連接本地的 Ollama 實(shí)現(xiàn)知識(shí)庫(kù)對(duì)話。
本文轉(zhuǎn)載自微信公眾號(hào)「gopher云原生」,可以通過(guò)以下二維碼關(guān)注。轉(zhuǎn)載本文請(qǐng)聯(lián)系gopher云原生公眾號(hào)。