跳到主要内容

07-知识库的loader和splitter:从各种来源加载文档并分割成小块

前言​

上节我们学了 RAG,它可以解决大模型的幻觉问题。

幻觉就是大模型对于它不知道的知识,会以为自己知道,然后胡乱回答。

解决方案 RAG 就是根据用户的 prompt,去知识库查询相关文档,加到 prompt 里给到大模型作为背景知识来回答。

这种相关文档的检索,要根据 prompt 的语义来搜,所以一般要结合向量来实现:

基于嵌入模型把文档向量化,存入向量数据库,查询的时候把 prompt 向量化,根据余弦相似度,来检索最相近的向量,然后把相关文档放到 prompt 里。

上节我们跑通了这个流程:prompt -> 向量 -> 检索 -> 相关文档 -> prompt + 相关文档 -> RAG

会查询出几个相似度最高的文档放到 prompt 里,大模型基于这些来回答。

但上节我们是直接创建的 Document 对象,然后用嵌入模型存入了向量数据库:new Document MemoryVectorStore.fromDocuments

实际上知识的来源可能有很多:

一个 word 文档、一个 pdf 文件、一个 youtube 视频、一个 url、一个 x 的推文等。

这种显然就不是直接创建 Document 对象了,而是要用各种 loader 来转换:

img

经过对应的 loader 处理后,变成 Document,之后再由嵌入模型向量化后存入知识库。

知识有各种来源,所以对应的各种 loader 也很多:

现在 langchain 文档里有 180+ loader:

https://docs.langchain.com/oss/python/integrations/document_loaders

你可以把各种知识来源通过 loader 转化为文档存入知识库。

当然,有的文档可能会很大,比如一个 pdf 文件可能是一本书的大小。

这种很明显不能直接把转化后的 Document 向量化,需要先拆分文档。

也就是需要 Splitter

Splitter​

img

大的文档经过 TextSplitter 分割后,变成一个个小文档,再给到嵌入模型做向量化。

分割最简单的就是按照字符,比如换行符 \n

但并不是每一行一个 Document,而是要设置一个 chunk size,按照换行符分割好的内容加入到这个 Chunk,当达到 chunk size 后,再继续生成下个 Chunk。

这个 Chunk 也是 Document 对象,只是文档内容是分割好的一个个大小合适的块。

我们写代码来跑一边这个流程。

创建 src/loader-and-splitter.mjs

import 'dotenv/config'
import { CheerioWebBaseLoader } from '@langchain/community/document_loaders/web/cheerio'

// 查找这边文章下面的.main-area 所有 p 标签
const cheerioLoader = new CheerioWebBaseLoader(
'https://juejin.cn/post/7299346799211315212',
{
selector: '.main-area p',
},
)

const documents = await cheerioLoader.load()

console.log(documents)

我们用 CheerioWebBaseLoader 这个 loader 来加载一个网页。

安装下用到的包:pnpm install cheerio @langchain/community

各种 loader 显然是社区维护,所以在 @langchain/community 这个包下。

这里我们用 loader 加载网页,取出 .main-area 下所有 p 标签的内容。

跑一下:

[
Document {
pageContent: '回家自由职业有一个半月了,这段时间确实过的挺开心的。虽然收入不稳定,但小册和公众号广告的收入也足够生活。我算过,在小县城的家里,早饭 10 元,午饭 60元(按照偶尔改善生活,买份酸菜鱼来算),晚饭 10 元,牛奶零食等 20 元,一天是 100 元。一年就是 36500 元。加上其他的支出,一年生活费差不多 5w 元。10 年是 50w,40 年就是 200 万元。而且还有利息,也就是说,我这辈子有 200 万就能正常活到去世了。我不会结婚,因为我喜欢一个人的状态,看小说、打游戏、自己想一些事情,不用迁就另一个人的感受,可以完全按照自己的想法来生活。并不是说我现在有 200 万了,我的意思是只要在二三十年内赚到这些就够了。我在大城市打工,可能一年能赚 50 万,在家里自由职业估计一年也就 20 万,但这也足够了。而且自由职业能赚到最宝贵的东西:时间。一方面是我自己的时间,我早上可以晚点起、晚上可以晚点睡、下午困了可以睡个午觉,可以写会东西打一会游戏,不想干的时候可以休息几天。而且我有大把的时间可以来做自己想做的事情,创造自己的东西。一方面是陪家人的时间,自从长大之后,明显感觉回家时间很少了,每年和爸妈也就见几天。前段时间我爸去世,我才发觉我和他最多的回忆还是在小时候在家里住的时候。我回家这段时间,每天都陪着我妈,一起做饭、吃饭,一起看电视,一起散步,一起经历各种事情。我买了个投影仪,很好用:这段时间我们看完了《皓镧传》、《锦绣未央》、《我的前半生》等电视剧,不得不说,和家人一起看电视剧确实很快乐、很上瘾。再就是我还养了只猫,猫的寿命就十几年,彼此陪伴的时间多一点挺好的:这些时间值多少钱?没法比较。回家这段时间我可能接广告多了一点,因为接一个广告能够我好多天的生活费呢。大家不要太排斥这个,可以忽略。其实我每次发广告总感觉对不起关注我的人,因为那些广告标题都要求起那种博人眼球的,不让改,就很难受。小册的话最近在写 Nest.js 的,但不只是 nest。就像学 java,我们除了学 spring 之外,还要学 mysql、redis、mongodb、rabbitmq、kafka、elasticsearch 等中间件,还有 docker、docker compose、k8s 等容器技术。学任何别的后端语言或框架,也是这一套,Nest.js 当然也是。所以我会在 Nest.js 小册里把各种后端中间件都讲一遍,然后会做一些全栈项目。写完估计得 200 节,大概会占据我半年到一年的时间。这段时间也经历过不爽的事情:这套房子是我爸还在的时候,他看邻居在青岛买的房子一周涨几十多万,而且我也提到过可能回青岛工作,然后他就非让我妈去买一套。当时 18 年青岛限购,而即墨刚撤市划区并入青岛,不限购,于是正好赶上房价最高峰买的。然而后来并没有去住。这套房子亏了其实不止 100 万。因为银行定存利息差不多 4%,200 万就是每年 8万利息,5年就是 40万。但我也看开了,少一百万多一百万对我影响大么?并不大,我还是每天花那些钱。相比之下,我爸的去世对我的打击更大,这对我的影响远远大于 100 万。我对钱没有太大的追求,对很多别人看重的东西也没啥追求。可能有的人有了钱,有了时间会选择环游中国,环游世界,我想我不会。我就喜欢宅在家里,写写东西、看看小说、打打游戏,这样活到去世我也不会有遗憾。我所追求的事情,在我小时候可能是想学医,一直觉得像火影里的纲手那样很酷,或者像大蛇丸那样研究一些东西也很酷。但近些年了解了学医其实也是按照固定的方法来治病,可能也是同样的东西重复好多年,并不是我想的那样。人这一辈子能把一件事做好就行。也就是要找到自己一生的使命,我觉得我找到了:我想写一辈子的技术文章。据说最高级的快乐有三种来源:自律、爱、创造。写文章对我来说就很快乐,我想他就是属于创造的那种快乐。此外,我还想把我的故事写下来,我成长的故事,我和东东的故事,那些或快乐或灰暗的日子,今生我一定会把它们写下来,只是现在时机还不成熟。世界那么大,我并不想去看看。我只想安居一隅,照顾好家人,写一辈子的技术文章,也写下自己的故事。这就是我的平凡之路。',
metadata: {
source: 'https://juejin.cn/post/7233327509919547452',
title: '世界那么大,我并不想去看看回家自由职业有一个半月了,这段时间确实过的挺开心的。 虽然收入不稳定,但小册和公众号广告的收入 - 掘金'
},
id: undefined
}
]

可以看到,网页内容中选择器的部分被取出来了,放入了 Document 对象。

现在的 Document 太大了,我们使用splitter分割一下

splitter 在 @langchain/textsplitters 这个包下,安装下:pnpm install @langchain/textsplitters

import 'dotenv/config'
import { ChatOpenAI, OpenAIEmbeddings } from '@langchain/openai'
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'
import { MemoryVectorStore } from '@langchain/classic/vectorstores/memory'
import { CheerioWebBaseLoader } from '@langchain/community/document_loaders/web/cheerio'

const model = new ChatOpenAI({
temperature: 0,
model: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
})

const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDINGS_MODEL_NAME,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
})

const cheerioLoader = new CheerioWebBaseLoader(
'https://juejin.cn/post/7233327509919547452',
{
selector: '.main-area p',
},
)

const documents = await cheerioLoader.load()

console.assert(documents.length === 1)
console.log(`Total characters: ${documents[0].pageContent.length}`)

const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 500, // 每个分块的字符数
chunkOverlap: 50, // 分块之间的重叠字符数
separators: ['。', '!', '?'], // 分割符,优先使用段落分隔
})

const splitDocuments = await textSplitter.splitDocuments(documents)

console.log(`文档分割完成,共 ${splitDocuments.length} 个分块\n`)

console.log('正在创建向量存储...')
const vectorStore = await MemoryVectorStore.fromDocuments(
splitDocuments,
embeddings,
)
console.log('向量存储创建完成\n')

const retriever = vectorStore.asRetriever({ k: 2 })

const questions = ['父亲的去世对作者的人生态度产生了怎样的根本性逆转?']

// RAG 流程:对每个问题进行检索和回答
for (const question of questions) {
console.log('='.repeat(80))
console.log(`问题: ${question}`)
console.log('='.repeat(80))

// 使用 retriever 获取相关文档
const retrievedDocs = await retriever.invoke(question)

// 使用 similaritySearchWithScore 获取相似度评分
const scoredResults = await vectorStore.similaritySearchWithScore(question, 2)

// 打印检索到的文档和相似度评分
console.log('\n【检索到的文档及相似度评分】')
retrievedDocs.forEach((doc, i) => {
// 找到对应的评分
const scoredResult = scoredResults.find(
([scoredDoc]) => scoredDoc.pageContent === doc.pageContent,
)
const score = scoredResult ? scoredResult[1] : null
const similarity = score !== null ? (1 - score).toFixed(4) : 'N/A'

console.log(`\n[文档 ${i + 1}] 相似度: ${similarity}`)
console.log(`内容: ${doc.pageContent}`)
if (doc.metadata && Object.keys(doc.metadata).length > 0) {
console.log(`元数据:`, doc.metadata)
}
})

// 构建 prompt
const context = retrievedDocs
.map((doc, i) => `[片段${i + 1}]\n${doc.pageContent}`)
.join('\n\n━━━━━\n\n')

const prompt = `你是一个文章辅助阅读助手,根据文章内容来解答:

文章内容:
${context}

问题: ${question}

你的回答:`

console.log('\n【AI 回答】')
const response = await model.invoke(prompt)
console.log(response.content)
console.log('\n')
}

我们指定了 chunkSize 是 400 个字符,然后前后重复 50 个字符。

分割符是优先 。 其次 !?

跑一下:

mac@macdeMacBook-Air-3 aiagent % pnpm run loader-and-splitter

> ai@1.0.0 loader-and-splitter /Users/mac/jiuci/github/aiagent
> node src/7/loader-and-splitter.mjs

Total characters: 1732
文档分割完成,共 4 个分块

正在创建向量存储...
向量存储创建完成

================================================================================
问题: 父亲的去世对作者的人生态度产生了怎样的根本性逆转?
================================================================================

【检索到的文档及相似度评分】

[文档 1] 相似度: 0.4021
内容: 。但我也看开了,少一百万多一百万对我影响大么?并不大,我还是每天花那些钱。相比之下,我爸的去世对我的打击更大,这对我的影响远远大于 100 万。我对钱没有太大的追求,对很多别人看重的东西也没啥追求。可能有的人有了钱,有了时间会选择环游中国,环游世界,我想我不会。我就喜欢宅在家里,写写东西、看看小说、打打游戏,这样活到去世我也不会有遗憾。我所追求的事情,在我小时候可能是想学医,一直觉得像火影里的纲手那样很酷,或者像大蛇丸那样研究一些东西也很酷。但近些年了解了学医其实也是按照固定的方法来治病,可能也是同样的东西重复好多年,并不是我想的那样。人这一辈子能把一件事做好就行。也就是要找到自己一生的使命,我觉得我找到了:我想写一辈子的技术文章。据说最高级的快乐有三种来源:自律、爱、创造。写文章对我来说就很快乐,我想他就是属于创造的那种快乐。此外,我还想把我的故事写下来,我成长的故事,我和东东的故事,那些或快乐或灰暗的日子,今生我一定会把它们写下来,只是现在时机还不成熟。世界那么大,我并不想去看看。我只想安居一隅,照顾好家人,写一辈子的技术文章,也写下自己的故事。这就是我的平凡之路。
元数据: {
source: 'https://juejin.cn/post/7233327509919547452',
title: '世界那么大,我并不想去看看回家自由职业有一个半月了,这段时间确实过的挺开心的。 虽然收入不稳定,但小册和公众号广告的收入 - 掘金',
loc: { lines: { from: 1, to: 1 } }
}

[文档 2] 相似度: 0.5217
内容: 。而且我有大把的时间可以来做自己想做的事情,创造自己的东西。一方面是陪家人的时间,自从长大之后,明显感觉回家时间很少了,每年和爸妈也就见几天。前段时间我爸去世,我才发觉我和他最多的回忆还是在小时候在家里住的时候。我回家这段时间,每天都陪着我妈,一起做饭、吃饭,一起看电视,一起散步,一起经历各种事情。我买了个投影仪,很好用:这段时间我们看完了《皓镧传》、《锦绣未央》、《我的前半生》等电视剧,不得不说,和家人一起看电视剧确实很快乐、很上瘾。再就是我还养了只猫,猫的寿命就十几年,彼此陪伴的时间多一点挺好的:这些时间值多少钱?没法比较。回家这段时间我可能接广告多了一点,因为接一个广告能够我好多天的生活费呢。大家不要太排斥这个,可以忽略。其实我每次发广告总感觉对不起关注我的人,因为那些广告标题都要求起那种博人眼球的,不让改,就很难受。小册的话最近在写 Nest.js 的,但不只是 nest
元数据: {
source: 'https://juejin.cn/post/7233327509919547452',
title: '世界那么大,我并不想去看看回家自由职业有一个半月了,这段时间确实过的挺开心的。 虽然收入不稳定,但小册和公众号广告的收入 - 掘金',
loc: { lines: { from: 1, to: 1 } }
}

【AI 回答】
父亲的去世对作者的人生态度产生了根本性的逆转。原本作者对金钱和物质生活有着较高的追求,甚至梦想着环游中国和世界。然而,父亲的离世使他对生活的看法发生了深刻的变化。他认为相比于物质财富,家庭和亲情的重要性远超一切。因此,他决定放弃对物质的追求,选择更加简单和充实的生活方式,比如宅在家里写技术文章、与家人共度时光等。这种转变体现了他对生命意义的重新思考和对家庭责任的重视。

整体流程和上节一样:用嵌入模型把文档存入向量数据库,先检索和用户的问题相似度最高的 2 个文档,把它加入 prompt,然后调用大模型基于文档回答。

可以看到,loader 加载了文档,用 splitter 分成了 4 个分块(chunk)。

回答的时候检索了相似度最高的 2 个文档块,基于这个做了回答。

总结​

这节我们学了 loader 和 splitter。

loader 可以从各种地方加载内容作为 Document,比如 word、pdf、网页、youtube、x 的推文等等。

现在有 180+ 的 loader,社区维护,所以是在 @langchain/community 这个包。

加载后的 Document 可能会很大,需要分割成一个个小的文档,所以需要 Splitter。

splitter 在 @langchain/text-splitters 这个包。

我们写了一个读取网页里的文章内容作为文档,分割后放入知识库的 RAG 案例。

这节只要理解这俩概念就行,具体 loader 和 splitter 有很多类型,下节我们详细过一遍

完整代码解释​

import 'dotenv/config'
import { ChatOpenAI, OpenAIEmbeddings } from '@langchain/openai'
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'
import { MemoryVectorStore } from '@langchain/classic/vectorstores/memory'
import { CheerioWebBaseLoader } from '@langchain/community/document_loaders/web/cheerio'

const model = new ChatOpenAI({
temperature: 0,
model: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
})

const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDINGS_MODEL_NAME,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
})

const cheerioLoader = new CheerioWebBaseLoader(
'https://juejin.cn/post/7233327509919547452',
{
selector: '.main-area p',
},
)

// 等待文档加载
const documents = await cheerioLoader.load()

// 如果断言为 false,则将一个错误消息写入控制台。如果断言是 true,没有任何反应
console.assert(documents.length === 1)
console.log(`Total characters: ${documents[0].pageContent.length}`)

const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 500, // 每个分块的字符数
chunkOverlap: 50, // 分块之间的重叠字符数
separators: ['。', '!', '?'], // 分割符,优先使用段落分隔
})

const splitDocuments = await textSplitter.splitDocuments(documents)

console.log(`文档分割完成,共 ${splitDocuments.length} 个分块\n`)

console.log('正在创建向量存储...')
const vectorStore = await MemoryVectorStore.fromDocuments(
splitDocuments,
embeddings,
)
console.log('向量存储创建完成\n')

// retriever 每次检索时,最多返回 2 个 Document
const retriever = vectorStore.asRetriever({ k: 2 })

const questions = ['父亲的去世对作者的人生态度产生了怎样的根本性逆转?']

// RAG 流程:对每个问题进行检索和回答
for (const question of questions) {
console.log('='.repeat(80))
console.log(`问题: ${question}`)
console.log('='.repeat(80))

// 使用 retriever 获取相关文档
const retrievedDocs = await retriever.invoke(question)

// 使用 similaritySearchWithScore 获取相似度评分
// 返回“最相似的前 2 个文档”,并附带相似度评分
const scoredResults = await vectorStore.similaritySearchWithScore(question, 2)

// 打印检索到的文档和相似度评分
console.log('\n【检索到的文档及相似度评分】')
retrievedDocs.forEach((doc, i) => {
// 找到对应的评分
const scoredResult = scoredResults.find(
([scoredDoc]) => scoredDoc.pageContent === doc.pageContent,
)
const score = scoredResult ? scoredResult[1] : null
const similarity = score !== null ? (1 - score).toFixed(4) : 'N/A'

console.log(`\n[文档 ${i + 1}] 相似度: ${similarity}`)
console.log(`内容: ${doc.pageContent}`)
if (doc.metadata && Object.keys(doc.metadata).length > 0) {
console.log(`元数据:`, doc.metadata)
}
})

// 构建 prompt
const context = retrievedDocs
.map((doc, i) => `[片段${i + 1}]\n${doc.pageContent}`)
.join('\n\n━━━━━\n\n')

const prompt = `你是一个文章辅助阅读助手,根据文章内容来解答:

文章内容:
${context}

问题: ${question}

你的回答:`

console.log('\n【AI 回答】')
const response = await model.invoke(prompt)
console.log(response.content)
console.log('\n')
}