DLAI-Langchain-大语言模型应用笔记-全-

DLAI Langchain 大语言模型应用笔记(全)

001:课程介绍 🚀

概述

在本节课中,我们将了解大型语言模型应用的重要性,并介绍一个能帮助JavaScript开发者更高效构建此类应用的工具——LangChain。


大型语言模型应用正变得日益重要和主流。我认为,对于JavaScript开发者而言,学会使用这些工具并将其集成到自己的应用中至关重要。我希望本课程能在这方面为你提供帮助。

在构建使用大型语言模型的应用时,开发者通常会遵循一些通用步骤。LangChain能让JavaScript开发者更轻松地完成这些步骤。

例如,如果你正在构建一个检索增强生成应用,你需要:选择一个语言模型来执行任务;找到如何检索相关文本来填充模型的上下文;调整提示词;并且可能还需要将模型的文本输出解析为更结构化的格式,以供应用的下游步骤使用。

有些工具能帮助你连接这些步骤,并让你能快速调整整个工作流程,例如轻松更换不同的语言模型。这类工具被称为“编排器”。

LangChain是一个非常流行的、用于LLM应用的开源编排器,它将帮助你更快地构建LLM应用。

本课程的讲师是Jacob Lee,他是LangChain的创始软件工程师,也是开源项目LangChain.js的维护者。

Jacob曾与许多开发者合作,帮助他们将语言模型集成到Web和移动应用中。

感谢Andrew的介绍。我对大家在本课程中将能学到的内容感到非常兴奋。


你将学到的核心内容

以下是本课程将涵盖的、在LLM应用中常见的几个核心元素:

  • 数据加载器:让你能方便地从PDF、网站、数据库等常见数据源提取数据,以增强LLM的生成能力。
  • 解析器:语言模型处理自然语言,而编程语言处理格式化数据。解析器能提取并格式化自然语言输出,为你的下游代码创建结构化的处理形式。
  • 提示词:用于为语言模型提供上下文。
  • 模型:在特定语言模型之上提供一个抽象层,使你编写的应用本身不依赖于特定供应商。
  • 其他模块:支持RAG应用的其他模块,例如文本分割器以及与向量数据库的集成。

你还将学习使用LangChain表达式语言来轻松组合这些模块,形成复杂的处理链。


课程贡献者

许多人共同促成了这门课程。我们感谢LangChain的创始人兼首席执行官Harrison Chase,以及同样来自LangChain的Noa Comfor。在DeepLearning.AI方面,Jeff Ludwig和Ashwin Gagari也为本课程做出了贡献。

这里有很多很棒的内容值得学习。完成本课程后,我希望你能够用JavaScript构建出一些非常酷的基于语言模型的应用。


Jacob,LangChain标志里的鹦鹉是怎么回事?Andrew,我也不太确定,但它已经成为我们标志性的一部分了。


总结

本节课我们一起了解了LLM应用的发展趋势,认识了能大幅提升开发效率的编排工具LangChain及其核心组成模块,并对本课程的内容和讲师有了初步认识。接下来,我们将开始深入学习如何使用这些工具。

002:核心构建模块 🧱

在本节课中,我们将学习LLM应用的一些基本构建模块,即提示模板模型输出解析器。同时,你将了解如何使用LangChain表达式语言将它们组合在一起,创建功能链。


为什么选择LangChain.js?🤔

在深入学习之前,我们先探讨一个重要问题:为何选择LangChain.js?JavaScript拥有全球最大的开发者生态系统,许多开发者更倾向于使用JavaScript。选择它也可能是因为其便捷的部署工具、强大的扩展特性(如Next.js的云服务、边缘函数或Cloudflare Workers),以及随之而来的先进工具链。此外,如果你需要为多个平台(如浏览器扩展、React Native移动应用、Electron桌面应用)进行开发,JavaScript也是一个理想选择。

一个注意事项:本教程使用Deno Jupyter内核,这是一个与Node和Web环境略有差异的JavaScript运行时。在大多数情况下,你可以直接将代码复制粘贴到不同环境中,如有差异,我们会特别指出。


LangChain表达式语言简介 🔗

LangChain使用表达式语言来组合各个组件。与这种语言兼容并可用的组件被称为Runnable。它们定义了一些核心方法、允许的输入和输出类型,并让你可以直接使用invokestreambatch等方法,这些是构建和使用LLM应用时的常用方法。你还可以使用bind方法在运行时修改参数。

一个具体的例子就是本节课将介绍的“提示-LLM-输出解析器”三元组。Runnable协议的其他好处包括:通过batch方法获得回退和并行处理能力,以及通过LangSmith(一个追踪和可观测性工具)内置的日志记录和追踪功能。在整个课程中,我们将链接到一些可探索的LangSmith追踪记录,以说明不同链的工作原理。


语言模型 🧠

让我们从LangChain最基础的组件之一——语言模型开始。LangChain支持两种类型的语言模型:

  1. 文本LLM:接收字符串输入,返回字符串输出。即 string -> string
  2. 聊天模型:接收消息列表作为输入,返回单个消息作为输出。例如流行的ChatGPT和GPT-4。

文本LLM的输入和输出易于可视化。让我们看看直接调用聊天模型是什么样子。

首先,我们导入环境变量。本课程主要使用OpenAI的GPT-3.5 Turbo(一个聊天模型)。我们导入其LangChain包装器,并导入HumanMessage类来包装和创建聊天模型输入。

import { ChatOpenAI } from "langchain/chat_models/openai";
import { HumanMessage } from "langchain/schema";

然后初始化模型:

const chatModel = new ChatOpenAI({
  modelName: "gpt-3.5-turbo",
  temperature: 0,
});

现在尝试查询它,传入一个消息列表(这里只是一个对应我们输入的HumanMessage):

const response = await chatModel.invoke([
  new HumanMessage("Tell me a joke"),
]);
console.log(response);
// 输出示例: AI消息,包含笑话内容

你会注意到,聊天模型输出的消息包含一个content字段(存放消息的文本值)和一个role字段(对应发送者)。这里,我们从发送原始消息的human(我们)开始,AI用ai消息回应。

虽然我们使用GPT-3.5 Turbo,但LangChain支持来自不同供应商的模型,你可以在任何代码示例中尝试替换提供的类。


提示模板 📝

虽然像上面那样单独调用模型很有用,但通常更便捷的做法是将模型输入背后的逻辑提取成可复用的、参数化的组件,而不是每次都输入完整的查询。为此,LangChain包含了提示模板,它负责为用户输入格式化,以便后续模型调用。

让我们看看它的样子。导入ChatPromptTemplate类并初始化:

import { ChatPromptTemplate } from "langchain/prompts";

const promptTemplate = ChatPromptTemplate.fromTemplate(
  `What are three good names for a company that makes {product}?`
);

这里,我们使用花括号{product}表示一个输入变量。传入提示的任何内容都会被注入并格式化到这个部分。

提示模板对于平滑处理模型输入类型的差异也很有用。这里我们直接从字符串构造了一个提示模板。我们可以使用这个提示模板的format方法为LLM生成字符串输入,并传入输入变量:

const formattedString = await promptTemplate.format({
  product: "colorful socks",
});
console.log(formattedString);
// 输出: "Human: What are three good names for a company that makes colorful socks?"

我们也可以使用formatMessages方法将提示格式化为消息数组,这适用于调用聊天模型:

const formattedMessages = await promptTemplate.formatMessages({
  product: "colorful socks",
});
console.log(formattedMessages);
// 输出: [ HumanMessage { content: "What are three good names for a company that makes colorful socks?" } ]

为了更精细地控制传递给提示的消息类型,我们可以直接为消息创建提示模板。例如,许多模型和供应商依赖系统消息来定义特定行为。这是一个格式化输入的好方法。

以下是示例:

import { SystemMessagePromptTemplate, HumanMessagePromptTemplate } from "langchain/prompts";

const prompt = ChatPromptTemplate.fromMessages([
  SystemMessagePromptTemplate.fromTemplate("You are a helpful assistant that generates company names."),
  HumanMessagePromptTemplate.fromTemplate("What are three good names for a company that makes {product}?"),
]);

const messages = await prompt.formatMessages({
  product: "shiny objects",
});
console.log(messages);
// 输出包含系统消息和人类消息的数组

一个方便的简写是使用元组,无需导入那些消息提示模板类:

const prompt = ChatPromptTemplate.fromMessages([
  ["system", "You are a helpful assistant that generates company names."],
  ["human", "What are three good names for a company that makes {product}?"],
]);

运行format会得到完全相同的输出。这在后面传递历史记录时很有用,因为你可以直接将历史消息注入提示中。


使用LCEL组合链 🔄

虽然我们可以将这些格式化后的值直接传递给模型,但实际上有一种更优雅的方式将提示和模型结合使用,那就是LangChain表达式语言。LCEL是一种用于链接LangChain模块的可组合语法。同样,与LCEL兼容的对象称为Runnable

我们可以使用pipe方法从上面声明的提示和模型构造一个简单的链:

const chain = promptTemplate.pipe(chatModel);

这将创建一个链,其中输入与序列中的第一步(这里是提示)相同,即一个具有product属性的对象。提示模板使用此输入,并将正确格式化的结果作为输入传递给链的下一步(这里的聊天模型)。

实际操作如下:

const response = await chain.invoke({
  product: "colorful socks",
});
console.log(response);
// 输出: AI消息,包含三个有趣的公司名

输出解析器 🎯

本节课要讨论的最后一个核心概念是格式化输出。例如,通常使用聊天模型输出的原始字符串值比使用AI消息对象更方便。LangChain中用于此目的的抽象称为输出解析器

我们导入一个将聊天模型输出(单个消息)强制转换为字符串的解析器:

import { StringOutputParser } from "langchain/schema/output_parser";

const outputParser = new StringOutputParser();

现在,让我们用这个输出解析器重新声明我们的链:

const chain = promptTemplate.pipe(chatModel).pipe(outputParser);

如果我们再次运行这个链(换一个产品),这次你会看到我们得到的是字符串,而不是聊天消息对象:

const result = await chain.invoke({
  product: "fancy cookies",
});
console.log(result);
// 输出示例: "1. Delicate Crumbs\n2. Gourmet Cookie Creations\n3. Elegant Sweet Scoop"

提示模板、模型和输出解析器这三个部分构成了许多复杂链的核心,因此现在熟练掌握它们非常重要。


Runnable的实用方法 ⚙️

所有Runnable及其序列本身也是Runnable,它们免费获得了一些有用的方法。

1. 流式传输
对于Web开发社区的许多人来说,流式传输非常重要。stream方法以可迭代流的形式返回链的输出。由于LLM响应通常需要很长时间才能完成,这在需要快速显示反馈的情况下非常有用。

以下是我们刚刚组合的链的示例:

const stream = await chain.stream({
  product: "really cool robots",
});

for await (const chunk of stream) {
  console.log(chunk);
  // 逐块输出字符串,例如 "RoboTech Innovations", "FutureBot Solutions", "MechanoWorks Unlimited"
}

输出解析器会在模型生成时转换这些块,从而产生字符串输出块,而不是模型原始输出。

2. 批量处理
这对于同时执行多个并发操作和生成非常有用。我们将输入定义为一个数组,每个输入都应匹配提示模板的语法。

const inputs = [
  { product: "large calculators" },
  { product: "alpaca wool sweaters" },
];

const results = await chain.batch(inputs);
console.log(results);
// 输出: 包含两个字符串的数组,分别是为两个不同产品生成的公司名列表

总结 📚

在本节课中,我们一起学习了LangChain.js的核心构建模块:

  • 语言模型:分为文本LLM和聊天模型,是应用的核心。
  • 提示模板:用于参数化和格式化用户输入,提高代码复用性。
  • 输出解析器:用于将模型输出转换为更易处理的格式(如字符串)。
  • LangChain表达式语言:使用pipe方法或RunnableSequence.from将这些组件优雅地组合成链。
  • 实用方法:Runnable协议免费提供了invokestream(用于流式响应)和batch(用于并行处理)等方法,极大地简化了开发。

这些模块是构建更复杂LLM应用的基础。在继续下一课之前,建议你尝试修改这些提示、模板甚至表达式语言链,以更好地理解它们是如何协同工作的。下一节课,我们将介绍检索增强生成的基础知识,并讨论如何加载和准备数据。

003:加载与准备数据 📚

在本节课中,我们将学习如何为构建“与你的数据对话”应用准备数据。具体来说,我们将介绍检索增强生成(RAG)的基本流程,并学习如何使用LangChain.js的文档加载器和文本分割器来加载和预处理文档。

概述

检索增强生成(RAG)是大型语言模型(LLM)的一个非常流行的应用。在本课中,你将初步了解RAG,并学习一些让构建过程更简单的LangChain模块:文档加载器和文本分割器。

上一节我们学习了如何将模块链接在一起。本节中,我们将继续构建“与你的数据对话”应用,学习一些技术来存储我们自己的文档,以便后续检索,从而为LLM的生成提供上下文基础。这通常被称为检索增强生成,简称RAG。

RAG的基本流程

基本流程如下:

  1. 从源(如PDF、数据库或网络)加载文档。
  2. 将文档分割成足够小的块,以适应LLM的上下文窗口,以避免干扰。
  3. 将这些块嵌入到向量存储中,以便后续基于输入查询进行检索。
  4. 当用户想要访问某些数据时,检索那些相关的、先前分割好的块,并以这些块为上下文生成最终输出。

本节课将涵盖此流程中的前两个步骤。

第一步:使用文档加载器

为了完成第一步,我们将使用LangChain的一些文档加载器。LangChain拥有多种文档加载器,可以从网络上的各种来源或专有公司引入数据。

以下是使用文档加载器的步骤:

  1. 导入并初始化加载器:首先,我们需要导入所需的加载器并实例化它,通常需要指定数据源路径或URL。
  2. 加载文档:调用加载器的加载方法,将原始数据转换为LangChain可以处理的文档对象。
  3. 查看结果:加载的文档通常包含内容和元数据,元数据可用于更高级的查询和过滤。

让我们从加载一个示例开始。LangChain的众多加载器之一是Github加载器。

import { GithubLoader } from "langchain/document_loaders/web/github";

这个特定的加载器需要一个对等依赖项。一旦加载,我们将用一个Github仓库(当然是LangChain.js的仓库)来实例化它。为了演示目的,我们不会深入探讨包的内容,因此我们将关闭递归选项,然后忽略某些路径,如Markdown格式的文档和很长的yarn.lock文件。

const loader = new GithubLoader(
  "https://github.com/langchain-ai/langchainjs",
  { recursive: false, ignorePaths: ["*.md", "yarn.lock"] }
);

接下来,实例化后,我们将加载它。

const docs = await loader.load();

然后让我们记录一些输出。

console.log(docs.slice(0, 3));

我们可以看到,我们确实从LangChain仓库的顶层返回了一些文件。所以有一个.editorconfig文件,.gitattributes,大多是元数据,然后是.gitignore。这些是直接从LangChain的Github仓库拉取的,我们也可以在这里看到内容。

所以数据的定义可以相当宽泛。它不一定只是结构化的PDF,可以是Github文件、代码,也可以是SQL行,非常广泛。但PDF是一个相当大的用例。

让我们看看加载PDF会是什么样子。当然,考虑到这是一门深度学习AI课程,我们为什么不用吴恩达著名的机器学习课程CS229的转录稿呢?

与上面的Github示例非常相似,我们将导入它,这次需要一个对等依赖项pdf-parse

import { PDFLoader } from "langchain/document_loaders/fs/pdf";

我已经在笔记本文件系统的这个文件路径下准备了这份转录稿的副本。

const loader = new PDFLoader("cs229_transcript.pdf");
const cs229Docs = await loader.load();

然后让我们只记录我们将得到的前三页。

console.log(cs229Docs.slice(0, 5));

这次我们看到,我们得到了转录稿的标题“Machine Learning Lecture 01”,以及我们著名的讲师Andrew Ng,然后是之后的页面。所以这个加载器是按页分割的:第1页、第2页、第3页、第4页,最后是第5页。这后面还有很多页,我们只展示了前五个加载的文档,还有元数据,这对于更高级的查询和过滤很有用,但这有点超出了本课的范围。

第二步:使用文本分割器

现在我们已经加载了一些数据,让我们进入分割环节。这里的想法和目标,作为提醒,是尝试将语义上相关的想法保持在同一块中,这样LLM就能获得一个完整的、自包含的想法,而不会分心。

你注意到之前CS229转录稿的例子,有很多很多页,文本量很大,而我们的LLM对每个块只能给予固定的注意力。因此,对于数据分割有许多不同的策略,这实际上取决于你加载的内容。

对于Github JavaScript示例,我们可能希望基于代码特定的分隔符进行分割,因为这些分隔符倾向于将输入文档分组为函数定义或类,以便LLM处理,而不是在中间某个地方分割。

为了展示这是什么样子,我们将导入一个文本分割器,然后像这样初始化它。

import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";

const splitter = new RecursiveCharacterTextSplitter({
  language: "js", // 指定为JavaScript语言
  chunkSize: 30,  // 为演示设置较小的最大块大小
  chunkOverlap: 0 // 块重叠设置为0
});

你会注意到我们在这里设置了一些参数:使用来自语言JavaScript的初始化器,它知道使用一些常见的JS语言特性作为块之间的分隔符;我们为演示设置了一个很小的最大块大小(30个字符),这比你在实践中可能想要使用的小得多;我们将块重叠设置为0。在某些情况下,设置重叠可能有用,可以让块之间更自然地衔接,但同样为了演示目的,我们将其设置为0。

初始化后,让我们给它一些代码。我们将使用一个简单的“hello world”函数,包括声明和一个带注释的调用。

const code = `function helloWorld() {
  console.log("Hello, world!");
}
// This is a comment
helloWorld();`;

const chunks = await splitter.splitText(code);
console.log(chunks);

你可以看到这里的结果是四个块,分割得非常自然:我们得到一个函数定义helloWorld,日志语句单独一行,注释单独一行,然后调用也单独一行。

为了展示这里的替代方案,如果我们天真地分割,例如使用空格作为分隔符,我们可能会得到一些块,例如,只包含半个日志语句,这会使LLM在最终生成时的工作更加困难。

为了展示这是什么样子,让我们看看一个更天真的字符分割器。

import { CharacterTextSplitter } from "langchain/text_splitter";

const naiveSplitter = new CharacterTextSplitter({
  chunkSize: 30,
  chunkOverlap: 0,
  separator: " " // 仅使用空格作为分隔符
});

const naiveChunks = await naiveSplitter.splitText(code);
console.log(naiveChunks);

正如你所看到的,我们确实在第一行得到了函数定义,这还不错,但我们分割了函数的参数。你可以想象,如果LLM得到一个只有半个函数调用的块,它不一定知道另一半是什么,从而丢失一些上下文。我们还分割了注释块,是的,这只会让LLM处理起来更困难一些。

为了快速展示一些调整方法以提高性能,假设我们想将函数体的一些内容也包含到声明中,因为我们有一个单独的函数声明,这很好,但也许我们也希望将一些函数体作为上下文。

我们可以尝试与上面类似的方法,使用我们的递归字符文本分割器,但让我们调大一点块大小,同时也给它一些重叠。

const tunedSplitter = new RecursiveCharacterTextSplitter({
  language: "js",
  chunkSize: 100, // 增加块大小
  chunkOverlap: 20 // 增加块重叠
});

const tunedChunks = await tunedSplitter.splitText(code);
console.log(tunedChunks);

这次你可以看到,即使这在某种程度上效率较低,因为我们把冗余信息放入了这些块中,但我们确实在同一个块中得到了整个函数定义和函数体。这意味着接收此数据的LLM将拥有关于该函数的完整上下文。

LangChain为不同类型的内容包含了几种不同的选项,包括你刚刚看到的Markdown、JavaScript、Python等。但对于通用的书面文本,递归字符文本分割器是一个很好的起点,因为它以段落作为自然边界进行分割,人们通常在这些地方分隔他们的想法和观点。

因此,让我们初始化一个,用于我们之前加载的CS229课程。

const textSplitter = new RecursiveCharacterTextSplitter({
  chunkSize: 512, // 使用稍大的块
  chunkOverlap: 64 // 设置字符重叠
});

让我们分割我们之前摄入的吴恩达课程转录稿,看看会发生什么。

const splitDocs = await textSplitter.splitDocuments(cs229Docs);
console.log(splitDocs.slice(0, 5)); // 再次取前五个,以免输出太长

这次我们可以看到,我们在第1页上得到了一个块,然后在第1页上又得到了另一个块。所以,比一页小,并且理想情况下仍然应该是我们将传递给LLM的自包含的想法。我们得到了一些关于Paul Bom Stark在机器学习方面做什么的信息,其他人Daniel Rammage将学习算法应用于自然语言问题,以及一些关于工程中深度学习日常应用的想法。是的,我认为这是一个相当不错的开始。

总结

在本节课中,我们一起学习了为RAG应用准备数据的前两个关键步骤。首先,我们使用LangChain的文档加载器从不同来源(如Github和PDF)加载原始文档。接着,我们深入探讨了文本分割的重要性,并使用递归字符文本分割器将长文档分割成语义连贯、大小合适的块,以便LLM处理。我们比较了不同分割策略的效果,并学习了如何通过调整块大小和重叠来优化分割结果。

在下一节中,我们将展示如何嵌入这些块并将它们添加到向量存储中,以便更容易地进行搜索和查询。

004:向量数据库与嵌入

概述

在本节课中,我们将学习检索增强生成流程中的关键环节:向量数据库与嵌入。我们将了解如何将文档块转换为向量表示,并将其存储在向量数据库中,以便后续根据用户查询进行高效检索。


向量数据库与嵌入简介

上一节我们介绍了如何加载和分割文档。本节中,我们来看看如何将这些文档块转换为向量并存储起来,以便进行语义搜索。

向量数据库是一种具有自然语言搜索能力的专用数据库。当用户提出查询时,我们将在向量数据库中搜索与查询语义相似的文档块,并将相关结果返回给大语言模型用于最终生成。

嵌入模型

实现上述功能的第一步是使用嵌入模型。嵌入模型是一种特殊的机器学习模型,用于将文本内容转换为一种称为“向量”的数字表示。

我们将使用OpenAI托管的嵌入模型,因此需要导入环境变量来获取API密钥。

import { OpenAIEmbeddings } from "@langchain/openai";
const embeddings = new OpenAIEmbeddings();

为了演示,我们将使用一个内存中的向量数据库。在生产环境中,您可能需要替换为其他持久化方案。

让我们尝试嵌入一个简短的查询,看看结果是什么样子。

const vector = await embeddings.embedQuery("What are vectors useful for in machine learning?");

结果是一个数字数组形式的向量。这些生成的数字可以被视为捕获了被嵌入文本的各种抽象特征,我们之后可以通过搜索这些向量来找到语义相近的文档块。

向量相似度比较

为了具体展示这个搜索过程,我们可以使用一个JavaScript库来比较不同嵌入向量之间的相似度。

首先,我们创建一个向量,对应查询“What are vectors useful for machine learning?”。

然后,我们创建一个不相关的查询向量进行对比,并计算它们的相似度得分。

// 假设有计算余弦相似度的函数 cosineSimilarity
const score = cosineSimilarity(vector1, unrelatedVector); // 得分约为 0.69621

现在,让我们尝试用一个更相关的向量进行比较,看看得分有何不同。

const similarVector = await embeddings.embedQuery("Vectors are representations of information.");
const newScore = cosineSimilarity(vector1, similarVector); // 得分显著高于 0.69621

我们使用的度量标准称为余弦相似度,它是比较两个向量相似度的众多方法之一。由于两个文本包含相似的信息,因此它们的相似度得分会更高。

准备文档并存入向量数据库

接下来,我们将使用上一节课介绍的技术来准备我们的文档。为了演示,我们将设置一个较小的块大小。

以下是准备文档的步骤:

  1. 使用PDF加载器加载文档。
  2. 使用文本分割器将文档分割成小块。
import { PDFLoader } from "@langchain/community/document_loaders/fs/pdf";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";

const loader = new PDFLoader("path/to/transcript.pdf");
const docs = await loader.load();

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 128,
});
const splitDocs = await splitter.splitDocuments(docs);

现在,让我们初始化我们的向量数据库。我们继续使用内存向量数据库进行演示。

import { MemoryVectorStore } from "langchain/vectorstores/memory";

const vectorStore = await MemoryVectorStore.fromDocuments(
  splitDocs,
  embeddings
);

请注意,我们在初始化时传入了嵌入模型。这是因为LangChain的向量数据库实现将使用此模型为每个添加的文档内容生成之前看到的数字数组(即向量)。

现在,让我们将分割后的文档添加到向量数据库中。

// fromDocuments 方法已经完成了添加操作

至此,我们拥有了一个已填充、可搜索的向量数据库。

执行搜索

由于LangChain的向量数据库暴露了直接用自然语言查询进行搜索的接口,我们可以立即尝试并查看结果。

让我们使用 similaritySearch 方法,查询“What is deep learning?”,并返回4个最相关的文档。

const results = await vectorStore.similaritySearch("What is deep learning?", 4);
console.log(results.map(doc => doc.pageContent));

我们期望看到的是四个与深度学习、机器学习、学习算法相关的小文本块,结果符合预期。

检索器抽象

我们刚刚展示了如何直接使用相似度搜索从向量数据库返回文档。但这实际上只是为大语言模型获取数据的多种方式之一。

LangChain用一个更广泛的“检索器”抽象封装了这种区别,它能根据给定的自然语言查询返回相关文档。

我们可以方便地通过一个简单的函数调用,从现有的向量数据库实例化一个检索器。

const retriever = vectorStore.asRetriever();

检索器的一个优点是,与向量数据库不同,它们实现了 invoke 方法,并且本身是我们在第一课中学到的“表达式语言可运行对象”。因此,它们可以与其他模块(如LLM、提示词等)链接起来。

为了展示这一点,我们用 invoke 方法运行刚刚实例化的检索器,使用相同的查询。

const retrieverResults = await retriever.invoke("What is deep learning?");

我们看到的结果是与之前相同的关于深度学习的文档。但现在,我们可以在一个链中将其与其他模块一起使用,这将在下一课关于构建检索链时发挥巨大作用。


总结

本节课中,我们一起学习了检索增强生成流程的核心组成部分:向量数据库与嵌入。我们了解了如何使用嵌入模型将文本转换为向量,如何将文档块存储到向量数据库,以及如何执行语义搜索来查找相关信息。最后,我们介绍了检索器这一更高级的抽象,它为构建复杂的LLM应用链奠定了基础。在下一课中,我们将利用检索器来构建完整的检索增强生成链。

005:问答系统构建 🧠

在本节课中,我们将综合运用前几节课的知识,构建一个能够利用外部数据作为上下文的对话式问答LLM应用程序。

概述

我们将创建一个链,通过向量相似性搜索检索与输入查询最相似的文本块,然后将它们作为上下文提供给LLM,以生成最终的答案。

加载文档与初始化向量存储

首先,我们需要加载环境变量,并分割、加载之前使用的CS229课程PDF转录本。为了模拟生产环境,我们将使用更大的文本块(1536个字符)和一定的重叠(128个字符)。

以下是初始化向量存储的代码:

// 初始化向量存储的辅助函数
async function initializeVectorStore(documents, chunkSize = 1536, chunkOverlap = 128) {
  // 分割文档
  const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: chunkSize,
    chunkOverlap: chunkOverlap,
  });
  const docs = await splitter.splitDocuments(documents);

  // 创建向量存储
  const vectorStore = await MemoryVectorStore.fromDocuments(
    docs,
    new OpenAIEmbeddings()
  );
  return vectorStore;
}

我们使用OpenAI嵌入模型将分割后的文档加载到向量存储中,并从中创建一个检索器,用于根据自然语言查询获取相关文档。

构建文档检索链

接下来,我们开始构建检索链。第一步是创建一个包装检索器的步骤,用于格式化其他步骤的输入和输出,我们称之为“文档检索链”。

以下是构建文档检索链的步骤:

  1. 定义一个格式化文档内容的函数,使用XML标签分隔不同文档,以帮助LLM区分不同的信息。
  2. 创建一个可运行序列,该序列接受一个包含question字段的对象作为输入。
  3. 使用一个lambda函数从输入对象中提取问题字符串,并将其传递给检索器。
  4. 将检索器返回的文档通过管道传递给格式化函数。

以下是代码示例:

// 格式化文档内容的函数
const formatDocumentsAsString = (docs) => {
  return docs.map(doc => `<doc>${doc.pageContent}</doc>`).join('\n');
};

// 创建文档检索链
const documentRetrievalChain = RunnableSequence.from([
  (input) => input.question, // 提取问题
  retriever, // 检索相关文档
  formatDocumentsAsString // 格式化文档
]);

现在,我们可以测试这个链。例如,询问“这门课程的先修要求是什么?”,链将返回格式化的文档内容,其中包含了关于CS229课程先修要求的信息。

构建答案生成链

上一节我们构建了文档检索链,本节中我们来看看如何将检索到的信息合成为人类可读的答案。

首先,我们需要定义一个提示模板,指导LLM基于提供的上下文回答问题。

以下是构建答案生成链的步骤:

  1. 创建一个聊天提示模板,要求LLM扮演经验丰富的研究员角色,仅使用提供的资源回答问题。
  2. 由于我们的文档检索链输出的是字符串,而提示模板需要一个包含contextquestion字段的对象,因此我们需要使用RunnableMap来处理输入和输出的转换。
  3. RunnableMap可以并行调用多个可运行对象,并将结果组合成一个对象。
  4. 最后,将RunnableMap、提示模板、LLM模型和输出解析器组合成一个序列链。

以下是代码示例:

// 定义提示模板
const answerGenerationPrompt = ChatPromptTemplate.fromTemplate(`
  你是一位经验丰富的研究员,擅长根据资料解读和回答问题。
  请仅使用以下资源来回答问题。

  上下文:
  {context}

  问题:
  {question}
`);

// 使用 RunnableMap 处理输入
const map = RunnableMap.from({
  context: documentRetrievalChain,
  question: (input) => input.question,
});

// 构建完整的问答链
const qaChain = RunnableSequence.from([
  map,
  answerGenerationPrompt,
  new ChatOpenAI({ temperature: 0 }),
  (output) => output.content,
]);

现在,我们可以用同样的问题“这门课程的先修要求是什么?”来调用这个完整的问答链。LLM将生成一个更清晰、更人性化的回答,总结出课程需要熟悉基础概率统计和线性代数等先修知识。

处理后续问题与当前局限

当我们尝试提出后续问题,例如“你能用项目符号列出它们吗?”,系统可能无法正确理解“它们”所指代的内容。这是因为LLM本身没有记忆,并且我们的向量存储检索也无法理解指代关系。

如果我们直接用这个后续问题查询向量存储,检索到的文档可能与“先修要求”无关,导致回答不准确。这揭示了当前简单检索问答系统在对话上下文处理上的局限性。

总结

本节课中我们一起学习了如何使用LangChain.js构建一个基础的检索增强生成(RAG)问答系统。我们完成了从文档加载、向量存储检索到利用LLM生成答案的完整流程。然而,该系统目前还无法有效处理涉及对话历史的后续问题。在下一节关于对话式问答的课程中,我们将探讨如何解决这个问题。

006:对话式问答 🗣️💬

概述

在本节课中,我们将学习如何为之前构建的问答链添加对话记忆能力。上一节我们构建的问答链无法记住过去的对话历史,导致无法回答涉及上下文的问题。本节中,我们将通过一系列技术来解决这个问题,使我们的链能够进行连贯的对话。

问题分析

上一节构建的问答链遵循以下步骤:

  1. 使用问题查询向量数据库。
  2. 向量数据库返回四个相关文档(称为上下文)。
  3. 将上下文和问题一起填入提示词,发送给大语言模型。

例如,我们问:“这门课程的先修要求是什么?”,模型根据检索到的上下文给出了很好的答案。

然而,当我们接着问:“你能用项目符号列出它们吗?”,大语言模型回复说它没有看到具体的问题或提示。问题出在第二个问题引用了过去的对话信息,但大语言模型没有记忆,因此无法回答。

解决方案:问题重写

解决此问题的一种方法是,在了解过去聊天历史的情况下,将新问题重写或改述为一个独立的、不依赖外部引用的问题。

如果我们这样做,大语言模型可能会将问题重写为:“你能用项目符号列出这门课程的先修要求吗?”。这样,上面的链就可以根据这个输入从向量数据库中获取正确的数据,大语言模型也能生成合适的答案。

以下是实现此方案的关键步骤:

第一步:保存聊天历史

基本思路是,每次我们运行链时,都将用户提出的问题作为“人类消息”,将大语言模型格式化的回复作为“AI消息”存储在一个历史变量中。之后,你可以将其作为额外上下文提供给大语言模型的提示词。

第二步:添加问题重写逻辑

我们将使用一个大语言模型来重写问题。首先,我们需要清理之前的链,为新的起点腾出空间。

接下来,我们将添加一个提示词、一个大语言模型和一个输出解析链,以形成一个独立的问题。提示词大致如下:

给定以下对话和一个后续问题,请将该后续问题改述为一个独立的问题。

现在,当被问到“你能用项目符号列出它们吗?”时,它可以回复一个新的、改述后的独立问题:“你能用项目符号列出这门课程的先修要求吗?”。然后将这个独立问题提供给我们的原始检索链。

第三步:修改原始检索链的提示词

原始检索链的提示词也需要稍作修改,以包含聊天历史和重写后的独立问题。

现在,给定一个像“你能用项目符号列出它们吗?”这样的输入,它可以提供如下答案。请注意,我们正在创建一个可以反复用于多个问题的链。即使对于第一个还没有任何历史记录的问题,我们的步骤也能正常工作,只是不会进行太多重写。

代码实现

现在,让我们进入代码并开始实现。

重建基础组件

首先,你需要从上一个实验中重建一些组件。
以下是需要重建的步骤:

  1. 加载配置。
  2. 加载文本分割器。
  3. 加载包含文档的向量存储。
  4. 定义检索器。
  5. 构建一个文档检索链,用于提取输入问题,将其发送给检索器,最后将输出转换为字符串。

构建检索链

然后,你可以构建你的检索链,该链将使用一个问题以及向量数据库查找的结果来调用大语言模型。
以下是构建检索链的步骤:

  1. 构建一个提示词模板。注意,它有一个用于向量数据库上下文的输入变量和一个用于问题的位置。
  2. 初始化你的模型。
  3. 构建你的链。它从文档检索链获取上下文和输入问题,并将所有这些传递给提示词,最后传递给模型和输出解析器。

创建问题重写链

现在,我们准备制作新的链。我们将创建一个新链,专门负责将用户可能包含对过去聊天历史引用的输入,重写为一个没有引用的、我们的向量存储和后续大语言模型调用都能理解的问题。

为了实现这一点,我们将定义一个新的提示词,使用一个叫做“消息占位符”的东西来传递历史记录。我们将使用一种更复杂的方式从消息声明模板,因为我们希望将历史记录作为一系列消息传递。

以下是创建重写链的步骤:

  1. 声明一个系统提示词模板:“给定以下对话和一个后续问题,请将该后续问题改述为一个独立的问题。”
  2. 使用消息占位符来传递实际的聊天消息作为历史记录。
  3. 添加一个小的人类提示词,要求它将问题改述为独立问题,并传递问题本身。
  4. 创建一个使用此提示词的链,使用我们熟悉的 RunnableSequence.from 方法,传入我们的提示词、模型和输出解析器。

整合所有组件

现在,让我们把所有部分整合到一个新的链中。

首先,提醒一下我们之前定义的文档检索和格式化链。

然后,定义我们的答案生成提示词,使其也使用消息占位符来接收聊天历史。它将看起来与我们现有的答案链非常相似,但使用 fromMessages 方法,以便我们可以有一个聊天历史的占位符。这使我们的最终答案生成链也能考虑聊天历史。

这个最终的提示词需要三个输入:上下文、聊天历史和最终问题。

使用 RunnablePassthrough.assign 传递状态

我们将使用一种新的 Runnable 类型,它在视觉上更容易描述。有时,链中的某个处理步骤可能希望将其部分输入原封不动地传递给下一步。实现这一点的一种方法是使用方便的 RunnablePassthrough.assign 方法。

在图中,步骤1是获取历史和问题并输出一个独立问题。但步骤2中的提示词也需要接收原始输入历史以及步骤1输出的修订后的独立问题。本质上,我们希望在传递旧属性的同时,为链的当前状态分配一个新属性。

在代码中,我们使用 RunnablePassthrough.assign 方法。这个模式非常常见,例如我们从原始输入中提取一个属性,然后将其作为对象属性传递给下一步(即我们的文档检索链)。你可以把它看作是获取原始输入并为其添加一个额外的字段,这是一个有用的小捷径。

我们的步骤是:

  1. 首先重写我们的问题,使其成为一个独立的问题,不引用聊天历史。
  2. 然后将这个去引用的(独立)问题传递到我们的向量存储中,以获取与查询相关的上下文文档。
  3. 最后,利用所有这些信息生成答案。

使用 RunnableWithMessageHistory 管理会话历史

我们可以使用 RunnableWithMessageHistory 类来简化聊天历史的跟踪和会话管理。它包装另一个链,并通过更新和注入聊天历史来添加持久状态。

以下是 RunnableWithMessageHistory 的工作原理:

  1. 它通过将链输入的一部分(在你的例子中是输入中用户定义的问题字段)保存为新的“人类消息”来自动更新聊天历史。
  2. 它还将链的输出保存为新的“AI消息”。
  3. 此外,它将当前的历史消息添加到被包装链的输入中,放在一个 history_messages 键下,我们将把它用作历史记录。
  4. 注意,在 RAG 应用中,向量数据库查找的结果不会存储在聊天历史中。

在代码中,我们初始化一个聊天历史对象,然后将我们刚刚定义的对话式检索链包装在这个新类中。它会自动添加一个由 history_messages 键给出的额外属性,这正是我们的答案生成提示词所期望的历史记录。它会在聊天消息历史对象中存储和更新聊天历史,将作为 input_messages_key(本例中是 question)传入的值追加为人类消息,并将链的最终输出追加为 AI 消息。

getMessageHistory 是一个函数,它根据过去的会话 ID 返回一个新的聊天历史对象。在演示中,我们每次都将使用同一个消息历史对象,但在生产环境中,你需要为每个会话分配一个新对象,以避免混淆不同用户的对话历史。

测试最终版本

现在,让我们尝试最终版本。我们将使用原始问题“这门课程的先修要求是什么?”来调用我们的最终检索链,以获取原始答案。因为我们使用了 RunnableWithMessageHistory 类,所以需要给它一个会话 ID(尽管我们暂时还没用到它)。然后,我们将再次调用它,使用后续问题“你能用项目符号列出它们吗?”,并记录结果。

最终,我们得到了格式良好的答案,例如:“熟悉基础概率与统计、线性代数和一些编程经验”。这正是我们期望的用项目符号列出的形式。

可视化追踪

这是一个追踪功能真正派上用场的例子。为了直观地了解幕后发生了什么,我们可以使用 LangSmith 追踪来可视化探索。

在追踪视图中,你可以看到 RunnableWithMessageHistory 类包装了我们的 RunnableSequence(即对话式检索链)。它会插入并将那些历史消息作为参数加载到该链中。

具体步骤包括:

  1. 问题重写步骤:接收从管理器加载的历史记录,并将“你能用项目符号列出它们吗?”重写为“你能用项目符号列出这门课程的先修要求吗?”。
  2. 文档检索步骤:检索与这个独立问题相关的文档,得到关于课程先修要求的上下文。
  3. 最终合成步骤:将所有信息(来自向量存储的上下文、聊天历史)传递进去,生成最终答案列表。

总结与展望

在本节课中,我们一起学习了如何为问答链添加对话记忆能力。我们通过引入问题重写逻辑、使用消息占位符传递历史、利用 RunnablePassthrough.assign 管理状态流,以及最终通过 RunnableWithMessageHistory 类自动化会话历史管理,构建了一个能够进行连贯多轮对话的检索增强生成链。

检索是一个非常深入的主题,没有一种适用于所有情况的通用方法。加载、分割和查询数据的方式很大程度上取决于数据的格式、信息密度和其他因素。因此,我们鼓励你针对不同的模型和数据类型修改上述提示词和参数。

在下一课中,我们将展示如何将这个检索链投入生产,包括展示一些与常见 Web API 和 HTTP 的交互。

007:部署为Web API 🚢

在本节课中,我们将学习如何将构建好的LangChain对话链部署为一个实时的Web API。我们将重点探讨流式响应和个性化聊天会话的实现,确保应用能够处理多用户并发请求。


概述

上一节我们完成了对话检索链的构建。本节中,我们将把该链部署为一个Web服务,使其能够通过HTTP接口被调用。我们将关注两个核心方面:流式输出以提升客户端响应速度,以及会话隔离以确保不同用户间的聊天历史不会混淆。


加载与准备组件

首先,我们延续上节课的工作,加载CS229课程讲稿并将其处理为向量存储以供检索。为简化代码,之前的步骤已被封装到一个辅助函数中。

// 辅助函数:加载文档、分割并初始化向量存储
async function loadAndPrepareVectorStore() {
  // 1. 加载文档
  // 2. 分割文本
  // 3. 使用OpenAI嵌入初始化向量存储
  // 4. 将向量存储转换为检索器
  return retriever;
}

该函数内部使用了OpenAI的嵌入模型。完成向量存储的初始化后,我们加载对话链的各个子组件。

以下是对话链的三个核心部分,它们也被封装成了辅助函数:

  1. 文档检索链:包装检索器,用于根据问题查找相关文档。
  2. 问题重述链:将后续问题重述为独立的、不依赖上下文的问句。
  3. 答案合成链:整合所有信息,生成最终答案。

现在,让我们重新构建答案合成链。我们将使用与之前相同的提示模板,但会做一个关键调整。

import { ChatPromptTemplate } from "@langchain/core/prompts";

const answerSynthesisPrompt = ChatPromptTemplate.fromMessages([
  ["system", "你是一位经验丰富的研究员,擅长根据提供的资料解读和回答问题。"],
  ["placeholder", "{chat_history}"],
  ["human", `基于以下上下文和独立问题,请给出答案。
  上下文:{context}
  独立问题:{standalone_question}`]
]);

在将所有组件组装在一起之前,需要注意一个关键点。


实现流式响应

流行的Web框架(如Next.js)的原生响应对象期望接收一个直接发射字节的ReadableStream,而不是发射字符串的流。我们之前使用StringOutputParser输出的是字符串块。

为了能直接将LangChain的流传递给服务器响应,LangChain提供了一个HttpResponseOutputParser。它能够将聊天模型的输出解析成符合多种内容类型的字节块。

使用它的方法是,像之前一样构建对话检索链,但跳过最后的StringOutputParser步骤。

import { HttpResponseOutputParser } from "langchain/output_parsers";

// 1. 创建不包含字符串解析器的可运行序列(与上节课类似)
const retrievalChainWithoutParser = ... // 包含历史处理器、重述链、检索链、答案合成链

// 2. 创建HTTP响应输出解析器
const parser = new HttpResponseOutputParser();

// 3. 创建最终的链:将序列的输出通过管道传递给解析器
const finalConversationalChain = retrievalChainWithoutParser.pipe(parser);

我们将解析器放在链的末端,而不是中间,是因为历史管理器需要最终的输出是字符串或聊天消息,而不是解析器转换后的字节流。


管理用户会话

另一个需要考虑的重点是,在Web环境中,多个用户可能同时访问我们的端点。我们不能像上节课演示那样复用同一个消息历史对象。

我们需要为每个用户会话创建一个新的消息历史对象,这样不同用户的消息就不会混在一起。用户不应共享聊天历史,我们必须为每个会话创建新对象。

为此,我们将重写getMessageHistory函数。

import { ChatMessageHistory } from "langchain/stores/message/in_memory";

const messageHistoryMap = new Map(); // 用于存储不同会话的历史

function getMessageHistory(sessionId) {
  if (messageHistoryMap.has(sessionId)) {
    // 如果会话ID已存在,返回已有的历史
    return messageHistoryMap.get(sessionId);
  }
  // 否则,创建一个新的聊天历史对象
  const newHistory = new ChatMessageHistory();
  messageHistoryMap.set(sessionId, newHistory);
  return newHistory;
}

然后,我们使用这个新的getMessageHistory函数来重新创建最终链,确保每个用户都有独立的聊天上下文。


设置Web服务器与端点

现在,让我们设置一个简单的服务器,并创建一个处理程序来调用我们的链并返回流式响应。

import { serve } from "http/server"; // Deno 示例,其他框架类似

const port = 8087;

const handler = async (request) => {
  // 1. 从请求体中解析问题和会话ID
  const { question, sessionId } = await request.json();

  // 2. 使用链的.stream方法创建流
  const stream = await finalConversationalChain.stream({
    question: question,
    configurable: { sessionId: sessionId }, // 传入会话ID
  });

  // 3. 将流直接作为响应返回
  return new Response(stream, {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  });
};

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/dlai-lngchn-llm-app/img/d7ddd2e0b23b404117a2a13486b3039f_11.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/dlai-lngchn-llm-app/img/d7ddd2e0b23b404117a2a13486b3039f_12.png)

// 启动服务器
serve(handler, { port });
console.log(`Server live on http://localhost:${port}`);

在实际生产部署中,您可能需要添加身份验证或输入验证中间件,但为了简单起见,这里我们暂时跳过。


测试API端点

服务器运行后,我们可以使用fetch API来测试它。以下是测试步骤:

  1. 发送第一个问题:询问课程的先决条件。
  2. 发送后续问题:测试聊天记忆功能。
  3. 使用新的会话ID:测试会话隔离,确保不会获取到其他用户的聊天历史。

以下是测试第一个问题的示例代码:

// 辅助函数:消费流式响应
async function readStream(reader) {
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    console.log(new TextDecoder().decode(value)); // 解码并打印字节块
  }
}

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/dlai-lngchn-llm-app/img/d7ddd2e0b23b404117a2a13486b3039f_19.png)

// 发起请求
const response = await fetch(`http://localhost:${port}`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    question: "这门课程的先决条件是什么?",
    sessionId: "session_001", // 用户A的会话ID
  }),
});

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/dlai-lngchn-llm-app/img/d7ddd2e0b23b404117a2a13486b3039f_21.png)

const reader = response.body.getReader();
await readStream(reader);

运行后,我们将收到关于课程先决条件的流式回答。

接着,我们使用相同的会话ID发送一个后续问题:“能用要点列表的形式列出它们吗?”。链应该能记住上下文,并将先决条件以列表形式重新表述。

最后,我们使用一个新的会话ID(如session_002)发送问题:“我刚才问你什么了?”。由于会话隔离,我们应该得到一个提示,表明在当前会话中没有之前的聊天历史,从而证明不同用户的对话是独立的。


总结

本节课中,我们一起学习了如何将LangChain对话链部署为Web API。我们实现了两个关键功能:

  1. 流式响应:通过HttpResponseOutputParser将模型输出转换为字节流,直接传递给Web响应,使得客户端能够更快地显示部分结果。
  2. 会话隔离:通过为每个用户会话创建独立的ChatMessageHistory实例,确保了多用户环境下聊天历史的私密性和准确性。

您现在拥有了一个可以处理并发、支持流式对话的LLM应用后端。鼓励您尝试提出不同的问题、使用不同的会话ID进行测试,并将这些概念应用到您自己的项目中。

008:总结 🎓

在本节课中,我们将对使用LangChain.js构建大型语言模型应用程序的整个课程进行总结,回顾所学到的核心知识与技能。


恭喜你完成本课程。现在你已经积累了一些经验,掌握了使用大型语言模型构建复杂、具备上下文感知能力的应用程序所需的基础知识。

同时,你也学习了一些关于如何将这些应用投入生产环境的知识。

对于Web开发者社区而言,利用他们独特的技能组合,结合这些强大的模型来开发出色的应用,存在着巨大的潜力。

我希望这门课程能对你的学习之旅有所帮助。

再次感谢你的观看,祝你使用LangChain开发愉快。


本节课中,我们一起学习了构建LLM应用程序的完整流程,从基础概念到生产部署。我们回顾了如何利用LangChain.js框架,结合开发者自身的技能,将大型语言模型的能力转化为实际可用的、智能的应用程序。希望这些知识能成为你未来开发之旅的坚实基础。

posted @ 2026-03-26 08:11  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报