学习地址:
学习小册
Embedding多数据源加载
RAG
的本质是给chat bot外挂数据源,数据源的形式多种多样,有的是文件/数据库/网络数据/代码等情况。langchain提供一系列开箱的loader来帮助开发者处理不同数据源的数据。
Document对象
Document 对象你可以理解成 langchain 对所有类型的数据的一个统一抽象
。
包括
pageContent
文本内容,即文档对象对应的文本数据- metadata 元数据,文本数据对应的元数据,例如 原始文档的标题、页数等信息,可以用于后面
Retriver
基于此进行筛选
ts
interface Document {
pageContent: string;
metadata: Record<string, any>;
}
Document对象一般由各种loader自动创建,也手动创建
ts
import { Document } from "langchain/document";
const test = new Document({ pageContent: "test text", metadata: { source: "ABC Title" } });
Loader
作用类似于webpack的loader,langchain内置很多loader来支持加载不同的数据。
TextLoader
处理文本数据。
ts
import { TextLoader } from "langchain/document_loaders/fs/text";
const loader = new TextLoader("data/qiu.txt");
const docs = await loader.load();
结果
ts
[
Document {
pageContent: "我是数据\n\n试试TextLoader作用",
metadata: { source: "data/1.txt" }
}
]
pageContent是文本的原始内容,metadata是这个对象相关的一些元数据。
PDFLoader
ts
import { Document } from "langchain/document";
import { TextLoader } from "langchain/document_loaders/fs/text";
import * as pdfParse from "pdf-parse";
import { PDFLoader } from "langchain/document_loaders/fs/pdf";
const loader = new PDFLoader("data/js.pdf", { splitPages: false });
const pdfs = await loader.load()
![](https://i-blog.csdnimg.cn/direct/c46f6e19471349b587090f63c0feb291.png)
DirectoryLoader
加载一个文件夹下多种格式的文件时,就可以使用 DirectoryLoader
ts
import { DirectoryLoader } from "langchain/document_loaders/fs/directory";
const loader = new DirectoryLoader(
"./data",
{
".pdf": (path) => new PDFLoader(path, { splitPages: false }),
".txt": (path) => new TextLoader(path),
}
);
const docs = await loader.load();
Github loader
ts
import { GithubRepoLoader } from "langchain/document_loaders/web/github";
import ignore from "ignore";
const loader = new GithubRepoLoader(
"https://github.com/RealKai42/qwerty-learner",
{
branch: "master",
recursive: false,
unknown: "warn",
ignorePaths: ["*.md", "yarn.lock", "*.json"],
}
);
const docs = await loader.load();
console.log(docs);
![](https://i-blog.csdnimg.cn/direct/6e960237f9e140169eb888e38c06167e.png)
GithubRepoLoader 会在爬取的文件的时候自动记录下相关的 metadata,方便后续使用
WebLoader
对于静态信息,一般使用Cheerio用来提取和处理html内容,无法运行其中js,对大部分场景还是够用的。
ts
import "cheerio";
import { CheerioWebBaseLoader } from "langchain/document_loaders/web/cheerio";
const loader = new CheerioWebBaseLoader(
"https://blog.csdn.net/lin_fightin/article/details/144394319"
);
const docs = await loader.load();
console.log(docs[0].pageContent);
![](https://i-blog.csdnimg.cn/direct/1ae0ea70f24443e3bd74505625fa55a1.png)
Search API
这是给 chatbot 接入网络支持最重要的 API
ts
import { SerpAPILoader } from "langchain/document_loaders/web/serpapi";
import { load } from "dotenv";
const env = await load();
const apiKey = env["SERP_KEY"]
const question = "什么 github copliot"
const loader = new SerpAPILoader({ q: question, apiKey });
const docs = await loader.load();
console.log(docs);
![](https://i-blog.csdnimg.cn/direct/e636eb247d8d45309d0c488dfecfec81.png)
不止返回google的搜索结果,还会爬区每个结果的汇总和信息放在pageContent。
提供了开箱即用的接入 google 搜索和爬取内容的能力,也就是给 chatbot 提供了访问互联网的能力。
大规模数据预处理
受限于常见 llm(大模型) 的上下文大小,例如 gpt3.5t 是 16k、gpt4t 是 128k,我们并不能把完整的数据整个塞到对话的上下文中
即使数据源接近llm的上下文窗口,llm在读取数据的时候也容易出现问题,忽略部分细节。所以我们需要对加载进来的数据进程拆分,切分成较小的块,然后根据对话内容,将最关联的数据塞到llm的上下文中(这快也是llm做的),强化llm输出的专注性和质量
分割的目的就是将文本切割为多个文档块,每个文档块内部语义相关,并能跟其他文档块保持独立性,切个文档的质量直接影响llm的回答质量。
TextSplitter
工作原理:将文档切割成句,然后根据用户限制的文档块大小,进行组装,然后用户还会配置重叠大小,比如,AABBCCDDEEFFGG,文档块大小限制6,重叠大小限制2,就是AABBCC,CCDDEE,EEFFGG。
重叠部分是因为,我们希望切的块独立,但自然语言的特殊性,为了减少切分时造成语义中断,所以添加一些重叠的部分,来减少语意中断的影响
在理解切分的逻辑后,每次切分要注意:
- 1目标文档类型是什么?
- 2如何衡量切分后文档块的大小?
langchan提供的切分工具有:
RecursiveCharacterTextSplitter
最常用的切分工具,根据内置的一些字符对原始文本进行递归的切分,来保持相关的文本片段相邻,保持切分结果内部的语意相关性。
默认根据["\n\n", "\n", " ", ""]
切分,也就是切分成段落,然后切分成句子,再切成单词,然后根据定义的每个chunk大小,组装。
影响切分质量也就是上述说的:
- chunkSize 切分块大小,太大,可能冗余信息过多,并且作为上下文传给llm的时候占据太多token,太小可能信息包含不足。
- chunkOverlap 块重叠部分。自然语言中内容是连续的,分割时配置chunkOverlap可以保证不会在奇怪的地方被分割,但太大会导致块重叠信息太多,冗余,太小可能会导致在奇怪的地方进行切割。
比如切分《孔乙己》:
效果:
txt
splitDocs=== [
Document {
pageContent: "鲁镇的酒店的格局,是和别处不同的:都是当街一个曲尺形的大柜台,柜里面预备着热水,可以随时温酒。做工的人,傍午傍晚散了工,每每花四文铜钱,买一碗酒,------这是二十多年前的事,现在每碗要涨到十文,------靠柜外",
metadata: { source: "data/kong.txt", loc: { lines: { from: 1, to: 1 } } }
},
Document {
pageContent: "年前的事,现在每碗要涨到十文,------靠柜外站着,热热的喝了休息;倘肯多花一文,便可以买一碟盐煮笋,或者茴香豆,做下酒物了,如果出到十几文,那就能买一样荤菜,但这些顾客,多是短衣帮,大抵没有这样阔绰。只有",
metadata: { source: "data/kong.txt", loc: { lines: { from: 1, to: 1 } } }
},
Document {
pageContent: "顾客,多是短衣帮,大抵没有这样阔绰。只有穿长衫的,才踱进店面隔壁的房子里,要酒要菜,慢慢地坐喝。",
metadata: { source: "data/kong.txt", loc: { lines: { from: 1, to: 1 } } }
},
Document {
pageContent: "我从十二岁起,便在镇口的咸亨酒店里当伙计,掌柜说,我样子太傻,怕侍候不了长衫主顾,就在外面做点事罢。外面的短衣主顾,虽然容易说话,但唠唠叨叨缠夹不清的也很不少。他们往往要亲眼看着黄酒从坛子里舀出,看",
metadata: { source: "data/kong.txt", loc: { lines: { from: 3, to: 3 } } }
},
Document {
pageContent: "。他们往往要亲眼看着黄酒从坛子里舀出,看过壶子底里有水没有,又亲看将壶子放在热水里,然后放心:在这严重监督下,羼水也很为难。所以过了几天,掌柜又说我干不了这事。幸亏荐头的情面大,辞退不得,便改为专管温",
metadata: { source: "data/kong.txt", loc: { lines: { from: 3, to: 3 } } }
},
Document {
pageContent: "幸亏荐头的情面大,辞退不得,便改为专管温酒的一种无聊职务了。",
metadata: { source: "data/kong.txt", loc: { lines: { from: 3, to: 3 } } }
},
...
配置了块大小是100,然后重叠部分为20,可以看到重叠了。
因为原始数据中,一行就是一段,中间用空行分割,所有前几个 Document 的 meta 都是 lines: { from: 1, to: 1 }。
切分函数最核心的两个参数是 chunkSize 和 chunkOverlap,就具体实践来说,先设定为默认的 1000 和 200,然后使用 ChunkViz 去检查部分结果是否符合预期,然后根据人类对语意的理解去调整到一个合适的值。然后,在整个 chain 完成后,根据最终结果的质量和生成过程中的 log 去查找是哪部分影响了最终的结果质量,再去决定是否调整这两个参数。
Code切割![](https://i-blog.csdnimg.cn/direct/64f3941e386b4cfc8bfce54baf7ea118.png)
这是Langchan支持的语言切割。
对 js 的分割本质上就是将 js 中常见的切分代码的特定字符传给 RecursiveCharacterTextSplitter,然后还是根据 Recursive 的逻辑进行切分,跟对正常 text 切分的逻辑是一样的。
Token切分
这个是使用场景不多,因为切分的时候不是根据各种符号(例如标点)等进行切分来尝试保持语义性,就是根据 token 的数量进行切分,仅适合对 token 比较敏感的场景,或者与其他切分函数组合使用。
小结
受限于llm上下文token大小限制,数据源需要切分,然后将最匹配的文档块作为上下文传给llm,这时就要用到切分函数: 方便将较长的文档切分成长度合适、语意相关的模块
,其中最重要的就是理解 chunkSize
和 chunkOverlap
这两个概念
构建向量数据库
前面我们对数据源进行了加载和切分,现在需要将切分后的数据集,转成embedding对象(向量),然后存在vecotr db(向量数据库)中
,将用户的提问转成embedding,然后在vector db中检索,找到与用户提问相关的数据集,再交给llm。
延续上面讲鲁迅的小说切块
然后就可以创建embedding对象,向量化的时候,模型关注的就是 pageContent,不关注metadata,
ts
const embeddings = new OllamaEmbeddings({
model: "deepseek-r1", // default value
baseUrl: "http://localhost:11434", // default value
requestOptions: {
useMMap: true, // use_mmap 1
numThread: 10, // num_thread 10
numGpu: 1, // num_gpu 1
},
});
const res = await embeddings.embedQuery(splitDocs[0].pageContent)
结果就是
本质就是用向量表示一段文本。
创建 MemoryVectorStore
故名思义,在内存的vector db,在内存中构建的向量数据库。
ts
import { MemoryVectorStore } from "langchain/vectorstores/memory";
// 创建一个内存向量数据库
const vectorstore = new MemoryVectorStore(embeddings);
// 将分割后的文档添加到向量数据库
await vectorstore.addDocuments(splitDocs);
// 将向量数据库转换为检索器。2是检索器的最大返回结果数(返回相似度最高的两个文本内容)
const retriever = vectorstore.asRetriever(2)
// 使用检索器检索
const res = await retriever.invoke("茴香豆是做什么用的")
console.log(res);
如上,创建vecotr stire,然后将切割后的文档添加进去,会转为向量存储,然后创建retriever,结果是
![](https://i-blog.csdnimg.cn/direct/ab3fc46391ed467caa0df0509596400e.png)
构建本地vector db
因为对数据生成 embedding 需要一定的花费,所以我们希望把 embedding 的结果持久化,这样可以持续复用。
ts
const run = async () => {
const loader = new TextLoader("../data/kong.txt");
const docs = await loader.load();
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 100,
chunkOverlap: 20,
});
const splitDocs = await splitter.splitDocuments(docs);
const embeddings = new OpenAIEmbeddings();
const vectorStore = await FaissStore.fromDocuments(splitDocs, embeddings);
const directory = "../db/kongyiji";
await vectorStore.save(directory);
};
run();
如上,还是加载数据源,然后切分,然后创建vectorStore,将切分的文档传入,转成向量后存储在文件夹下面。结果
![](https://i-blog.csdnimg.cn/direct/1f13378e79424e239dd6babf884b46ab.png)
可以看到已经存储了。接着加载我们的持久化vector db
结果
可以看到已经读取成功了,结果跟memoryVectorStore一样。
小结
Vector store 是 RAG 和 LLM App 非常核心的内容,通过对retriever进行交互,会发现不同的提问,返回的结果是不同的,合适的关键词和关键字就能够让 retriever 提取出跟你提问相关性最高的内容。跟llm交互也是类似,在 prompt 中使用合适的关键词,能让 llm "召回" 到跟你提问最相关的资。
retriever常用优化方式
embedding和retriever是RAG的非常重要的环节。
对于embedding来说,直接用最流行的embedding和vector store对大部分应用是足够的。
而对应用侧有比较大优化空间的就是retriever。
比如上述的例子,如果用户提问的关键词较少,或者恰好与原文中的关键词不一致,就容易导致retriever返回的文档质量不高,影响llm的输出效果。
MultiQueryRetriever
MultiQueryRetriever(顾名思义,多个query的retirever)思路,跟其他解决llm缺陷的思路是一致的,加入更多的llm。MultiQueryRetriever是其中一种比较简单的办法,先用LLm将用户的问题转成多个不同的写法但意思一样的提问,再交给LLM,从不同的角度来表达问题。`
如上,先加载向量数据库,然后创建retriever,这里需要用到MultiQueryRetriever创建,几个参数非常重要。
- llm 传入的llm模型,因为retriever需要使用llm进行改写。
- retriever,vector store的retriever,因为会使用这个retriever去获取向量数据库的数据,这里asRetriever(3)标识对一个query,每次会检索返回三条数据。
- queryCount,对每个提问,会用llm改写成三条不同写法但意义相同的提问。
- vebose: 详细的debug信息。
运行之后
llm会生成三个query,其中prompts如上,是LLM生成的。
输出的结果就是
因为原始输入茴香豆是做什么用的,比较有歧义,对用户来说,他可能想了解的答案就是"茴香豆是下酒用的",但因为自然语言的特点,这是有歧义的,MultiQueryRetriever的意义就是,找出这句话所有可能的意义,然后去检索,避免因为歧义导致检索错误
。
然后MultiQueryRetriever会对每一个提问,调用vector store的retirever,一个提问返回三个,也就是返回9个文档片段,然后其中去重在返回。
这可能是最简单的retriever优化方式,在后面的优化中,对"解决 llm 缺陷的方式就是引入更多 llm"会有更深的理解。
Document Compressor
retriever的另一个问题,如果设置的k(每次检索返回的文档数量)较小,可能相似度较高的并不是准确答案,就跟搜索引擎也是根据相似度来返回的,但排名较高不一定是最佳答案。k设置过大就会浪费lm上下文窗口
切割后的文档并不全是有参考价值的内容,有很多跟用户无关的提问,如何提取这部分有价值的数据作为retriever返回的文档。
核心两个参数
- baseCompressor,也就是在压缩上下文时会调用 chain,这里接收任何符合 Runnable interface 的对象,也就是你可以自己实现一个 chain 作为 compressor
- baseRetriever,在检索数据时用到的 retriever
看看步骤:
当我们调用retriever.invole("茴香豆是做什么用的"),会调用传入的baseRetriever
根据query进行检索,因为设置了k=2,所以返回两个document对象,
然后调用传入的baseCompressor根据用户的问题和document对象的内容,进行核心信息的提心,打印一下langchain prompt
因为用的是本地模型,所以不太准确。
openai 返回的:
![](https://i-blog.csdnimg.cn/direct/ff5b4aece06b4998a8fbc778ac4d2089.png)
核心 prompt,就是根据用户提问从文档中提取出最相关的部分,并且强调不要让 LLM 去改动提取出来的部分,来避免 LLM 发挥自己的幻想改动原文。
最终返回
没有内容,因为对于那两条document,llm返回的事NO_OUPUT,也就是LLM认为没有跟上下文相关的信息。
经过ContextualCompressionRetriever的处理,减少了最终输出的文档内容长度,给上下文留下了更多的空间。
ScoreThresholdRetriever
上述我们的k设置了2,因为原文中茴香豆可能就出现了两次,所以是合理的,但是如果是其他问题,比如孔乙己平常在做什么,设置2就不满足了,所以我们需要定义另一种返回参考文档数量的方式,而不是暴力的定义数量。
![](https://i-blog.csdnimg.cn/direct/d9e73776ccc347238d8c199148734381.png)
我们之前设置的切割文档的chunkSize/chunkOverlap也会影响参数的设置。
- minSimilarityScore, 定义了最小的相似度阈值,也就是文档向量和 query 向量相似度达到多少,我们就认为是可以被返回的。这个要根据你的文档类型设置,一般是 0.8 左右,可以避免返回大量的文档导致消耗过多的 token 。
- maxK,一次最多返回多少条数据,这个主要是为了避免返回太多的文档造成 token 过度的消耗。
- kIncrement,定义了算法的布厂,你可以理解成 for 循环中的 i+k 中的 k。其逻辑是每次多获取 kIncrement 个文档,然后看这 kIncrement 个文档的相似度是否满足要求,满足则返回。
因为设置的相似度较低,所以返回了最大值(naxK位5)的数据。
这种我们就不需要设置k,而是通过设置相似度来返回。
小结
Retrieer在RAG非常重要,也有足够的优化空间,上述是几种常见的优化方式,其中引入llm进行优化的效果是最好的,但话费和耗时也比较久。可以使用廉价的llm来提高速度节约花费。解决LLM的缺陷就是引入更多的LLM