DLAI-Langchian-大模型应用开发笔记-全-
DLAI Langchian 大模型应用开发笔记(全)
001:LangChain 简介 🚀

在本节课中,我们将学习什么是LangChain,它为何被创建,以及它的核心价值与主要组件。LangChain是一个用于构建大语言模型应用的开源框架,旨在简化开发流程。

通过提示大语言模型来开发应用,如今变得更快。然而,应用通常需要多次提示模型并解析其输出,这导致开发者需要编写大量“胶水代码”。Harrison Chase创建的LangChain正是为了简化这一过程。
我们很高兴Harrison能参与本课程。DeepLearning.AI与他合作开发了这门课程,旨在教授如何使用这个强大的工具。
感谢邀请,我非常高兴来到这里。LangChain最初是一个开源框架。当我与领域内的一些人交流时,发现他们正在构建更复杂的应用程序,并看到了开发过程中的一些共同抽象模式。
我们一直对LangChain社区的广泛采纳感到兴奋,因此期待与大家分享,并期待看到人们用它构建出什么。实际上,作为LangChain核心动力的标志,还有数百名开源贡献者,这对快速开发至关重要。团队以惊人的速度发布代码和功能。
因此,希望在这门短期课程后,你能快速使用LangChain开发出很酷的应用。也许你甚至会决定回馈开源LangChain社区。
LangChain是用于构建大语言模型应用的开源框架,它有两个不同的包:一个是Python包,另一个是JavaScript包。其设计专注于组合性和模块化。

它们包含许多可以单独使用或相互结合的独立组件,这是其关键价值之一。

另一个关键价值是它支持一系列不同的用例。这些模块化组件可以组合成更多端到端的应用程序,并且使得开始使用这些用例变得非常容易。

在接下来的课程中,我们将涵盖LangChain的常见组件。

我们将讨论模型,这是应用的核心。
我们将讨论提示,这是如何让模型执行有用和有趣任务的关键。

我们将讨论索引,即数据摄入的方式,以便你能将其与模型结合使用。

然后,我们将讨论用于端到端用例的链,以及代理。这些都是令人兴奋的端到端用例类型,它们将模型用作推理引擎。

我们也感谢Anish Gola,他与Harrison Chase共同创办了公司,深入思考了这些材料,并协助制作了这门短期课程。

在DeepLearning.AI方面,Jeff、Ludwig、Eddie Shu和Dilara作为院长也为这些材料做出了贡献。
那么,让我们继续观看下一个视频,在那里我们将学习模型的基础知识。


本节课总结:本节课我们一起学习了LangChain的起源、核心价值(模块化与组合性)以及其主要组件(模型、提示、索引、链和代理)。它为简化大语言模型应用开发提供了强大的框架支持。
002:模型、提示与输出解析


在本节课中,我们将学习LangChain的核心概念:模型、提示和输出解析。我们将了解如何使用LangChain的抽象来更高效地调用语言模型、构建可重用的提示模板,并将模型输出解析为结构化的格式,以便于下游应用使用。

🚀 开始之前:环境设置

要开始使用,我们需要导入必要的库并设置环境。

import os
import openai

# 加载你的OpenAI API密钥
openai.api_key = os.getenv("OPENAI_API_KEY")


如果你在本地运行且尚未安装openai库,你需要先运行以下命令:
pip install openai




🤖 直接调用模型

首先,我们回顾一下直接调用OpenAI API的方式。以下是一个辅助函数,用于调用GPT-3.5 Turbo模型。

def get_completion(prompt, model="gpt-3.5-turbo"):
messages = [{"role": "user", "content": prompt}]
response = openai.ChatCompletion.create(
model=model,
messages=messages,
temperature=0,
)
return response.choices[0].message["content"]

# 示例调用
prompt = "1加1是什么?"
response = get_completion(prompt)
print(response)


运行上述代码,模型会返回答案“2”。



📝 构建应用:翻译客户邮件


假设我们收到一封非英语(例如“海盗英语”)的客户投诉邮件,我们需要将其翻译成礼貌、尊重的美式英语,以便客服人员处理。

客户邮件内容如下:
“Arrr, I be fuming that me blender lid flew off and splattered me kitchen walls with smoothie! And to make matters worse, the warranty don't cover the cost of cleaning up me kitchen! I need yer help right now, matey!”


我们的目标是将其翻译为:“美式英语,语气冷静且尊重”。

我们可以构建一个提示来完成这个任务:

style = "美式英语,语气冷静且尊重"
text = "Arrr, I be fuming that me blender lid flew off and splattered me kitchen walls with smoothie! And to make matters worse, the warranty don't cover the cost of cleaning up me kitchen! I need yer help right now, matey!"


prompt = f"""将以下文本翻译成 {style}:
{text}
"""

response = get_completion(prompt)
print(response)

模型返回的翻译结果类似于:
“我非常沮丧,我的搅拌机盖子飞了出去,把厨房墙壁弄得一团糟!更糟糕的是,保修不包括清理厨房的费用!我现在真的需要你的帮助,朋友。”

这种方法有效,但如果我们有大量不同语言的邮件需要处理,手动构建每个提示会很繁琐。接下来,我们将看到如何使用LangChain来优化这个过程。


🔄 使用LangChain:模型与提示模板


LangChain提供了ChatOpenAI类来抽象化对ChatGPT API的调用,以及ChatPromptTemplate类来创建可重用的提示模板。


首先,我们导入LangChain的相关模块并设置模型。

from langchain.chat_models import ChatOpenAI
from langchain.prompts import ChatPromptTemplate


# 创建ChatOpenAI实例,设置temperature=0使输出更确定
chat = ChatOpenAI(temperature=0)

接着,我们定义一个提示模板。这个模板包含两个变量:style(目标风格)和text(待翻译文本)。


template_string = """将以下文本翻译成 {style}:
{text}
"""
# 创建提示模板
prompt_template = ChatPromptTemplate.from_template(template_string)


现在,我们可以使用这个模板为不同的场景生成具体的提示。

# 场景一:将客户邮件翻译成美式英语
customer_style = "美式英语,语气冷静且尊重"
customer_email = "Arrr, I be fuming that me blender lid flew off and splattered me kitchen walls with smoothie! And to make matters worse, the warranty don't cover the cost of cleaning up me kitchen! I need yer help right now, matey!"

# 生成消息列表(LangChain的标准格式)
customer_messages = prompt_template.format_messages(
style=customer_style,
text=customer_email
)


# 调用模型
customer_response = chat(customer_messages)
print(customer_response.content)
提示模板的优势在于可重用性。例如,当客服用英语回复后,我们可以使用同一个模板将回复翻译成“海盗英语”风格返回给客户。


# 场景二:将客服回复翻译成海盗英语
service_reply = """您好,根据保修条款,因您使用叉子戳搅拌机盖导致损坏,清理费用不在保修范围内。很抱歉带来不便。"""
service_style_pirate = "英语海盗风格,语气礼貌"

service_messages = prompt_template.format_messages(
style=service_style_pirate,
text=service_reply
)

service_response = chat(service_messages)
print(service_response.content)
使用提示模板,我们无需重复编写复杂的提示字符串,只需更改变量即可。这对于构建复杂、提示较长的应用程序尤其有用。LangChain还内置了许多常见任务(如摘要、问答、连接数据库)的提示模板,可以进一步加速开发。


🧩 输出解析:从文本到结构化数据


很多时候,我们希望语言模型的输出是结构化的(例如JSON),而不是纯文本,以便程序能够直接处理。这就是输出解析器的用武之地。

假设我们有一个产品评论,我们希望从中提取特定信息(例如:是否为礼物、送达天数、价格),并以JSON格式输出。


原始评论如下:
“这款睡眠风扇效果惊人,有4档风力设置,从轻柔的微风到龙卷风般强劲。两天后就送到了,正好赶上我给妻子的周年纪念礼物。我想我妻子很喜欢它,因为她至今没说话...不过我一直是唯一在使用它的人...”


我们期望的输出格式是一个Python字典:
{
"gift": true,
"delivery_days": 2,
"price_value": "相当实惠"
}

不使用解析器的尝试


我们可以先尝试用提示让模型直接输出JSON字符串。


review_template = """\
针对由三个反引号分隔的文本,请提取以下信息:
是否为礼物:如果购买是为了送给别人,则为真;如果是购买者自用,则为假;如果无法确定,则为未知。
送达天数:产品下单后多少天送达。如果未提及送达信息,输出-1。
价格价值:提取评论中关于产品价值的任何描述,例如“物超所值”、“价格合理”、“太贵了”等。
文本: ```{text}```
"""
prompt_template = ChatPromptTemplate.from_template(review_template)
messages = prompt_template.format_messages(text=product_review)
response = chat(messages)
print(response.content)

模型可能会输出类似这样的字符串:
是否为礼物:真
送达天数:2
价格价值:未明确提及


虽然内容正确,但response.content的类型是字符串,不是Python字典。我们无法直接通过键(如response[“gift”])来访问值。

使用LangChain的输出解析器


LangChain的StructuredOutputParser可以帮我们解决这个问题。它允许我们定义期望的输出模式(schema),并自动将模型输出解析为结构化的Python对象。
首先,我们定义输出模式。

from langchain.output_parsers import ResponseSchema, StructuredOutputParser


# 定义每个字段的模式
gift_schema = ResponseSchema(
name="gift",
description="购买的商品是送给别人的礼物吗?如果是,则为真;如果是自用,则为假;如果不确定,则为未知。"
)
delivery_days_schema = ResponseSchema(
name="delivery_days",
description="产品下单后多少天送达?如果未提及,则为-1。"
)
price_value_schema = ResponseSchema(
name="price_value",
description="评论中关于产品价值的描述,例如'物超所值'、'价格合理'、'太贵了'等。"
)


# 将模式放入列表
response_schemas = [gift_schema, delivery_days_schema, price_value_schema]


然后,我们创建输出解析器,并将其与提示模板结合。解析器会自动生成格式指令,并添加到提示中。


# 创建输出解析器
output_parser = StructuredOutputParser.from_response_schemas(response_schemas)

# 获取解析器提供的格式指令
format_instructions = output_parser.get_format_instructions()


# 创建包含格式指令的新提示模板
review_template_with_format = """\
针对由三个反引号分隔的文本,请提取以下信息:
{format_instructions}


文本: ```{text}```
"""


# 注意:提示中包含了{format_instructions}占位符
prompt = ChatPromptTemplate.from_template(template=review_template_with_format)


# 生成最终消息
messages = prompt.format_messages(
text=product_review,
format_instructions=format_instructions
)

# 调用模型
response = chat(messages)


最后,我们使用解析器来解析模型的输出字符串。


# 解析输出
output_dict = output_parser.parse(response.content)


# 现在output_dict是一个真正的Python字典
print(type(output_dict)) # <class 'dict'>
print(output_dict)
# 输出可能为:{'gift': True, 'delivery_days': 2, 'price_value': '未明确提及'}


# 我们可以像操作普通字典一样访问数据
print(f"是否为礼物: {output_dict['gift']}")
print(f"送达天数: {output_dict['delivery_days']}")
print(f"价格评价: {output_dict['price_value']}")


通过结合提示模板和输出解析器,我们构建了一个强大的流程:用定义好的模式提示模型,并自动将返回的文本解析为结构化的数据,极大地方便了后续的程序化处理。




📚 本节总结

在本节课中,我们一起学习了LangChain的三个核心组件:



- 模型:使用
ChatOpenAI等类来抽象化对大语言模型的调用,简化参数设置。 - 提示:使用
ChatPromptTemplate创建可重用的提示模板,提高代码的复用性和可维护性,并能方便地使用LangChain内置的各类提示。 - 输出解析:使用
StructuredOutputParser定义输出模式,将语言模型返回的非结构化文本自动解析为结构化的Python对象(如字典),便于下游应用程序直接使用。

通过将这些组件组合使用,你可以更高效、更可靠地构建基于大语言模型的应用程序。在下一节课中,我们将探索如何使用LangChain构建更复杂的聊天机器人或让模型进行更有效的多轮对话。
003:记忆 🧠


在本节课中,我们将要学习LangChain中的“记忆”功能。大型语言模型本身是无状态的,不会记住之前的对话。为了构建能够进行连贯对话的聊天机器人等应用,我们需要一种机制来存储和利用对话历史。本节课将介绍LangChain提供的多种记忆管理方式。

概述



与大型语言模型互动时,它们默认不会记住之前的对话内容。这是一个问题,因为当你构建如聊天机器人等应用时,你希望对话能够连贯进行。因此,在这一节中我们将涵盖记忆功能,即如何记住对话的前一部分并将其输入语言模型。这样,当你与模型互动时,它们可以有这种对话流程。LangChain提供了多种高级选项来管理这些记忆。


环境设置与基础记忆

首先,我们需要导入必要的API密钥和工具。

# 导入API密钥
import os
os.environ['OPENAI_API_KEY'] = 'your-api-key-here'

# 导入所需工具
from langchain.chains import ConversationChain
from langchain.chat_models import ChatOpenAI
from langchain.memory import ConversationBufferMemory


上一节我们介绍了环境设置,本节中我们来看看如何使用基础记忆功能。

我们将使用LangChain来管理聊天对话。为此,需要设置语言模型和记忆组件。


# 设置语言模型和记忆
llm = ChatOpenAI(temperature=0)
memory = ConversationBufferMemory()
conversation = ConversationChain(llm=llm, memory=memory, verbose=False)


在这门短课中,我们不会深入探讨链的本质和LangChain的所有细节,现在不必太担心语法的细节。但这构建了一个可以对话的LLM应用。


现在,让我们开始一段对话。


# 开始对话
response = conversation.predict(input="Hi, my name is Andrew.")
print(response) # 输出可能是:你好,很高兴见到你,Andrew。


response = conversation.predict(input="What is 1+1?")
print(response) # 输出:1加1等于2。

response = conversation.predict(input="What is my name?")
print(response) # 输出:你的名字是Andrew,如你之前所说。


通过设置 verbose=True,你可以查看LangChain生成的实际提示,了解记忆是如何被整合到每次对话中的。


记忆的工作原理


上一节我们介绍了基础对话,本节中我们来看看记忆内部是如何存储的。


LangChain存储对话的方式是使用“对话缓冲内存”。我们可以打印出当前记忆的内容。


# 查看记忆缓冲区
print(memory.buffer)
# 或者使用 load_memory_variables 方法
print(memory.load_memory_variables({}))


记忆缓冲区存储了到目前为止的所有对话。它只是AI或人类所说的一切内容的记录。

如果你想明确地向记忆中添加内容,可以手动保存上下文。


# 手动保存上下文到记忆
memory.save_context({"input": "Hello"}, {"output": "What's up?"})
memory.save_context({"input": "Not much, just hanging"}, {"output": "Cool"})
print(memory.load_memory_variables({}))



当你使用大型语言模型进行聊天对话时,语言模型本身实际上是无状态的。每次API调用都是独立的。聊天机器人之所以能记住对话,是因为有代码提供了迄今为止的完整对话作为上下文。因此,记忆功能可以明确存储对话历史,并将其作为输入或附加上下文提供给模型,以便它们可以生成知道之前说了什么的回复。

不同类型的记忆



随着对话变长,所需内存量会变得非常大,发送大量令牌到语言模型的成本也会增加。因此,LangChain提供了几种方便的内存类型来存储和累积对话。


我们一直在看“对话缓冲内存”。让我们看看另一种类型的内存。


对话缓冲窗口内存


这种内存只保留最近的一段对话。


from langchain.memory import ConversationBufferWindowMemory


# 只记住最近一次对话交换(k=1)
memory = ConversationBufferWindowMemory(k=1)
conversation = ConversationChain(llm=llm, memory=memory, verbose=False)


# 进行对话
conversation.predict(input="Hi, my name is Andrew.")
conversation.predict(input="What is 1+1?")
response = conversation.predict(input="What is my name?")
print(response) # 输出可能:抱歉,我没有访问这些信息。


因为 k=1,它只记得最近一次交流(“1+1=2”),而忘记了更早的交流(“我的名字是Andrew”)。这是一个很好的功能,因为它让你可以跟踪最近的几次对话,防止内存无限制增长。在实践中,你可能会将 k 设置为一个较大的数字。



对话令牌缓冲记忆


这种记忆将限制保存的令牌数,这更直接地映射到语言模型调用的成本。



from langchain.memory import ConversationTokenBufferMemory


# 设置最大令牌限制
memory = ConversationTokenBufferMemory(llm=llm, max_token_limit=50)
conversation = ConversationChain(llm=llm, memory=memory, verbose=False)


# 进行一段较长的对话
inputs = ["AI is amazing.", "Backpropagation is beautiful.", "Chatbots are cool."]
for inp in inputs:
_ = conversation.predict(input=inp)

print(memory.load_memory_variables({}))

如果你减少令牌限制,那么它会切掉对话的早期部分,以保留对应最近交流的令牌数,但受限于不超过令牌限制。需要指定 llm 参数是因为不同的语言模型使用不同的计数令牌方式。



对话摘要缓冲记忆


这种记忆的想法不是限制记忆到固定数量的令牌或基于最近的陈述,而是使用语言模型为对话生成摘要,让摘要成为记忆。


from langchain.memory import ConversationSummaryBufferMemory



# 创建摘要记忆
memory = ConversationSummaryBufferMemory(llm=llm, max_token_limit=100)
conversation = ConversationChain(llm=llm, memory=memory, verbose=True)


# 输入一段长文本(例如日程)
schedule = "早上9点与产品团队会议,需要准备PPT。中午12点在意大利餐厅与客户共进午餐,记得带上笔记本电脑展示最新的产品演示。下午3点进行代码审查。"
conversation.predict(input=f"Today's schedule is: {schedule}")

# 询问关于日程的问题
response = conversation.predict(input="What should I present to the client?")
print(response)
它试图做的是保持消息的显式存储,直到达到我们指定的标记数为止。任何超出部分它将使用语言模型生成摘要来保存。
其他记忆类型与总结

尽管我们已用聊天示例说明了这些不同记忆,但这些记忆对其他应用也有用。例如,若系统反复在线搜索事实,但你想保持总记忆量,不让列表任意增长。


实际上,LangChain还支持其他类型的内存。


- 向量数据库记忆:如果你熟悉词嵌入和文本嵌入,向量数据库可以存储这样的嵌入,并检索最相关的文本块作为记忆。这是一种强大的记忆方式。
- 实体记忆:适用于你想记住特定人或特定实体的细节时。比如谈论特定朋友,可以让LangChain记住关于那个朋友的事实。


在用LangChain实现应用时,也可以组合使用多种类型记忆。例如,使用一种对话记忆来记住对话流程,同时使用实体记忆来回忆对话中重要人物的具体信息。



当然,除了使用这些记忆类型,开发人员将整个对话存储在传统数据库(如键值存储或SQL数据库)中也很常见。


总结



本节课中我们一起学习了LangChain中的记忆功能。我们了解到大型语言模型本身是无状态的,需要外部机制来管理对话历史。我们介绍了对话缓冲内存、对话缓冲窗口内存、对话令牌缓冲内存和对话摘要缓冲内存这几种核心记忆类型,并了解了它们各自的适用场景和配置方法。最后,我们还简要提到了更高级的向量数据库记忆和实体记忆。合理利用记忆功能,是构建流畅、智能对话应用的关键。
004:链 🔗


在本节课中,我们将学习LangChain中最重要的基础构建块——链。链通常结合一个大语言模型和一个提示,你也可以将这些构建块组合在一起,执行文本或其他数据的序列操作。

环境与数据准备


我们将像以前一样加载环境变量。我们还将加载一些稍后要使用的数据。

这些链的强大之处在于你可以同时运行它们。


我们将加载一个Pandas数据框。Pandas数据框是一种数据结构,包含大量不同元素的数据。

如果你不熟悉Pandas,不用担心。主要观点是我们正在加载一些稍后可以使用的数据。

所以如果我们查看这个Pandas数据框,我们可以看到有一个产品列,然后有一个评论列。每行都是我们可以开始通过链条的不同数据点。


LLM链:基础构建块

我们要覆盖的第一个链条是LLM链。这是一个简单但非常强大的链条,它支撑着未来将讨论的许多链条。

我们将导入三个不同的东西。我们将导入OpenAI模型,所以我们将导入LLM聊天提示模板,这就是提示,然后我们将导入LLM链条。


所以首先我们要做的是,我们将初始化要使用的语言模型。

我们将初始化聊天OpenAI,带有高温度。这样我们可以得到一些有趣的描述。

现在我们将初始化一个提示。这个提示将接受一个名为product的变量,它将要求LLM生成描述生产该产品的公司的最佳名称。

最后,我们将把这两个东西组合成一个链条,这就是我们所说的LLM链条。它非常简单,它只是LLM和提示的组合。

但现在这个链条将允许我们按顺序通过提示和LLM运行。


以序列方式,所以如果我们有一个名为“queen size sheet set”的产品,我们可以通过使用chain.run运行它。这将内部格式化提示,然后将整个提示传递给LLM。

因此,我们可以看到我们得到了这个假设公司的名称,称为“皇家床品”。


现在暂停是个好时机,可输入任何产品描述,可查看链输出结果。


LLM链是最基本类型,未来将广泛使用。可看到如何用于下一种链,即顺序链。


顺序链:串联执行

顺序链依次运行一系列链。

首先导入简单顺序链。这效果很好,当子链只期望一个输入和一个输出时。


因此,我们首先将创建一个链。它将使用一个LLM和一个提示,这个提示将接受产品,并将返回描述该公司的最佳名称。所以这将是第一个链。


然后,我们将在第二个链中创建第二个链,我们将输入公司名称,然后输出该公司的20字描述。

因此,你可以想象这些链可能希望一个接一个地运行。第一链输出所在,公司名传入第二链。


创建简单序列链可轻易实现。描述的两个链在此,整体称为简单链,现可运行此链于任何产品描述。

若与上述皇后尺寸床单一起使用,可运行并查看首先输出“皇家寝具”,然后传入第二链,得出该公司描述。


简单序列链在单输入单输出时效果好。但多输入或多输出时如何?仅用常规序列链即可做到。


导入它,然后创建将使用的多个链,一个接一个。将使用上面的评论数据,含评论。


以下是我们要创建的链:


- 翻译链:首先将评论翻译成英语。
- 总结链:用第二条链,将(英文)评论总结为一句话。
- 语言检测链:第三条链将检测评论的原始语言。
- 响应链:最后,第四条链将接受多个输入。这将接受我们使用第二条链计算的
summary变量,以及我们使用第三条链计算的language变量,并将以指定语言请求对摘要的后续响应。

关于所有这些子链的一个重要事项,输入和输出键需要精确。我们正在回顾,这是开始时传入的变量。

我们可以看到明确设置了输出键为english_review。这在下面的下一个提示中被使用,我们使用相同的变量名输入english_review。并将该链的输出键设置为summary。我们可以在最终链中看到它被使用。

第三个提示输入原始变量和输出language,这再次在最终提示中被使用。


变量名对齐很重要。因输入输出很多,若有键错误,应检查对齐。

简单序列链接受多链,每链一输入一输出。看图,一链入另一链。这里序列链视觉描述。


与上链比较,可见链中任一步可多输入。这很有用,当你有更复杂的下游链需要组合多个先前链时。


现在我们有所有这些链,我们可以轻松地在顺序链中组合它们。


所以你会注意到,我们将创建的四个链传递给链,变量将创建包含一个人类输入的输入变量,即评论。然后我们想要返回所有中间输出,所以英语评论,摘要,然后是后续消息。

现在我们可以运行数据。让我们选个评论,通过总链传递。


可见原始评论似乎是法语,可见英文评论为翻译,可见该评论的摘要,然后可见原始语言的法语后续消息。

你应该在这里暂停视频,尝试不同的输入。


我们涵盖了LLM链和顺序链,但如果你想做更复杂的事,一个常见但基本操作是将输入路由到链。


路由器链:智能分发
根据输入内容,想象一下,如果你有多个子链,每个都针对特定类型输入,你可以有一个路由器链,它首先决定将输入传递给哪个子链,然后传递给该链。

具体例子,让我们看看我们在不同类型链之间路由,根据进来的主题。我们有不同的提示,一个好问题解答物理,第二个解答数学,第三个历史,第四个计算机科学,定义所有提示模板。

有了这些提示模板,然后提供更多信息,可以给每个命名。然后描述,物理的描述,一个适合回答物理问题。此信息将传递给路由器链。因此路由器链可决定何时使用此子链。


现在导入我们需要的其他类型链。我们需要一个多提示链,这是一种特定类型的链,用于在多个不同的提示模板之间路由。如你所见,我们所有的选项都是提示模板本身。但这只是你可以路由之间的类型之一,你可以在任意类型的链之间路由。

我们还将实现的其他类是LLM路由器链,使用语言模型路由子链。将使用上述描述和名称。



导入路由器输出解析器,解析输出为字典,用于下游确定使用哪个链,以及该链的输入。


现在可以开始使用它,导入并定义将使用的语言模型。


创建目标链,由路由器链调用的链。如你所见,每个目标链本身是语言模型链,一个LLM链。


除了目标链外,我们还需要一个默认链,这是一个被称为,当路由器无法决定使用哪个子链时。上面的例子可能在输入问题与物理无关时被调用,数学历史,或计算机科学。


现在定义LLM用于在不同链间路由的模板。这是任务说明,输出格式需符合特定要求。


让我们把这些部件组合起来构建路由器链。


首先,我们以上述目的地格式化完整路由器模板。此模板适用于多种目的地,这里你可以做的是暂停,添加不同类型的目的地。所以在这里,与其只是物理学,数学史和计算机科学,可添加不同主题,如英语或拉丁语。


接下来创建基于此模板的提示模板,然后通过传入LLM创建路由器链。注意,总路由器提示包含路由器输出解析器,这很重要,因为它将帮助此链决定路由到哪些子链。


最后,整合所有内容,可以创建总链,它有一个在此定义的路由器链,它包含在此传递的目的地链,然后还传递默认链。


现在可以使用此链。所以让我们问你一些问题,如果我们问它一个关于物理的问题,希望看到它路由到物理链并带有输入。

什么是黑体辐射,然后传递到下面的链。我们可以看到响应非常详细,包含许多物理细节。

在这里暂停视频并尝试输入不同内容,可以尝试使用上面定义的所有其他特殊链。


例如,如果我们问它一个数学问题,应该看到它路由到数学链,然后传递到那里。


我们还可以看到当我们传递一个问题时,它与任何子链无关,所以这里我们问它一个关于生物的问题。我们可以看到它选择的链是空,这意味着它将传递到默认链。它本身只是一个对语言模型的通用调用,幸运的是,语言模型对生物学了解很多。


总结


本节课中我们一起学习了LangChain中的核心构建块——链。


我们首先介绍了最基本的LLM链,它结合了语言模型和提示模板。接着,我们学习了如何将多个链串联起来,构建顺序链,以实现更复杂的多步处理流程。最后,我们探讨了路由器链,它能够根据输入内容智能地将任务分发给不同的专业子链进行处理。

这些链是构建强大LangChain应用的基础,通过组合它们,你可以创建出能够执行序列化、条件化处理的智能应用程序。在接下来的课程中,我们将利用这些基础构建更高级的应用。
005:基于文档的问答 📚


在本节课中,我们将要学习如何构建一个基于文档的问答系统。这是利用大语言模型(LLM)构建的最常见、最强大的复杂应用之一。我们将学习如何将语言模型与您自己的文档(如PDF、网页或公司内部资料)结合起来,让模型能够回答关于这些文档内容的问题,从而帮助用户深入理解并获取所需信息。
环境准备与导入 🛠️

上一节我们介绍了课程目标,本节中我们来看看如何准备开发环境。首先,我们需要导入必要的库和设置环境变量。
import os
# 假设已设置 OPENAI_API_KEY 环境变量


from langchain.chains import RetrievalQA
from langchain.chat_models import ChatOpenAI
from langchain.document_loaders import CSVLoader
from langchain.vectorstores import DocArrayInMemorySearch
from IPython.display import display, Markdown

以下是关键导入项的说明:
RetrievalQA: 用于构建检索式问答链的核心类。ChatOpenAI: 我们将使用的聊天语言模型。CSVLoader: 用于加载CSV格式的专有数据文档。DocArrayInMemorySearch: 一个易于入门的内存向量存储,无需连接外部数据库。display和Markdown: 在Jupyter Notebook中显示格式化结果的工具。


快速开始:一行代码构建问答链 ⚡

现在,让我们使用LangChain提供的高级接口快速构建一个可运行的问答系统。我们将加载一个关于户外服装的CSV文件作为我们的知识库。


file = 'OutdoorClothingCatalog_1000.csv'
loader = CSVLoader(file_path=file)



from langchain.indexes import VectorstoreIndexCreator


index = VectorstoreIndexCreator(
vectorstore_cls=DocArrayInMemorySearch
).from_loaders([loader])



代码解释:
- 使用
CSVLoader加载指定路径的CSV文件。 - 使用
VectorstoreIndexCreator并指定向量存储类为DocArrayInMemorySearch。 - 调用
.from_loaders([loader])方法,传入文档加载器列表,自动完成文档加载、分块、创建嵌入和构建向量存储索引的过程。

索引创建完成后,我们就可以直接进行提问了。


query = "Please list all your shirts with sun protection in a table. Summarize each one."
response = index.query(query)
display(Markdown(response))

执行以上代码,语言模型会基于CSV文档中的信息,返回一个包含防晒衬衫名称和描述的Markdown表格,并附上总结。

核心原理:嵌入与向量存储 🧠


上一节我们快速体验了文档问答的效果,本节中我们来深入了解一下其背后的核心原理。直接让语言模型处理长文档存在一个关键问题:语言模型的上下文窗口有限,一次只能处理几千个单词。


那么,如何让语言模型回答超长文档中的所有问题呢?这时,嵌入(Embeddings) 和 向量存储(Vector Stores) 就开始发挥作用了。


理解嵌入


嵌入是为文本片段创建数值表示(即向量)的过程。这种表示能够捕获文本的语义含义。

# 伪代码示意:文本 -> 向量
embedding_vector = embed("I love programming.")


具有相似含义的文本,其向量在向量空间中的位置也会相近。例如,关于“宠物”的句子向量会彼此靠近,而与关于“汽车”的句子向量则相距较远。这使我们能够通过计算向量相似度来找出语义相近的文本。

理解向量数据库


向量数据库是专门用于存储和检索这些向量表示的数据系统。构建向量数据库的典型流程如下:
- 文档分块:将长文档拆分成较小的文本块,以适应语言模型的上下文限制。
- 创建嵌入:为每个文本块生成对应的向量。
- 存储向量:将文本块及其向量存入向量数据库。


当用户提出查询时,系统会:
- 为查询文本生成嵌入向量。
- 在向量数据库中搜索与查询向量最相似的K个文本块(即相似性搜索)。
- 将这些最相关的文本块作为上下文,与原始问题一起构造提示(Prompt),发送给语言模型以生成最终答案。

逐步实现:拆解问答链的构建 🛠️


上一节我们了解了核心概念,本节我们将手动逐步实现一个问答链,以彻底理解每个环节。

第一步:加载文档

这与快速开始的第一步类似。

loader = CSVLoader(file_path=file)
docs = loader.load()
# 查看一个文档,对应CSV中的一行(一个产品)
print(docs[0].page_content)



第二步:创建嵌入并构建向量存储


由于我们的文档(每个产品描述)本身已经很小,可以跳过显式的分块步骤,直接创建嵌入。



from langchain.embeddings import OpenAIEmbeddings
embedding = OpenAIEmbeddings()



# 为单个句子创建嵌入示例
sample_embedding = embedding.embed_query("Hi, my name is Harrison")
print(f"嵌入向量维度: {len(sample_embedding)}")


# 为所有文档创建嵌入并构建向量存储
db = DocArrayInMemorySearch.from_documents(docs, embedding)


第三步:执行相似性搜索


现在我们可以使用向量存储来查找与用户查询最相关的文档。


query = "Please recommend a shirt with sunblocking."
docs_result = db.similarity_search(query)
print(docs_result[0].page_content)

第四步:创建检索器并组装问答链



检索器(Retriever)是一个通用接口,db.as_retriever() 会返回一个基于我们向量存储的检索器对象。



retriever = db.as_retriever()
llm = ChatOpenAI(temperature=0.0)

# 手动模拟问答过程(仅示意)
# 1. 检索相关文档
relevant_docs = retriever.get_relevant_documents(query)
# 2. 合并文档内容到提示中
combined_context = "\n\n".join([doc.page_content for doc in relevant_docs])
# 3. 构造最终提示并调用LLM(实际链中自动完成)


LangChain 的 RetrievalQA 链封装了以上所有步骤。

qa_chain = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff", # 使用最简单的“堆叠”方法
retriever=retriever,
verbose=True # 显示详细执行过程
)
response = qa_chain.run(query)
display(Markdown(response))



chain_type="stuff" 表示将所有检索到的文档内容“堆叠”到一个提示中,然后调用一次语言模型。这种方法简单高效,适用于检索到的文档总长度不超过模型上下文限制的场景。


进阶讨论:不同的链类型与总结 🚀


我们使用了 stuff 方法,但它并非唯一选择。LangChain 提供了多种处理多文档的链类型,适用于不同场景:

以下是几种主要的链类型:
- Stuff: 将所有文档内容放入一个提示中。优点是简单、成本低、只调用一次LLM。缺点是受限于模型的上下文长度。
- MapReduce: 首先为每个文档单独调用LLM生成答案(Map),然后调用另一个LLM来汇总所有独立答案(Reduce)。优点是可处理任意数量的文档,且Map步骤可并行。缺点是LLM调用次数多,成本较高,且文档间信息不交互。
- Refine: 迭代处理文档,基于前一个文档的答案和当前文档来逐步完善最终答案。优点适合需要整合跨文档信息的复杂答案。缺点是速度慢,调用不独立,且答案可能冗长。
- MapRerank: 为每个文档调用LLM,要求其返回一个答案及相关性分数,最后选择分数最高的答案。优点是速度快,可批量处理。缺点是严重依赖LLM评分能力,需要精心设计提示,且调用次数多。


对于文档摘要等需要处理超长文本的任务,MapReduce 是一种非常常用的模式。



课程总结 📝


本节课中我们一起学习了基于文档的问答系统的构建。
- 目标:我们学会了如何将大语言模型与外部文档结合,构建能够回答特定领域问题的智能应用。
- 核心组件:我们深入理解了嵌入和向量存储这两个关键技术,它们通过语义搜索解决了模型上下文有限的瓶颈。
- 实践方法:我们掌握了两种构建方式:使用
VectorstoreIndexCreator快速原型开发,以及手动分步实现以获得更精细的控制。 - 链类型:我们了解了
stuff、map_reduce、refine和map_rerank等不同的文档处理策略及其适用场景。

通过本课,你已经掌握了构建强大文档问答应用的基础。在接下来的课程中,我们将探索LangChain中更多的链和组件。
006:评估 📊
在本节课中,我们将学习如何评估基于大语言模型(LLM)构建的应用。评估是开发流程中至关重要但有时又很棘手的一步,它能帮助我们判断应用是否满足准确性标准,并在我们更改实现(如替换模型、调整检索策略或修改参数)时,确认改进是否有效。
1. 理解评估的挑战
上一节我们介绍了如何构建一个问答链。本节中,我们来看看如何评估它的表现。LLM应用通常由多个步骤串联而成,因此理解每个步骤的输入和输出是评估的基础。虽然一些工具可以像调试器一样可视化这些步骤,但要全面评估模型表现,查看大量数据点会更有帮助。
手动检查是一种方法,但使用语言模型本身来评估其他模型和应用则更为高效和强大。随着开发范式转向基于提示的工程,评估流程也在被重新思考,这带来了许多激动人心的新概念。
2. 设置待评估的应用
首先,我们需要一个待评估的链或应用。我们将使用上一节课构建的文档问答链作为示例。
以下是设置步骤:
# 导入所需库,加载数据,创建索引
from langchain.vectorstores import Chroma
from langchain.embeddings import OpenAIEmbeddings
from langchain.chains import RetrievalQA
from langchain.chat_models import ChatOpenAI
# 加载文档并创建向量数据库
documents = load_documents()
vectorstore = Chroma.from_documents(documents, OpenAIEmbeddings())
# 创建检索QA链
qa_chain = RetrievalQA.from_chain_type(
llm=ChatOpenAI(),
chain_type="stuff",
retriever=vectorstore.as_retriever(),
verbose=False
)
3. 创建评估数据集
有了应用后,我们需要确定用于评估的数据点。我们将介绍几种方法。
方法一:手动创建示例
最简单的方法是手动构思一些好的查询-答案对。例如,浏览文档后,我们可以提出:
- 查询:舒适套头衫套装是否有侧口袋?
- 真实答案:是。
但这种方法扩展性差,需要为每个示例花费时间。
方法二:使用语言模型自动生成
我们可以利用语言模型本身来自动化生成评估数据集。LangChain提供了QAGenerationChain工具。
以下是生成问答对的步骤:
from langchain.evaluation.qa import QAGenerationChain
# 创建问答生成链
example_gen_chain = QAGenerationChain.from_llm(ChatOpenAI())
# 为文档生成问答对
examples = example_gen_chain.apply_and_parse([{"doc": t} for t in documents[:5]])
这个链会分析每个文档的内容,并自动生成相关的查询和对应的真实答案,极大地节省了时间。
现在,我们可以将手动和自动生成的示例合并,形成一个评估数据集。
4. 调试与检查链的内部状态
在评估所有示例之前,了解单个查询在链中是如何处理的很有帮助。仅仅查看最终答案是不够的,我们需要知道中间步骤、检索到的文档以及传递给LLM的完整提示。
LangChain提供了一个实用工具 langchain.debug。
设置并运行调试:
import langchain
langchain.debug = True
# 运行链,将看到详细的内部信息输出
result = qa_chain.run("舒适套头衫套装是否有侧口袋?")
langchain.debug = False
启用调试模式后,运行链会输出详细信息,例如:
- 进入
RetrievalQA链。 - 进入
StuffDocuments链(使用stuff方法合并文档)。 - 进入
LLMChain,显示输入(原始问题、检索到的上下文)。 - 进入
ChatOpenAI模型,显示完整的提示模板和系统消息。 - 最终输出答案及Token使用量等元数据。
这有助于定位问题:如果答案错误,可能是检索步骤没找到相关文档,而不是语言模型本身的问题。
5. 使用语言模型进行自动化评估
为所有示例手动检查预测结果非常乏味。我们可以再次借助语言模型来自动化评估过程。
首先,为数据集中的所有示例生成预测:
# 为所有示例运行QA链,生成预测答案
predictions = []
for example in evaluation_dataset:
pred = qa_chain.run(example["query"])
predictions.append(pred)
然后,使用LangChain的QAEvalChain进行评估:
from langchain.evaluation.qa import QAEvalChain
# 创建评估链
eval_chain = QAEvalChain.from_llm(ChatOpenAI())
# 执行评估
graded_outputs = eval_chain.evaluate(
evaluation_dataset,
predictions,
question_key="query",
answer_key="answer",
prediction_key="result"
)
# 查看评估结果
for i, (example, prediction, grade) in enumerate(zip(evaluation_dataset, predictions, graded_outputs)):
print(f"示例 {i+1}:")
print(f"问题: {example['query']}")
print(f"真实答案: {example['answer']}")
print(f"预测答案: {prediction}")
print(f"评分: {grade['text']}")
print("-" * 50)
使用语言模型评估的核心优势在于它能理解语义。两个字符串在字面上可能完全不同(例如“是”和“舒适保暖套头衫条纹确实有侧口袋”),但只要含义正确,语言模型就能给出“正确”的评分。这是传统的字符串匹配或正则表达式方法无法做到的。
6. 利用LangSmith平台进行持久化评估
最后,我们介绍LangChain的官方平台——LangSmith。它可以将我们在笔记本中进行的运行、调试和评估工作持久化,并在一个统一的UI界面中展示。
在LangSmith平台中,你可以:
- 追踪运行:查看所有历史查询的输入和输出。
- 可视化链:以更清晰的方式查看链的每一步,包括中间状态和传递给模型的提示。
- 构建数据集:直接将从应用运行中得到的好的查询-答案对添加到数据集中,方便后续评估。
- 实现评估飞轮:持续运行应用,收集数据,进行评估,并用结果指导应用优化,形成一个闭环。
这为管理和规模化评估流程提供了一个强大的工具。


本节课总结:
在本节课中,我们一起学习了评估LLM应用的完整流程。我们从理解评估挑战开始,然后设置了一个待评估的问答链。接着,我们探讨了两种创建评估数据集的方法:手动创建和使用LLM自动生成。为了深入理解应用行为,我们使用了langchain.debug工具来调试和检查链的内部状态。最重要的是,我们学会了如何利用语言模型本身作为评估器,对预测结果进行自动化、基于语义的评分。最后,我们介绍了LangSmith平台,它能为评估工作流提供持久化、可视化和规模化的支持。掌握这些评估方法,是构建可靠、高效大模型应用的关键。
007:代理 🧠

在本节课中,我们将要学习LangChain中的代理框架。代理允许我们将大语言模型视为一个推理引擎,而非仅仅是知识库。通过代理,模型可以调用外部工具(如计算器、搜索引擎或自定义API)来获取信息、进行计算并决定下一步行动,从而完成更复杂的任务。
环境与模型初始化 ⚙️
上一节我们介绍了代理的基本概念,本节中我们来看看如何设置环境并初始化模型。
首先,我们需要设置环境变量并导入必要的库。然后,我们将初始化语言模型。这里我们使用ChatOpenAI,并将温度设置为0,以确保模型作为推理引擎时输出稳定且精确。


from langchain.chat_models import ChatOpenAI

llm = ChatOpenAI(temperature=0)


加载与使用内置工具 🛠️


初始化模型后,我们需要为代理配备工具。LangChain提供了一些内置工具,例如用于数学计算的LLMMathTool和用于查询的Wikipedia工具。

以下是加载这两个工具的代码:


from langchain.agents import load_tools


tools = load_tools(["llm-math", "wikipedia"], llm=llm)

llm-math工具:本身是一个链,结合了语言模型和计算器来解决数学问题。wikipedia工具:一个API包装器,允许代理对维基百科执行搜索查询并获取结果。


创建并运行代理 🤖

现在,我们可以使用加载的工具和语言模型来初始化一个代理。我们将使用initialize_agent函数,并指定代理类型为"chat-zero-shot-react-description"。这个类型专为与聊天模型配合使用而优化,并采用了“ReAct”提示技术以获得更好的推理性能。


from langchain.agents import initialize_agent


agent = initialize_agent(
tools,
llm,
agent="chat-zero-shot-react-description",
handle_parsing_errors=True, # 处理模型输出解析错误
verbose=True # 打印详细执行步骤
)

代理创建完成后,我们就可以向它提问了。让我们先问一个简单的数学问题。

agent.run("三百的百分之二十五是多少?")

执行时,verbose=True会让我们看到代理的思考过程:
- 思考:模型分析问题,决定需要做什么。
- 动作:模型输出一个JSON结构,指定要使用的工具(如
Calculator)和输入(如"300*0.25")。 - 观察:工具执行并返回结果(如
75.0)。 - 最终答案:模型根据观察结果,生成最终答案返回给用户。


探索更复杂的查询:维基百科示例 🔍


接下来,我们看看代理如何处理需要外部知识的问题。我们将询问关于“汤姆·米切尔”的信息。

agent.run("汤姆·米切尔写了哪本书?")

代理会识别出需要使用Wikipedia工具,并执行搜索。它可能得到多个结果(例如,同名的计算机科学家和足球运动员)。通过阅读返回的摘要,代理能够推理出正确的答案(《机器学习》),并最终给出回应。这个过程展示了代理如何串联使用工具和推理能力。
创建代码执行代理 💻
代理的一个强大功能是能够编写并执行代码。这类似于ChatGPT的代码解释器插件。我们将创建一个使用PythonREPLTool的代理,它可以在一个安全的沙箱环境中运行Python代码。
以下是创建Python代理的步骤:


from langchain.agents import load_tools
python_tools = load_tools(["python_repl"])
python_agent = initialize_agent(
python_tools,
llm,
agent="chat-zero-shot-react-description",
verbose=True
)

现在,我们可以让这个代理解决一个编程问题,例如对一个名字列表进行排序。

question = """
请按姓氏对以下名字列表进行排序,然后按名字排序,并打印结果:
[‘哈里森·蔡斯’, ‘LangChain LLM’, ‘杰夫·融合’, ‘变压器·生成AI’]
"""
python_agent.run(question)


代理会思考如何用代码解决这个问题,然后生成并执行相应的Python代码(如使用sorted函数和lambda表达式),最后将代码的输出作为观察结果返回,并整理出最终答案。


深入调试与理解内部机制 🔬


为了更深入地理解代理的工作流程,我们可以开启LangChain的调试模式。这将打印出链中所有层级的详细输入和输出,帮助我们看清模型接收的完整提示、工具调用的细节以及中间状态的变化。



import langchain
langchain.debug = True

# 再次运行代理
agent.run("一个简单的问题")


通过调试输出,你可以看到:
- 传递给语言模型的完整提示模板,其中包含了工具说明和输出格式要求。
- 语言模型生成的原始响应。
- 传递给工具的精确输入和工具返回的输出。
- 代理如何将历史上下文(动作、观察) 组合起来,形成下一步推理的输入。

这对于诊断代理出错的原因或优化提示工程非常有帮助。



构建自定义工具 🔗

目前我们使用了LangChain的内置工具,但代理的真正优势在于能够连接到任何自定义的数据源或API。接下来,我们将介绍如何创建自定义工具。
我们将创建一个简单的工具,用于返回当前日期。

首先,导入工具装饰器并定义函数:
from langchain.tools import tool
from datetime import datetime

@tool
def time(text: str) -> str:
"""
当需要知道当前日期时使用此工具。
输入应始终为空字符串。
"""
return datetime.now().strftime("%Y-%m-%d")


关键点:函数的文档字符串至关重要。代理通过阅读它来决定何时以及如何调用这个工具。我们需要清晰说明工具的用途和输入格式。


然后,将这个新工具加入到工具列表中,并创建一个新的代理:


custom_tools = [time] + tools # 将自定义工具与之前加载的工具合并
custom_agent = initialize_agent(
custom_tools,
llm,
agent="chat-zero-shot-react-description",
verbose=True
)


# 现在可以询问日期了
custom_agent.run("今天的日期是什么?")


代理会识别出需要使用time工具,调用它获取当前日期,并最终给出回答。



总结 📝


本节课中我们一起学习了LangChain代理框架的核心内容。我们了解到代理如何将大语言模型转变为推理引擎,通过调用外部工具来扩展其能力。我们实践了:

- 如何初始化代理并为其加载内置工具(如数学计算和维基百科查询)。
- 如何观察代理的思考-行动-观察循环,理解其问题解决流程。
- 如何创建代码执行代理,让模型能够编写和运行Python代码。
- 如何利用调试模式深入探查代理的内部工作机制。
- 如何构建自定义工具,将代理连接到任意的数据源或API。

代理是LangChain中强大且前沿的部分,它为实现复杂、动态的AI应用打开了大门。希望本教程能帮助你开始构建自己的智能代理应用。
008:【LangChain大模型应用开发】课程总结 🎯
在本节课中,我们将对这门短课的核心内容进行回顾与总结。我们将梳理已学习的应用类型,理解LangChain如何简化开发流程,并展望语言模型的更多可能性。

在这门短课中,我们学习了一系列基于大语言模型的应用开发实例。

这些应用包括处理客户评论、构建文档问答系统,以及使用语言模型决策何时调用外部工具(如网络搜索)来回答复杂问题。
如果在一两周前,有人问及构建所有这些应用程序需要多少工作量,许多人可能会认为这需要数周甚至更长时间。
然而,在这门短课中,我们仅用了几行简洁的代码就实现了这些功能。这证明了你可以使用LangChain高效地构建各类应用程序。
因此,我希望你能吸收这些想法,并尝试将在Jupyter笔记本中看到的代码片段应用到自己的项目中。
这些应用只是一个起点。由于大语言模型功能强大且适用于广泛的任务,你还可以利用它们开发许多其他类型的应用。
无论是回答关于CSV文件的问题、查询SQL数据库,还是与API进行交互,LangChain都提供了丰富的示例。
这些功能主要通过组合使用链(Chains)、提示(Prompts) 和输出解析器(Output Parsers) 来实现。LangChain中更多的链式组件使得完成所有这些任务成为可能。
这一切在很大程度上要归功于LangChain社区的贡献。

在此,我想向社区中的每一位成员表示衷心的感谢。无论是通过改进文档帮助他人更容易上手,还是通过创建新的链式组件开启了全新的可能性世界。


课程到此结束。如果你还没有开始实践,我希望你现在就打开你的笔记本电脑或台式机,运行 pip install langchain 命令来安装LangChain,并开始你的探索之旅。

本节课总结
在本节课中,我们一起回顾了本课程涵盖的核心应用类型,理解了LangChain框架如何通过简洁的代码大幅降低大模型应用开发的门槛。我们认识到,借助强大的语言模型和活跃的社区生态,开发者可以高效构建从文本处理到复杂决策的多样化应用。最后,我们鼓励你立即动手安装LangChain,将所学知识付诸实践。
009:📚 构建与数据对话的聊天机器人(全)—— 介绍


概述
在本节课中,我们将要学习如何使用 LangChain 框架来构建一个能与你的专属数据进行对话的聊天机器人。大型语言模型(LLMs)虽然强大,但其知识通常局限于训练数据。本课程将指导你如何让 LLM 访问并利用你的私有文档来回答问题。
课程内容
大型语言模型(LLMs),例如 ChatGPT,能够回答许多主题的问题。但一个孤立的 LLM 只知道它被训练过的内容,不包括你的个人资料。例如,如果你在一家公司,拥有不在网络上的专有文件,或者 LLM 训练后编写的数据或文章,那么 LLM 就无法直接利用这些信息。
因此,如果你或其他人,比如你的客户,希望与自己的文件进行对话并获得基于这些文档信息的回答,就需要特殊的方法。在本短期课程中,我们将介绍如何使用 LangChain 与你的数据聊天。
LangChain 是一个用于构建 LLM 应用程序的开源开发者框架。LangChain 由几个模块化组件以及更多的端到端模板组成。LangChain 中的模块化组件包括:提示、模型、索引、链和代理。要更详细地了解这些组件,你可以参考我和 Andrew 教授的第一门课。
在本课程中,我们将聚焦于 LangChain 一个更受欢迎的用例:如何使用 LangChain 与你的数据聊天。
学习路径
以下是本课程将涵盖的核心步骤:
首先,我们将介绍如何使用 LangChain 的文档加载器,从各种来源加载数据。
上一节我们介绍了课程目标,本节中我们来看看数据处理的第一步。以下是加载数据后的关键预处理步骤:
- 我们将讨论如何将这些文档拆分为语义上有意义的块。这个预处理步骤可能看起来很简单,但其中有很多细微差别。
接下来,我们将概述语义搜索,这是一种根据用户问题获取相关信息的基本方法。这是最简单的入门方法,但也有几种情况会失败,我们会仔细检查这些案例。
然后,我们将讨论如何修复这些失败案例。之后,我们将展示如何使用这些检索到的文档,使 LLM 能够回答有关文档的问题。
但此时,你仍然缺少一个关键的部分来完全重现聊天机器人的体验。最后,我们将讨论这个缺失的部分——记忆,并展示如何构建一个功能齐全的、可以与你的数据聊天的聊天机器人。
这将是一个激动人心的短期课程。我们感谢 Ankura、Lance Martin,感谢 LangChain 团队为 Harrison 提供的所有材料,以及 DeepLearning.AI 的方杰夫。


Ludwig 和 Diala Eine,万一你要上这门课,并决定想复习一下 LangChain 的基础知识,我鼓励你也参加之前的 LangChain 短期培训班,关于 LLM 应用开发,Harrison 也提到过。那么现在让我们进入下一个视频,Harrison 将向你展示如何使用。
总结
本节课中,我们一起学习了构建与数据对话的聊天机器人的核心动机和整体路线图。我们了解到,为了让 LLM 利用私有数据,需要借助 LangChain 框架,并依次完成数据加载、分块、检索、增强生成以及添加记忆功能等关键步骤。接下来,我们将深入每个环节的具体实现。
010:文档加载

在本节课中,我们将学习LangChain中一个核心且基础的概念:文档加载。为了创建一个能与您的数据对话的应用程序,首先必须将数据加载到可用的格式中。LangChain提供了80多种不同类型的文档加载器来处理这一任务。本节课将介绍其中最重要的一些加载器,帮助您理解其基本概念和工作原理。
概述:什么是文档加载器?
文档加载器负责处理访问和转换数据的细节,将各种不同格式和来源的数据转换为标准化的格式。这些数据来源广泛,包括网站、数据库、YouTube视频等。数据格式也多种多样,例如PDF、HTML、JSON等。
文档加载器的核心目的是获取各种数据源,并将它们加载到标准的文档对象中。每个文档对象由内容和相关的元数据组成。

文档加载器的分类
LangChain中有许多不同类型的文档加载器。虽然没有时间全部介绍,但我们可以将其大致分为几类。

以下是主要的分类:

- 处理非结构化公共数据:用于加载来自公共数据源的文本文件,例如YouTube、Twitter、Hacker News等。
- 处理非结构化专有数据:用于加载您或您的公司拥有的专有数据源,例如Figma、Notion等。
- 处理结构化数据:用于加载表格格式的数据,这些数据可能在单元格或行中包含文本信息,适用于问答或语义搜索任务。相关来源包括Airbyte、Stripe、Airtable等。

实践:使用文档加载器
上一节我们介绍了文档加载器的概念和分类,本节中我们来看看如何实际使用它们。首先,我们需要加载一些环境变量,例如OpenAI API密钥。
1. 加载PDF文档

我们要处理的第一类文档是PDF。让我们从LangChain导入相关的文档加载器。
from langchain.document_loaders import PyPDFLoader
我们已将一些PDF文件放入工作区的documents文件夹中。现在,让我们选择一个PDF文件,并使用加载器加载它。

loader = PyPDFLoader("documents/example.pdf")
接下来,我们通过调用load方法来加载文档,并查看加载的内容。
docs = loader.load()
print(len(docs)) # 查看加载了多少个文档(PDF页面)
默认情况下,这将加载一个文档列表。对于PDF,每个页面通常是一个独立的文档。让我们查看第一个文档的组成。

first_doc = docs[0]
print(first_doc.page_content[:500]) # 打印前500个字符的内容

另一个重要的信息是与每个文档关联的元数据,可以通过.metadata属性访问。
print(first_doc.metadata)
您会看到元数据中包含source(来源文件)和page(对应的PDF页码)等信息。

2. 从YouTube加载音频并转录
我们将要看到的下一种文档加载器可以从YouTube加载内容。YouTube上有大量有趣的内容,许多人使用这个加载器来向他们喜欢的视频或讲座提问。
以下是实现步骤:

# 导入必要的组件
from langchain.document_loaders.generic import GenericLoader
from langchain.document_loaders.parsers import OpenAIWhisperParser
from langchain.document_loaders.blob_loaders.youtube_audio import YoutubeAudioLoader

关键部分是YoutubeAudioLoader(用于从YouTube视频加载音频文件)和OpenAIWhisperParser(使用OpenAI的Whisper语音转文本模型,将音频转换为文本)。
现在,我们可以指定一个YouTube视频的URL,并创建加载器。

url = "https://www.youtube.com/watch?v=example_video_id"
save_dir = "tmp/youtube_audio" # 指定保存音频文件的目录

loader = GenericLoader(
YoutubeAudioLoader([url], save_dir),
OpenAIWhisperParser()
)
docs = loader.load() # 加载并转录,此过程可能需要几分钟

加载完成后,我们可以查看转录的文本内容。
print(docs[0].page_content[:1000]) # 打印视频第一部分的内容

3. 从网页URL加载内容
互联网上有很多优秀的教育内容。如果能与这些内容“对话”,将会非常酷。接下来,我们学习如何从网页URL加载数据。

我们将通过从LangChain导入基于Web的加载器来实现。

from langchain.document_loaders import WebBaseLoader
然后,我们可以选择任何URL(例如一个GitHub页面的Markdown文件),并为它创建一个加载器。

url = "https://raw.githubusercontent.com/langchain-ai/langchain/master/README.md"
loader = WebBaseLoader(url)
docs = loader.load()

现在,我们可以查看加载的页面内容。

print(docs[0].page_content[:1500])
您可能会注意到内容中包含很多空白和原始文本。这是一个很好的例子,说明了为什么通常需要对加载的信息进行后处理,以使其成为更易于处理的格式。

4. 从Notion加载数据

Notion是一个非常流行的个人和公司数据存储工具。许多人希望创建能与自己的Notion数据库对话的聊天机器人。
您需要先将数据从Notion数据库导出为特定的格式(如Markdown),然后才能将其加载到LangChain中。
一旦有了导出文件,就可以使用NotionDirectoryLoader来加载数据。

from langchain.document_loaders import NotionDirectoryLoader

loader = NotionDirectoryLoader("path/to/notion/export/directory")
docs = loader.load()
查看加载的内容,您会发现它是Markdown格式的。

print(docs[0].page_content[:1000])

这是一个很好的机会,您可以导出自己的Notion数据,并将其带入LangChain中开始工作。
总结与展望
本节课中,我们一起学习了如何使用LangChain的文档加载器从各种来源(PDF、YouTube、网页、Notion)加载数据,并将其转换为标准化的文档对象。
然而,这些加载后的文档通常仍然比较大。在下一节中,我们将学习如何将这些大文档分割成更小的“块”。这一步至关重要,因为在执行检索增强生成时,我们通常只需要检索与问题最相关的内容片段,而不是整个文档。这有助于提高检索的效率和准确性。

同时,这也是一个思考数据来源的好机会。LangChain目前可能还没有覆盖您需要的所有数据源,但社区在不断扩展,也许您可以探索甚至贡献新的加载器。
011:文档分割 📄✂️

概述
在本节课中,我们将要学习如何将加载好的文档分割成更小的“块”。文档分割是构建高效检索系统(RAG)的关键步骤,它直接影响后续信息检索的质量。我们将探讨分割的重要性、不同分割策略的原理,并通过代码示例学习如何使用LangChain中的各种文本分割器。



为什么文档分割既重要又棘手?
上一节我们介绍了如何将文档加载为标准格式。本节中我们来看看如何将它们分割成更小的块。这听起来可能很简单,但其中有很多微妙之处,对未来步骤有巨大影响。


将数据加载为文档格式后,在它进入向量存储之前,需要进行文档分割。一个简单的想法是根据固定字符长度来分割块。

但为什么这既棘手又非常重要呢?请看以下例子:
我们有一个关于丰田凯美瑞的句子和一些规格。

如果我们进行简单的分割,可能会将句子的一部分放在一个块中,另一部分放在另一个块中。当我们试图回答“凯美瑞的规格是什么?”这个问题时,实际上在这两个块中都没有完整的正确信息。因此,如何分割块,使得语义上相关的内容能保持在一起,具有很多细微差别和重要性。
LangChain文本分割器基础
LangChain中所有文本分割器的基础都涉及几个核心概念:块大小 和 块重叠。

- 块大小:指每个块的大小,可以用几种不同的方法来测量(例如字符数或令牌数)。我们通过
length_function参数来指定测量方式。 - 块重叠:指相邻两个块之间保留的重叠部分,就像一个滑动窗口。这允许相同的上下文出现在一个块的末尾和下一个块的开头,有助于保持语义的连贯性。
文本分割器通常有 create_documents(接收文本列表)和 split_documents(接收文档列表)两种方法,它们底层的分割逻辑相同,只是接口略有不同。


LangChain中有多种类型的文本分割器,它们在以下几个维度上有所不同:
- 分割依据:根据什么字符或规则进行分割。
- 长度测量:是按字符、令牌计数,还是使用其他模型(如判断句子结尾的小模型)来测量。
- 元数据处理:如何在所有块中维护原始元数据,并在适当时添加新的元数据(例如块在文档中的位置)。
- 文档类型特异性:分割方式通常可以针对特定文档类型进行优化,这在代码分割上尤为明显。例如,有专门的
Language文本分割器,针对Python、Ruby、C等不同编程语言使用不同的分隔符。



实践:两种基础分割器
现在,让我们通过代码来了解具体如何使用这些分割器。首先,我们设置环境并导入两种最常见的文本分割器。

# 设置环境(例如OpenAI API密钥)
import os
os.environ[‘OPENAI_API_KEY‘] = ‘your-api-key-here‘


# 导入分割器
from langchain.text_splitter import RecursiveCharacterTextSplitter, CharacterTextSplitter

我们将先讨论一些简单的用例,以了解这些分割器的工作原理。设置一个较小的块大小(26)和块重叠(4)。


# 初始化分割器
chunk_size = 26
chunk_overlap = 4

r_splitter = RecursiveCharacterTextSplitter(chunk_size=chunk_size, chunk_overlap=chunk_overlap)
c_splitter = CharacterTextSplitter(chunk_size=chunk_size, chunk_overlap=chunk_overlap)


以下是几个不同的用例:
用例1:长度等于块大小的字符串
text1 = “abcdefghijklmnopqrstuvwxyz“
print(r_splitter.split_text(text1))
# 输出: [‘abcdefghijklmnopqrstuvwxyz‘]
字符串长度为26个字符,与指定的块大小一致,因此无需分割。

用例2:长度超过块大小的字符串
text2 = “abcdefghijklmnopqrstuvwxyzabcdefg“
print(r_splitter.split_text(text2))
# 输出: [‘abcdefghijklmnopqrstuvwxyz‘, ‘wxyzabcdefg‘]
这里创建了两个块。第一个块以“z”结尾(26个字符)。第二个块以“wxyz”开头,这体现了4个字符的重叠,然后继续字符串的其余部分。
用例3:包含空格的字符串
text3 = “a b c d e f g h i j k l m n o p q r s t u v w x y z“
print(r_splitter.split_text(text3))
# 输出: [‘a b c d e f g h i j k l‘, ‘i j k l m n o p q r s t u v‘, ‘s t u v w x y z‘]
由于空格也占用字符位置,现在分成了三个块。注意重叠部分:“i j k l”同时出现在第一和第二个块的末尾/开头。

现在尝试使用 CharacterTextSplitter。默认情况下,它在换行符(\n)上分割。
print(c_splitter.split_text(text1))
# 输出: [‘abcdefghijklmnopqrstuvwxyz‘]
它没有分割,因为字符串中没有换行符。如果我们将分隔符设置为空格:
c_splitter = CharacterTextSplitter(chunk_size=chunk_size, chunk_overlap=chunk_overlap, separator=“ “)
print(c_splitter.split_text(text3))
# 输出与r_splitter类似

建议:在此处暂停视频,尝试自己编造不同的字符串,并交换分割器、调整块大小和块重叠,观察会发生什么。通过几个简单例子建立直觉,有助于理解后续更真实的场景。


处理真实文本
现在,让我们用一些更真实的例子来尝试。这里有一段较长的文本,其中包含典型的段落分隔符——双换行符(\n\n)。


some_text = “““
... (此处是一段约500字符的文本,包含多个段落) ...
“““

print(len(some_text)) # 大约500

定义两个文本分割器:
# 1. 字符文本分割器,以空格为分隔符
c_splitter = CharacterTextSplitter(chunk_size=450, chunk_overlap=0, separator=“ “)
# 2. 递归字符文本分割器,使用默认分隔符列表
from langchain.text_splitter import RecursiveCharacterTextSplitter
r_splitter = RecursiveCharacterTextSplitter(
chunk_size=450,
chunk_overlap=0,
separators=[“\n\n“, “\n“, “ “, ““] # 这是默认列表,此处显式写出以便理解
)


观察它们对上述文本的处理:
c_splitter在空格上分割,可能导致句子从中间被切断。r_splitter首先尝试在双换行符(\n\n)上分割。即使第一个段落比指定的450字符短,它也会将其作为一个独立的块分割出来。这通常更好,因为每个段落保持了完整性。
为了在句子级别分割,我们可以添加句号作为分隔符。但需要注意正则表达式的使用,以确保句号在正确的位置(例如,避免将“Mr.”中的点号误判为句子结束)。
r_splitter = RecursiveCharacterTextSplitter(
chunk_size=150,
chunk_overlap=0,
separators=[“\n\n“, “\n“, “(?<=\. )“, “ “, ““] # “(?<=\. )“ 是一个“向后查找”正则,匹配后面跟着空格的句号
)
# 运行后,文本将在句子结尾处被正确分割。



应用于真实文档
让我们使用在文档加载课程中用过的PDF文件进行实践。

from langchain.document_loaders import PyPDFLoader
loader = PyPDFLoader(“docs/cs229_lectures/MachineLearning-Lecture01.pdf“)
pages = loader.load()

# 定义文本分割器
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=150,
length_function=len, # 默认就是len,此处显式说明是按字符数计算
)

# 分割文档
docs = text_splitter.split_documents(pages) # 传入文档列表

print(f“原始页面数: {len(pages)}“)
print(f“分割后的文档数: {len(docs)}“)
# 可以看到创建了更多的文档,因为进行了分割。
我们也可以像第一节课那样,查看分割前后文档的内容长度对比。
print(pages[0].page_content[:500]) # 查看原始第一页的前500字符
print(“---“)
print(docs[0].page_content) # 查看分割后第一个块的内容
print(“---“)
print(docs[1].page_content) # 查看分割后第二个块的内容

建议:这是一个很好的暂停点,可以尝试用不同的块大小和重叠值进行实验。




基于令牌的分割
到目前为止,我们都是基于字符进行分割。但还有另一种重要方法:基于令牌进行分割。LLM通常有由令牌数定义的上下文窗口,因此基于令牌分割能更准确地反映模型对文本的“看法”。

from langchain.text_splitter import TokenTextSplitter

# 初始化令牌文本分割器,块大小为1,重叠为0,这会将文本拆分为单独的令牌列表。
token_splitter = TokenTextSplitter(chunk_size=1, chunk_overlap=0)

text = “foo bar bazzy foo“
print(token_splitter.split_text(text))
# 输出可能是: [‘foo‘, ‘ bar‘, ‘ baz‘, ‘zy‘, ‘ foo‘]
这个例子展示了在字符上拆分和在令牌上拆分的区别。令牌“bazzy”被拆分成了“baz”和“zy”。

让我们将其应用到之前加载的PDF文档上:
token_splitter = TokenTextSplitter(chunk_size=100, chunk_overlap=10)
token_docs = token_splitter.split_documents(pages)
print(token_docs[0].page_content[:100]) # 查看第一个令牌块的内容
print(token_docs[0].metadata) # 查看元数据
可以看到,metadata(如source和page)被正确地传递到了每个分割后的块中。

增强元数据的分割
有些情况下,你希望在分割时向块添加更多元数据,例如块在文档中的位置信息。这可以在回答问题时提供更多上下文。
一个具体的例子是 MarkdownHeaderTextSplitter。它会根据标题(如# Header1, ## Header2)来分割Markdown文件,并将这些标题作为元数据添加到每个块中。

首先看一个玩具示例:
from langchain.text_splitter import MarkdownHeaderTextSplitter

markdown_document = “““
# Title
## Chapter 1
Hi, I‘m Jim. Hi, I‘m Joe.
## Chapter 2
Hi, I‘m Lance.
### Section 2.1
Hi, I‘m Harry.
“““

headers_to_split_on = [
(“#“, “Header 1“),
(“##“, “Header 2“),
(“###“, “Header 3“),
]
markdown_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
md_header_splits = markdown_splitter.split_text(markdown_document)

print(md_header_splits[0])
# 输出内容包含 “Hi, I‘m Jim. Hi, I‘m Joe.“,其元数据为 {‘Header 1‘: ‘Title‘, ‘Header 2‘: ‘Chapter 1‘}
print(md_header_splits[2])
# 输出内容包含 “Hi, I‘m Harry.“,其元数据为 {‘Header 1‘: ‘Title‘, ‘Header 2‘: ‘Chapter 2‘, ‘Header 3‘: ‘Section 2.1‘}


现在,让我们在一个真实世界的Markdown文档上尝试(使用之前加载Confluence目录的示例):
from langchain.document_loaders import ConfluenceLoader
# ... 加载Confluence文档 ...
markdown_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=[(“#“, “Header 1“), (“##“, “Header 2“)])
md_header_splits = markdown_splitter.split_documents(confluence_docs)

# 查看分割后的块及其丰富的元数据
for split in md_header_splits[:2]:
print(split.page_content[:100])
print(split.metadata)
print(“---“)



总结
本节课中,我们一起学习了文档分割的核心概念与实践:
- 分割的重要性:不恰当的分割会破坏语义,影响后续检索效果。
- 核心参数:块大小 (
chunk_size) 和 块重叠 (chunk_overlap) 是控制分割粒度和连贯性的关键。 - 多种分割器:我们实践了
RecursiveCharacterTextSplitter、CharacterTextSplitter、TokenTextSplitter和MarkdownHeaderTextSplitter,它们分别适用于不同场景和需求。 - 长度测量:分割可以基于字符或令牌,后者更贴近LLM的运作方式。
- 元数据保留与增强:分割时需确保原始元数据传递到每个块,并可以利用特定分割器(如
MarkdownHeaderTextSplitter)添加结构性元数据,为检索提供更丰富的上下文。
我们已经讨论了如何获得带有合适元数据的、语义相关的文档块。下一步,就是将这些数据块存入向量数据库,以便进行高效的相似性检索。
012:向量和嵌入 📚


在本节课中,我们将学习如何将文本数据转化为数字表示(即嵌入),并将其存储在向量数据库中,以便后续高效地检索与问题相关的信息。这是构建基于文档的问答系统的核心步骤。



从文本拆分到向量存储 🔄


上一节我们介绍了如何将文件分割成有意义的文本块。本节中,我们来看看如何将这些文本块转化为数字形式并建立索引。


嵌入技术能将文本转换为数字向量。语义相近的文本,其向量在数字空间中也更为接近。这使得我们可以通过比较向量来快速找到相似的文本片段。

以下是构建索引的基本流程:
- 从原始文件开始。
- 将文档拆分为较小的、语义完整的块。
- 为每个文本块创建嵌入向量。
- 将所有向量存储到向量数据库中。


当用户提出问题时,系统会:
- 为问题本身创建嵌入向量。
- 在向量数据库中搜索与该问题向量最相似的文本块向量。
- 将找到的最相关文本块与原始问题一同传递给大语言模型(LLM),最终生成答案。



环境设置与数据准备 ⚙️


我们将使用一套课程讲义(CS229)作为示例数据。为了模拟现实世界中可能存在的“脏数据”,我们特意复制了第一讲的内容。

首先,加载文档并使用递归字符文本分割器创建文本块。

# 示例:加载文档并分割
from langchain.text_splitter import RecursiveCharacterTextSplitter


# ... 加载 documents ...
text_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
splits = text_splitter.split_documents(documents)
print(f"创建了 {len(splits)} 个文本块。")


完成上述步骤后,我们得到了200多个不同的文本块。接下来,进入核心环节:为这些块创建嵌入。



理解嵌入:概念与示例 🧠


在进入实际应用前,我们先通过几个简单的例子来理解嵌入的工作原理。


我们准备三个句子:
- 句子1: “我爱我的宠物狗。”
- 句子2: “我有一只可爱的宠物狗。”
- 句子3: “今天天气晴朗,我开车去上班。”


前两个句子语义相似(都关于宠物狗),第三个句子则无关。我们将使用OpenAI的嵌入模型为每个句子生成向量。

# 示例:创建和比较嵌入
from langchain.embeddings import OpenAIEmbeddings
import numpy as np


embeddings = OpenAIEmbeddings()
sentences = [
“我爱我的宠物狗。”,
“我有一只可爱的宠物狗。”,
“今天天气晴朗,我开车去上班。”
]
vecs = [embeddings.embed_query(s) for s in sentences]


# 使用点积计算相似度
def dot_product(vec_a, vec_b):
return np.dot(vec_a, vec_b)

# 比较相似度
print(f"句子1与句子2的相似度: {dot_product(vecs[0], vecs[1]):.2f}")
print(f"句子1与句子3的相似度: {dot_product(vecs[0], vecs[2]):.2f}")


点积值越高,表示两个向量的相似度越高。运行后,我们会发现句子1和句子2的点积值(例如0.96)远高于句子1和句子3的点积值(例如0.70),这与我们的语义判断一致。



构建向量数据库 💾


现在,让我们回到实际案例,为所有PDF文本块创建嵌入并存入向量数据库。本节课我们使用轻量级的Chroma数据库。

以下是创建并持久化向量数据库的步骤:

# 示例:创建并保存向量数据库
from langchain.vectorstores import Chroma


persist_directory = ‘./docs/chroma/‘
# 确保目录为空
import shutil
shutil.rmtree(persist_directory, ignore_errors=True)


# 创建向量存储
vectordb = Chroma.from_documents(
documents=splits,
embedding=OpenAIEmbeddings(),
persist_directory=persist_directory
)
print(f"向量库中的文档数量: {vectordb._collection.count()}")

创建完成后,我们可以立即使用它进行语义搜索。



进行语义搜索 🔍

假设我们的课程资料中提到了寻求帮助的邮箱,我们可以通过提问来检索该信息。

# 示例:在向量库中进行相似性搜索
question = “如果有问题,可以通过什么邮箱寻求帮助?”
docs = vectordb.similarity_search(question, k=3)
print(f"检索到 {len(docs)} 个相关文档。")
print(“最相关文档的内容:”, docs[0].page_content)


运行后,系统应能返回包含帮助邮箱(如 cs229-qa@cs.stanford.edu)的文本块。最后,别忘了持久化保存数据库以供后续使用:vectordb.persist()。

面临的挑战与边缘情况 ⚠️


基于嵌入的语义搜索虽然强大,但并不完美。以下是两种常见的失效情况:


1. 重复信息导致冗余
由于我们加载数据时故意复制了第一讲,导致完全相同的文本块出现在数据库中。当进行搜索时,这些重复块可能被同时检索出来,挤占了其他有价值信息的位置,降低了检索结果的多样性。

2. 结构化信息检索失败
考虑这个问题:“他们在第三堂课上对回归说了什么?” 我们期望的答案应仅来自第三讲的资料。然而,语义搜索可能返回所有提及“回归”的文本块,包括来自第一讲或第二讲的内容。这是因为嵌入模型更关注“回归”这个主题词,而未能完美捕捉“第三堂课”这个结构化的过滤条件。


你可以尝试调整返回文档的数量(k值),观察其对结果相关性和多样性的影响。



课程总结 📝
本节课中,我们一起学习了:
- 嵌入的概念:将文本转化为数字向量,使语义相似的文本在向量空间中靠近。
- 向量数据库的构建:使用Chroma存储文本块的嵌入,以便快速进行相似性检索。
- 语义搜索实践:通过提问,从向量库中检索出最相关的文档片段。
- 当前方法的局限性:认识到基于纯语义相似度的检索可能面临信息冗余和结构化查询失效等挑战。
在下一节课中,我们将探讨如何改进检索策略,例如同时考虑相关性和多样性,以应对这些边缘情况。
013:高级检索技术 🔍

在本节课中,我们将深入学习几种克服基础语义搜索局限性的高级检索技术。我们将探讨最大边际相关性、自我查询检索以及上下文压缩等方法,以提升检索结果的相关性和多样性。




概述 📋

在上一课中,我们介绍了语义搜索的基础知识,并看到它在大量用例中工作得很好。但我们也看到了一些边缘情况,事情可能会出一点问题。在本节课中,我们将深入探讨检索,并介绍一些克服这些边缘情况的更先进的方法。


检索在查询时很重要。当您有一个查询进来时,您想检索最相关的文档片段。我们在上一课中谈到了语义相似性搜索,但我们将在这里讨论一些不同的、更先进的方法。




最大边际相关性 (MMR) ⚖️

上一节我们介绍了语义搜索,本节中我们来看看最大边际相关性。这背后的想法是:如果您总是获取与查询在嵌入空间中最相似的文档,您可能会错过不同的信息,就像我们在一个边缘案例中看到的那样。


在本例中,我们有一个查询:“所有的白蘑菇”。如果我们查看最相似的结果,这将是前两份文档,它们有很多类似于子实体查询的信息(“全身都是白的”)。但我们真的想确保我们也能得到其他信息,比如“它真的有毒”。这就是MMR发挥作用的地方,因为它将为一组不同的文档进行选择。


MMR背后的想法是:我们发送一个查询,然后我们最初得到一组响应。参数 fetch_k 是我们可以控制的,它决定了我们最初得到多少回应。这完全基于语义相似性。从那里,我们处理这组较小的文档,并优化选择,使其不仅是基于语义相似性的最相关文档,也是多样化的。从这组文档中,我们选择最后的 k 个返回给用户。

以下是MMR过程的简化描述:

1. 输入查询 Q。
2. 使用语义相似性检索前 fetch_k 个文档 D_fetch。
3. 从 D_fetch 中,根据“相关性”和“与已选文档的差异性”的加权组合,选择最终的 k 个文档 D_final。
4. 返回 D_final。



自我查询检索 🤖


接下来,我们看看自我查询检索。当您遇到不仅要从语义上查找内容,还要根据元数据进行筛选的问题时,这很有用。


让我们来回答这个问题:“1980年拍的关于外星人的电影有哪些?” 这真的有两个组成部分:
- 它有一个语义部分:“关于外星人”。
- 它有一个引用元数据的部分:“年份应该是1980年”。


我们能做的就是使用语言模型本身将最初的问题分成两个独立的部分:筛选器和搜索词。大多数向量存储支持元数据筛选器,因此您可以轻松地基于元数据(如“年份=1980”)筛选记录。


以下是使用自我查询检索器的步骤:


# 伪代码示例
from langchain.retrievers.self_query.base import SelfQueryRetriever
from langchain.chains.query_constructor.base import AttributeInfo

# 1. 定义元数据字段及其描述
metadata_field_info = [
AttributeInfo(name="year", description="电影上映的年份", type="integer"),
AttributeInfo(name="genre", description="电影的类型", type="string"),
]
# 2. 初始化语言模型和检索器
llm = OpenAI(temperature=0)
retriever = SelfQueryRetriever.from_llm(
llm,
vectorstore,
document_content_description="电影的描述",
metadata_field_info=metadata_field_info,
)
# 3. 进行查询
docs = retriever.get_relevant_documents("1980年拍的关于外星人的电影有哪些?")



上下文压缩 🗜️


最后我们将讨论压缩。这对于只提取检索到的段落中最相关的部分是有用的。例如,在问问题时,您拿回存储的文档,但可能只有前一两句是相关的部分。


然后,您可以通过语言模型运行所有这些文档,并提取最相关的段落,然后只将最相关的段传递到最终的语言模型调用中。这是以对语言模型进行更多调用为代价的,但它也非常有利于将最终答案集中在最重要的事情上。所以这是一种权衡。


以下是上下文压缩的基本流程:


1. 使用基础检索器(如向量存储)获取一组初始文档 D_initial。
2. 使用一个“压缩器”(通常是另一个LLM链)处理每个文档,提取或总结与查询最相关的片段。
3. 将压缩后的文档片段 D_compressed 传递给生成答案的LLM。



技术实践演示 💻


现在让我们看看这些不同的技术如何起作用。


我们将从加载环境变量开始,就像我们一直做的那样。导入必要的库(如Chroma和OpenAI),并加载我们之前使用的文档集合。

MMR 示例


以下是MMR的一个应用示例:


# 伪代码:对比相似性搜索和MMR
from langchain.vectorstores import Chroma
from langchain.embeddings import OpenAIEmbeddings

# 初始化检索器
vectorstore = Chroma(...)
# 1. 标准相似性搜索
standard_docs = vectorstore.similarity_search("所有的白蘑菇", k=2)
# 2. MMR 搜索
mmr_docs = vectorstore.max_marginal_relevance_search("所有的白蘑菇", k=2, fetch_k=5)
# 比较结果,MMR结果可能包含关于“毒性”的多样化信息。


自我查询示例


对于之前“第三堂课回归”的问题,我们可以使用自我查询自动生成元数据过滤器:

# 运行自我查询检索器
question = "他们在第三堂课上对回归说了什么?"
docs = self_query_retriever.get_relevant_documents(question)
# 检索器内部会将问题解析为:查询词=“回归”, 过滤器=“source == ‘lecture3.pdf’”

上下文压缩与MMR结合


我们可以结合多种技术以获得最佳结果。例如,在使用上下文压缩时,可以指定基础检索器使用MMR:

from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import LLMChainExtractor


# 创建基础检索器(使用MMR)
base_retriever = vectorstore.as_retriever(search_type="mmr", search_kwargs={"k": 5, "fetch_k": 10})
# 创建压缩器
compressor = LLMChainExtractor.from_llm(llm)
# 创建压缩检索器
compression_retriever = ContextualCompressionRetriever(base_compressor=compressor, base_retriever=base_retriever)
# 进行查询
compressed_docs = compression_retriever.get_relevant_documents("他们怎么说Matlab?")




其他检索技术概览 🧠

到目前为止,我们提到的所有附加检索技术都建立在矢量数据库之上。值得一提的是,还有其他类型的检索根本不使用矢量数据库,而是使用更传统的NLP技术。
例如,SVM检索器和TF-IDF检索器。如果您从传统的NLP或机器学习中认识这些术语,那很好;如果不认识,也没关系,这只是其他技术的一个例子。除了这些,还有很多技术,我鼓励你去探索。

from langchain.retrievers import SVMRetriever, TFIDFRetriever


# SVM 检索器(需要嵌入)
svm_retriever = SVMRetriever.from_texts(texts, embeddings)
# TF-IDF 检索器
tfidf_retriever = TFIDFRetriever.from_texts(texts)




总结 🎯

在本节课中,我们一起学习了三种高级检索技术:
- 最大边际相关性 (MMR):用于在保证相关性的同时,增加检索结果的多样性,避免信息冗余。
- 自我查询检索:利用语言模型自动将自然语言查询分解为语义搜索词和元数据过滤器,特别适用于混合查询。
- 上下文压缩:通过额外的LLM调用,从检索到的大篇幅文档中提取最相关的片段,有助于提升最终答案的聚焦度和效率,但会增加延迟和成本。

我们还了解到,这些技术可以组合使用(如MMR+压缩),并且除了基于向量的方法,还存在其他基于传统NLP的检索器(如SVM、TF-IDF)。
我鼓励您在各种问题上尝试这些不同的检索技术。自我查询检索器尤其是我最喜欢的,所以我建议用越来越复杂的元数据过滤器来尝试。既然我们已经深入探讨了检索,在接下来的课程中,我们将讨论这个过程的下一步。
014:构建问答聊天机器人 🤖


在本节课中,我们将学习如何利用检索到的文档,结合语言模型来构建一个能够回答问题的聊天机器人。我们将探讨几种不同的实现方法,并了解它们各自的优缺点。


环境准备与数据加载

上一节我们介绍了文档检索的基本概念,本节中我们来看看如何将检索到的文档用于生成答案。首先,我们需要设置环境并加载数据。


以下是初始化步骤:

- 导入必要的环境变量。
- 加载之前持久化保存的向量数据库。
- 验证数据库是否正确加载了文档。
- 执行一次快速的相似性搜索,确保检索功能正常工作。


# 示例代码:加载向量数据库并验证
from langchain.vectorstores import Chroma
from langchain.embeddings import OpenAIEmbeddings

# 加载已持久化的向量数据库
persist_directory = ‘your_persist_dir’
embedding = OpenAIEmbeddings()
vectordb = Chroma(persist_directory=persist_directory, embedding_function=embedding)


# 验证文档数量
print(f“数据库中文档数量:{vectordb._collection.count()}”)

# 执行相似性搜索测试
docs = vectordb.similarity_search(“这门课的主要主题是什么?”, k=3)
print(docs[0].page_content)



初始化语言模型与问答链


在准备好数据后,我们需要初始化用于生成答案的语言模型,并创建一个问答链。


以下是核心步骤:

- 初始化语言模型(例如 GPT-3.5),并将温度设置为0以保证答案的稳定性和准确性。
- 导入并创建
RetrievalQA链,将语言模型和向量数据库作为检索器传入。

# 示例代码:初始化模型与问答链
from langchain.chat_models import ChatOpenAI
from langchain.chains import RetrievalQA

# 初始化语言模型
llm = ChatOpenAI(model_name=“gpt-3.5-turbo”, temperature=0)


# 创建检索问答链
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
retriever=vectordb.as_retriever()
)

# 进行提问
question = “这门课的主要主题是什么?”
result = qa_chain({“query”: question})
print(result[“result”])


理解提示模板与返回源文档

为了更深入地理解问答链的工作原理,我们可以自定义提示模板,并让链返回它用于生成答案的源文档。


以下是具体操作:

- 定义一个提示模板,其中包含指导语言模型如何利用上下文(文档)来回答问题的指令。
- 创建新的问答链时,传入自定义的提示模板,并设置参数以返回源文档。

# 示例代码:自定义提示模板并返回源文档
from langchain.prompts import PromptTemplate

# 定义提示模板
template = “””请根据以下上下文信息回答问题。如果你不知道答案,就说不知道。
上下文:{context}
问题:{question}
答案:“””
QA_CHAIN_PROMPT = PromptTemplate.from_template(template)

# 创建新的问答链,返回源文档
qa_chain_new = RetrievalQA.from_chain_type(
llm=llm,
retriever=vectordb.as_retriever(),
return_source_documents=True, # 返回源文档
chain_type_kwargs={“prompt”: QA_CHAIN_PROMPT}
)

# 提问并查看结果与源文档
result = qa_chain_new({“query”: “概率是课堂主题吗?”})
print(“答案:”, result[“result”])
print(“\n参考的源文档:”)
for doc in result[“source_documents”]:
print(doc.page_content[:200], “...”) # 打印文档前200字符


探索不同的问答策略:MapReduce 与 Refine

默认的“stuff”方法将所有文档塞入一次模型调用,当文档过多时可能超出上下文窗口限制。因此,我们需要了解其他策略。

以下是两种替代方法:


- MapReduce:首先将每个文档单独发送给语言模型得到初步答案,然后将这些答案组合起来,通过最后一次模型调用生成最终答案。这种方法能处理大量文档,但速度较慢,且可能因信息分散在不同文档中而影响答案质量。
- 公式:
Final_Answer = LLM( Combine( LLM(Doc1), LLM(Doc2), … ) )
- 公式:
- Refine:按顺序处理文档。首先基于第一个文档生成初始答案,然后依次将后续文档和当前答案提供给模型,要求其优化或完善答案。这种方法允许信息在文档间传递,通常能产生比MapReduce更好的结果。
- 公式:
Answer_i = LLM( Answer_{i-1}, Doc_i ), 其中Answer_0基于Doc_1生成。
- 公式:

# 示例代码:尝试不同的链类型
# MapReduce 方法
qa_chain_mapreduce = RetrievalQA.from_chain_type(
llm=llm,
retriever=vectordb.as_retriever(),
chain_type=“map_reduce” # 指定链类型
)

# Refine 方法
qa_chain_refine = RetrievalQA.from_chain_type(
llm=llm,
retriever=vectordb.as_retriever(),
chain_type=“refine” # 指定链类型
)

# 比较结果
question = “概率是课堂主题吗?”
print(“MapReduce 答案:”, qa_chain_mapreduce.run(question))
print(“Refine 答案:”, qa_chain_refine.run(question))

提示:你可以使用 LangChain 的平台(如 LangSmith)来追踪和可视化这些链的调用过程,观察模型每一步的输入和输出,这对于调试和理解内部机制非常有帮助。


实现带记忆的对话:处理后续问题
基本的问答链是无状态的,无法记住之前的对话历史。为了构建能处理后续问题的聊天机器人,我们需要引入记忆机制。
以下是实现思路:

- 使用
ConversationalRetrievalChain代替基础的RetrievalQA链。 - 该链内部会管理一个“聊天历史”,将当前问题与历史对话一起提供给模型,使其能理解上下文。
# 示例代码:创建带记忆的对话链
from langchain.chains import ConversationalRetrievalChain
from langchain.memory import ConversationBufferMemory
# 创建记忆对象
memory = ConversationBufferMemory(memory_key=“chat_history”, return_messages=True)
# 创建对话式检索链
conversational_qa_chain = ConversationalRetrievalChain.from_llm(
llm=llm,
retriever=vectordb.as_retriever(),
memory=memory
)

# 进行多轮对话
result1 = conversational_qa_chain({“question”: “概率是课堂主题吗?”})
print(“第一轮回答:”, result1[“answer”])


result2 = conversational_qa_chain({“question”: “为什么需要这些先决条件?”})
print(“第二轮回答(基于上下文):”, result2[“answer”])


总结

本节课中我们一起学习了构建问答聊天机器人的核心步骤。我们从加载数据和初始化模型开始,创建了基本的问答链,并深入了解了提示模板的作用。接着,我们探讨了处理大量文档的 MapReduce 和 Refine 策略。最后,我们引入了记忆机制,使机器人能够处理连贯的、有上下文的对话。你现在已经掌握了使用 LangChain 构建一个功能完整的问答系统的基础知识。
015:构建完整功能的聊天机器人 🤖

在本节课中,我们将学习如何构建一个功能完整的聊天机器人。我们将整合之前学到的知识——加载文档、分割文本、创建向量存储和检索信息——并加入“聊天历史”的概念,使机器人能够处理对话中的后续问题。
环境与数据准备

首先,我们需要设置环境并加载数据。

我们将加载环境变量,并启动LangSmith平台以便观察内部运行过程。

# 加载环境变量
from dotenv import load_dotenv
load_dotenv()

# 可选:打开LangSmith平台以观察内部过程
import os
os.environ["LANGCHAIN_TRACING_V2"] = "true"

接下来,加载我们之前创建的向量存储,其中包含了所有课程材料的嵌入。

from langchain.vectorstores import Chroma
from langchain.embeddings import OpenAIEmbeddings


# 加载向量存储
embeddings = OpenAIEmbeddings()
vectorstore = Chroma(persist_directory="./chroma_db", embedding_function=embeddings)

我们可以对这个向量存储进行基础的相似性搜索。
# 进行相似性搜索示例
docs = vectorstore.similarity_search("什么是机器学习?", k=3)
然后,初始化我们将要使用的语言模型。

from langchain.chat_models import ChatOpenAI
# 初始化语言模型
llm = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0)
以上步骤与之前课程的内容一致。现在,让我们进入核心部分:为聊天机器人添加记忆能力。


添加记忆与对话能力

上一节我们介绍了基础的检索问答链。本节中,我们来看看如何通过引入“记忆”来让机器人理解对话上下文。

我们将使用“会话缓冲内存”。它的作用是保存一个聊天消息的历史记录列表,并在每次提问时,将这段历史与当前问题一起传递给模型。

以下是初始化内存的代码:

from langchain.memory import ConversationBufferMemory

# 初始化会话缓冲内存
memory = ConversationBufferMemory(
memory_key="chat_history", # 与提示模板中的输入变量对齐
return_messages=True # 以消息列表形式返回历史,而非单个字符串
)

这是最简单的一种内存类型。关于内存更深入的内容,可以参考本系列的第一门课程。


创建会话检索链
现在,让我们创建一种新型的链——会话检索链。它在基础的检索问答链之上,增加了一个关键步骤:将聊天历史和新问题浓缩成一个独立的、可检索的问题。

以下是创建该链的代码:

from langchain.chains import ConversationalRetrievalChain

# 创建会话检索链
qa_chain = ConversationalRetrievalChain.from_llm(
llm=llm,
retriever=vectorstore.as_retriever(),
memory=memory
)


这个链的工作流程是:
- 接收用户的新问题和聊天历史。
- 使用一个语言模型,将二者重写为一个独立的、完整的问题。
- 将这个独立的问题发送到向量存储中进行检索。
- 利用检索到的文档和原始问题,生成最终答案。

测试对话功能

让我们来测试一下这个具有对话能力的机器人。

首先,问一个没有历史背景的问题:
# 第一个问题
question = "这门课的主题是什么?"
result = qa_chain({"question": question})
print(result["answer"])


假设得到的答案是:“教师假设学生对概率和统计学有基本的了解”。
现在,问一个后续问题:

# 后续问题
follow_up = "为什么需要这些先决条件?"
result = qa_chain({"question": follow_up})
print(result["answer"])

此时,机器人能够理解“这些先决条件”指代的是上一个答案中提到的“概率和统计学”,并给出相关的解释。


内部机制解析

我们可以通过LangSmith观察内部发生的过程。链的输入现在不仅包含问题,还包含了从内存中获取的聊天历史。

在追踪日志中,你会看到两个主要步骤:
- 生成独立问题:调用一个LLM,根据对话历史将后续问题重写为独立问题。提示模板类似于:
给定以下对话和后续问题,请将后续问题重写为一个独立的问题。
- 检索并回答:将独立问题送入检索器,获取相关文档,最后利用这些文档回答用户的原始问题。


你可以尝试调整这个链的各个部分,例如:
- 修改用于回答问题或重写问题的提示模板。
- 尝试其他类型的内存(如
ConversationSummaryMemory)。


整合为完整应用

最后,我们将所有组件整合到一个具有图形用户界面的完整应用中。虽然UI代码较多,但核心逻辑与我们上面构建的链一致。

以下是核心流程的总结:
- 加载与处理文档:使用PDF加载器读取文件,分割成块。
- 创建知识库:为文本块生成嵌入,存入向量数据库。
- 设置检索器:基于向量存储创建检索器,使用相似性搜索。
- 构建对话链:创建
ConversationalRetrievalChain,但为了便于GUI管理,我们从外部传入聊天历史,而非绑定内部内存。 - 构建交互界面:创建一个界面,用于输入问题、显示答案、查看检索到的源文档和完整的对话历史。
运行这个应用后,你将获得一个功能完整的问答聊天机器人。你可以:
- 上传自己的文档。
- 进行多轮对话。
- 点击查看机器人检索到的源文档块。
- 在配置区调整参数。
课程总结 🎉
在本节课中,我们一起学习了如何构建一个完整功能的聊天机器人。我们回顾并整合了整个课程的知识点:
- 数据加载:使用LangChain丰富的文档加载器从各种源加载数据。
- 文本分割:将文档分割成适合处理的文本块。
- 向量化与存储:为文本块创建嵌入,并存入向量数据库以实现语义搜索。
- 高级检索:探讨了多种检索算法来克服简单语义搜索的不足。
- 问答生成:结合检索到的文档和大型语言模型来生成答案。
- 对话能力:通过引入“聊天历史”和“会话检索链”,赋予机器人处理多轮对话的能力。

至此,你已经掌握了使用LangChain基于自有数据构建端到端聊天机器人的完整流程。这是一个快速发展的领域,鼓励你继续探索、分享所学,甚至为开源社区做出贡献。祝你构建愉快!
016:【LangChain大模型应用开发】课程总结 🎓
在本节课中,我们将一起回顾整个课程的核心内容与学习路径。课程涵盖了如何利用LangChain框架,构建一个能够与您自有数据进行对话的完整聊天机器人应用。

课程内容回顾 📚
上一节我们介绍了课程的整体目标,本节中我们来详细梳理已学习的各个模块。
数据加载与处理
我们从各种文档源加载数据开始。LangChain提供了超过80种不同的文档加载器。
以下是数据处理的核心步骤:
- 使用文档加载器读取原始文件。
- 将文档分割成更小的文本块(Chunking)。这个过程涉及许多细微差别,例如块的大小和重叠度的选择。
向量化存储与检索
接着,我们为这些文本块创建嵌入向量,并将它们存入向量数据库。这使我们能够轻松实现语义搜索。
然而,语义搜索在某些边缘情况下可能存在不足。为了克服这些限制,我们深入探讨了检索环节。
高级检索与答案生成
检索部分介绍了许多新颖、先进且有趣的检索算法。这些算法旨在提升在复杂查询下的准确率。
然后,我们将检索到的相关文档与大型语言模型结合。具体流程是:接收用户问题,检索相关文档,并将它们一同传递给LLM,最终生成针对原始问题的答案。
构建端到端对话应用
至此,系统还缺少对话的连贯性。我们通过创建一个功能齐全的端到端聊天机器人,完善了这一环节,实现了与数据的流畅对话。
总结与展望 🌟

本节课中,我们一起学习了利用LangChain构建对话式AI应用的完整流程:从数据加载、分块、向量化存储,到高级检索、与LLM结合生成答案,最终集成对话能力,打造出完整的聊天机器人。
这是一个快速发展的领域,充满了激动人心的创新。我真诚地期待看到您如何应用所学知识,构建出属于自己的应用。欢迎您在社区分享您的成果、新技巧,甚至为LangChain项目贡献代码。

浙公网安备 33010602011771号