基于langchaingo实现知识库对接本地模型ollama的分步探索
# 前言
在前边的两篇文章中,首先介绍了当下最火热的本地大语言模型管理框架 ollama 的入门,之后又单独开了一篇介绍 rag 的核心概念及问题,谈到 rag 的问题之后,最后我得出的结论是,劝退,劝退你,也劝退我自己。
但,且慢,上篇文章已经把理论,以及流程都介绍完了,那,不亲自上手玩一玩,岂不是显得太过纸上谈兵了。
因此,这篇就是通过一个简单的示例,结合 langchaingo 来实现一下自己开发 rag 应用的整个流程。
本文项目代码地址:langchaingo-ollama-rag (opens new window)
# 正文
前文说到,rag 的核心流程大概有如下几步:
- 先将知识库内容切分
- 再把切分后的内容向量化存入向量数据库
- 用户提问之后,先将问题在向量库中进行相似性检索,找出匹配度高的答案。
- 然后把查询出来的结果,包装好 Prompt。
- 最后调用大语言模型,让大语言模型基于上一步的结果进行分析并形成最终的答案,返回给用户。
接下来我就通过代码,来按照上边的流程,做下实践。
# 前置准备
关于如何配置 ollama 的模型,这里就不再赘述了,ollama 的用法详见我第一篇文章。
这里向量数据库使用的是 qdrant
,可通过如下方式快速拉起测试环境。
$ docker pull qdrant/qdrant
$ docker run -itd --name qdrant -p 6333:6333 qdrant/qdrant
2
使用如下命令可创建一个集合:
$ curl -X PUT http://localhost:6333/collections/langchaingo-ollama-rag \
-H 'Content-Type: application/json' \
--data-raw '{
"vectors": {
"size": 768,
"distance": "Dot"
}
}'
2
3
4
5
6
7
8
使用如下命令可删除该集合:
$ curl --location --request DELETE 'http://localhost:6333/collections/langchaingo-ollama-rag'
# 先切分文档
文档我准备的内容取自个人之前发布的一篇小作:我这一生--一个显示器的故事
主要代码如下:
// TextToChunks 函数将文本文件转换为文档块
func TextToChunks(dirFile string, chunkSize, chunkOverlap int) ([]schema.Document, error) {
file, err := os.Open(dirFile)
if err != nil {
return nil, err
}
// 创建一个新的文本文档加载器
docLoaded := documentloaders.NewText(file)
// 创建一个新的递归字符文本分割器
split := textsplitter.NewRecursiveCharacter()
// 设置块大小
split.ChunkSize = chunkSize
// 设置块重叠大小
split.ChunkOverlap = chunkOverlap
// 加载并分割文档
docs, err := docLoaded.LoadAndSplit(context.Background(), split)
if err != nil {
return nil, err
}
return docs, nil
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
这里将 chunkSize
和 chunkOverlap
两个变量参数化,也是为了能够更加清晰地看到参数所代表的含义,以及对于整个流程的影响。
比如我默认情况下,执行效果如下:
$ go run main.go filetochunks
2024/04/17 23:03:32 INFO [转换文件为块儿成功,块儿数量: 40]
🗂 块儿内容==> 工程师对我虽然恩为再造,但我很长一段时间里并不感谢他,因为他既然塑我成异类,就应该把我留在他身边,那样我也会好过一些,然而或许是他马虎,把我丢在了旁的显示器中间,我的一生坎坷也正是自此开始。
几天后,我与其它四十九个同胞一起被货车拉到一个毫不起眼的地方,几个男人搬运着我们。
我转身问在路上认识的小杰:“小杰,他们这是要干什么?”
小杰转过脸:“你不知道吗?把我们装备到网吧里呀。”
🗂 块儿内容==> 小杰转过脸:“你不知道吗?把我们装备到网吧里呀。”
“网吧是干什么的?”
“网吧是让我们的爸爸赚钱的呀。怎么了?李尤?”
我不仅愤然:“他拿我们赚钱使,你还叫他爸爸,你是不是脑子进水了?”
......
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
通过上边切分之后,可以看出,单个 chunkSize
将决定单个块儿的内容大小,chunkOverlap
将决定有多少向前重复的内容。同理,当我调试时把块儿调大,那么最终块儿的数量就会减少:
$ go run main.go filetochunks -c 500
2024/04/17 23:06:30 INFO [转换文件为块儿成功,块儿数量: 13]
2
# 块儿文本向量化
主逻辑代码如下:
// storeDocs 将文档存储到向量数据库
func storeDocs(docs []schema.Document, store *qdrant.Store) error {
// 如果文档数组长度大于0
if len(docs) > 0 {
// 添加文档到存储
_, err := store.AddDocuments(context.Background(), docs)
if err != nil {
return err
}
}
return nil
}
2
3
4
5
6
7
8
9
10
11
12
执行如下命令可将切分后的文本块儿存入向量数据库:
$ go run main.go embedding
转换块儿为向量成功
2
# 获取用户输入并查询向量数据库
主逻辑代码如下:
// useRetriaver 函数使用检索器
func useRetriaver(store *qdrant.Store, prompt string, topk int) ([]schema.Document, error) {
// 设置选项向量
optionsVector := []vectorstores.Option{
vectorstores.WithScoreThreshold(0.80), // 设置分数阈值
}
// 创建检索器
retriever := vectorstores.ToRetriever(store, topk, optionsVector...)
// 搜索
docRetrieved, err := retriever.GetRelevantDocuments(context.Background(), prompt)
if err != nil {
return nil, fmt.Errorf("检索文档失败: %v", err)
}
// 返回检索到的文档
return docRetrieved, nil
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
执行如下命令可得到如下结果:
$ go run main.go retriever -t 3
请输入你的问题: 规定
🗂 根据输入的内容检索出的块儿内容==> 果,转身笑脸同那小孩儿讲:“你换一台机器吧。”接着告诉伙计:“如果有人来玩这一台,你就说坏了。我明天找那个工程师来,他一定知道怎么回事儿!”
🗂 根据输入的内容检索出的块儿内容==> 利、物质的享受,却不去思考金钱的短暂,物质的虚无;旁的显示器似也承认并接受那规划,并坦率的说:“显示器嘛,不就是用来显示的。”至于显示什么,它们也全不管。身处浑浊之中,独我欲清何其难哉!我于是沉默了。
🗂 根据输入的内容检索出的块儿内容==> 我被成功改造,在后来的岁月里,我也渐渐变得健忘,有时候有人朝我讥吼“不自主,毋宁死啊”,“去他妈的规定”说完他们便笑作一团。而我也只是静静地看着他们发狂地吼,发疯地笑,不做一言。 后来偶然听说网吧里的电脑两年换一次新,我于是又像牢犯盼出狱那样地期待着更新换代。
2
3
4
5
# 将检索到的内容,交给大语言模型处理
主逻辑代码如下:
// GetAnswer 获取答案
func GetAnswer(ctx context.Context, llm llms.Model, docRetrieved []schema.Document, prompt string) (string, error) {
// 创建一个新的聊天消息历史记录
history := memory.NewChatMessageHistory()
// 将检索到的文档添加到历史记录中
for _, doc := range docRetrieved {
history.AddAIMessage(ctx, doc.PageContent)
}
// 使用历史记录创建一个新的对话缓冲区
conversation := memory.NewConversationBuffer(memory.WithChatHistory(history))
executor := agents.NewExecutor(
agents.NewConversationalAgent(llm, nil),
nil,
agents.WithMemory(conversation),
)
// 设置链调用选项
options := []chains.ChainCallOption{
chains.WithTemperature(0.8),
}
// 运行链
res, err := chains.Run(ctx, executor, prompt, options...)
if err != nil {
return "", err
}
return res, nil
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
通过该方法拿到的结果,我这边调试下来,拿到的总是英文结果,各种调试 prompt,都没有成功,这里猜测可能是跟选用的模型有关系,暂时通过将得到的结果再次丢给大模型,做一次翻译来解决。
// Translate 将文本翻译为中文
func Translate(llm llms.Model, text string) (string, error) {
completion, err := llms.GenerateFromSinglePrompt(
context.TODO(),
llm,
"将如下这句话翻译为中文,只需要回复翻译后的内容,而不需要回复其他任何内容。需要翻译的英文内容是: \n"+text,
llms.WithTemperature(0.8))
if err != nil {
return "", err
}
return completion, nil
}
2
3
4
5
6
7
8
9
10
11
12
我之所以怀疑可能是模型的因素,是因为我在调试这段翻译功能时发现,使用 mistral 模型总是很难直接把英文转换为中文,虽然功能上他给转换了,但是仍旧还会输出一些英文,所以应该是模型的原因。
经过两个方法的加持,接下来就是见证奇迹的时刻了:
$ go run main.go getanswer
请输入你的问题: 这篇文章讲了什么
🗂 原始回答==> This article talks about the speaker's inner struggle between adhering to truth and enjoying material pleasures. The speaker expresses frustration with the fact that they must conform to societal expectations of living according to parallel rules, and questions the meaning of their own existence in this world. They also reflect on the futility of reality and contemplate suicide as a means of escape from their suffering. However, they ultimately remain silent and continue to endure the pain.
🗂 翻译后的回答==> 这篇文章讲述了讲话者内心的斗争,他在追求真相和愉快的物质待遇之间犹豫不决。他表达了对社会对我们所定义生活中必须遵守平行原则的不满,并怀疑自己在这个世界中的意义。他还思考现实的无用和死亡作为逃离他的痛苦的方式。然而,最终他保持沉默并继续忍受痛苦。
2
3
4
5
如上得到的结果虽然不算很贴切,但感觉还算是相对沾边的,这就是我上篇文章提到的,当你掌握了整个概念,也学会了整个流程的玩法,最终得到的结果,可能只有实际预期的 50%不到。
那么如何通过优化来提高这个结果所达到的预期值呢,这就要从如上步骤的每一个细节,每一个参数开始调优,且这种调优并不是一劳永逸的,还需要结合原始文档的格式,内容等情况进行不同的调整。
这也是我上篇为什么得出劝退的结论的原因,而为了印证劝退的合理性,本文应运而生。也算是给我自己一个交代,关于 rag,关于大语言模型,可先到此告一段落。