DLAI-嵌入模型笔记-全-

DLAI 嵌入模型笔记(全)

001:课程介绍 🎬

在本节课中,我们将要学习嵌入模型的基本概念、历史发展及其在检索系统中的应用。嵌入模型能够生成代表文本含义的向量,这对于构建基于语义的检索系统至关重要。我们将从宏观应用入手,逐步深入到模型的技术架构和实现细节。

嵌入模型通过生成嵌入向量,使得构建基于语义或含义的检索系统成为可能。本课程将描述它们的历史、详细的技术架构和具体实现。这是一门技术课程,因此将侧重于模型的构建模块,而非其应用场景。

你可能听说过嵌入向量在生成式AI应用中的使用。这些向量具有捕捉单词或短语含义的惊人能力。你可能使用过嵌入模型来创建这些向量,但你是否想过这些模型究竟是如何工作的呢?

为了深入探讨这个问题,我们很高兴地介绍Amin Ahmad,他是Vectara的联合创始人,以及Op Mendlovich,他是公司的开发者关系负责人。在Vectara,我们构建了自己的嵌入模型来支持不同的RAG系统。因此,我们必须深入研究如何选择、构建和训练它们。在本课程中,我们将与您分享一些关键的技术细节。

创建一个能够生成代表单词含义的向量的模型是一个具有挑战性的问题。你会希望利用大量现有文本作为训练数据,但具体该如何操作呢?

一个想法是利用目标词周围的词语作为线索。以单词“树”为例。

一个文本训练句子可能会说“树上的叶子是绿色的”,而另一个句子可能会说“树上的树枝正在掉落”。因此,出现在“树”这个词附近的词语可以告诉你一些关于“树”的含义的信息。

如果你有数百万个这样的句子,那么你可能会对“树”这个词的含义有一个不错的理解,或者至少获得一些感觉。

这种方法由一个名为Word2Vec的嵌入模型推广开来,该模型由Thomas Mikolov、Greg Corrado和Jeff Dean(我在谷歌的前同事)提出。Word2Vec是一个在自然语言上训练的模型,它根据目标词两侧的几个词来预测目标词。

不久之后,斯坦福大学的Jeffrey Pennington、Richard Socher和Christopher Manning提出的一种名为GloVe的方法,通过简化学习嵌入所需的数学计算,进一步改进了Word2Vec。它通过扩大单词的上下文窗口来产生更准确的嵌入。

此前,处理更长的上下文窗口需要使用像LSTM这样的循环神经网络。2017年Transformer架构的引入改变了这一点,它允许前馈神经网络(比循环网络训练效率高得多)有效地处理序列数据。

次年发布的BERT模型,使用深度Transformer网络,在一个简单的“填空”任务上进行训练,从而获得了对语言的深刻理解。这开启了NLP的现代时代,并为后续的GPT等系统奠定了基础。

很快,研究也超越了单词层面,扩展到更长的文本块,如短语或句子。在一个检索系统中,你可能希望为一个查询句子生成一个嵌入向量,并将其与响应句子的向量进行比较。事实证明,你可以对这些强大的词嵌入模型进行微调,以评估句子级别的语义。在本课程中,我们将向你展示具体如何操作。

以下是本课程的主要内容结构:

  • 嵌入模型在检索系统中的应用:首先,你将学习嵌入模型在何处以及如何被使用。
  • BERT模型详解:然后,你将学习关于BERT的知识。这是一个双向Transformer的示例。BERT被应用于许多场景,但在这里我们将专注于它在检索中的使用。
  • 对比学习与双编码器模型:接着,你将学习如何构建和使用对比损失来训练一个非常适合RAG应用的双编码器模型。该模型包含一个为查询训练的编码器和一个为响应训练的独立编码器。
  • 实践演示:你将看到所有这些内容在实际中的应用。

许多人共同努力创建了这门课程。我们要感谢来自Vectara的Vivek Srikumar,以及来自DeepLearning.AI的Emma Gengler和Joel Ludwig,他们也为本课程做出了贡献。

第一课将从检索系统中嵌入模型的概述开始。

听起来很棒,让我们进入下一个视频,正式开始学习吧。

本节课中,我们一起学习了嵌入模型的基本介绍、其发展历史以及本课程的核心学习路径。我们了解到,嵌入模型通过生成语义向量,是构建智能检索系统的基石。从Word2Vec、GloVe到Transformer和BERT,技术的演进使得模型能够更精准地捕捉文本含义。在接下来的课程中,我们将深入这些技术的内部,学习如何构建和训练用于检索的嵌入模型。

002:课程概述

在本节课中,我们将要学习嵌入模型的基本概念、历史发展及其在检索系统中的应用。嵌入模型能够生成代表文本含义的向量,是实现语义检索的核心技术。

课程简介

欢迎来到《嵌入模型:从架构到实现》课程。本课程与Vara合作开发,将深入探讨嵌入模型如何创建嵌入向量,从而构建基于语义或含义的检索系统。课程将描述其历史、详细的技术架构和实现方法。这是一门技术课程,因此将侧重于构建模块而非具体应用。

嵌入模型的应用与挑战

你可能听说过嵌入向量在生成式AI应用中的使用。这些向量具有捕捉单词或短语含义的惊人能力。你可能使用过嵌入模型来创建这些向量,但这些模型究竟是如何工作的呢?

为了深入探讨这个问题,我们很高兴地介绍Amin Ahmad和Victorctors的联合创始人Op Mendlovich,他也是公司开发者关系的负责人。感谢Andrew。很高兴来到这里。在Victorctorara,我们构建了自己的嵌入模型来支持不同的RAG系统。因此,我们必须深入研究如何选择、构建和训练它们。在本课程中,我们将与您分享一些关键的技术细节。

嵌入模型的发展历程

创建一个能生成代表单词含义的向量的模型是一个具有挑战性的问题。你会希望利用大量现有文本作为训练数据,但具体该如何操作呢?

一个想法是利用目标词周围的单词作为线索。以单词“tree”为例。一个训练句子可能会说“the leaves on the tree are green”,另一个句子可能会说“the branches on the tree are dropping”。因此,出现在“tree”附近的单词可以告诉你它的含义。如果你有数百万个这样的句子,那么你可能会对“tree”这个词的含义有一个不错的理解。

这种方法由一个名为Word2Vec的嵌入模型推广开来,由Thomas Mikolov、Greg Corrado和Jeff Dean(他们曾是谷歌大脑的团队成员)提出。Word2Vec是一个在自然语言上训练的模型,旨在根据目标词两侧的几个词来预测该词。

不久之后,斯坦福大学的Jeffrey Pennington、Richard Socher和Christopher Manning提出了一种名为GloVe的方法,通过简化学习嵌入所需的数学运算,并扩大单词的上下文窗口以产生更准确的嵌入,进一步改进了Word2Vec。

早期的模型涉及使用循环神经网络,如LSTM。2017年Transformer的引入改变了这一点,它允许前馈神经网络(比循环网络训练效率高得多)有效地处理序列数据。次年发布的BERT模型使用了深度Transformer网络,通过一个简单的“填空”任务进行训练,从而获得了对语言的深刻理解,开启了NLP的现代时代,并为紧随其后的GPT等系统奠定了基础。

从词嵌入到句嵌入

你也可以超越单词,处理更长的文本块,如短语或句子。在一个检索系统中,你可能想为一个查询句子生成一个嵌入向量,并将其与响应句子的向量进行比较。事实证明,你可以微调这些强大的词嵌入模型来评估句子。

那么具体如何操作呢?在本课程中,你将首先学习嵌入模型在何处以及如何使用。然后,你将学习关于BERT的知识,这是一个双向Transformer的例子。BERT被应用于许多场景,但这里我们将专注于其在检索中的使用。接着,你将学习如何构建和使用对比损失来训练一个非常适合RAG应用的双编码器模型。该模型有一个为查询训练的编码器和一个为响应训练的独立编码器。你将在实践中看到所有这些内容。

致谢与课程安排

许多人为创建这门课程付出了努力。我要感谢来自Vara的Vi Vk Surb,以及来自DeepLearning.AI的Eshma Gagari和Jeff Ladwig,他们也为本课程做出了贡献。

第一课将从检索系统中嵌入模型的概述开始。


本节课中,我们一起学习了嵌入模型的基本概念、从Word2Vec到BERT的发展历程,以及如何将词嵌入技术扩展到句子级别以用于检索系统。下一节,我们将深入探讨嵌入模型在检索系统中的应用场景。

003:3.L2 - 上下文感知的词嵌入 🧠

在本节课中,我们将学习上下文感知的词嵌入的重要性,以及Transformer模型,特别是BERT,如何开创了学习上下文感知词嵌入的能力。这些嵌入被广泛应用于句子嵌入模型中。


在上一节中,我们学习了词向量嵌入。例如,单词“Yoda”及其在使用Word2Vec或GloVe等词嵌入模型下的嵌入向量。这些嵌入能够捕捉每个词的语义含义,可用于计算词与词之间的相似度。

但词嵌入模型存在一个问题:它们无法理解上下文。

请看以下两个句子,它们在不同的语境中使用了单词“bat”。像Word2Vec这样的词嵌入模型无法根据上下文区分这些词的含义。使用这些模型,你会得到完全相同的向量嵌入。

所以,核心问题是上下文感知的词嵌入

2017年,一篇名为《Attention is All You Need》的论文将Transformer架构引入了自然语言处理领域。这一突破不仅催生了大型语言模型,也解决了上下文感知词嵌入的问题。


Transformer架构最初是为翻译任务设计的,因此包含两个组件:一个编码器和一个解码器。

  • 编码器的输入是一个词或标记的序列,输出是一个连续的表示序列。
  • 解码器的输出同样是词或标记。

翻译任务的工作流程如下:编码器接收一种语言的短语,并生成代表输入短语含义的输出向量。为了生成这些向量,编码器可以关注给定标记左侧和右侧的所有标记。

相比之下,解码器一次处理一个标记,并考虑迄今为止已预测的标记以及编码器的输出。解码器预测第一个词“This”,然后将其反馈回输入。接着,解码器考虑编码器输入和之前生成的标记,预测“man”,以此类推,逐个标记进行。

总结一下,编码器会关注其正在生成的输出标记的左右两侧的标记。这导致编码器的输出向量正是我们寻找的上下文感知向量。而解码器只关注左侧的输入。


但带有注意力机制的Transformer被用于更多任务。最著名的实现是像GPT-2、GPT-3和GPT-4这样的大型语言模型,它们使用了仅解码器的架构。当然,还有BERT,这是一个仅编码器的Transformer模型,在句子嵌入模型中作为核心组件被广泛使用。

让我们进一步了解BERT。它有两种规模:

  • BERT-base:12个Transformer层,1.1亿参数。
  • BERT-large:24个Transformer层,3.4亿参数。

BERT在33亿单词上进行了预训练,并且通常会在特定任务上进行额外的微调步骤。


BERT模型通过两个任务进行预训练。

第一个任务称为掩码语言建模。示例如下:输入句子以特殊标记[CLS]开始,以分隔符标记[SEP]结束。输入中15%的词被掩码,模型被训练来预测这些被掩码的词。这个任务至关重要,因为模型正是在这里学会了根据周围的词来生成上下文感知的向量。

第二个任务是下一句预测。在这个任务中,模型预测一个句子是否可能跟在另一个句子后面。例如,如果句子A是“The men went to the store”,句子B是“He bought a gallon of milk”,那么预测输出是“True”。反之,如果句子B是“Penguins are flightless birds”,那么预测就是“False”。这个任务训练模型理解两个句子之间的关系。


预训练之后,你可以将迁移学习的思想应用于BERT,并通过微调使其适应特定任务,例如分类、命名实体识别或问答。本课程中我们特别感兴趣的一个任务是交叉编码器。具体来说,这是一种分类器,其输入由两个句子组成,中间用特殊的[SEP]标记分隔。然后,分类器被要求判断这两个句子之间的语义相似度。


现在,让我们在代码中看看所有这些概念。

首先,导入必要的库,包括来自transformers库的BERT分词器和BERT模型。

import warnings
warnings.filterwarnings('ignore')

from transformers import BertTokenizer, BertModel
import torch
import numpy as np
from sklearn.decomposition import PCA
import matplotlib.pyplot as plt

接下来,加载GloVe词嵌入。我们查看单词“king”的嵌入向量,它是一个100维的向量。

# 加载GloVe词向量(此处为示意,实际需下载文件)
# glove_embeddings = load_glove('path/to/glove.6B.100d.txt')
# king_vector = glove_embeddings['king']
# print(f"King vector shape: {king_vector.shape}")
# print(king_vector[:20])  # 打印前20个值

为了可视化,我们选取一组词,将其嵌入向量放入数组,并使用PCA降维到2维以便绘图。语义相近的词在图中会彼此靠近。

词向量的一个有趣特性是你可以对它们进行代数运算。例如,king - man + woman的结果向量最接近queen的向量。你可以自己尝试,比如计算Paris - France + Spain,看看结果是什么。


现在,让我们将词嵌入与BERT的上下文感知嵌入进行比较。

首先加载BERT的分词器和模型,并创建一个函数来获取给定句子和词的嵌入。

tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertModel.from_pretrained('bert-base-uncased')

def get_bert_embedding(sentence, target_word):
    inputs = tokenizer(sentence, return_tensors='pt')
    outputs = model(**inputs)
    # 找到目标词在分词后序列中的位置(简化处理,实际需处理子词)
    # ... 此处省略具体索引查找逻辑 ...
    # embedding = outputs.last_hidden_state[0, word_index]
    return embedding

我们看两个例句:

  1. “The bat flew out of the cave at night.”
  2. “He swung the bat and hit the home run.”

我们关注单词“bat”。分别计算它在两个句子上下文中的BERT嵌入,同时也获取GloVe中“bat”的静态词嵌入。

运行后你会发现,“bat”在句子1上下文中的嵌入与在句子2上下文中的嵌入看起来非常不同。而GloVe的嵌入则是非上下文的、相同的。计算余弦相似度会发现,两个BERT上下文嵌入的相似度可能只有0.45左右,而GloVe嵌入与自身的相似度当然是1。


我们讨论了BERT如何用作交叉编码器。现在来看一个具体的例子,一个在MS MARCO问答对数据集上微调过的交叉编码器,用于段落检索任务。

from sentence_transformers import CrossEncoder
model = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')

question = "What is the capital of France?"
answer_candidates = [
    "Berlin is the capital of Germany.",
    "Paris is the capital of France.",
    "London is the capital of England."
]

scores = model.predict([(question, candidate) for candidate in answer_candidates])
print(f"Scores: {scores}")
best_answer_idx = np.argmax(scores)
print(f"Best answer: {answer_candidates[best_answer_idx]}")

运行模型后,我们看到最佳答案是“Paris is the capital of France”。鼓励你尝试其他问题和答案选项,看看训练良好的交叉编码器如何真正理解问题和答案的语义。


在本节课中,我们了解到像Word2Vec和GloVe这样的词嵌入模型无法捕捉句子中词的上下文,而BERT嵌入能够捕捉这种上下文。但是,上下文感知的词嵌入还不是句子嵌入。在下一节课中,你将学习如何从上下文感知的词嵌入过渡到完整的句子嵌入模型。

004:4.L3 词元嵌入与句子嵌入

在本节课中,我们将学习句子嵌入。我们将了解早期创建句子嵌入的朴素尝试为何失败,以及是什么促成了使用双编码器架构来成功构建句子嵌入。

概述

到目前为止,我们一直将“词”和“词元”互换使用。实际上,自然语言处理系统处理的是词元。一个词元可以是一个词,但并不总是如此。例如,句子“we love training the learning networks”可以被分词为完整的英文单词,并为每个单词分配一个整数值,如23或112等。

子词分词意味着一个词元并不总是一个完整的单词,它可以是一个子词或任何字符序列。在分词时,你需要定义一个可能的单词或子词词汇表,并根据这个词汇表将文本分解。结果是每个句子都由一个整数值序列表示。

大型语言模型和嵌入模型中常用的分词技术通常是子词分词器,如BPE(字节对编码)、WordPiece,或最近流行的变体SentencePiece。

词元嵌入的工作原理

让我们看看词元嵌入在BERT中是如何工作的。BERT的词汇表大约有30,000个词元,嵌入维度为768。分词器将输入句子前加上一个特殊的起始词元[CLS],然后将所有词元转换为词元嵌入。

你可以将这一层嵌入视为固定的词元嵌入,它只关注单词本身。BERT中每个编码器层的输出也为输入序列中的每个词元提供了嵌入,但这些嵌入现在整合了句子其余部分的信息。因此,我们称它们为上下文化嵌入。随着我们从一层到另一层,这些表示在整合整个句子的上下文方面变得越来越好。

早期句子嵌入的朴素尝试

在使用Word2Vec或GloVe等词嵌入取得成功后,研究人员探索的下一个问题是:我们能否为句子创建嵌入向量,使得嵌入空间中的余弦相似度或点积相似度能够表示句子之间的语义相似性?

最初的尝试是朴素的。有些人尝试取Transformer模型最后一层所有词元的输出嵌入,并对它们进行平均。这也被称为平均池化。另一些人则尝试直接使用[CLS]词元的嵌入作为句子的代表。这些方法都失败了。

让我们在实践中看看这一点。

实践:平均池化的失败

首先,我们导入必要的库,如PyTorch、SciPy、Seaborn,以及我们的BERT模型和分词器。

import torch
import scipy
import seaborn as sns
from transformers import AutoTokenizer, AutoModel

我们使用bert-base-uncased模型,并加载其分词器和模型。然后,我们定义一个辅助函数来执行句子嵌入的平均池化操作。该函数接收一个句子,将其编码为词元,创建注意力掩码,通过BERT模型运行,获取输出嵌入,然后对最后一层的隐藏状态进行平均池化,得到最终输出。

def mean_pooling(model_output, attention_mask):
    token_embeddings = model_output[0]
    input_mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float()
    return torch.sum(token_embeddings * input_mask_expanded, 1) / torch.clamp(input_mask_expanded.sum(1), min=1e-9)

我们还需要一个辅助函数来计算矩阵的余弦相似度,以及一个函数来绘制相似度热力图。

import numpy as np
def cosine_similarity_matrix(matrix):
    norm = np.linalg.norm(matrix, axis=1, keepdims=True)
    normalized_matrix = matrix / norm
    return np.dot(normalized_matrix, normalized_matrix.T)

def plot_similarity(labels, features):
    corr = cosine_similarity_matrix(features)
    sns.set(font_scale=1.2)
    g = sns.heatmap(corr, xticklabels=labels, yticklabels=labels, vmin=0, vmax=1, cmap="YlOrRd")
    g.set_xticklabels(labels, rotation=90)
    g.set_title("Semantic Textual Similarity")

现在,我们尝试一些句子。我们有一些关于智能手机和天气等的句子。

messages = [
    "I love my new smartphone.",
    "The weather is nice today.",
    "This phone has a great camera.",
    "It's raining outside.",
    "The battery life of this device is amazing.",
    "I need to buy an umbrella."
]

我们对每个句子计算其平均池化嵌入,然后绘制相似度热力图。

# 计算每个句子的平均池化嵌入
embeddings = []
for msg in messages:
    encoded_input = tokenizer(msg, return_tensors='pt', padding=True, truncation=True)
    with torch.no_grad():
        model_output = model(**encoded_input)
    sentence_embedding = mean_pooling(model_output, encoded_input['attention_mask'])
    embeddings.append(sentence_embedding.squeeze().numpy())
embeddings = np.array(embeddings)

# 绘制热力图
plot_similarity(messages, embeddings)

从热力图中可以看到,大部分区域是红色和橙色,表示非常高的相似度,但这并不合理,因为这些句子并没有相同的语义含义。这表明平均池化方法无效。

在标准数据集上验证

我们还可以在STS基准数据集上进行测试。该数据集包含许多句子对(sentence1和sentence2),以及一个表示它们相似度的真实分数。

import pandas as pd
# 假设我们加载了STS数据集的一个子集
sts_data = pd.read_csv('sts_sample.csv')  # 示例,实际需加载真实数据
# 计算BERT平均池化分数
sts_scores_mean_pool = []
for idx, row in sts_data.head(50).iterrows(): # 使用50个例子加速
    # 计算sentence1和sentence2的嵌入并求余弦相似度
    # ... (代码类似上面)
    sts_scores_mean_pool.append(cosine_sim)

打印这些分数,你会发现BERT平均池化给出的分数普遍很高,表明它认为所有句子对都非常相似。计算这些分数与真实分数之间的皮尔逊相关性,会发现相关性非常低,进一步证实了这种方法不可行。

使用预训练句子嵌入模型

既然平均池化无效,我们可以尝试一个预训练的句子嵌入模型,看看经过训练的模型效果如何。这里我们使用all-MiniLM-L6-v2模型。

from sentence_transformers import SentenceTransformer
minilm_model = SentenceTransformer('all-MiniLM-L6-v2')
# 计算相同消息的嵌入
minilm_embeddings = minilm_model.encode(messages)
# 绘制热力图
plot_similarity(messages, minilm_embeddings)

这次的热力图看起来好多了。相似度更加准确,反映了许多句子彼此并不相似的事实。

在STS数据集上运行相同的方法,创建第三列minilm_score。你会发现minilm_score与真实分数ground_truth_score非常接近,而平均池化分数mean_pool_score仍然认为所有句子对高度相似。计算皮尔逊相关性,minilm_score与真实分数的相关性要高得多。

我鼓励你尝试STS数据集中的其他例子,亲自看看句子相似度对于不同句子对是如何工作的。

句子嵌入研究的进展

句子嵌入研究的真正进展最初是在2018年左右,随着谷歌基于原始Transformer架构的通用句子编码器的引入而实现的。事实上,我们的创始人Andrew Ng曾是通用句子编码器原始团队的一员。

SBERT是第一个在包含句子对的数据上进行训练的模型。2018年SBERT的工作迅速在随后几年引发了更多创新,出现了如DPR、Sentence-T5、E5、ColBERT等许多其他设计。

构建句子编码器的重要考量

现在,让我解释构建句子编码器时一个微妙但重要的考量。有两个可能的目标:

  1. 纯粹的句子相似度。例如,如果你想使用嵌入来查找相似的项目。
  2. 将相关句子作为问题的答案进行排序。例如,在检索增强生成中。

这两个目标并不相同。让我们看一个例子数据集,其中有四个文本块,标记为A1到A4。我们的问题是:“What is the tallest mountain in the world?”。

  • 如果使用纯粹的相似度,显然A1与问题完全相同,相似度最高(等于1),因此A1会被排在最前面。但这并不是我们想要的。
  • 我们想要的是答案“A2: Mount Everest is the tallest.”被选中。

这导致了双编码器双塔架构的出现。我们有两个独立的编码器:问题编码器和答案编码器。模型使用对比损失进行训练,以学习将相关的问题-答案对拉近,将不相关的推远。

总结

在本节课中,我们亲眼看到了BERT嵌入的平均池化为何不适用于句子嵌入,以及像MiniLM-L6这样的预训练句子嵌入模型如何做得更好。你可能好奇如何构建这些双编码器模型。这正是你将在下一课中学到的内容。我们下节课见。

005:5.L4 训练一个双编码器 🏋️‍♂️

在本节课中,我们将深入理解双编码器的内部工作原理。你将学习如何使用 PyTorch 构建一个双编码器,并利用问答对数据集来训练它。


理解双编码器架构

上一节我们介绍了双编码器的基本概念,本节中我们来看看其训练架构的具体细节。

我们将训练两个独立的 BERT 模型,一个用于处理问题,另一个用于处理答案。在训练过程中,我们使用每个 BERT 模型最后一层的 CLS 嵌入向量 作为分别代表问题或答案的向量嵌入。

它们之间的点积相似度代表了语义匹配程度。高相似度值 意味着答案在语义上与问题相关,而 低相似度值 则意味着不相关。


对比损失函数

双编码器架构利用了 对比损失。对比损失的核心思想是确保相似(正样本)数据点的嵌入在嵌入空间中更接近,而不相似(负样本)数据点的表示则相距更远。

损失函数鼓励模型最大化正样本对嵌入之间的相似度,并最小化负样本对之间的相似度。在我们的上下文中,一个正样本对包含一个问题及其正确答案的嵌入,而一个负样本对则被设置为一个问题与批次中任何其他被视为错误的答案。

让我们看一个例子。这里我们有四对问答,以及计算出的每个问题与每个答案之间的相似度矩阵。

对于 Q1,我们希望模型预测 A1 为最可能的答案;对于 Q2,我们希望模型预测 A2,依此类推。因此,基于这个 4x4 矩阵计算出的单个损失会随着对角线上的 softmax 值越来越接近 1,而其他值越来越接近 0 而变小。


在 PyTorch 中实现对比损失

为了在 PyTorch 中实现这一点,我们使用了一个涉及交叉熵损失函数的小技巧,这在实际应用中效果很好。

在代码中,我们将交叉熵损失函数的 target 参数设置为 [0, 1, ..., n-1],其中 n 是批次大小。这告诉交叉熵损失函数,对于每一行(即每个问题),正确的类别(即答案)是与其关联的对角线上的那个。换句话说,A1 是 Q1 的正确类别,A2 是 Q2 的正确类别,依此类推。

通过这种方式,我们使用交叉熵损失来匹配对比损失的目标。损失函数中 softmax 的使用鼓励了问题 i 和答案 i 之间的相似度 S_i 的指数变大(即正确对具有高相似度),而 ij 不相同时的 S_j 的指数变小,这正是对比学习所期望的。

让我们看看代码实现。首先,我们像往常一样忽略警告,并导入一些库。

import warnings
warnings.filterwarnings('ignore')
from transformers import BertTokenizer, BertModel
import pandas as pd
import torch
import torch.nn as nn

为了更直观地理解如何使用交叉熵损失技巧计算对比损失,我们现在定义一个 4x4 的数据框,类似于幻灯片中展示的。

# 示例相似度矩阵
similarity_matrix = torch.tensor([[0.9, 0.1, 0.2, 0.3],
                                   [0.1, 0.8, 0.1, 0.2],
                                   [0.2, 0.1, 0.7, 0.1],
                                   [0.3, 0.2, 0.1, 0.6]])

这是我们的对比损失函数,它使用交叉熵损失根据数据计算实际损失。

def contrastive_loss(similarity_matrix):
    batch_size = similarity_matrix.size(0)
    # 目标标签:对角线索引 [0, 1, 2, ..., batch_size-1]
    targets = torch.arange(batch_size)
    loss_fn = nn.CrossEntropyLoss()
    loss = loss_fn(similarity_matrix, targets)
    return loss

交叉熵损失会对每一行应用 softmax 操作,这迫使每一行的指数和接近 1。让我们看一个例子。

# 计算 softmax
softmax_vals = torch.softmax(similarity_matrix, dim=1)
print("Softmax 值:\n", softmax_vals)
print("每行和:", torch.sum(softmax_vals, dim=1))

现在,我们运行一个小实验。我们取这个 4x4 矩阵,进行四轮简单操作:每次将对角线值增加 0.5,并将其他非对角线值减少 0.02。

modified_matrix = similarity_matrix.clone()
for i in range(4):
    for j in range(4):
        if i == j:
            modified_matrix[i, j] += 0.5
        else:
            modified_matrix[i, j] -= 0.02
    loss = contrastive_loss(modified_matrix)
    print(f"第 {i+1} 轮后损失: {loss.item():.4f}")

你会发现,随着迭代的进行,对角线值不断增长,非对角线值不断降低,通过交叉熵计算的对比损失也在不断减少。这正是我们希望对比损失具有的行为:它迫使对角线值越来越接近更高的值,而非对角线值越来越接近零。


构建编码器

现在我们已经了解了对比损失如何与交叉熵配合工作,接下来看看编码器的构建。

编码器包含多个不同的子组件。在这个例子中,我们使用批次大小为 32,嵌入大小为 512,输出嵌入大小为 128。代码量不大,但包含了许多重要细节。

以下是编码器的关键步骤:

  1. 嵌入层nn.Embedding 模型接收单个标记(整数),并将它们映射为标记嵌入。输入形状为 [32, 序列长度],输出为 [32, 序列长度, 512],因为每个标记由一个 512 维的嵌入向量表示。
  2. Transformer 编码器层:我们使用 nn.TransformerEncoderLayer,这里为了简化,只用了 8 个注意力头和 3 层,而不是 BERT 模型中的 12 层,但结构是相同的。输出是序列中每个向量的上下文化嵌入向量,形状为 [32, 序列长度, 512]
  3. CLS 标记:在 BERT 中,CLS 标记是一个特殊标记,它学习了整个句子的粗略嵌入。我们的编码器使用 CLS 标记的嵌入作为下一步的输入。
  4. 投影层:最后,我们通过一个额外的线性投影层,将学习到的 CLS 标记嵌入投影到可能更小的输出嵌入维度(这里是 128)。这并非绝对必要,但允许我们减少编码器输出的总体嵌入大小,从而节省内存。最终输出形状为 [32, 128],这就是我们最终的上下文化嵌入。
class DualEncoder(nn.Module):
    def __init__(self, vocab_size, embed_size=512, output_embed_size=128, num_heads=8, num_layers=3):
        super(DualEncoder, self).__init__()
        self.embedding = nn.Embedding(vocab_size, embed_size)
        encoder_layer = nn.TransformerEncoderLayer(d_model=embed_size, nhead=num_heads)
        self.transformer_encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers)
        self.projection = nn.Linear(embed_size, output_embed_size)

    def forward(self, input_ids):
        # input_ids 形状: [batch_size, seq_len]
        embeddings = self.embedding(input_ids)  # 形状: [batch_size, seq_len, embed_size]
        # Transformer 期望输入形状为 [seq_len, batch_size, embed_size]
        embeddings = embeddings.transpose(0, 1)
        contextual_embeddings = self.transformer_encoder(embeddings)  # 形状: [seq_len, batch_size, embed_size]
        # 取 CLS 标记(假设是第一个标记)的嵌入
        cls_embedding = contextual_embeddings[0]  # 形状: [batch_size, embed_size]
        output_embedding = self.projection(cls_embedding)  # 形状: [batch_size, output_embed_size]
        return output_embedding

训练循环

现在我们有了编码器,让我们看看训练是如何工作的。首先,我们构建一个函数来执行训练循环。

以下是训练参数的定义:

embed_size = 512
output_embed_size = 128
max_seq_len = 64
batch_size = 32

接下来,定义问题和答案编码器:

tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
vocab_size = tokenizer.vocab_size

question_encoder = DualEncoder(vocab_size, embed_size, output_embed_size)
answer_encoder = DualEncoder(vocab_size, embed_size, output_embed_size)

加载数据集和数据加载器,定义优化器和损失函数:

# 假设有一个自定义的 Dataset 类 `MyDataset`
from torch.utils.data import DataLoader

# dataset = MyDataset(...) # 你的数据集
# dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=True)

optimizer = torch.optim.Adam(list(question_encoder.parameters()) + list(answer_encoder.parameters()), lr=1e-5)
loss_fn = nn.CrossEntropyLoss()

现在,如何构建训练循环:

  1. 首先,遍历数据加载器,以 32 个为一组获取批次数据。
  2. 将问题和答案批次分开,并对每个问题和每个答案进行标记化。
  3. 使用标记化的输出,通过上面定义的问题编码器或答案编码器计算问题嵌入和答案嵌入。
  4. 计算相似度得分,这里有一个很酷的 PyTorch 单行代码:相似度 = 问题嵌入 @ 答案嵌入.T
  5. 使用交叉熵技巧计算对比损失。同样,目标需要是 [0, 1, 2, ..., 31](对于批次大小 32)。
  6. 将本次迭代的损失添加到运行损失中,用于跟踪损失随时间的变化。
  7. 最后,在训练循环中必须执行优化器步骤和反向传播,以使学习真正生效。
def train_one_epoch(question_encoder, answer_encoder, dataloader, optimizer, loss_fn, device):
    question_encoder.train()
    answer_encoder.train()
    total_loss = 0

    for batch in dataloader:
        questions, answers = batch # 假设批次返回 (questions, answers)
        # 标记化(此处简化,实际需使用tokenizer)
        # q_inputs = tokenizer(questions, ...)
        # a_inputs = tokenizer(answers, ...)
        # 假设 q_input_ids, a_input_ids 是标记化后的张量
        q_input_ids = ... # 形状 [batch_size, seq_len]
        a_input_ids = ... # 形状 [batch_size, seq_len]

        q_embeddings = question_encoder(q_input_ids.to(device)) # 形状 [batch_size, output_embed_size]
        a_embeddings = answer_encoder(a_input_ids.to(device))   # 形状 [batch_size, output_embed_size]

        # 计算相似度矩阵
        similarity_scores = q_embeddings @ a_embeddings.T # 形状 [batch_size, batch_size]

        # 计算对比损失
        batch_size = q_embeddings.size(0)
        targets = torch.arange(batch_size).to(device) # [0, 1, 2, ..., batch_size-1]
        loss = loss_fn(similarity_scores, targets)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        total_loss += loss.item()

    avg_loss = total_loss / len(dataloader)
    return avg_loss

多轮次训练

你看到了训练循环如何工作,但这只是对数据集的一次遍历。在深度学习网络中,我们需要运行多次遍历(轮次)。

让我们稍微修改一下这个函数,在外部添加一个循环,运行指定数量的轮次。

def train(question_encoder, answer_encoder, dataloader, optimizer, loss_fn, device, num_epochs):
    for epoch in range(num_epochs):
        avg_loss = train_one_epoch(question_encoder, answer_encoder, dataloader, optimizer, loss_fn, device)
        print(f'Epoch [{epoch+1}/{num_epochs}], Loss: {avg_loss:.4f}')
    return question_encoder, answer_encoder

开始训练

我们将使用一个包含一些问答样本的数据集。假设有一个名为 MyDataset 的外部类,它本质上将数据加载到 Pandas 数据框中并使其可用。

# 加载数据
# dataset = MyDataset('your_data_path')
# dataloader = DataLoader(dataset, batch_size=32, shuffle=True)

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
question_encoder.to(device)
answer_encoder.to(device)

num_epochs = 10
trained_q_encoder, trained_a_encoder = train(question_encoder, answer_encoder, dataloader, optimizer, loss_fn, device, num_epochs)

训练是一个漫长的过程,可能需要一段时间。我们会看到每轮训练后打印出的损失值。

训练完成后,损失值可能不会太低。请记住,我们只训练了 10 个轮次,使用了 300 个示例,这非常少,而且模型本身也很小。这只是为了向你展示一个可以操作的例子。如果你有更强大的机器,可以使用更大的参数集和更多的轮次来运行,以获得更好的结果,我鼓励你这样做。


模型推理示例

尽管如此,为了练习,让我们看看我们得到了什么。你可以取一个问题,例如“世界上最高的山是什么”,对其进行标记化,并使用刚刚训练好的模型(从训练输出中获得)对这个标记进行处理。

question = "What is the tallest mountain in the world?"
q_inputs = tokenizer(question, return_tensors='pt', padding=True, truncation=True, max_length=max_seq_len)
q_embedding = trained_q_encoder(q_inputs['input_ids'].to(device))

print("问题嵌入(部分):", q_embedding[0, :5]) # 打印前5个值

同样,创建一些潜在的答案并进行类似的运行。

answers = ["Mount Everest is the tallest mountain.",
           "The capital of France is Paris.",
           "Python is a programming language."]
# ... 对每个答案进行编码,得到 a_embeddings

第一个答案与我们的问题相同(“世界上最高的山是什么”),你会看到标记看起来完全相同,这正是我们所期望的。然而,这里的嵌入使用的是答案编码器,你可以看到这里的嵌入值与上面相同问题的嵌入值并不相同。这正是因为问题编码器和答案编码器不是同一个编码器,它们有不同的权重,并且按照我们讨论的形式被训练成不同的模型。

如果你计算这两个嵌入之间的相似度(点积),你会发现问题与其相同答案的匹配度最高,点积值最大。考虑到模型只训练了 10 个轮次,规模很小,并且没有大量数据用于训练,这个结果是合理的。


总结

本节课中我们一起学习了以下内容:

  1. 我们看到了如何使用 PyTorch 模块构建编码器。
  2. 我们逐步了解了训练循环的工作原理。
  3. 这是一个小规模模型的示例,仅用一个小数据集训练了 10 个轮次。

在下一课中,你将看到一个完全训练好的问答模型,以及它如何很好地为 RAG 提供检索的全部优势。

006:推理

概述

在本节课中,我们将学习如何在生产环境中使用句子嵌入模型,并了解在检索增强生成(RAG)等检索流程中,问题编码器和答案编码器这两种不同的编码器是如何被使用的。

推理流程

上一节我们介绍了双编码器模型的训练。现在,我们有两个已训练好的编码器:问题编码器和答案编码器。

在数据摄取阶段,我们使用答案编码器对每个文本块进行编码,并将生成的向量嵌入存储到向量数据库中。

当用户发出查询时,我们使用问题编码器生成查询嵌入向量。该向量随后被用于检索最匹配的事实或文本片段,这些内容将作为RAG流程的一部分发送给大语言模型。

检索匹配文本

在计算出问题嵌入后,我们如何找到匹配的文本块呢?

以下是几种方法:

  • 朴素方法:计算问题嵌入与所有答案嵌入之间的相似度。这种方法计算量大,对于实际生产系统来说可能耗时过长。
  • 近似最近邻算法:幸运的是,我们有许多近似最近邻算法可供选择,例如HNSW、Annoy、Faiss等。这些算法能以高精度近似最近邻搜索,同时显著降低计算时间,因此被广泛用于此任务。

大多数ANN算法是在内存中运行的。因此,当你在生产中实现它,并且面对非常大的数据集时,你还需要额外考虑使用基于磁盘的持久化数据存储来实现你的ANN方法。

代码示例

让我们通过代码来看看这一切。在这个笔记本中,我们首先忽略警告信息,并像往常一样导入一系列我们将要使用的包。

以下是关键步骤:

  1. 导入包:特别注意两个新包:DPRContextEncoderDPRQuestionEncoder。我们将使用它们来加载预训练的双编码器模型。
  2. 准备数据:我们准备了五个不同的潜在答案和一个问题:“世界上最高的山是什么?”
  3. 使用纯相似度模型:你可以使用名为 all-MiniLM-L6-v2 的纯相似度模型。计算问题的嵌入,然后计算每个答案的嵌入,最后计算问题与每个答案之间的相似度。你会发现,相似度最高的答案正是与问题完全相同的那个,相似度为1.0,这符合预期。
  4. 使用双编码器模型:我们使用完全预训练的DPR模型。加载模型后,再次计算问题的标记和嵌入。嵌入是一个768维的向量。
  5. 处理答案:对每个答案进行标记化,使用答案编码器获取答案嵌入,计算相似度,并找出最佳答案。结果是,我们得到了正确的答案:“世界上最高的山是珠穆朗玛峰”。与问题完全相同的那个答案(答案0)并没有得到最高分。

完整的RAG流程

现在,让我们退一步,看看完整的RAG流程。

  • 数据摄取阶段:接收输入文档或文本,将其分块,使用答案编码器将这些块编码成嵌入向量,然后将其存储到向量数据库中。
  • 查询响应阶段:收到用户查询后,使用问题编码器获取问题嵌入,然后使用ANN算法检索最相关的文本块。这些文本块随后作为上下文包含在提示词中,并发送给LLM,由LLM生成所需的响应。

在实践中,有几种方法可以构建RAG流程:

  • 从零开始编码:完全自己编写代码。
  • 使用DIY框架:例如LangChain或LlamaIndex。
  • 使用RAG服务平台:例如Vectara,它为你完成了大部分繁重的工作。

总结

本节课中,我们一起学习了如何在生产RAG流程中使用问题编码器和答案编码器,并了解了ANN算法对于使检索延迟达到可接受水平的重要性。在下一课也是最后一课中,我们将总结本课程,并解释两阶段检索流程是如何工作的。

007:课程概述

在本节课中,我们将要学习嵌入模型的基本概念、历史发展及其在检索系统中的应用。嵌入模型能够将文本转换为代表其含义的向量,这是构建现代语义检索系统的核心。

课程简介

欢迎来到与Vectara合作的《从架构到实现的嵌入模型》课程。嵌入模型创建嵌入向量,使构建语义或基于意义的检索系统成为可能。本课程将详细描述历史、技术架构和实施细节。这是一门技术课程,因此我们将专注于构建模块而非应用。

你可能听说过嵌入向量在生成式人工智能应用中的应用。这些向量具有捕捉单词或短语含义的惊人能力。你可能已经使用过嵌入模型来创建这些向量。但是,这些模型究竟是如何工作的呢?

为了帮助我们深入了解这一点,我很高兴介绍Amin Ahmad,Vectara的联合创始人兼作者,以及Mendeleevich,他是公司的开发者关系负责人。

谢谢,Andrew。我很高兴能在这里。在Vectara,我们已经构建了自己的嵌入模型来支持不同的RAG系统。因此,我们必须深入研究如何选择、构建和训练它们。在这门课程中,我们将与您分享一些关键的技术细节。

听起来很棒。

嵌入模型的核心思想

创建一个能够生成代表单词含义的向量模型是一个具有挑战性的问题。你可能希望利用大量现有文本作为训练数据。但是,你该如何操作呢?

有一个想法是使用目标词周围的词作为线索。以单词“tree”为例。一个文本训练句子可能会说:“树上的叶子是绿色的。”而另一个句子可能会说:“树上的树枝正在落下。”因此,出现在单词“tree”附近的词会告诉你有关“tree”含义的一些信息。如果你有数百万个类似的句子,那么你可能会对“tree”这个词有一个相当不错的理解。

历史发展:从Word2Vec到Transformer

这种方法由Thomas Mikolov、Kai Chen、Greg Corrado和Jeff Dean开发的Word2Vec嵌入模型推广开来,他们大多数曾是我在Google Brain的前同事。Word2Vec模型是一个在自然语言上训练的模型,通过预测目标词周围几个词来学习嵌入。

随后,斯坦福大学的Jeffrey Pennington、Richard Socher和Chris Manning提出的GloVe方法进一步改进了Word2Vec,简化了学习嵌入所需的数学方法。

扩大单词周围的上下文窗口以产生更精确的嵌入之前,一直涉及使用像LSTM这样的循环神经网络。2017年,引入了transformer模型,这改变了局面,使得前馈神经网络能够高效处理顺序数据,远比循环网络更易训练。

BERT模型于随后的一年发布,采用了深度transformer网络,在简单的填空任务训练中深入理解语言,开启了现代自然语言处理的时代,并为随后出现的GPT等系统奠定了基础。

从词到句:嵌入的应用扩展

你还可以将注意力从单词扩展到短语或句子等更长的文本段落。在检索系统中,你可能希望为查询句子生成一个嵌入向量,并将其与响应句子的向量进行比较。事实证明,你可以对这些强大的词嵌入模型进行微调,以评估句子。Ofer会向你展示如何操作。

没错。在本课程中,你将首先学习嵌入模型的应用领域和使用方式。接着,你将学习BERT模型。这是一个典型的双向transformer的例子。BERT被广泛应用于多种场景,但在这里我们将专注于其在检索中的应用。

随后,你将学习如何构建和使用对比损失来训练双编码器模型,这对于RAG应用非常适用。它包含一个用于查询的编码器和一个专门用于响应的编码器。你将亲眼见证这一切。

致谢

许多人为创作这门课程付出了努力。我要感谢Vectara的Vivek Saurabh,以及deeplearning.ai的Eshmel Gargari和Jeff Ludwig也为这门课程做出了贡献。

课程预告

第一课将从检索系统中的嵌入模型概述开始。听起来很不错。


本节课中我们一起学习了嵌入模型的基本概念、其通过上下文学习词义的核心思想,以及从Word2Vec到Transformer和BERT的关键技术演进。我们还了解了本课程的结构,即从应用概述到BERT模型,再到用于检索的双编码器训练。下一节课,我们将深入探讨嵌入模型在检索系统中的具体应用。

posted @ 2026-03-26 08:16  布客飞龙II  阅读(0)  评论(0)    收藏  举报