LLM学习教程

前言

LLM 正在逐步改变人们的生活,而对于开发者,如何基于 LLM 提供的 API 快速、便捷地开发一些具备更强能力、集成LLM 的应用,来便捷地实现一些更新颖、更实用的能力,是一个急需学习的重要能力。

随着 LLM 的发展,其大致可以分为两种类型,后续称为基础 LLM 指令微调(Instruction Tuned) LLM基础LLM是基于文本训练数据,训练出预测下一个单词能力的模型。其通常通过在互联网和其他 来源的大量数据上训练,来确定紧接着出现的最可能的词。例如,如果你以“从前,有一只独角兽”作为 Prompt ,基础 LLM 可能会继续预测“她与独角兽朋友共同生活在一片神奇森林中”。但是,如果你以“法 国的首都是什么”为 Prompt ,则基础 LLM 可能会根据互联网上的文章,将回答预测为“法国最大的城市是什么?法国的人口是多少?”,因为互联网上的文章很可能是有关法国国家的问答题目列表。 与基础语言模型不同,指令微调 LLM 通过专门的训练,可以更好地理解并遵循指令。举个例子,当询问 “法国的首都是什么?”时,这类模型很可能直接回答“法国的首都是巴黎”。

指令微调 LLM 的训练通常基于预训练语言模型,先在大规模文本数据上进行预训练,掌握语言的基本规律。在此基础上进行进一步 的训练与微调(finetune),输入是指令,输出是对这些指令的正确回复。有时还会采用RLHF (reinforcement learning from human feedback,人类反馈强化学习)技术,根据人类对模型输 出的反馈进一步增强模型遵循指令的能力。通过这种受控的训练过程。指令微调 LLM 可以生成对指令高 度敏感、更安全可靠的输出,较少无关和损害性内容。因此。许多实际应用已经转向使用这类大语言模型。

Prompt,提示,最初是 NLP 研究者为下游任务设计出来的一种任务专属的输入形式或模板,在 ChatGPT 引发大语言模型新时代之后,Prompt 即成为与大模型交互输入的代称。即我们一般将给大模型的输入称为 Prompt,将大模型返回的输出称为 Completion

随着 ChatGPT 等 LLM(大语言模型)的出现,自然语言处理的范式正在由 Pretrain-Finetune(预训练- 微调)向 Prompt Engineering(提示工程)演变。对于具有较强自然语言理解、生成能力,能够实现多 样化任务处理的 LLM 来说,一个合理的 Prompt 设计极大地决定了其能力的上限与下限。Prompt Engineering,即是针对特定任务构造能充分发挥大模型能力的 Prompt 的技巧。要充分、高效地使用 LLM,Prompt Engineering 是必不可少的技能。

一、提示工程

1、提示原则

如何去使用 Prompt,以充分发挥 LLM 的性能?首先我们需要知道设计 Prompt 的原则,它们是每一个开发者设计 Prompt 所必须知道的基础概念。设计高效 Prompt 的两个关键原则:编写清晰具体的指令指引模型深入思考。掌握这两点,对创建可靠的语言模型交互尤为重要。

  • 编写清晰具体的指令

需要 Prompt 清晰明确地表达需求,这并不是说 Prompt 就必须非常短小简洁。复杂的 Prompt 提供了更丰富的上下文和细节,让模型可以更准确地把握所需的操作和响应方式。但是由于大模型一般会有token限制,过长的Prompt会影响输出的token数。

  • 指引模型深入思考

让语言模型深入思考极为关键,就像人类解题一样,面对复杂问题需要分步骤分析思考。因此 Prompt 应加入逐步推理的要求,引导模型“大事化小,小事化了”,这样生成的结果才更准确可靠。

1.1 如何编写清晰具体的指令

1. 使用分隔符

在编写 Prompt 时,我们可以使用各种标点符号作为“分隔符”,将不同的文本部分区分开来。 分隔符就像是 Prompt 中的墙,将不同的指令、上下文、输入隔开,避免意外的混淆。你可以选择用 ```,""",< >,<tag> </tag>,: 等做分隔符,只要能明确起到隔断作用即可。

使用分隔符尤其重要的是可以防止 提示词注入(Prompt Rejection)。什么是提示词注入?就是用户 输入的文本可能包含与你的预设 Prompt 相冲突的内容,如果不加分隔,这些输入就可能“注入”并操纵语 言模型,导致模型产生毫无关联的乱七八糟的输出。

2. 结构化输出

有时候我们需要语言模型给我们一些结构化的输出,而不仅仅是连续的文本。

什么是结构化输出呢?就是按照某种格式组织的内容,例如JSON、HTML等。这种输出非常适合在代码中进一步解析和处理。例如,您可以在 Python 中将其读入字典或列表中。

3. 检查是否满足条件

如果任务包含不一定能满足的假设(条件),我们可以告诉模型先检查这些假设,如果不满足,则会指出并停止执行后续的完整流程。您还可以考虑可能出现的边缘情况及模型的应对,以避免意外的结果或错误发生。

4. 提供少量示例

"Few-shot" prompting,即在要求模型执行实际任务之前,给模型一两个已完成的样例,让模型了解我们的要求和期望的输出样式。

利用少样本样例,我们可以轻松“预热”语言模型,让它为新的任务做好准备。这是一个让模型快速上手新 任务的有效策略。

1.2 指引模型深入思考

1、指定所需任务所需的步骤

对于给定的一个复杂任务,可以通过给出完成该任务的一系列步骤,来指引模型将复杂任务分解成许多简单任务,然后按步骤逐步推理,能让语言模型构成一个更完善的思维逻辑,输出结果也将更可靠准确。

2、指导语言模型进行自主思考

可以在 Prompt 中先要求语言模型自己尝试解决这个问题,思考出自己的解法,然后再与提供的解答进行对比,判断正确性。这种先让语言模型自主思考的方式,能帮助它更深入理解问题,做出更准确的判断。

举个例子,假设我们要语言模型判断一个数学问题的解答是否正确。仅仅提供问题和解答是不够的,语言模型可能会匆忙做出错误判断。

1.3 局限性

开发大模型相关应用时请务必铭记:

虚假知识:模型偶尔会生成一些看似真实,实则编造的知识

在开发与应用语言模型时,需要注意它们可能生成虚假信息的风险。尽管模型经过大规模预训练,掌握了丰富知识,但它实际上并没有完全记住所见的信息,难以准确判断自己的知识边界,可能做出错误推断。若让语言模型描述一个不存在的产品,它可能会自行构造出似是而非的细节。这被称为“幻觉”(Hallucination),是语言模型的一大缺陷。

语言模型生成虚假信息的“幻觉”问题,是使用与开发语言模型时需要高度关注的风险。由于幻觉信息往往令人无法辨别真伪,开发者必须警惕并尽量避免它的产生。

2、提示策略及应用

2.1 迭代优化

在开发大语言模型应用时,很难通过第一次尝试就得到完美适用的 Prompt。但关键是要有一个良好的迭代优化过程,以不断改进 Prompt。相比训练机器学习模型,Prompt 的一次成功率可能更高,但仍需要通过多次迭代找到最适合应用的形式。

2.2 文本概括

1、限制输出文本长度

将文本的长度限制在N个字以内,语言模型将给我们一个符合要求的结果。

2、设置关键角度侧重

在某些情况下,我们会针对不同的业务场景对文本的侧重会有所不同。例如,在商品评论文本中,物流部门可能更专注于运输的时效性,商家则更关注价格和商品质量,而平台则更看重整体的用户体验。我们可以通过增强输入提示(Prompt),来强调我们对某一特定视角的重视

3、关键信息提取

虽然通过添加关键角度侧重的 Prompt ,确实让文本摘要更侧重于某一特定方面,然而,在结果中也会保留一些其他信息,比如偏重价格与质量角度的概括中仍保留了“快递提前到货”的信息。如果我们只想要提取某一角度的信息,并过滤掉其他所有信息,则可以要求 LLM 进行文本提取(Extract) 而非概括( Summarize )

2.3 推断

1、情感推断
  • 情感倾向分析
  • 识别情感类型
2、主题推断
  • 推断对话主题
  • 推断文本主题

2.4 文本转换

大语言模型具有强大的文本转换能力,可以实现多语言翻译、拼写纠正、语法调整、格式转换等不同类型的文本转换任务。利用语言模型进行各类转换是它的典型应用之一。

1、文本翻译

文本翻译是大语言模型的典型应用场景之一。相比于传统统计机器翻译系统,大语言模型翻译更加流畅自然,还原度更高。通过在大规模高质量平行语料上进行 Fine-Tune,大语言模型可以深入学习不同语言间的词汇、语法、语义等层面的对应关系,模拟双语者的转换思维,进行意义传递的精准转换,而非简单的逐词替换。

2、语气与写作风格调整

在写作中,语言语气的选择与受众对象息息相关。比如工作邮件需要使用正式、礼貌的语气和书面词汇;而与朋友的聊天可以使用更轻松、口语化的语气。

选择恰当的语言风格,让内容更容易被特定受众群体所接受和理解,是技巧娴熟的写作者必备的能力。随着受众群体的变化调整语气也是大语言模型在不同场景中展现智能的一个重要方面。

3、文件格式转换

大语言模型如 ChatGPT 在不同数据格式之间转换方面表现出色。它可以轻松实现 JSON 到 HTML、 XML、Markdown 等格式的相互转化。下面是一个示例,展示如何使用大语言模型将 JSON 数据转换为

HTML 格式

4、拼写及语法纠正

在使用非母语撰写时,拼写和语法错误比较常见,进行校对尤为重要。例如在论坛发帖或撰写英语论文时,校对文本可以大大提高内容质量。利用大语言模型进行自动校对可以极大地降低人工校对的工作量。

2.5 文本扩展

文本扩展是大语言模型的一个重要应用方向,它可以输入简短文本,生成更加丰富的长文。这为创作提供了强大支持,但也可能被滥用。因此开发者在使用时,必须谨记社会责任,避免生成有害内容。

2.6 聊天机器人

大型语言模型带给我们的激动人心的一种可能性是,我们可以通过它构建定制的聊天机器人(Chatbot),而且只需很少的工作量。

二、基于ChatGPT的问答系统

1、语言模型

大语言模型(LLM)是通过预测下一个词的监督学习方式进行训练的。以预测下一个词为训练目标的方法使得语言模型获得强大的语言生成能力。

大型语言模型主要可以分为两类:基础语言模型和指令调优语言模型。

基础语言模型(Base LLM)通过反复预测下一个词来训练的方式进行训练,没有明确的目标导向。

指令微调的语言模型(Instruction Tuned LLM)则进行了专门的训练,以便更好地理解问题并给出符合指令的回答。

那么,如何将基础语言模型转变为指令微调语言模型呢?这也就是训练一个指令微调语言模型(例如ChatGPT)的过程。

  • 首先,在大规模文本数据集上进行无监督预训练,获得基础语言模型。这一步需要使用数千亿词甚至更多的数据,在大型超级计算系统上可能需要数月时间。
  • 之后,使用包含指令及对应回复示例的小数据集对基础模型进行有监督 fine-tune,这让模型逐步学会遵循指令生成输出,可以通过雇佣承包商构造适合的训练示例。
  • 接下来,为了提高语言模型输出的质量,常见的方法是让人类对许多不同输出进行评级,例如是否有用、是否真实、是否无害等。
  • 然后,您可以进一步调整语言模型,增加生成高评级输出的概率。这通常使用基于人类反馈的强化学习(RLHF)技术来实现。

相较于训练基础语言模型可能需要数月的时间,从基础语言模型到指令微调语言模型的转变过程可能只

需要数天时间,使用较小规模的数据集和计算资源。

2、分词器Tokens

到目前为止对 LLM 的描述中,我们将其描述为一次预测一个单词,但实际上还有一个更重要的技术细节。即 LLM 实际上并不是重复预测下一个单词,而是重复预测下一个 token 。对于一个句子,语言模型会先使用分词器将其拆分为一个个 token ,而不是原始的单词。对于生僻词,可能会拆分为多个 token 。这样可以大幅降低字典规模,提高模型训练和推断的效率。例如,对于 "Learning new things is fun!" 这句话,每个单词都被转换为一个 token ,而对于较少使用的单词,如 "Prompting as powerful developer tool",单词 "prompting" 会被拆分为三个 token,即"prom"、"pt"和"ing"。

因此,语言模型以 token 而非原词为单位进行建模,这一关键细节对分词器的选择及处理会产生重大影

响。开发者需要注意分词方式对语言理解的影响,以发挥语言模型最大潜力。

对于英文输入,一个 token 一般对应 4 个字符或者四分之三个单词;对于中文输入,一个token 一般对应一个或半个词。不同模型有不同的 token 限制,需要注意的是,这里的 token 限制是输入的 Prompt 和输出的 completion 的 token 数之和,因此输入的 Prompt 越长,能输出的completion 的上限就越低。 ChatGPT3.5-turbo 的 token 上限是 4096。

3、提问范式

语言模型提供了专门的“提问格式”,可以更好地发挥其理解和回答问题的能力。

这种提问格式区分了“系统消息”和“用户消息”两个部分。系统消息是我们向语言模型传达讯息的语句,用户消息则是模拟用户的问题。

4、评估输入——分类

在处理不同情况下的多个独立指令集的任务时,首先对查询类型进行分类,并以此为基础确定要使用哪些指令,具有诸多优势。这可以通过定义固定类别和硬编码与处理特定类别任务相关的指令来实现。

分隔符是用来区分指令或输出中不同部分的工具 ,它可以帮助模型更好地识别各个部分,从而提高系统在执行特定任务时的准确性和效率。 “#” 也是一个理想的分隔符,因为它可以被视为一个单独的token 。

5、检查输入——审核

使用 OpenAI 的审核函数接口(Moderation API )对用户输入的内容进行审核。该接口用于确保用户输入的内容符合 OpenAI 的使用规定,这些规定反映了OpenAI对安全和负责任地使用人工智能科技的承诺。使用审核函数接口可以帮助开发者识别和过滤用户输入。

具体来说,审核函数会审查以下类别:

  • 性(sexual):旨在引起性兴奋的内容,例如对性活动的描述,或宣传性服务(不包括性教育和健康)的内容。
  • 仇恨(hate):表达、煽动或宣扬基于种族、性别、民族、宗教、国籍、性取向、残疾状况或种姓的仇恨的内容。
  • 自残(self-harm):宣扬、鼓励或描绘自残行为(例如自杀、割伤和饮食失调)的内容。
  • 暴力(violence):宣扬或美化暴力或歌颂他人遭受苦难或羞辱的内容。

Prompt注入:在构建一个使用语言模型的系统时, 提示注入是指用户试图通过提供输入来操控 AI 系统,以覆盖或绕过开发者设定的预期指令或约束条件

我们将介绍检测和避免 Prompt 注入的两种策略:

1. 在系统消息中使用分隔符(delimiter)和明确的指令。

2. 额外添加提示,询问用户是否尝试进行 Prompt 注入。

6、处理输入——思维链推理

以通过“思维链推理”(Chain of Thought Reasoning)的策略,在查询中明确要求语言模型先提供一系列相关推理步骤,进行深度思考,然后再给出最终答案,这更接近人类解题的思维过程。

相比直接要求输出结果,这种引导语言模型逐步推理的方法,可以减少其匆忙错误,生成更准确可靠的响应。思维链推理使语言模型更好地模拟人类逻辑思考,是提升其回答质量的重要策略之一。

思维链提示是一种引导语言模型进行逐步推理的 Prompt 设计技巧。它通过在 Prompt 中设置系统消息,要求语言模型在给出最终结论之前,先明确各个推理步骤。

链式提示是将复杂任务分解为多个简单Prompt的策略。

主要是因为链式提示它具有以下优点:

    1. 分解复杂度,每个 Prompt 仅处理一个具体子任务,避免过于宽泛的要求,提高成功率。这类似于分阶段烹饪,而不是试图一次完成全部。
    2. 降低计算成本。过长的 Prompt 使用更多 tokens ,增加成本。拆分 Prompt 可以避免不必要的计算。
    3. 更容易测试和调试。可以逐步分析每个环节的性能。
    4. 融入外部工具。不同 Prompt 可以调用 API 、数据库等外部资源。
    5. 更灵活的工作流程。根据不同情况可以进行不同操作

6.1 提取产品和类别

6.2 检索详细信息

6.3 生成查询答案

7、检查结果

7.1 检查有害内容

通过 OpenAI 提供的 Moderation API 来实现对有害内容的检查。

检查输出质量的另一种方法是向模型询问其自身生成的结果是否满意,是否达到了你所设定的标准。这可以通过将生成的输出作为输入的一部分再次提供给模型,并要求它对输出的质量进行评估。

借助审查 API 来检查输出是一个可取的策略。但在我看来,这在大多数情况下可能是不必要的,特别是当你使用更先进的模型,比如 GPT-4 。实际上,在真实生产环境中,我们并未看到很多人采取这种方式。这种做法也会增加系统的延迟和成本,因为你需要等待额外的 API 调用,并且需要额外的token 。

三、LangChain开发

LangChain 是一套专为LLM 开发打造的开源框架,实现了 LLM 多种强大能力的利用,提供了 Chain、Agent、Tool 等多种封装工具,基于 LangChain 可以便捷开发应用程序,极大化发挥 LLM 潜能。

LangChain 是用于构建大模型应用程序的开源框架,有Python和JavaScript两个不同版本的包。

LangChain 也是一个开源项目,社区活跃,新增功能快速迭代。LangChain基于模块化组合,有许多单独的组件,可以一起使用或单独使用。

LangChain 的常用组件:

      • 模型(Models):集成各种语言模型与向量模型。
      • 提示(Prompts): 向模型提供指令的途径。
      • 索引(Indexes): 提供数据检索功能。
      • 链(Chains): 将组件组合实现端到端应用。
      • 代理(Agents): 扩展模型的推理能力

1、模型、提示和输出解释器

1.1 模型

从 langchain.chat_models 导入 OpenAI 的对话模型ChatOpenAI 。 除去OpenAI以外,langchain.chat_models 还集成了其他对话模型,更多细节可以查看 Langchain 官方文档(https://python.langchain.com/en/latest/modules/models/chat/integrations.html)

from langchain.chat_models import ChatOpenAI

# 这里我们将参数temperature设置为0.0,从而减少生成答案的随机性。
# 如果你想要每次得到不一样的有新意的答案,可以尝试调整该参数。
chat = ChatOpenAI(temperature=0.0)
ChatOpenAI(cache=None, verbose=False, callbacks=None, callback_manager=None, tags=None,
            metadata=None, client=<class 'openai.api_resources.chat_completion.ChatCompletion'>,
            model_name='gpt-3.5-turbo', temperature=0.0, model_kwargs={}, 
            openai_api_key='skIBJfPyi4LiaSSiYxEB2wT3BlbkFJjfw8KCwmJez49eVF1O1b', 
            openai_api_base='', openai_organization='',
            openai_proxy='', request_timeout=None, max_retries=6, streaming=False, n=1, 
            max_tokens=None,
            tiktoken_model_name=None)

上面的输出显示ChatOpenAI的默认模型为 gpt-3.5-turbo

1.2 使用提示模版

通过f字符串把Python表达式的值 style 和 customer_email 添加到 prompt 字符串内。langchain 提供了接口方便快速的构造和使用提示。

from langchain.prompts import ChatPromptTemplate

# 首先,构造一个提示模版字符串:`template_string`
template_string = """把由三个反引号分隔的文本\
翻译成一种{style}风格。\
文本: ```{text}```
"""
# 然后,我们调用`ChatPromptTemplatee.from_template()`函数将
# 上面的提示模版字符`template_string`转换为提示模版`prompt_template`
prompt_template = ChatPromptTemplate.from_template(template_string)
print("\n", prompt_template.messages[0].prompt)

customer_style = """正式普通话 \
                    用一个平静、尊敬的语气
                 """
customer_email = """
                 嗯呐,我现在可是火冒三丈,我那个搅拌机盖子竟然飞了出去,
                 把我厨房的墙壁都溅上了果汁!
                 更糟糕的是,保修条款可不包括清理我厨房的费用。
                 伙计,赶紧给我过来!
                 """
# 使用提示模版
customer_messages = prompt_template.format_messages(style=customer_style,
                                                    text=customer_email)
# 打印客户消息类型
print("客户消息类型:",type(customer_messages),"\n")
# 打印第一个客户消息类型
print("第一个客户客户消息类型类型:", type(customer_messages[0]),"\n")

对于给定的 customer_style 和 customer_email , 我们可以使用提示模版 prompt_template 的format_messages 方法生成想要的客户消息 customer_messages 。

在应用于比较复杂的场景时,提示可能会非常长并且包含涉及许多细节。使用提示模版,可以让我们更为方便地重复使用设计好的提示。

此外,LangChain还提供了提示模版用于一些常用场景。比如自动摘要、问答、连接到SQL数据库、连接到不同的API。通过使用LangChain内置的提示模版,你可以快速建立自己的大模型应用,而不需要花时间去设计和构造提示。

最后,我们在建立大模型应用时,通常希望模型的输出为给定的格式,比如在输出使用特定的关键词来让输出结构化。

1.3 输出解释器

from langchain.prompts import ChatPromptTemplate

customer_review = """\
这款吹叶机非常神奇。 它有四个设置:\
吹蜡烛、微风、风城、龙卷风。 \
两天后就到了,正好赶上我妻子的\
周年纪念礼物。 \
我想我的妻子会喜欢它到说不出话来。 \
到目前为止,我是唯一一个使用它的人,而且我一直\
每隔一天早上用它来清理草坪上的叶子。 \
它比其他吹叶机稍微贵一点,\
但我认为它的额外功能是值得的。
"""
review_template = """\
对于以下文本,请从中提取以下信息:
礼物:该商品是作为礼物送给别人的吗? \
如果是,则回答 是的;如果否或未知,则回答 不是。
交货天数:产品需要多少天\
到达? 如果没有找到该信息,则输出-1。
价钱:提取有关价值或价格的任何句子,\
并将它们输出为逗号分隔的 Python 列表。
使用以下键将输出格式化为 JSON:
礼物
交货天数
价钱
文本: {text}
"""
prompt_template = ChatPromptTemplate.from_template(review_template)
print("提示模版:", prompt_template)
messages = prompt_template.format_messages(text=customer_review)
chat = ChatOpenAI(temperature=0.0)
response = chat(messages)
print("结果类型:", type(response.content))
print("结果:", response.content)

2、存储

在与语言模型交互时,你可能已经注意到一个关键问题:它们并不记忆你之前的交流内容,这在我们构建一些应用程序(如聊天机器人)的时候,带来了很大的挑战,使得对话似乎缺乏真正的连续性。

当使用 LangChain 中的储存(Memory)模块时,它旨在保存、组织和跟踪整个对话的历史,从而为用户和模型之间的交互提供连续的上下文。

LangChain 提供了多种储存类型。其中,缓冲区储存允许保留最近的聊天消息,摘要储存则提供了对整个对话的摘要。实体储存则允许在多轮对话中保留有关特定实体的信息。这些记忆组件都是模块化的,可与其他组件组合使用,从而增强机器人的对话管理能力。储存模块可以通过简单的 API 调用来访问和更新,允许开发人员更轻松地实现对话历史记录的管理和维护。

此次课程主要介绍其中四种储存模块,其他模块可查看文档学习。

      • 对话缓存储存 (ConversationBufferMemory)
      • 对话缓存窗口储存 (ConversationBufferWindowMemory)
      • 对话令牌缓存储存 (ConversationTokenBufferMemory)
      • 对话摘要缓存储存 (ConversationSummaryBufferMemory)

2.1 对话存储缓存

from langchain.chains import ConversationChain
from langchain.chat_models import ChatOpenAI
from langchain.memory import ConversationBufferMemory

# 初始化对话模型
# =====================================================
# 这里我们将参数temperature设置为0.0,从而减少生成答案的随机性。
# 如果你想要每次得到不一样的有新意的答案,可以尝试增大该参数。
llm = ChatOpenAI(temperature=0.0)
memory = ConversationBufferMemory()
# 新建一个 ConversationChain Class 实例
# verbose参数设置为True时,程序会输出更详细的信息,以提供更多的调试或运行时信息。
# 相反,当将verbose参数设置为False时,程序会以更简洁的方式运行,只输出关键的信息。
conversation = ConversationChain(llm=llm, memory = memory, verbose=True )

# 多轮对话
#=====================================================
# 第一轮对话
conversation.predict(input="你好, 我叫皮皮鲁")
# 第二轮对话
conversation.predict(input="1+1等于多少?")
# 第三轮对话
conversation.predict(input="我叫什么名字?")

# 查看存储缓存
# ======================================================
print(memory.buffer)
print(memory.load_memory_variables({})) # 也可以打印缓存中的历史消息

# 直接添加内容到储存缓存
# =======================================================
memory = ConversationBufferMemory()
# 可以使用 save_context 来直接添加内容到 buffer 中
memory.save_context({"input": "你好,我叫皮皮鲁"}, {"output": "你好啊,我叫鲁西西"})
memory.load_memory_variables({})
# 继续添加新的内容
# =======================================================
memory.save_context({"input": "很高兴和你成为朋友!"}, {"output": "是的,让我们一起去冒险吧!"})
memory.load_memory_variables({})

在使用大型语言模型进行聊天对话时,大型语言模型本身实际上是无状态的。语言模型本身并不记得到目前为止的历史对话。每次调用API结点都是独立的。储存(Memory)可以储存到目前为止的所有术语或对话,并将其输入或附加上下文到LLM中用于生成输出。如此看起来就好像它在进行下一轮对话的时候,记得之前说过什么。

2.2 对话缓存窗口存储

随着对话变得越来越长,所需的内存量也变得非常长。将大量的tokens发送到LLM的成本,也会变得更加昂贵,这也就是为什么API的调用费用,通常是基于它需要处理的tokens数量而收费的。

针对以上问题,LangChain也提供了几种方便的储存方式来保存历史对话。其中,对话缓存窗口储存只保留一个窗口大小的对话。它只使用最近的n次交互。这可以用于保持最近交互的滑动窗口,以便缓冲区不会过大。

from langchain.memory import ConversationBufferWindowMemory

# ==============添加两轮对话到窗口储存====================
# k=1表明只保留一个对话记忆
memory = ConversationBufferWindowMemory(k=1)
memory.save_context({"input": "你好,我叫皮皮鲁"}, {"output": "你好啊,我叫鲁西西"})
memory.save_context({"input": "很高兴和你成为朋友!"}, {"output": "是的,让我们一起去冒
险吧!"})
memory.load_memory_variables({})

# ==============在对话链中应用窗口储存===================
llm = ChatOpenAI(temperature=0.0)
memory = ConversationBufferWindowMemory(k=1)
conversation = ConversationChain(llm=llm, memory=memory, verbose=False )
print("第一轮对话:")
print(conversation.predict(input="你好, 我叫皮皮鲁"))
print("第二轮对话:")
print(conversation.predict(input="1+1等于多少?"))
print("第三轮对话:")
print(conversation.predict(input="我叫什么名字?"))                                        
                                              

注意此处!由于这里用的是一个窗口的记忆,因此只能保存一轮的历史消息,因此AI并不能知道你第一 轮对话中提到的名字,他最多只能记住上一轮(第二轮)的对话信息

2.3 对话字符缓存存储

使用对话字符缓存记忆,内存将限制保存的token数量。如果字符数量超出指定数目,它会切掉这个对话的早期部分

以保留与最近的交流相对应的字符数量,但不超过字符限制。

添加对话到Token缓存储存,限制token数量,进行测试

from langchain.llms import OpenAI
from langchain.memory import ConversationTokenBufferMemory
memory = ConversationTokenBufferMemory(llm=llm, max_token_limit=30)
memory.save_context({"input": "朝辞白帝彩云间,"}, {"output": "千里江陵一日还。"})
memory.save_context({"input": "两岸猿声啼不住,"}, {"output": "轻舟已过万重山。"})
memory.load_memory_variables({})
{'history': 'AI: 轻舟已过万重山。'}

ChatGPT 使用一种基于字节对编码(Byte Pair Encoding,BPE)的方法来进行 tokenization (将输入文本拆分为token)。BPE 是一种常见的 tokenization 技术,它将输入文本分割成较小的子词单元。OpenAI 在其官方 GitHub 上公开了一个最新的开源 Python 库 tiktoken(https://github.com/openai/tiktoken),这个库主要是用来计算 tokens 数量的。相比较 HuggingFace 的 tokenizer ,其速度提升了好几倍。

具体 token 计算方式,特别是汉字和英文单词的 token 区别,具体可参考知乎文章(The domain name zhihu.co is for sale | Dan.com

m/question/594159910)。

2.4、对话摘要缓存存储

对话摘要缓存储存,使用 LLM 对到目前为止历史对话自动总结摘要,并将其保存下来。

from langchain.chains import ConversationChain
from langchain.chat_models import ChatOpenAI
from langchain.memory import ConversationSummaryBufferMemory
# 创建一个长字符串
schedule = "在八点你和你的产品团队有一个会议。 \
你需要做一个PPT。 \
上午9点到12点你需要忙于LangChain。\
Langchain是一个有用的工具,因此你的项目进展的非常快。\
中午,在意大利餐厅与一位开车来的顾客共进午餐 \
走了一个多小时的路程与你见面,只为了解最新的 AI。 \
确保你带了笔记本电脑可以展示最新的 LLM 样例."
llm = ChatOpenAI(temperature=0.0)
memory = ConversationSummaryBufferMemory(llm=llm, max_token_limit=100)
memory.save_context({"input": "你好,我叫皮皮鲁"}, {"output": "你好啊,我叫鲁西西"})
memory.save_context({"input": "很高兴和你成为朋友!"}, {"output": "是的,让我们一起去冒
险吧!"})
memory.save_context({"input": "今天的日程安排是什么?"}, {"output": f"{schedule}"})
print(memory.load_memory_variables({})['history'])

3、模型链

链(Chains)通常将大语言模型(LLM)与提示(Prompt)结合在一起,基于此,我们可以对文本或数据进行一系列操作。链(Chains)可以一次性接受多个输入。例如,我们可以创建一个链,该链接受用户输入,使用提示模板对其进行格式化,然后将格式化的响应传递给 LLM 。我们可以通过将多个链组合在一起,或者通过将链与其他组件组合在一起来构建更复杂的链。

3.1 大语言模型链

  1. 初始化语言模型
import warnings
warnings.filterwarnings('ignore')
from langchain.chat_models import ChatOpenAI
from langchain.prompts import ChatPromptTemplate
from langchain.chains import LLMChain
# 这里我们将参数temperature设置为0.0,从而减少生成答案的随机性。
# 如果你想要每次得到不一样的有新意的答案,可以尝试调整该参数。
llm = ChatOpenAI(temperature=0.0)
  1. 初始化提示模版
prompt = ChatPromptTemplate.from_template("描述制造{product}的一个公司的最佳名称是什
么?")
  1. 构建大语言模型链

将大语言模型(LLM)和提示(Prompt)组合成链。这个大语言模型链非常简单,可以让我们以一种顺序的方 式去通过运行提示并且结合到大语言模型中。

chain = LLMChain(llm=llm, prompt=prompt)
  1. 运行大语言模型链

因此,如果我们有一个名为"Queen Size Sheet Set"的产品,我们可以通过使用 chain.run 将其通过这个链运行

product = "大号床单套装"
chain.run(product)

3.2 简单顺序链

顺序链(SequentialChains)是按预定义顺序执行其链接的链。具体来说,简单顺序链(SimpleSequentialChain)每个步骤都有一个输入/输出,一个步骤的输出是下一个步骤的输入。

from langchain.chains import SimpleSequentialChain
llm = ChatOpenAI(temperature=0.9)

#  ===============创建两个子链=======================
# 提示模板 1 :这个提示将接受产品并返回最佳名称来描述该公司
first_prompt = ChatPromptTemplate.from_template(
"描述制造{product}的一个公司的最好的名称是什么"
)
chain_one = LLMChain(llm=llm, prompt=first_prompt)
# 提示模板 2 :接受公司名称,然后输出该公司的长为20个单词的描述
second_prompt = ChatPromptTemplate.from_template(
"写一个20字的描述对于下面这个\
公司:{company_name}的"
)
chain_two = LLMChain(llm=llm, prompt=second_prompt)

#  ===============构建简单顺序链=======================
overall_simple_chain = SimpleSequentialChain(chains=[chain_one, chain_two],
verbose=True)

#  ===============运行简单顺序链=======================
product = "大号床单套装"
overall_simple_chain.run(product)

3.3 顺序链

当只有一个输入和一个输出时,简单顺序链(SimpleSequentialChain)即可实现。当有多个输入或多个 输出时,我们则需要使用顺序链(SequentialChain)来实现。

import pandas as pd
from langchain.chains import SequentialChain
from langchain.chat_models import ChatOpenAI #导入OpenAI模型
from langchain.prompts import ChatPromptTemplate #导入聊天提示模板
from langchain.chains import LLMChain #导入LLM链。
llm = ChatOpenAI(temperature=0.9)


#  ===============创建四个子链=======================
#子链1
# prompt模板 1: 翻译成英语(把下面的review翻译成英语)
first_prompt = ChatPromptTemplate.from_template(
"把下面的评论review翻译成英文:"
"\n\n{Review}"
)
# chain 1: 输入:Review 输出:英文的 Review
chain_one = LLMChain(llm=llm, prompt=first_prompt, output_key="English_Review")
#子链2
# prompt模板 2: 用一句话总结下面的 review
second_prompt = ChatPromptTemplate.from_template(
"请你用一句话来总结下面的评论review:"
"\n\n{English_Review}"
)
# chain 2: 输入:英文的Review 输出:总结
chain_two = LLMChain(llm=llm, prompt=second_prompt, output_key="summary")
#子链3
# prompt模板 3: 下面review使用的什么语言
third_prompt = ChatPromptTemplate.from_template(
"下面的评论review使用的什么语言:\n\n{Review}"
)
# chain 3: 输入:Review 输出:语言
chain_three = LLMChain(llm=llm, prompt=third_prompt, output_key="language")
#子链4
# prompt模板 4: 使用特定的语言对下面的总结写一个后续回复
fourth_prompt = ChatPromptTemplate.from_template(
"使用特定的语言对下面的总结写一个后续回复:"
"\n\n总结: {summary}\n\n语言: {language}"
)
# chain 4: 输入: 总结, 语言 输出: 后续回复
chain_four = LLMChain(llm=llm, prompt=fourth_prompt,
output_key="followup_message")

#  ===============对四个子链进行组合=======================
#输入:review
#输出:英文review,总结,后续回复
overall_chain = SequentialChain(
chains=[chain_one, chain_two, chain_three, chain_four],
input_variables=["Review"],
output_variables=["English_Review", "summary","followup_message"],
verbose=True
)


df = pd.read_csv('../data/Data.csv')
review = df.Review[5]
overall_chain(review)

3.4 路由链

一个相当常见但基本的操作是根据输入将其路由到一条链,具体取决于该输入到底是什么。如果你有多个子链,每个子链都专门用于特定类型的输入,那么可以组成一个路由链,它首先决定将它传递给哪个子链,然后将它传递给那个链。

路由器由两个组件组成:

    • 路由链(Router Chain):路由器链本身,负责选择要调用的下一个链
    • destination_chains:路由器链可以路由到的链

1、定义提示模版

首先,我们定义提示适用于不同场景下的提示模板。

# 中文
#第一个提示适合回答物理问题
physics_template = """你是一个非常聪明的物理专家。 \
你擅长用一种简洁并且易于理解的方式去回答问题。\
当你不知道问题的答案时,你承认\
你不知道.
这是一个问题:
{input}"""
#第二个提示适合回答数学问题
math_template = """你是一个非常优秀的数学家。 \
你擅长回答数学问题。 \
你之所以如此优秀, \
是因为你能够将棘手的问题分解为组成部分,\
回答组成部分,然后将它们组合在一起,回答更广泛的问题。
这是一个问题:
{input}"""
#第三个适合回答历史问题
history_template = """你是以为非常优秀的历史学家。 \
你对一系列历史时期的人物、事件和背景有着极好的学识和理解\
你有能力思考、反思、辩证、讨论和评估过去。\
你尊重历史证据,并有能力利用它来支持你的解释和判断。
这是一个问题:
{input}"""
#第四个适合回答计算机问题
computerscience_template = """ 你是一个成功的计算机科学专家。\
你有创造力、协作精神、\
前瞻性思维、自信、解决问题的能力、\
对理论和算法的理解以及出色的沟通技巧。\
你非常擅长回答编程问题。\
你之所以如此优秀,是因为你知道 \
如何通过以机器可以轻松解释的命令式步骤描述解决方案来解决问题,\
并且你知道如何选择在时间复杂性和空间复杂性之间取得良好平衡的解决方案。
这还是一个输入:
{input}"""

2、对提示模版进行命名和描述

在定义了这些提示模板后,我们可以为每个模板命名,并给出具体描述。例如,第一个物理学的描述适合回答关于物理学的问题,这些信息将传递给路由链,然后由路由链决定何时使用此子链。

# 中文
prompt_infos = [
{
"名字": "物理学",
"描述": "擅长回答关于物理学的问题",
"提示模板": physics_template
},
{
"名字": "数学",
"描述": "擅长回答数学问题",
"提示模板": math_template
},
{
"名字": "历史",
"描述": "擅长回答历史问题",
"提示模板": history_template
},
{
"名字": "计算机科学",
"描述": "擅长回答计算机科学问题",
"提示模板": computerscience_template
}
]

在这里,我们需要一个多提示链。这是一种特定类型的链,用于在多个不同的提示模板之间进行路由。但是这只是路由的一种类型,我们也可以在任何类型的链之间进行路由。

这里我们要实现的几个类是大模型路由器链。这个类本身使用语言模型来在不同的子链之间进行路由。这就是上面提供的描述和名称将被使用的地方。

3、基于提示模版信息创建相应目标链

目标链是由路由链调用的链,每个目标链都是一个语言模型链。

destination_chains = {}
for p_info in prompt_infos:
name = p_info["名字"]
prompt_template = p_info["提示模板"]
prompt = ChatPromptTemplate.from_template(template=prompt_template)
chain = LLMChain(llm=llm, prompt=prompt)
destination_chains[name] = chain
destinations = [f"{p['名字']}: {p['描述']}" for p in prompt_infos]
destinations_str = "\n".join(destinations)

4、创建默认目标链

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

default_prompt = ChatPromptTemplate.from_template("{input}")
default_chain = LLMChain(llm=llm, prompt=default_prompt)

5、定义不同链之间的路由模板

这包括要完成的任务的说明以及输出应该采用的特定格式。

# 多提示路由模板
MULTI_PROMPT_ROUTER_TEMPLATE = """给语言模型一个原始文本输入,\
让其选择最适合输入的模型提示。\
系统将为您提供可用提示的名称以及最适合改提示的描述。\
如果你认为修改原始输入最终会导致语言模型做出更好的响应,\
你也可以修改原始输入。
<< 格式 >>
返回一个带有JSON对象的markdown代码片段,该JSON对象的格式如下:
```json
{{{{
"destination": 字符串 \ 使用的提示名字或者使用 "DEFAULT"
"next_inputs": 字符串 \ 原始输入的改进版本
}}}}
记住:“destination”必须是下面指定的候选提示名称之一,\
或者如果输入不太适合任何候选提示,\
则可以是 “DEFAULT” 。
记住:如果您认为不需要任何修改,\
则 “next_inputs” 可以只是原始输入。
<< 候选提示 >>
{destinations}
<< 输入 >>
{{input}}
<< 输出 (记得要包含 ```json)>>
样例:
<< 输入 >>
"什么是黑体辐射?"
<< 输出 >>
```json
{{{{
"destination": 字符串 \ 使用的提示名字或者使用 "DEFAULT"
"next_inputs": 字符串 \ 原始输入的改进版本
}}}}
"""

6、构建路由链

  • 通过格式化上面定义的目标创建完整的路由器模板。这个模板可以适用许多不同类型的目标。
  • 在这里可以添加一个不同的学科,如英语或拉丁语,而不仅仅是物理、数学、历史和计算机科学。
  • 从这个模板创建提示模板。
  • 通过传入llm和整个路由提示来创建路由链。需要注意的是这里有路由输出解析,这很重要,因为它将帮助这个链路决定在哪些子链路之间进行路由。
router_template = MULTI_PROMPT_ROUTER_TEMPLATE.format(destinations=destinations_str)
router_prompt = PromptTemplate(template=router_template,
                                input_variables=["input"],
                                output_parser=RouterOutputParser(),
                                )
router_chain = LLMRouterChain.from_llm(llm, router_prompt)

7、创建整体链路

#多提示链
chain = MultiPromptChain(router_chain=router_chain, #l路由链路
destination_chains=destination_chains, #目标链路
default_chain=default_chain, #默认链路
verbose=True
)

8、进行提问

如果我们问一个物理问题,我们希望看到他被路由到物理链路。

chain.run("什么是黑体辐射?")

Entering new MultiPromptChain chain...
物理学: {'input': '什么是黑体辐射?'}
Finished chain.

'黑体辐射是指一个理想化的物体,它能够完全吸收并且以最高效率地辐射出所有入射到它上面的电磁辐射。这种辐射的特点是它的辐射强度与波长有关,且在不同波长下的辐射强度符合普朗克辐射定律。黑体辐射在物理学中有广泛的应用,例如研究热力学、量子力学和宇宙学等领域。'

如果我们传递一个与任何子链路都无关的问题时,我们可以看到它选择的链路是无。这意味着它将被传递到默认链路,它本身只是对语言模型的通用调用。

chain.run("为什么我们身体里的每个细胞都包含DNA?")

Entering new MultiPromptChain chain...
物理学: {'input': '为什么我们身体里的每个细胞都包含DNA?'}
Finished chain.

'我们身体里的每个细胞都包含DNA,因为DNA是遗传信息的载体。DNA是由四种碱基(腺嘌呤、胸腺嘧啶、鸟嘌呤和胞嘧啶)组成的长链状分子,它存储了我们的遗传信息,包括我们的基因和遗传特征。每个细胞都需要这些遗传信息来执行其特定的功能和任务。所以,DNA存在于每个细胞中,以确保我们的身体正常运作。'

4、基于文档的问答

使用大语言模型构建一个能够回答关于给定文档和文档集合的问答系统是一种非常实用和有效的应用场景。与仅依赖模型预训练知识不同,这种方法可以进一步整合用户自有数据,实现更加个性化和专业的问答服务。例如,我们可以收集某公司的内部文档、产品说明书等文字资料,导入问答系统中。然后用户针对这些文档提出问题时,系统可以先在文档中检索相关信息,再提供给语言模型生成答案。

这样,语言模型不仅利用了自己的通用知识,还可以充分运用外部输入文档的专业信息来回答用户问题,显著提升答案的质量和适用性。构建这类基于外部文档的问答系统,可以让语言模型更好地服务于具体场景,而不是停留在通用层面。

基于文档问答的这个过程,我们会涉及 LangChain 中的其他组件,比如:嵌入模型(Embedding Models)和向量储存(Vector Stores),

4.1 直接使用向量存储查询

1、导入数据

from langchain.chains import RetrievalQA #检索QA链,在文档上进行检索
from langchain.chat_models import ChatOpenAI #openai模型
from langchain.document_loaders import CSVLoader #文档加载器,采用csv格式存储
from langchain.vectorstores import DocArrayInMemorySearch #向量存储
from IPython.display import display, Markdown #在jupyter显示信息的工具
import pandas as pd
file = '../data/OutdoorClothingCatalog_1000.csv'
# 使用langchain文档加载器对数据进行导入
loader = CSVLoader(file_path=file)
# 使用pandas导入数据,用以查看
data = pd.read_csv(file,usecols=[1, 2])
data.head()

2、基本文档加载器创建向量存储

#导入向量存储索引创建器
from langchain.indexes import VectorstoreIndexCreator
# 创建指定向量存储类, 创建完成后,从加载器中调用, 通过文档加载器列表加载
index = VectorstoreIndexCreator(vectorstore_cls=DocArrayInMemorySearch).from_loaders([loader])

3、查询创建的向量存储

query ="请用markdown表格的方式列出所有具有防晒功能的衬衫,对每件衬衫描述进行总结"
#使用索引查询创建一个响应,并传入这个查询
response = index.query(query)
#查看查询返回的内容
display(Markdown(response))

4.2 结合表征模型和向量存储

由于语言模型的上下文长度限制,直接处理长文档具有困难。为实现对长文档的问答,我们可以引入向量嵌入(Embeddings)和向量存储(Vector Store)等技术:

首先,使用文本嵌入(Embeddings)算法对文档进行向量化,使语义相似的文本片段具有接近的向量表示。其次,将向量化的文档切分为小块,存入向量数据库,这个流程正是创建索引(index)的过程。向量数据库对各文档片段进行索引,支持快速检索。这样,当用户提出问题时,可以先将问题转换为向量,在数据库中快速找到语义最相关的文档片段。然后将这些文档片段与问题一起传递给语言模型,生成回答。

通过嵌入向量化和索引技术,我们实现了对长文档的切片检索和问答。这种流程克服了语言模型的上下文限制,可以构建处理大规模文档的问答系统。

1、导入数据

#创建一个文档加载器,通过csv格式加载
file = '../data/OutdoorClothingCatalog_1000.csv'
loader = CSVLoader(file_path=file)
docs = loader.load()
#查看单个文档,每个文档对应于CSV中的一行数据
docs[0]

2、文本向量表征模型

#使用OpenAIEmbedding类
from langchain.embeddings import OpenAIEmbeddings
embeddings = OpenAIEmbeddings()
#因为文档比较短了,所以这里不需要进行任何分块,可以直接进行向量表征
#使用初始化OpenAIEmbedding实例上的查询方法embed_query为文本创建向量表征
embed = embeddings.embed_query("你好呀,我的名字叫小可爱")
#查看得到向量表征的长度
print("\n\033[32m向量表征的长度: \033[0m \n", len(embed))
#每个元素都是不同的数字值,组合起来就是文本的向量表征
print("\n\033[32m向量表征前5个元素: \033[0m \n", embed[:5])

3、基于向量表征创建并查询向量存储

# 将刚才创建文本向量表征(embeddings)存储在向量存储(vector store)中
# 使用DocArrayInMemorySearch类的from_documents方法来实现
# 该方法接受文档列表以及向量表征模型作为输入
db = DocArrayInMemorySearch.from_documents(docs, embeddings)
query = "请推荐一件具有防晒功能的衬衫"
#使用上面的向量存储来查找与传入查询类似的文本,得到一个相似文档列表
docs = db.similarity_search(query)
print("\n\033[32m返回文档的个数: \033[0m \n", len(docs))
print("\n\033[32m第一个文档: \033[0m \n", docs[0])

4、使用查询结果构造提示来回答问题

#导入大语言模型, 这里使用默认模型gpt-3.5-turbo会出现504服务器超时,
#因此使用gpt-3.5-turbo-0301
llm = ChatOpenAI(model_name="gpt-3.5-turbo-0301",temperature = 0.0)
#合并获得的相似文档内容
qdocs = "".join([docs[i].page_content for i in range(len(docs))])
#将合并的相似文档内容后加上问题(question)输入到 `llm.call_as_llm`中
#这里问题是:以Markdown表格的方式列出所有具有防晒功能的衬衫并总结
response = llm.call_as_llm(f"{qdocs}问题:请用markdown表格的方式列出所有具有防晒功能的衬
衫,对每件衬衫描述进行总结")
display(Markdown(response))

5、使用检索问答链来回答问题

通过LangChain创建一个检索问答链,对检索到的文档进行问题回答。检索问答链的输入包含以下

  • llm : 语言模型,进行文本生成
  • chain_type : 传入链类型,这里使用stuff,将所有查询得到的文档组合成一个文档传入下一步。其他的方式包括:
    • Map Reduce: 将所有块与问题一起传递给语言模型,获取回复,使用另一个语言模型调用将所有单独的回复总结成最终答案,它可以在任意数量的文档上运行。可以并行处理单个问题,同时也需要更多的调用。它将所有文档视为独立的;
    • Refine: 用于循环许多文档,际上是迭代的,建立在先前文档的答案之上,非常适合前后因果信息并随时间逐步构建答案,依赖于先前调用的结果。它通常需要更长的时间,并且基本上需要与Map Reduce一样多的调用;
    • Map Re-rank: 对每个文档进行单个语言模型调用,要求它返回一个分数,选择最高分,这依赖于语言模型知道分数应该是什么,需要告诉它,如果它与文档相关,则应该是高分,并在那里精细调整说明,可以批量处理它们相对较快,但是更加昂贵。
  • retriever :检索器
#基于向量储存,创建检索器
retriever = db.as_retriever()
qa_stuff = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff",
retriever=retriever,
verbose=True
)
#创建一个查询并在此查询上运行链
query = "请用markdown表格的方式列出所有具有防晒功能的衬衫,对每件衬衫描述进行总结"
response = qa_stuff.run(query)
display(Markdown(response))

5、评估

评估是检验语言模型问答质量的关键环节。评估可以检验语言模型在不同文档上的问答效果,找出其弱点。还可以通过比较不同模型,选择最佳系统。此外,定期评估也可以检查模型质量的衰减。评估通常有两个目的:

  • 检验LLM应用是否达到了验收标准
  • 分析改动对于LLM应用性能的影响

基本的思路就是利用语言模型本身和链本身,来辅助评估其他的语言模型、链和应用程序。

5.1 创建LLM应用

首先,按照 langchain 链的方式构建一个 LLM 的文档问答应用

from langchain.chains import RetrievalQA #检索QA链,在文档上进行检索
from langchain.chat_models import ChatOpenAI #openai模型
from langchain.document_loaders import CSVLoader #文档加载器,采用csv格式存储
from langchain.indexes import VectorstoreIndexCreator #导入向量存储索引创建器
from langchain.vectorstores import DocArrayInMemorySearch #向量存储
#加载中文数据
file = '../data/product_data.csv'
loader = CSVLoader(file_path=file)
data = loader.load()
#查看数据
import pandas as pd
test_data = pd.read_csv(file,skiprows=0)
display(test_data.head())

# 将指定向量存储类,创建完成后,我们将从加载器中调用,通过文档记载器列表加载
index = VectorstoreIndexCreator(
vectorstore_cls=DocArrayInMemorySearch
).from_loaders([loader])
#通过指定语言模型、链类型、检索器和我们要打印的详细程度来创建检索QA链
llm = ChatOpenAI(temperature = 0.0)
qa = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff",
retriever=index.vectorstore.as_retriever(),
verbose=True,
chain_type_kwargs = {
"document_separator": "<<<<>>>>>"
}
)

1、设置测试的数据

查看一下经过档加载器 CSVLoad 加载后生成的 data 内的信息,随机抽取 data 中的几条数据,看看它们的主要内容。

2、手动创建测试数据

需要说明的是这里我们的文档是 csv 文件,所以我们使用的是文档加载器是 CSVLoader ,CSVLoader 会对 csv 文件中的每一行数据进行分割,所以这里看到的 data[10], data[11]的内容则是 csv 文件中的第10条,第11条数据的内容。下面我们根据这两条数据手动设置两条“问答对”,每一个“问答对”中包含一个query ,一个 answer :

examples = [
{
"query": "高清电视机怎么进行护理?",
"answer": "使用干布清洁。"
},
{
"query": "旅行背包有内外袋吗?",
"answer": "有。"
}
]

3、通过LLM生成测试用例

在前面的内容中,我们使用的方法都是通过手动的方法来构建测试数据集,比如说我们手动创建10个问题和10个答案,然后让 LLM 回答这10个问题,再将 LLM 给出的答案与我们准备好的答案做比较,最后再给 LLM 打分,评估的流程大概就是这样。但是这里有一个问题,就是我们需要手动去创建所有的问题集和答案集,那会是一个非常耗费时间和人力的成本。那有没有一种可以自动创建大量问答测试集的方法呢?那当然是有的,今天我们就来介绍 Langchain 提供的方法: QAGenerateChain ,我们可以通过QAGenerateChain 来为我们的文档自动创建问答集。

由于 QAGenerateChain 类中使用的 PROMPT 是英文,故我们继承 QAGenerateChain 类,将 PROMPT 加上“请使用中文输出”。下面是 generate_chain.py 文件中的 QAGenerateChain 类的源码。

from langchain.evaluation.qa import QAGenerateChain #导入QA生成链,它将接收文档,并从
每个文档中创建一个问题答案对
# 下面是langchain.evaluation.qa.generate_prompt中的源码,在template的最后加上“请使用中
文输出”
from langchain.output_parsers.regex import RegexParser
from langchain.prompts import PromptTemplate
from langchain.base_language import BaseLanguageModel
from typing import Any
template = """You are a teacher coming up with questions to ask on a quiz.
Given the following document, please generate a question and answer based on that
document.
Example Format:
<Begin Document>
...
<End Document>
QUESTION: question here
ANSWER: answer here
These questions should be detailed and be based explicitly on information in the
document. Begin!
<Begin Document>
{doc}
<End Document>
请使用中文输出
"""
output_parser = RegexParser(
regex=r"QUESTION: (.*?)\nANSWER: (.*)", output_keys=["query", "answer"]
)
PROMPT = PromptTemplate(
input_variables=["doc"], template=template, output_parser=output_parser
)
# 继承QAGenerateChain
class ChineseQAGenerateChain(QAGenerateChain):
"""LLM Chain specifically for generating examples for question answering."""
@classmethod
def from_llm(cls, llm: BaseLanguageModel, **kwargs: Any) -> QAGenerateChain:
"""Load QA Generate Chain from LLM."""
return cls(llm=llm, prompt=PROMPT, **kwargs)
example_gen_chain = ChineseQAGenerateChain.from_llm(ChatOpenAI())#通过传递chat
open AI语言模型来创建这个链
new_examples = example_gen_chain.apply([{"doc": t} for t in data[:5]])
#查看用例数据
new_examples

4、整合测试集

还记得我们前面手动创建的两个问答集吗?现在我们需要将之前手动创建的问答集合并到QAGenerateChain 创建的问答集中,这样在答集中既有手动创建的例子又有 llm 自动创建的例子,这会使我们的测试集更加完善。

接下来我们就需要让之前创建的文档问答链 qa 来回答这个测试集里的问题,来看看 LLM 是怎么回答的吧

examples += [ v for item in new_examples for k,v in item.items()]
qa.run(examples[0]["query"])

5.2 人工评估

import langchain
langchain.debug = True
#重新运行与上面相同的示例,可以看到它开始打印出更多的信息
qa.run(examples[0]["query"])

5.3 通过LLM进行评估实例

来简要梳理一下问答评估的流程:

  • 首先,我们使用 LLM 自动构建了问答测试集,包含问题及标准答案。
  • 然后,同一 LLM 试图回答测试集中的所有问题,得到响应。
  • 下一步,需要评估语言模型的回答是否正确。这里奇妙的是,我们再使用另一个 LLM 链进行判断,所以 LLM 既是“球员”,又是“裁判”。

具体来说,第一个语言模型负责回答问题。第二个语言模型链用来进行答案判定。最后我们可以收集判断结果,得到语言模型在这一任务上的效果分数。需要注意的是,回答问题的语言模型链和答案判断链是分开的,职责不同。这避免了同一个模型对自己结果的主观判断。

总之,语言模型可以自动完成构建测试集、回答问题和判定答案等全流程,使评估过程更加智能化和自动化。我们只需要提供文档并解析最终结果即可。

langchain.debug = False
#为所有不同的示例创建预测
predictions = qa.apply(examples)
# 对预测的结果进行评估,导入QA问题回答,评估链,通过语言模型创建此链
from langchain.evaluation.qa import QAEvalChain #导入QA问题回答,评估链
#通过调用chatGPT进行评估
llm = ChatOpenAI(temperature=0)
eval_chain = QAEvalChain.from_llm(llm)
#在此链上调用evaluate,进行评估
graded_outputs = eval_chain.evaluate(examples, predictions)
#我们将传入示例和预测,得到一堆分级输出,循环遍历它们打印答案
for i, eg in enumerate(examples):
print(f"Example {i}:")
print("Question: " + predictions[i]['query'])
print("Real Answer: " + predictions[i]['answer'])
print("Predicted Answer: " + predictions[i]['result'])
print("Predicted Grade: " + graded_outputs[i]['results'])
print()

6、代理

代理作为语言模型的外部模块,可提供计算、逻辑、检索等功能的支持,使语言模型获得异常强大的推

理和获取信息的超能力。

6.1 使用LangChain内置工具llm-math和wikipedia

要使用代理 (Agents) ,我们需要三样东西:

  • 一个基本的 LLM
  • 我们将要进行交互的工具 Tools
  • 一个控制交互的代理 (Agents)
from langchain.agents import load_tools, initialize_agent
from langchain.agents import AgentType
from langchain.python import PythonREPL
from langchain.chat_models import ChatOpenAI

首先,让我们新建一个基本的 LLM

# 参数temperature设置为0.0,从而减少生成答案的随机性。
llm = ChatOpenAI(temperature=0)

接下来,初始化 工具 Tool ,我们可以创建自定义工具 Tool 或加载预构建工具 Tool。无论哪种情况, 工具 Tool 都是一个给定工具 名称 name 和 描述 description 的 实用链。

  • llm-math 工具结合语言模型和计算器用以进行数学计算
  • wikipedia 工具通过API连接到wikipedia进行搜索查询。
tools = load_tools(
["llm-math","wikipedia"],
llm=llm #第一步初始化的模型
)

现在我们有了 LLM 和工具,最后让我们初始化一个简单的代理 (Agents) :

# 初始化代理
agent= initialize_agent(
tools, #第二步加载的工具
llm, #第一步初始化的模型
agent=AgentType.CHAT_ZERO_SHOT_REACT_DESCRIPTION, #代理类型
handle_parsing_errors=True, #处理解析错误
verbose = True #输出中间步骤
)
  • agent : 代理类型。这里使用的是 AgentType.CHAT_ZERO_SHOT_REACT_DESCRIPTION 。其中CHAT 代表代理模型为针对对话优化的模型; Zero-shot 意味着代理 (Agents) 仅在当前操作上起作用,即它没有记忆; REACT 代表针对REACT设计的提示模版。 DESCRIPTION 根据工具的描述description 来决定使用哪个工具。(我们不会在本章中讨论 * REACT 框架 ,但您可以将其视为LLM 可以循环进行 Reasoning 和 Action 步骤的过程。它启用了一个多步骤的过程来识别答案。)
  • handle_parsing_errors : 是否处理解析错误。当发生解析错误时,将错误信息返回给大模型,让其进行纠正。
  • verbose : 是否输出中间步骤结果。

使用代理回答数学问题

agent("计算300的25%")

> Entering new AgentExecutor chain...

Question: 计算300的25%

Thought: I can use the calculator tool to calculate 25% of 300.

Action:

```json

{

"action": "Calculator",

"action_input": "300 * 0.25"

}

```

Observation: Answer: 75.0

Thought:The calculator tool returned the answer 75.0, which is 25% of 300.

Final Answer: 25% of 300 is 75.0.

> Finished chain.

{'input': '计算300的25%', 'output': '25% of 300 is 75.0.'}

上面的过程可以总结为下

1. 模型对于接下来需要做什么,给出思考 (Thought)

思考:我可以使用计算工具来计算300的25%

2. 模型基于思考采取行动 (Action)

行动: 使用计算器(calculator),输入(action_input)300*0.25

3. 模型得到观察 (Observation)

观察:答案: 75.0

4. 基于观察,模型对于接下来需要做什么,给出思考 (Thought)

思考: 计算工具返回了300的25%,答案为75

5. 给出最终答案(Final Answer)

最终答案: 300的25%等于75。

6. 以字典的形式给出最终答案。

{'input': '计算300的25%', 'output': '25% of 300 is 75.0.'}

值得注意的是,模型每次运行推理的过程可能存在差异,但最终的结果一致。

6.2 使用LangChain内置工具PythonREPLTool

创建一个将名字转换为拼音的 python 代理,步骤与上一部分的一样。

from langchain.agents.agent_toolkits import create_python_agent
from langchain.tools.python.tool import PythonREPLTool
agent = create_python_agent(
llm, #使用前面一节已经加载的大语言模型
tool=PythonREPLTool(), #使用Python交互式环境工具 REPLTool
verbose=True #输出中间步骤
)
customer_list = ["小明","小黄","小红","小蓝","小橘","小绿",]
agent.run(f"将使用pinyin拼音库这些客户名字转换为拼音,并打印输出列表: {customer_list}。")

6.3 定义自己的工具并在代理中使用

创建和使用自定义时间工具。LangChian tool 函数装饰器可以应用用于任何函数,将函数转化为LangChain 工具,使其成为代理可调用的工具。我们需要给函数加上非常详细的文档字符串,使得代理知道在什么情况下、如何使用该函数/工具。比如下面的函数 time ,我们加上了详细的文档字符串。

# 导入tool函数装饰器
from langchain.agents import tool
from datetime import date

@tool
def time(text: str) -> str:
"""
返回今天的日期,用于任何需要知道今天日期的问题。\
输入应该总是一个空字符串,\
这个函数将总是返回今天的日期,任何日期计算应该在这个函数之外进行。
"""
return str(date.today())
# 初始化代理
agent= initialize_agent(
tools=[time], #将刚刚创建的时间工具加入代理
llm=llm, #初始化的模型
agent=AgentType.CHAT_ZERO_SHOT_REACT_DESCRIPTION, #代理类型
handle_parsing_errors=True, #处理解析错误
verbose = True #输出中间步骤
)
# 使用代理询问今天的日期.
# 注: 代理有时候可能会出错(该功能正在开发中)。如果出现错误,请尝试再次运行它。
agent("今天的日期是?")

> Entering new AgentExecutor chain...

根据提供的工具,我们可以使用`time`函数来获取今天的日期。

Thought: 使用`time`函数来获取今天的日期。

Action:

```

{

"action": "time",

"action_input": ""

}

```

Observation: 2023-08-09

Thought:我现在知道了最终答案。

Final Answer: 今天的日期是2023-08-09。

> Finished chain.

{'input': '今天的日期是?', 'output': '今天的日期是2023-08-09。'}

四、LangChain访问个人数据

1、文档加载

用户个人数据可以以多种形式呈现:PDF 文档、视频、网页等。基于 LangChain 提供给 LLM 访问用户个人数据的能力,首先要加载并处理用户的多样化、非结构化个人数据。

1.1 PDF文档

首先,我们将从以下链接加载一个PDF文档。这是 DataWhale 提供的开源教程,名为《Fantastic Matplotlib》。注意,要运行以下代码,你需要安装第三方库 pypdf:

!pip install -q pypdf

1、加载PDF文档

利用 PyPDFLoader 来对 PDF 文件进行读取和加载。

from langchain.document_loaders import PyPDFLoader

# 创建一个 PyPDFLoader Class 实例,输入为待加载的pdf文档路径
loader = PyPDFLoader("docs/matplotlib/第一回:Matplotlib初相识.pdf")

# 调用 PyPDFLoader Class 的函数 load对pdf文件进行加载
pages = loader.load()

2、探索加载的数据

一旦文档被加载,它会被存储在名为 pages 的变量里。此外, pages 的数据结构是一个 List 类型。为 了确认其类型,我们可以借助Python内建的 type 函数来查看 pages 的确切数据类型。

在 page 变量中,每一个元素都代表一个文档,它们的数据类型是 langchain.schema.Document 。

page = pages[0]
print(type(page))

<class 'langchain.schema.document.Document'>

langchain.schema.Document 类型包含两个属性:

  1. page_content :包含该文档页面的内容。

print(page.page_content[0:500])

第⼀回:Matplotlib 初相识

⼀、认识matplotlib

Matplotlib 是⼀个 Python 2D 绘图库,能够以多种硬拷⻉格式和跨平台的交互式环境⽣成出版物质量的

图形,⽤来绘制各种静态,动态,

交互式的图表。

Matplotlib 可⽤于 Python 脚本, Python 和 IPython Shell 、 Jupyter notebook , Web

应⽤程序服务器和各种图形⽤户界⾯⼯具包等。

Matplotlib 是 Python 数据可视化库中的泰⽃,它已经成为 python 中公认的数据可视化⼯具,我们所

熟知的 pandas 和 seaborn 的绘图接⼝

其实也是基于 matplotlib 所作的⾼级封装。

为了对matplotlib 有更好的理解,让我们从⼀些最基本的概念开始认识它,再逐渐过渡到⼀些⾼级技巧中。

⼆、⼀个最简单的绘图例⼦

Matplotlib 的图像是画在 figure (如 windows , jupyter 窗体)上的,每⼀个 figure ⼜包含

了⼀个或多个 axes (⼀个可以指定坐标系的⼦区

域)。最简单的创建 figure

  1. meta_data :为文档页面相关的描述性数据

print(page.metadata)

{'source': 'docs/matplotlib/第一回:Matplotlib初相识.pdf', 'page': 0}

1.2 YouTube音频

  • 利用 langchain 加载工具,为指定的 YouTube 视频链接下载对应的音频至本地
  • 通过 OpenAIWhisperPaser 工具,将这些音频文件转化为可读的文本内容

注意,要运行以下代码,你需要安装如下两个第三方库

!pip -q install yt_dlp

!pip -q install pydub

1、加载Youtube音频文档

构建一个 GenericLoader 实例来对 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
url="https://www.youtube.com/watch?v=_PHdzsQaDgw"
save_dir="docs/youtube-zh/"
# 创建一个 GenericLoader Class 实例
loader = GenericLoader(
#将链接url中的Youtube视频的音频下载下来,存在本地路径save_dir
YoutubeAudioLoader([url],save_dir),
#使用OpenAIWhisperPaser解析器将音频转化为文本
OpenAIWhisperParser()
)
# 调用 GenericLoader Class 的函数 load对视频的音频文件进行加载
pages = loader.load()

2、探索加载的数据

Youtube 音频文件加载得到的变量同PDF类似,此处不再一一解释,通过类似代码可以展示加载数据:

print("Type of pages: ", type(pages))
print("Length of pages: ", len(pages))
page = pages[0]
print("Type of page: ", type(page))
print("Page_content: ", page.page_content[:500])
print("Meta Data: ", page.metadata)

1.3 网页文档

以 GitHub 上的一个markdown格式文 档为例,学习如何对其进行加载。

1、加载网页文档

构建一个 WebBaseLoader 实例来对网页进行加载。

from langchain.document_loaders import WebBaseLoader

# 创建一个 WebBaseLoader Class 实例
url = "https://github.com/datawhalechina/d2l-ai-solutionsmanual/blob/master/docs/README.md"
header = {'User-Agent': 'python-requests/2.27.1',
'Accept-Encoding': 'gzip, deflate, br',
'Accept': '*/*',
'Connection': 'keep-alive'}
loader = WebBaseLoader(web_path=url,header_template=header)

# 调用 WebBaseLoader Class 的函数 load对文件进行加载
pages = loader.load()

2、探索加载的数据

print("Type of pages: ", type(pages))
print("Length of pages: ", len(pages))
page = pages[0]
print("Type of page: ", type(page))
print("Page_content: ", page.page_content[:500])
print("Meta Data: ", page.metadata)

Type of pages: <class 'list'>

Length of pages: 1

Type of page: <class 'langchain.schema.document.Document'>

Page_content: {"payload":{"allShortcutsEnabled":false,"fileTree":{"docs":

{"items":[{"name":"ch02","path":"docs/ch02","contentType":"directory"},

{"name":"ch03","path":"docs/ch03","contentType":"directory"},

{"name":"ch05","path":"docs/ch05","contentType":"directory"},

{"name":"ch06","path":"docs/ch06","contentType":"directory"},

{"name":"ch08","path":"docs/ch08","contentType":"directory"},

{"name":"ch09","path":"docs/ch09","contentType":"directory"},

{"name":"ch10","path":"docs/ch10","contentType":"directory"},{"na

Meta Data: {'source': 'https://github.com/datawhalechina/d2l-ai-solutions

manual/blob/master/docs/README.md'}

可以看到上面的文档内容包含许多冗余的信息。通常来讲,我们需要进行对这种数据进行进一步处理 (Post Processing)。

import json
convert_to_json = json.loads(page.page_content)
extracted_markdow = convert_to_json['payload']['blob']['richText']
print(extracted_markdow)

动手学深度学习习题解答 {docsify-ignore-all}

李沐老师的《动手学深度学习》是入门深度学习的经典书籍,这本书基于深度学习框架来介绍深度学习,书 中代码可以做到“所学即所用”。对于一般的初学者来说想要把书中课后习题部分独立解答还是比较困难。本项 目对《动手学深度学习》习题部分进行解答,作为该书的习题手册,帮助初学者快速理解书中内容。

使用说明

动手学深度学习习题解答,主要完成了该书的所有习题,并提供代码和运行之后的截图,里面的内容是以深 度学习的内容为前置知识,该习题解答的最佳使用方法是以李沐老师的《动手学深度学习》为主线,并尝试完成 课后习题,如果遇到不会的,再来查阅习题解答。

如果觉得解答不详细,可以点击这里提交你希望补充推导或者习题编号,我们看到后会尽快进行补充。 选用的《动手学深度学习》版本

书名:动手学深度学习(PyTorch版)

著者:阿斯顿·张、[美]扎卡里 C. 立顿、李沐、[德]亚历山大·J.斯莫拉

译者:何孝霆、瑞潮儿·胡

出版社:人民邮电出版社

版次:2023年2月第1版

项目结构

codes----------------------------------------------习题代码

docs-----------------------------------------------习题解答

notebook-------------------------------------------习题解答JupyterNotebook格式

requirements.txt-----------------------------------运行环境依赖包

关注我们

1.4 Notion文档

1、加载Notion Markdown文档

使用 NotionDirectoryLoader 来对Notion Markdown文档进行加载。

from langchain.document_loaders import NotionDirectoryLoader
loader = NotionDirectoryLoader("docs/Notion_DB")
pages = loader.load()

2、探索加载的数据

print("Type of pages: ", type(pages))
print("Length of pages: ", len(pages))
page = pages[0]
print("Type of page: ", type(page))
print("Page_content: ", page.page_content[:500])
print("Meta Data: ", page.metadata)

2、文档分割

2.1 为什么文档分割

  1. 模型大小和内存限制:GPT 模型,特别是大型版本如 GPT-3 或 GPT-4 ,具有数十亿甚至上百亿的
    参数。为了在一次前向传播中处理这么多的参数,需要大量的计算能力和内存。但是,大多数硬件
    设备(例如 GPU 或 TPU )有内存限制。文档分割使模型能够在这些限制内工作。
  2. 计算效率:处理更长的文本序列需要更多的计算资源。通过将长文档分割成更小的块,可以更高效
    地进行计算。
  3. 序列长度限制:GPT 模型有一个固定的最大序列长度,例如2048个 token 。这意味着模型一次只
    能处理这么多 token 。对于超过这个长度的文档,需要进行分割才能被模型处理。
  4. 更好的泛化:通过在多个文档块上进行训练,模型可以更好地学习和泛化到各种不同的文本样式和
    结构。
  5. 数据增强:分割文档可以为训练数据提供更多的样本。例如,一个长文档可以被分割成多个部分,
    并分别作为单独的训练样本。

需要注意的是,虽然文档分割有其优点,但也可能导致一些上下文信息的丢失,尤其是在分割点附近。 因此,如何进行文档分割是一个需要权衡的问题。

若仅按照单一字符进行文本分割,很容易使文本的语义信息丧失,这样在回答问题时可能会出现偏差。 因此,为了确保语义的准确性,我们应该尽量将文本分割为包含完整语义的段落或单元。

2.2 文档分割方式

Langchain 中文本分割器都根据 chunk_size (块大小)和 chunk_overlap (块与块之间的重叠大小)进行分割。

  • chunk_size 指每个块包含的字符或 Token (如单词、句子等)的数量 ;
  • chunk_overlap 指两个块之间共享的字符数量,用于保持上下文的连贯性,避免分割丢失上下文信息;

Langchain提供多种文档分割方式,区别在怎么确定块与块之间的边界、块由哪些字符/token组成、以及 如何测量块大小

2.3 基于字符分割

如何进行文本分割,往往与我们的任务类型息息相关。当我们拆分代码时,这种相关性变得尤为突出。

因此,我们引入了一个语言文本分割器,其中包含各种为 Python、Ruby、C 等不同编程语言设计的分隔符。在对这些文档进行分割时,必须充分考虑各种编程语言之间的差异

CharacterTextSplitter是字符文本分割,分隔符的参数是单个的字符串;

RecursiveCharacterTextSplitter 是递归字符文本分割,将按不同的字符递归地分割(按照这个优先级["\n\n", "\n", " ", ""]),这样就能尽量把所有和语义相关的内容尽可能长时间地保留在同一位置。因此, RecursiveCharacterTextSplitter 比 CharacterTextSplitter 对文档切割得更加碎片化

RecursiveCharacterTextSplitter 需要关注的是如下4个参数:

  • separators - 分隔符字符串数组
  • chunk_size - 每个文档的字符数量限制
  • chunk_overlap - 两份文档重叠区域的长度
  • length_function - 长度计算函数

1、短句分割

# 导入文本分割器
from langchain.text_splitter import RecursiveCharacterTextSplitter,
CharacterTextSplitter
chunk_size = 20 #设置块大小
chunk_overlap = 10 #设置块重叠大小
# 初始化递归字符文本分割器
r_splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap
)
# 初始化字符文本分割器
c_splitter = CharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap
)

接下来我们对比展示两个字符文本分割器的效果。

text = "在AI的研究中,由于大模型规模非常大,模型参数很多,在大模型上跑完来验证参数好不好训练时间
成本很高,所以一般会在小模型上做消融实验来验证哪些改进是有效的再去大模型上做实验。" #测试文本
r_splitter.split_text(text)

['在AI的研究中,由于大模型规模非常大,模',

'大模型规模非常大,模型参数很多,在大模型',

'型参数很多,在大模型上跑完来验证参数好不',

'上跑完来验证参数好不好训练时间成本很高,',

'好训练时间成本很高,所以一般会在小模型上',

'所以一般会在小模型上做消融实验来验证哪些',

'做消融实验来验证哪些改进是有效的再去大模',

'改进是有效的再去大模型上做实验。']

可以看到,分割结果中,第二块是从“大模型规模非常大,模”开始的,刚好是我们设定的块重叠大小

#字符文本分割器
c_splitter.split_text(text)

['在AI的研究中,由于大模型规模非常大,模型参数很多,在大模型上跑完来验证参数好不好训练时间成本很高,所以一般会在小模型上做消融实验来验证哪些改进是有效的再去大模型上做实验。']

可以看到字符分割器没有分割这个文本,因为字符文本分割器默认以换行符为分隔符,因此需要设置“,” 为分隔符

# 设置空格分隔符
c_splitter = CharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
separator=','
)
c_splitter.split_text(text)

Created a chunk of size 23, which is longer than the specified 20

['在AI的研究中,由于大模型规模非常大',

'由于大模型规模非常大,模型参数很多',

'在大模型上跑完来验证参数好不好训练时间成本很高',

'所以一般会在小模型上做消融实验来验证哪些改进是有效的再去大模型上做实验。']

设置“,”为分隔符后,分割效果与递归字符文本分割器类似。

可以看到出现了提示"Created a chunk of size 23, which is longer than the specified 20",意思是“创建了一个长度为23的块,这比指定的20要长。”。这是因为 CharacterTextSplitter 优先使用我们自定义的分隔符进行分割,所以在长度上会有较小的差距。

2、长文本分割

# 中文版

some_text = """在编写文档时,作者将使用文档结构对内容进行分组。 \
这可以向读者传达哪些想法是相关的。 例如,密切相关的想法\
是在句子中。 类似的想法在段落中。 段落构成文档。 \n\n\
段落通常用一个或两个回车符分隔。 \
回车符是您在该字符串中看到的嵌入的“反斜杠 n”。 \
句子末尾有一个句号,但也有一个空格。\
并且单词之间用空格分隔"""
print(len(some_text))

c_splitter = CharacterTextSplitter(
chunk_size=80,
chunk_overlap=0,
separator=' '
)
'''
对于递归字符分割器,依次传入分隔符列表,分别是双换行符、单换行符、空格、空字符,
因此在分割文本时,首先会采用双分换行符进行分割,同时依次使用其他分隔符进行分割
'''
r_splitter = RecursiveCharacterTextSplitter(
chunk_size=80,
chunk_overlap=0,
separators=["\n\n", "\n", " ", ""]
)

字符分割器结果:

c_splitter.split_text(some_text)

['在编写文档时,作者将使用文档结构对内容进行分组。 这可以向读者传达哪些想法是相关的。 例如,密切 相关的想法 是在句子中。 类似的想法在段落中。 段落构成文档。',

'段落通常用一个或两个回车符分隔。 回车符是您在该字符串中看到的嵌入的“反斜杠 n”。 句子末尾有一个 句号,但也有一个空格。 并且单词之间用空格分隔']

递归字符分割器效果:

r_splitter.split_text(some_text)

['在编写文档时,作者将使用文档结构对内容进行分组。 这可以向读者传达哪些想法是相关的。 例如,密切相关的想法 是在句子中。 类似的想法在段落中。',

'段落构成文档。',

'段落通常用一个或两个回车符分隔。 回车符是您在该字符串中看到的嵌入的“反斜杠 n”。 句子 末尾有一个句号,但也有一个空格。',

'并且单词之间用空格分隔']

如果需要按照句子进行分隔,则还要用正则表达式添加一个句号分隔符

r_splitter = RecursiveCharacterTextSplitter(
chunk_size=30,
chunk_overlap=0,
separators=["\n\n", "\n", "(?<=\。 )", " ", ""]
)
r_splitter.split_text(some_text)

['在编写文档时,作者将使用文档结构对内容进行分组。',

'这可以向读者传达哪些想法是相关的。',

'例如,密切相关的想法 是在句子中。',

'类似的想法在段落中。 段落构成文档。',

'段落通常用一个或两个回车符分隔。',

'回车符是您在该字符串中看到的嵌入的“反斜杠 n”。',

'句子末尾有一个句号,但也有一个空格。',

'并且单词之间用空格分隔']

这就是递归字符文本分割器名字中“递归”的含义,总的来说,我们更建议在通用文本中使用递归字符文本分割器。

2.4 基于Token分割

很多 LLM 的上下文窗口长度限制是按照 Token 来计数的。因此,以 LLM 的视角,按照 Token 对文本进 行分隔,通常可以得到更好的结果。 通过一个实例理解基于字符分割和基于 Token 分割的区别

# 使用token分割器进行分割,
# 将块大小设为1,块重叠大小设为0,相当于将任意字符串分割成了单个Token组成的列

from langchain.text_splitter import TokenTextSplitter
text_splitter = TokenTextSplitter(chunk_size=1, chunk_overlap=0)
text = "foo bar bazzyfoo"
text_splitter.split_text(text)
# 注:目前 LangChain 基于 Token 的分割器还不支持中文

['foo', ' bar', ' b', 'az', 'zy', 'foo']

可以看出token长度和字符长度不一样,token通常为4个字符。

2.5 分割Markdown文档

1、分割一个自定义Markdown文档

分块的目的是把具有上下文的文本放在一起,我们可以通过使用指定分隔符来进行分隔,但有些类型的文档(例如 Markdown )本身就具有可用于分割的结构(如标题)。

Markdown 标题文本分割器会根据标题或子标题来分割一个 Markdown 文档,并将标题作为元数据添加到每个块中。

# 定义一个Markdown文档
from langchain.document_loaders import NotionDirectoryLoader#Notion加载器
from langchain.text_splitter import MarkdownHeaderTextSplitter#markdown分割器

markdown_document = """# Title\n\n \
## 第一章\n\n \
李白乘舟将欲行\n\n 忽然岸上踏歌声\n\n \
### Section \n\n \
桃花潭水深千尺 \n\n
## 第二章\n\n \
不及汪伦送我情"""

# 定义想要分割的标题列表和名称
headers_to_split_on = [
("#", "Header 1"),
("##", "Header 2"),
("###", "Header 3"),
]
markdown_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)#message_typemessage_type
md_header_splits = markdown_splitter.split_text(markdown_document)
print("第一个块")
print(md_header_splits[0])
print("第二个块")
print(md_header_splits[1])

第一个块

page_content='李白乘舟将欲行 \n忽然岸上踏歌声' metadata={'Header 1': 'Title', 'Header

2': '第一章'}

第二个块

page_content='桃花潭水深千尺' metadata={'Header 1': 'Title', 'Header 2': '第一章',

'Header 3': 'Section'}

2、分割数据库中的Markdown文档

Notion 文档就是一个 Markdown 文档。我们在此处加载 Notion 数据库中的文档并进行分割。

#加载数据库的内容

loader = NotionDirectoryLoader("docs/Notion_DB")
docs = loader.load()
txt = ' '.join([d.page_content for d in docs])#拼接文档
headers_to_split_on = [
("#", "Header 1"),
("##", "Header 2"),
]
#加载文档分割器
markdown_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on
)
md_header_splits = markdown_splitter.split_text(txt)#分割文本内容
print(md_header_splits[0])#分割结果

page_content='Let’s talk about stress. Too much stress. \nWe know this can be a

topic. \nSo let’s get this conversation going. \n[Intro: two things you should

know]

(#letstalkaboutstress%2064040a0733074994976118bbe0acc7fb/Intro%20two%20things%20y

ou%20should%20know%20b5fd0c5393a9498b93396e79fe71e8bf.md) \n[What is stress]

(#letstalkaboutstress%2064040a0733074994976118bbe0acc7fb/What%20is%20stress%20b19

8b685ed6a474ab14f6fafff7004b6.md) \n[When is there too much stress?]

(#letstalkaboutstress%2064040a0733074994976118bbe0acc7fb/When%20is%20there%20too%20much%20stress%20dc135b9a86a843cbafd115aa128c5c90.md) \n[What can I do]

(#letstalkaboutstress%2064040a0733074994976118bbe0acc7fb/What%20can%20I%20do%2009c1b13703ef42d4a889e2059c5b25fe.md) \n[What can Blendle do?]

(#letstalkaboutstress%2064040a0733074994976118bbe0acc7fb/What%20can%20Blendle%20do%20618ab89df4a647bf96e7b432af82779f.md) \n[Good reads]

(#letstalkaboutstress%2064040a0733074994976118bbe0acc7fb/Good%20reads%20e817491d84d549f886af972e0668192e.md) \nGo to **#letstalkaboutstress** on slack to chat about this topic' metadata={'Header 1': '#letstalkaboutstress'}

3、向量数据库与词向量

前面讨论了 Document Loading (文档加载)和 Splitting (分割)。下面我们将前面的知识对文档进行加载分割。

3.1 读取文档

注意,本章节需要安装第三方库 pypdf 、 chromadb

from langchain.document_loaders import PyPDFLoader

# 加载 PDF
loaders_chinese = [
# 故意添加重复文档,使数据混乱
PyPDFLoader("docs/matplotlib/第一回:Matplotlib初相识.pdf"),
PyPDFLoader("docs/matplotlib/第一回:Matplotlib初相识.pdf"),
PyPDFLoader("docs/matplotlib/第二回:艺术画笔见乾坤.pdf"),
PyPDFLoader("docs/matplotlib/第三回:布局格式定方圆.pdf")
]
docs = []
for loader in loaders_chinese:
docs.extend(loader.load())

在文档加载后,我们可以使用 RecursiveCharacterTextSplitter (递归字符文本拆分器)来创建块。

# 分割文本
from langchain.text_splitter import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
chunk_size = 1500, # 每个文本块的大小。这意味着每次切分文本时,会尽量使每个块包含 1500
个字符。
chunk_overlap = 150 # 每个文本块之间的重叠部分。
)
splits = text_splitter.split_documents(docs)
print(len(splits))

3.2 Embeddings

在机器学习和自然语言处理(NLP)中, Embeddings (嵌入)是一种将类别数据,如单词、句子或者整个文档,转化为实数向量的技术。这些实数向量可以被计算机更好地理解和处理。嵌入背后的主要想法是,相似或相关的对象在嵌入空间中的距离应该很近。

举个例子,我们可以使用词嵌入(word embeddings)来表示文本数据。在词嵌入中,每个单词被转换为一个向量,这个向量捕获了这个单词的语义信息。例如,"king" 和 "queen" 这两个单词在嵌入空间中的位置将会非常接近,因为它们的含义相似。而 "apple" 和 "orange" 也会很接近,因为它们都是水果。而 "king" 和 "apple" 这两个单词在嵌入空间中的距离就会比较远,因为它们的含义不同。让我们取出我们的切分部分并对它们进行 Embedding 处理。

from langchain.embeddings.openai import OpenAIEmbeddings
embedding = OpenAIEmbeddings()

3.3 Vectorstores

1、初始化Chroma

Langchain集成了超过30个不同的向量存储库。我们选择Chroma是因为它轻量级且数据存储在内存中, 这使得它非常容易启动和开始使用。

首先我们指定一个持久化路径:

from langchain.vectorstores import Chroma
persist_directory_chinese = 'docs/chroma/matplotlib/'

如果该路径存在旧的数据库文件,可以通过以下命令删除:

!rm -rf './docs/chroma/matplotlib' # 删除旧的数据库文件(如果文件夹中有文件的话)

接着从已加载的文档中创建一个向量数据库:

vectordb_chinese = Chroma.from_documents(
documents=splits,
embedding=embedding,
persist_directory=persist_directory_chinese # 允许我们将persist_directory目录保存到磁盘上
)

可以看到数据库长度也是30,这与我们之前的切分数量是一样的。现在让我们开始使用它。

print(vectordb_chinese._collection.count())

27

2、相似性搜索(Similarity Search)

# 首先定义一个需要检索答案的问题
question_chinese = "Matplotlib是什么?"

# 接着调用已加载的向量数据库根据相似性检索答案
docs_chinese = vectordb_chinese.similarity_search(question_chinese,k=3)

# 查看检索答案数量
len(docs_chinese)

# 打印其 page_content 属性可以看到检索答案的文本
print(docs_chinese[0].page_content)

# 在此之后,我们要确保通过运行vectordb.persist来持久化向量数据库
vectordb_chinese.persist()

3.4 失败的情况(Failure modes)

这看起来很好,基本的相似性搜索很容易就能让你完成80%的工作。但是,可能会出现一些相似性搜索失败的情况。这里有一些可能出现的边缘情况

1、重复块

question_chinese = "Matplotlib是什么?"
docs_chinese = vectordb_chinese.similarity_search(question_chinese,k=5)

请注意,我们得到了重复的块(因为索引中有重复的 第一回:Matplotlib初相识.pdf )。

语义搜索获取所有相似的文档,但不强制多样性。

docs[0] 和 docs[1] 是完全相同的。

2、检索错误答案

下面的问题询问了关于第二讲的问题,但也包括了来自其他讲的结果。

question_chinese = "他们在第二讲中对Figure说了些什么?"
docs_chinese = vectordb_chinese.similarity_search(question_chinese,k=5)
for doc_chinese in docs_chinese:
print(doc_chinese.metadata)

{'source': 'docs/matplotlib/第一回:Matplotlib初相识.pdf', 'page': 0}

{'source': 'docs/matplotlib/第一回:Matplotlib初相识.pdf', 'page': 0}

{'source': 'docs/matplotlib/第二回:艺术画笔见乾坤.pdf', 'page': 9}

{'source': 'docs/matplotlib/第二回:艺术画笔见乾坤.pdf', 'page': 10}

{'source': 'docs/matplotlib/第一回:Matplotlib初相识.pdf', 'page': 1}

可见,虽然我们询问的问题是第二讲,但第一个出现的答案却是第一讲的内容。而第三个答案才是我们

想要的正确回答。

4、检索

在构建检索增强生成 (RAG) 系统时,信息检索是核心环节。检索模块负责对用户查询进行分析,从知识 库中快速定位相关文档或段落,为后续的语言生成提供信息支持。检索是指根据用户的问题去向量数据 库中搜索与问题相关的文档内容,当我们访问和查询向量数据库时可能会运用到如下几种技术:

  • 基本语义相似度(Basic semantic similarity)
  • 最大边际相关性(Maximum marginal relevance,MMR)
  • 过滤元数据
  • LLM辅助检索

4.1 向量数据库检索

本章节需要使用 lark 包,若环境中未安装过此包,请运行以下命令安装:

!pip install -Uq lark

1、相似性检索—相似性

2、最大边际相关性(MMR)—多样性

最大边际相关模型 (MMR,Maximal Marginal Relevance) 是实现多样性检索的常用算法。

MMR 的基本思想是同时考量查询与文档的相关度,以及文档之间的相似度。相关度确保返回结果对查询高度相关,相似度则鼓励不同语义的文档被包含进结果集。具体来说,它计算每个候选文档与查询的相关度,并减去与已经选入结果集的文档的相似度。这样更不相似的文档会有更高的得分。

总之,MMR 是解决检索冗余问题、提供多样性结果的一种简单高效的算法。它平衡了相关性和多样性,适用于对多样信息需求较强的应用场景。

docs_mmr_chinese = vectordb_chinese.max_marginal_relevance_search(question_chinese,k=3)

3、元数据—特殊性

关于失败的应用场景我们还提出了一个问题,是询问了关于文档中某一讲的问题,但得到的结果中也包括了来自其他讲的结果。这是我们所不希望看到的结果,之所以产生这样的结果是因为当我们向向量数据库提出问题时,数据库并没有很好的理解问题的语义,所以返回的结果不如预期。要解决这个问题,我们可以通过过滤元数据的方式来实现精准搜索,当前很多向量数据库都支持对 元数据(metadata) 的操作。

metadata 为每个嵌入的块(embedded chunk)提供上下文。

question_chinese = "他们在第二讲中对Figure说了些什么?"

现在,我们以手动的方式来解决这个问题,我们会指定一个元数据过滤器 filter。

docs_chinese = vectordb_chinese.similarity_search(question_chinese,
                                                    k=3,
                                                    filter={"source":"docs/matplotlib/第二回:艺术画笔见乾坤.pdf"}
                                                    )
for d in docs_chinese:
    print(d.metadata)

{'source': 'docs/matplotlib/第二回:艺术画笔见乾坤.pdf', 'page': 9}

{'source': 'docs/matplotlib/第二回:艺术画笔见乾坤.pdf', 'page': 10}

{'source': 'docs/matplotlib/第二回:艺术画笔见乾坤.pdf', 'page': 0}

可以看到结果都来自对应的章节。当然,我们不能每次都采用手动的方式来解决这个问题,这会显得不够智能。

4、自查询检索器—LLM辅助检索

在上例中,我们手动设置了过滤参数 filter 来过滤指定文档。但这种方式不够智能,需要人工指定过滤条 件。如何自动从用户问题中提取过滤信息呢?

LangChain提供了SelfQueryRetriever模块,它可以通过语言模型从问题语句中分析出:

1. 向量搜索的查询字符串(search term)

2. 过滤文档的元数据条件(Filter)

以“除了维基百科,还有哪些健康网站”为例,SelfQueryRetriever可以推断出“除了维基百科”表示需要过滤的 条件,即排除维基百科的文档。

它使用语言模型自动解析语句语义,提取过滤信息,无需手动设置。这种基于理解的元数据过滤更加智能方 便,可以自动处理更复杂的过滤逻辑。

掌握利用语言模型实现自动化过滤的技巧,可以大幅降低构建针对性问答系统的难度。这种自抽取查询的 方法使检索更加智能和动态。

下面我们就来实现一下LLM辅助检索

from langchain.llms import OpenAI
from langchain.retrievers.self_query.base import SelfQueryRetriever
from langchain.chains.query_constructor.base import AttributeInfo
llm = OpenAI(temperature=0)

metadata_field_info_chinese = [
    AttributeInfo(
        name="source",
        description='''The lecture the chunk is from, should be one of \
                    `docs/matplotlib/第一回:Matplotlib初相识.pdf`, \
                    `docs/matplotlib/第二回:艺术画笔见乾
                    坤.pdf`, or `docs/matplotlib/第三回:布局格式定方圆.pdf`''',
        type="string",
        ),
    AttributeInfo(
        name="page",
        description="The page from the lecture",
        type="integer",
        ),
]
document_content_description_chinese = "Matplotlib 课堂讲义"
retriever_chinese = SelfQueryRetriever.from_llm(
                    llm,
                    vectordb_chinese,
                    document_content_description_chinese,
                    metadata_field_info_chinese,
                    verbose=True
                    )
question_chinese = "他们在第二讲中对Figure做了些什么?"

docs_chinese = retriever_chinese.get_relevant_documents(question_chinese)
for d in docs_chinese:
    print(d.metadata)

首先定义了 metadata_field_info_chinese ,它包含了元数据的过滤条件 source 和 page , 其中 source 的作用是告诉 LLM 我们想要的数据来自于哪里, page 告诉 LLM 我们需要提取相关的内容在原始文档的哪一页。有了 metadata_field_info_chinese 信息后,LLM会自动从用户的问题中提取出上图中的 Filter 和 Search term 项,然后向量数据库基于这两项去搜索相关的内容。下面我们看一下查询结果:

{'source': 'docs/matplotlib/第二回:艺术画笔见乾坤.pdf', 'page': 9}

{'source': 'docs/matplotlib/第二回:艺术画笔见乾坤.pdf', 'page': 10}

{'source': 'docs/matplotlib/第二回:艺术画笔见乾坤.pdf', 'page': 0}

{'source': 'docs/matplotlib/第二回:艺术画笔见乾坤.pdf', 'page': 6}

打印可以看到查询结果,基于子查询检索器,我们检索到的结果都是在第二回的文档中

5、其他技巧:压缩

在使用向量检索获取相关文档时,直接返回整个文档片段可能带来资源浪费,因为实际相关的只是文档的一小部分。为改进这一点,LangChain提供了一种“ 压缩 ”检索机制。其工作原理是,先使用标准向量检索获得候选文档,然后基于查询语句的语义,使用语言模型压缩这些文档,只保留与问题相关的部分。例如,对“蘑菇的营养价值”这个查询,检索可能返回整篇有关蘑菇的长文档。经压缩后,只提取文档中与“营养价值”相关的句子

从上图中我们看到,当向量数据库返回了所有与问题相关的所有文档块的全部内容后,会有一个Compression LLM来负责对这些返回的文档块的内容进行压缩,所谓压缩是指仅从文档块中提取出和用户问题相关的内容,并舍弃掉那些不相关的内容。

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

def pretty_print_docs(docs):
    print(f"\n{'-' * 100}\n".join([f"Document {i+1}:\n\n" + d.page_content for i, d in enumerate(docs)]))

llm = OpenAI(temperature=0)
compressor = LLMChainExtractor.from_llm(llm) # 压缩器

compression_retriever_chinese = ContextualCompressionRetriever(
    base_compressor=compressor,
    base_retriever=vectordb_chinese.as_retriever()
)
# 对源文档进行压缩

question_chinese = "Matplotlib是什么?"
compressed_docs_chinese = compression_retriever_chinese.get_relevant_documents(question_chinese)
pretty_print_docs(compressed_docs_chinese)

在上面的代码中我们定义了一个 LLMChainExtractor ,它是一个压缩器,它负责从向量数据库返回的文档块中提取相关信息,然后我们还定义了 ContextualCompressionRetriever ,它有两个参数:base_compressor 和 base_retriever,其中 base_compressor 是我们前面定义的 LLMChainExtractor的实例,base_retriever是早前定义的 vectordb 产生的检索器。

压缩可以有效提升输出质量,同时节省通过长文档带来的计算资源浪费,降低成本。上下文相关的压缩检索技术,使得到的支持文档更严格匹配问题需求,是提升问答系统效率的重要手段。

4.2 结合各种技术

为了去掉结果中的重复文档,我们在从向量数据库创建检索器时,可以将搜索类型设置为 MMR 。然后我们可以重新运行这个过程,可以看到我们返回的是一个过滤过的结果集,其中不包含任何重复的信息。

4.3 其他类型的检索

值得注意的是,vetordb 并不是唯一一种检索文档的工具。 LangChain 还提供了其他检索文档的方式,例如: TF-IDF 或 SVM 。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

机器人涮火锅

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值