大家好,我是feng,欢迎关注公众号和我一起探索。如果文章对你有所启发,请为我点赞、转发!
前后大概花了7个小时才艰难完成这5000+字的博文,虽是AI应用开发的基础逻辑,但却涉及到开发一个AI应用所需要掌握的多个知识点。个人觉着可以此文为基础,仔细阅读LangChain官网的相关内容,一定会让你有所收获。
LangChain最重要的功能之一,就是它能够将AI应用程序连接到外部的数据源并进行检索处理。
一、重点回顾
博文《AI探索实践9 - Typescript开发AI应用1:不用Python!用前端也能开发一个本地运行的“ChatGPT”!》
介绍了前端程序直连本地大模型,实现“ChatGPT”的应用效果。
博文《AI探索实践10 - Typescript开发AI应用2:前端实现本地模型流式响应输出》介绍了如何实现大模型响应在前端页面的打字机效果。
博文《AI探索实践11 - Typescript开发AI应用3:Prompt Template (提示语模版) 功能》介绍了提示语模板的作用和使用方法。
博文《AI探索实践12 - Typescript开发AI应用4:大模型响应数据的格式化输出》介绍了如何控制大模型输出的内容格式,比如输出json格式。
二、大模型的幻觉是如何产生的
和之前的示例一样,我们首先创建一个模型,并设置一个提示语模板。
import { ChatOllama } from '@langchain/community/chat_models/ollama';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { StringOutputParser } from '@langchain/core/output_parsers';
// 和之前的示例一样,我们首先创建一个模型
const model = new ChatOllama({
baseUrl: 'http://localhost:11434', // Default value
model: 'qwen:4b',
temperature: 0.7,
});
// 创建一个提示语模板对象
const prompt = ChatPromptTemplate.fromTemplate(`
回答用户的问题。 {input}。
`);
// 使用字符串格式化输出
const outputParser = new StringOutputParser();
// 创建一个链对象
const chain = prompt.pipe(model);
// 输入参数,调用链
const response = await chain.invoke({
input: '什么是 LCEL?',
});
// 打印响应
console.log(response);
上面的代码,我们应该很熟悉了。调用链时,提了一个简单的问题:什么是 LCEL?,看看大模型是怎么回答的:
图1
我们知道:LCEL是指 LangChain Expression Language。是LangChain技术框架中的技术术语。可通过链接:LangChain Expression Language (LCEL) | 🦜️🔗 Langchain 进行详细了解。
图1中,大模型的回答显然不是我们需要的答案,而是在“一本正经的胡说八道”,即通常所说的“幻觉”。
当我们询问大模型一些问题时,比如上面的问题或者 “今天天气如何?”,大模型只能根据其训练的数据来回答问题。而“什么是LCEL?”、“今天天气如何?”这样的问题,是当前模型(qwen:4b)预训练以后才创建的,大模型并没有存储这些信息,不会了解这个技术术语或最新的天气情况。根据大模型的预测下一个字符的特性,就产生了“幻觉”答案。
LangChain通过提供一组工具解决了这个问题。这些工具允许我们首先从外部数据源(比如文档、外部系统/网站的API接口、数据库等)获取相关的信息,然后将此信息作为上下文注入到提示语中,大模型将能够使用这个上下文来辅助推理,进而提供准确的答案。
三、使用检索链 Retrieval Chain
为了使大模型回答我们知识库中的问题,这个问题可以是任何信息,比如来自你的企业信息系统、第三方系统API、数据库或者来自某个网站的一些数据。为了实现这个目的,我们需要使用Retrieval功能。
Retrieval 检索,是AI应用开发人员需要理解的重要概念之一。通过Retrieval,我们可以从几乎任何来源获取数据并将其输入到链中。
让我们看看,如何从LangChain的官网的LCEL网页中提取信息,然后将这些信息输入到链中,以便让模型更准确的回答问题。
3.1 固定上下文
先尝试一个固定上下文的例子,这个例子可以让我们理解一个大模型基本概念:你越是提供了足够的上下文,大模型就越能给出更理想的答案。
稍微更改一下提示语模板,在模板中我们直接注入上下文内容—— 从LangChain官网将 LCEL 的文字复制进去:
const prompt = ChatPromptTemplate.fromTemplate(`
回答用户的问题。
Context:LangChain Expression Language or LCEL is a declarative way to easily compose chains together. Any chain constructed this way will automatically have full sync, async, and streaming support.
问题:{input}。
`);
运行链:
图2
这一次,我们确实得到了正确的答案。
通常,我们不会采用上面这种硬编码的方式。LangChain提供了一种优雅的方式:支持加载文档以将文档信息传递到上下文。我们将提示语模板中的上下文改为占位符的变量:
const prompt = ChatPromptTemplate.fromTemplate(`
回答用户的问题。
Context:{context}
问题:{input}。
`);
3.2 加载文档
在LangChain中,document是一个包含文本内容,以及可选的元数据的对象。元数据包含网站URL、PDF名称等。下面看一下如何使用 document 。
导入相关的包,创建一个Document对象,为了只是展示它是如何工作的,我们还是从简单的固定文本开始:
import { Document } from '@langchain/core/documents';
// 创建一个文档对象
const documnetA = new Document({
// 参数可以传递一个页面内容或者元数据
pageContent:
'LangChain Expression Language or LCEL is a declarative way to
easily compose chains together. Any chain constructed this way
will automatically have full sync, async, and streaming support.',
});
为了把文档对象传递到链中,我们需要使用一种特殊类型的链: createStuffDocumentChain,这个链允许我们传入一个文档列表,它会自动重新格式化这些文档并将其文本注入到提示语模板中的上下文中。我们创建一个该链对象:
import { createStuffDocumentsChain } from 'langchain/chains/combine_documents';
// 创建一个链
//const chain = prompt.pipe(model).pipe(outputParser);
// 创建一个 createStuffDocumentChain 链
const chain = await createStuffDocumentsChain({
llm: model,
prompt,
outputParser,
});
createStuffDocumentChain对象主要接收2个对象:
- llm:大模型对象
- prompt:提示语模板
使用createStuffDocumentChain链,我们在调用时,就可以传入文档对象:
const response = await chain.invoke({
input: '什么是 LCEL?',
context: [documnetA],
});
console.log(response);
执行链:
当我们调用链时,这个链会从 documentA 对象中获取内容,即文档对象中定义的pageContent的值。然后会将该值注入到提示语模板中的变量 {context} ,并交给大模型推理。
我们再来添加一个文档对象以增加新的上下文内容,并且提出新的问题:
// 创建另一个文档对象
const documentB = new Document({
pageContent: '我的密码是: hello world',
});
const response = await chain.invoke({
//input: '什么是 LCEL?',
input: '密码是多少',
context: [documnetA, documentB],
});
console.log(response);
执行输出:
通过加载外部文档对象,可以让提示语中上下文得到更多的补充,使得大模型可以回答它原本并不知道的问题。
在LangChain的网站上,可以看到其支持的文档加载器列表:
Document loaders | 🦜️🔗 Langchain
3.3 抓取网页
很显然,我们不会情愿每次都手动创建这些文档对象,而是希望一些自动化的方式。比如将这些信息从互联网上的某个网站上抓取(比如 LCEL 的解释),自动的传递给链。
LangChain可以使用名为 Cheerio 的工具来抓取网页内容。为使用Cheerio库,我们需要安装它: yarn add cheerio
接下来,我们创建一个web资源加载器,并加载url页面内容: 以 LCEL 官网地址为例
const loader = new CheerioWebBaseLoader(
'https://js.langchain.com/docs/expression_language/',
);
const docs = await loader.load();
load() 方法实现了将网页内容抓取并转换为对象,其返回的是Promise<Document[]>,代码中有关于Document的代码可以删除。修改调用方法,将文档对象数组,替换为docs对象:
const response = await chain.invoke({
input: '什么是 LCEL?',
//input: '密码是多少',
context: docs,
});
console.log(response);
我们来看输出:
可以看到,程序实现了从目标url中抓取的网页内容作为上下文,供大模型推理,从而得到正确的答案。
3.4 文档分割
我们会发现上面的代码实际上存在一个问题,就是我们需要的其实只是网页的一部分内容,而load方法抓取的却是整个网页的内容,其实我们不需要那么多内容。如果我们打印抓取的网页内容和长度:
会发现抓取的整个的网页长度是1485 ,这代表了当前的代码逻辑是将这个网页全部内容的1485个字符都发送给大模型。
LCEL 介绍这样的页面非常简单就有这么多字符,如果抓取的页面非常复杂则代表着发送给大模型内容的长度则会更长。
问题是,目前所有的大模型对于提示语的上下文都有长度限制,如果传递整个网页的内容,很容易就超出大模型的上下文的长度限制。
解决方案是获取网页内容后,将内容分割成更小的块,找到且只传递给大模型和问题最相关的文本块。
LangChain支持文本拆分功能,并提供了不同的拆分器,我们这次使用递归文本拆分器: RecursiveCharacterTextSplitter
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
// 创建一个递归文本拆分器
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 200,
chunkOverlap: 20,
});
// 拆分抓取内容的文档
const splitDocs = await splitter.splitDocuments(docs);
console.log(splitDocs);
执行代码,看看拆分效果:
docs是抓取的整个网页内容,经过拆分程序得到了多个文档的列表。现在你可能想简单地将分割文档对象传入到链正,但这实际上不是我们想要的。我们只想从该数组中检索出最相关的文档并将其注入地提示语模板中。为了获得最相关的文档,我们需要使用向量存储。向量存储是一种特殊地数据库,不了解的同学,可以看我的博文《 AI探索实践5 - 打造企业智能体(AI Agent)的重要技术-向量数据库》。我们可以使用向量数据库来存储文档,并且该数据库允许我们对数据进行语义搜索,检索与我们的问题相关的所有文档。
3.5 嵌入 Embedding与向量存储
embedding就是把文本数据转为数字化(向量)的过程。我们需要新声明一个嵌入的对象:
import { OllamaEmbeddings } from '@langchain/community/embeddings/ollama';
import { MemoryVectorStore } from 'langchain/vectorstores/memory';
// 创建一个嵌入对象,对象的类型取决你所使用的模型。
const embeddings = new OllamaEmbeddings({
model: 'qwen:4b',
});
// 这里简单的使用内存向量数据库
const vectorStore = await MemoryVectorStore.fromDocuments(
splitDocs,
embeddings,
);
声明一个OllamaEmbeddings类型的嵌入对象,默认的BaseUrl会指向 localhost:11434,这与本地环境一致因此省略。这里只需要设置一个模型的名称即可。
splitDocs 是分割后的文档集合。当我们指定文档集合与嵌入方案后,就可以将文档转为向量并存入向量数据库了。此处为简化起见我们使用内存向量数据库。LangChain支持很多向量数据库,希望了解更多信息的可自行登录其官网查阅。
3.6 语义检索
当我们将文档分割,并存入向量数据库后我们就可以从向量数据库进行语义检索了。
import { createRetrievalChain } from 'langchain/chains/retrieval';
// 检索数据,设置相关性最高的数量:3个
const retriever = vectorStore.asRetriever({
k: 3,
});
// 创建一个检索链
const retrieverChain = await createRetrievalChain({
combineDocsChain: chain,
retriever,
});
const response = await retrieverChain.invoke({
input: 'What is LCEL?',
//input: '密码是多少',
//context: docs,
});
console.log(response);
console.log(response.answer);
语义检索的重点,就创建一个检索链对象。它接收2个参数:
- 代表合并文档的链
- 向量检索对象。这里设置返回3个最高相关性的文档
我们调用这个检索链对象时,会将检索链对象检索到的3个相关性最高的文档作为上下文变量注入到提示语模板中,连同问题一起发给大模型。来看看结果:
可以看到,context是一个包含3个元素的数组,也就是从向量数据库中查询到的相关性最高的3个文档内容。
注:文档分割方案、向量库存储与检索的不同设置都会对结果产生影响。
至此,我们实现了从网页抓取内容,进行文档分割、文档嵌入化、向量存储、向量查询等完整的链操作。
四、总结
本文介绍了如何使用文档加载、使用抓取网页内容工具cheerio来实现将网页内容抓取下来并转换为文档集合,通过文档分割、文档嵌入化,将抓取的内容嵌入化并存入内存向量数据库。最后介绍了如何从向量数据库检索相关性最高的3个文档,并得到准确的模型响应。