掘金 后端 ( ) • 2024-04-12 14:43

句子嵌入简介

本系列旨在揭开嵌入的神秘面纱,并向您展示如何在项目中使用它们。第一篇博文将教您如何使用和扩展开源嵌入模型。我们将研究选择现有模型的标准、当前的评估方法以及生态系统的状态。我们将研究三个令人兴奋的应用程序:

  • 查找最相似的 Quora 或 StackOverflow 问题
  • 给定一个巨大的数据集,找到最相似的项目
  • 直接在用户的浏览器中运行搜索嵌入模型(无需服务器)

您可以在此处阅读内容,也可以通过单击页面顶部的徽章在 Google Colab 中执行内容。让我们深入研究嵌入!

The TL;DR TL;DR 太长了,直接读这

您不断看到“嵌入这个”和“嵌入那个”,但您可能仍然不知道它们到底是什么。你并不是个例!即使您对嵌入是什么有一个模糊的概念,您也可能通过黑盒 API 使用它们,而没有真正了解幕后发生的事情。这是一个问题,因为开源嵌入模型的当前状态非常强大 - 它们非常容易部署,体积小(因此托管成本低),并且优于许多闭源模型。

嵌入将信息表示为数字向量(将其视为数字列表!)。例如,我们可以获得单词、句子、文档、图像、音频文件等的嵌入。给定句子“Today is a sunny day”,我们可以获得它的嵌入,这将是一个特定大小的向量,例如 384 个数字(此类向量可能类似于 [0.32, 0.42, 0.15, …, 0.72])。有趣的是,嵌入捕获了信息的语义。例如,嵌入句子“今天是晴天(Today is a sunny day)”将与句子“今天天气很好(The weather is nice today)”非常相似。即使单词不同,含义也相似,嵌入会反映这一点。

如果您不确定“向量”、“语义相似性”、向量大小或“预训练”等词的含义,请不要担心!我们将在以下部分中解释它们。首先关注高层理解。

因此,该向量捕获了信息的语义,使其更容易相互比较。例如,我们可以使用嵌入在 Quora 或 StackOverflow 中查找相似的问题、搜索代码、查找相似的图像等。让我们看一些代码!

我们将使用 Sentence Transformers,这是一个开源库,可以轻松使用预先训练的嵌入模型。特别是,ST 允许我们快速将句子转化为嵌入。让我们运行一个示例,然后讨论它的幕后工作原理。

让我们从安装库开始:

!pip install sentence_transformers

第二步是加载现有模型。我们将开始使用all-MiniLM-L6-v2.。它不是最好的开源嵌入模型,但它非常流行并且非常小(2300 万个参数),这意味着我们可以很快开始使用它。

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")

现在我们加载了一个模型,让我们用它来编码一些句子。我们可以使用 encode 方法来获取句子列表的嵌入。我们来尝试一下吧!

from sentence_transformers import util

sentences = ["The weather today is beautiful", "It's raining!", "Dogs are awesome"]
embeddings = model.encode(sentences)
embeddings.shape
(3, 384)

all-MiniLM-L6-v2 创建 384 个值的嵌入。我们获得三个嵌入,每个句子一个。将 embeddings 视为嵌入的“数据库”。给定一个新句子,我们如何找到最相似的句子?我们可以使用 util.pytorch_cos_sim 方法来计算新句子嵌入与数据库中所有嵌入之间的余弦相似度(我们很快就会详细讨论)。余弦相似度是 0 到 1 之间的数字,表示两个嵌入的相似程度。值 1 表示嵌入相同,而 0 表示嵌入完全不同。我们来尝试一下吧!

first_embedding = model.encode("Today is a sunny day")
for embedding, sentence in zip(embeddings, sentences):
    similarity = util.pytorch_cos_sim(first_embedding, embedding)
    print(similarity, sentence)
tensor([[0.7344]]) The weather today is beautiful
tensor([[0.4180]]) It's raining!
tensor([[0.1060]]) Dogs are awesome

我们可以对此有何解释?虽然“今天是一个阳光明媚的日子 today is a sunny day”和“今天天气很好 the weather today is beautiful”没有相同的词,但嵌入可以捕获一些语义,因此余弦相似度相对较高。另一方面,“狗真棒(Dogs are awesome)”虽然是真的,但与天气或今天无关;因此,余弦相似度非常低。

为了扩展类似嵌入的想法,我们来看看如何在产品中使用它们。想象一下,美国社会保障局希望允许用户在输入字段中写入与医疗保险相关的问题。这个话题非常敏感,我们可能不希望模型对不相关的东西产生幻觉!相反,我们可以利用问题数据库(在本例中,有一个现有的 Medicare 常见问题解答)。过程与上面类似”

  1. 我们有一个问题和答案的语料库(集合)。
  2. 我们计算所有问题的嵌入。
  3. 给定一个新问题,我们计算它的嵌入。
  4. 我们计算新问题嵌入与数据库中所有嵌入之间的余弦相似度。
  5. 我们返回最相似的问题(与最相似的嵌入相关)。

步骤 1 和 2 可以离线完成(也就是说,我们只计算嵌入一次并存储它们)。其余步骤可以在搜索时完成(每次用户提出问题时)。让我们看看代码中会是什么样子。

Representation of embeddings in two dimensions

二维嵌入的表示

让我们首先创建常见问题映射。

# Data from https://faq.ssa.gov/en-US/topic/?id=CAT-01092

faq = {
    "How do I get a replacement Medicare card?": "If your Medicare card was lost, stolen, or destroyed, you can request a replacement online at Medicare.gov.",
    "How do I sign up for Medicare?": "If you already get Social Security benefits, you do not need to sign up for Medicare. We will automatically enroll you in Original Medicare (Part A and Part B) when you become eligible. We will mail you the information a few months before you become eligible.",
    "What are Medicare late enrollment penalties?": "In most cases, if you don’t sign up for Medicare when you’re first eligible, you may have to pay a higher monthly premium. Find more information at https://faq.ssa.gov/en-us/Topic/article/KA-02995",
    "Will my Medicare premiums be higher because of my higher income?": "Some people with higher income may pay a larger percentage of their monthly Medicare Part B and prescription drug costs based on their income. We call the additional amount the income-related monthly adjustment amount.",
    "What is Medicare and who can get it?": "Medicare is a health insurance program for people age 65 or older. Some younger people are eligible for Medicare including people with disabilities, permanent kidney failure and amyotrophic lateral sclerosis (Lou Gehrig’s disease or ALS). Medicare helps with the cost of health care, but it does not cover all medical expenses or the cost of most long-term care.",
}

我们再次使用 encode 方法来获取所有问题的嵌入。

corpus_embeddings = model.encode(list(faq.keys()))
print(corpus_embeddings.shape)
(5, 384)

一旦用户提出问题,我们就获得其嵌入。我们通常将这种嵌入称为查询嵌入。

user_question = "Do I need to pay more after a raise?"
query_embedding = model.encode(user_question)
query_embedding.shape
(384,)

我们现在可以计算语料库嵌入和查询嵌入之间的相似度。我们可以有一个循环并像以前一样使用 util.pytorch.cos_sim ,但是 Sentence Transformers 提供了一个更友好的方法,称为 semantic_search ,它可以为我们完成所有工作。它返回前 k 个最相似的嵌入及其相似度得分。我们来尝试一下吧!

similarities = util.semantic_search(query_embedding, corpus_embeddings, top_k=3)
similarities
[[{'corpus_id': 3, 'score': 0.35796287655830383},
  {'corpus_id': 2, 'score': 0.2787758708000183},
  {'corpus_id': 1, 'score': 0.15840476751327515}]]

现在让我们看看这对应于哪些问题和答案:

for i, result in enumerate(similarities[0]):
    corpus_id = result["corpus_id"]
    score = result["score"]
    print(f"Top {i+1} question (p={score}): {list(faq.keys())[corpus_id]}")
    print(f"Answer: {list(faq.values())[corpus_id]}")
Top 1 question (p=0.35796287655830383): Will my Medicare premiums be higher because of my higher income?
Answer: Some people with higher income may pay a larger percentage of their monthly Medicare Part B and prescription drug costs based on their income. We call the additional amount the income-related monthly adjustment amount.
Top 2 question (p=0.2787758708000183): What are Medicare late enrollment penalties?
Answer: In most cases, if you don’t sign up for Medicare when you’re first eligible, you may have to pay a higher monthly premium. Find more information at https://faq.ssa.gov/en-us/Topic/article/KA-02995
Top 3 question (p=0.15840476751327515): How do I sign up for Medicare?
Answer: If you already get Social Security benefits, you do not need to sign up for Medicare. We will automatically enroll you in Original Medicare (Part A and Part B) when you become eligible. We will mail you the information a few months before you become eligible.

太好了,所以考虑到“加薪后我需要支付更多费用吗?Do I need to pay more after a raise?”这个问题,我们知道最相似的问题是“我的医疗保险保费会因为我的收入更高而更高吗?Will my Medicare premiums be higher because of my higher income?”因此我们可以返回提供的答案。在实践中,您可能会有数千到数百万个嵌入,但这是一个简单而强大的示例,说明了如何使用嵌入来查找类似的问题。

现在我们更好地了解了嵌入是什么以及如何使用它们,让我们更深入地研究它们!

从词嵌入到句子嵌入

Word2Vec 和 GloVe

是时候退后一步,了解更多关于嵌入以及为什么需要它们的信息了。神经网络,例如 BERT,不能直接处理单词;需要把单词转化为数字。而提供单词的方式就是将它们表示为向量,也称为词嵌入。

在传统设置中,您定义一个词汇表(所有允许的单词),然后该词汇表中的每个单词都有一个指定的嵌入。不在词汇表中的单词被映射到一个特殊的token,比如<UNK>,通常称为(训练期间未找到的单词的标准占位符)。例如,假设我们有一个包含三个单词的词汇表,我们为每个单词分配一个大小为 5 的向量。我们可以有以下嵌入:

Word Embedding king [0.15, 0.2, 0.2, 0.3, 0.5]
[0.15、0.2、0.2、0.3、0.5] queen [0.12, 0.1, 0.19, 0.3, 0.47] potato [0.13, 0.4, 0.1, 0.15, 0.01] <UNK> [0.01, 0.02, 0.01, 0.4, 0.11]

我上面写的嵌入是我随机写的数字。在实践中,嵌入通过学习获取的。这就是Word2Vec and GloVe等方法的主要思想。他们学习语料库中单词的嵌入,使得出现在相似上下文中的单词具有相似的嵌入。例如,“king”和“queen”的嵌入是相似的,因为它们出现在相似的上下文中。

Word embeddings

Word embeddings 词嵌入

一些开源库,例如 Gensim 和 fastText,允许您快速获得预先训练的 Word2Vec 和 GloVe 嵌入。在 NLP 的美好时光(2013 年),人们使用这些模型来计算词嵌入,这对于其他模型的输入很有帮助。例如,您可以计算句子中每个单词的单词嵌入,然后将其作为输入传递给 sci-kit 学习分类器,以对句子的情感进行分类。

Glove 和 Word2Vec 有固定长度的表示。一旦训练完成,每个单词都会被分配一个固定的向量表示,无论其上下文如何(因此“河岸”和“储蓄银行”中的“银行”将具有相同的嵌入)。 Word2vec 和 GloVe 将难以处理具有多重含义的单词

NLP 的美好时光

了解 word2vec 和 GloVe 的细节对于理解博客文章的其余部分和句子嵌入来说是不必要的,所以我将跳过它们。如果您有兴趣,我建议您阅读chapter from the excellentinteractive NLP course的这一章。

As a TL;DR 作为一名 TL;DR

Word2Vec 的训练是通过一个非常大的语料库并训练一个浅层神经网络来预测 周围的单词。后来的替代方案根据周围的单词来预测中心单词。 GloVe的训练方法是查看单词的共现矩阵(单词在一定距离内一起出现的频率),然后使用该矩阵来获取嵌入。

Word2Vec 和GloVe 的训练目标是确保相似上下文中出现的单词具有相似的嵌入

使用 Transformer 进行词嵌入

最近,随着Transformer的出现,我们有了计算嵌入的新方法。嵌入是学习到的,但是 Transformer 不是训练一个嵌入模型,然后针对特定任务训练另一个模型,而是在其任务的上下文中学习有用的嵌入。例如,流行的 Transformer 模型 BERT 在掩码语言模型(预测哪个单词填空)和下一个句子预测(句子 B 是否在句子 A 之后)的背景下学习单词嵌入。

Transformer 在许多 NLP 任务中都是最先进的,并且能够捕获 word2vec 和 GloVe 无法捕获的上下文信息,这要归功于一种称为注意力的机制。注意力使模型能够权衡其他单词的重要性并捕获上下文信息。例如,在“我去银行存钱”这句话中,“银行”一词是有歧义的。是河岸还是储蓄银行?该模型可以使用“存款”一词来理解它是一家储蓄银行。这些是上下文嵌入——它们的词嵌入可以根据周围的词而有所不同

好吧……我们讨论了很多关于词嵌入的内容;是时候运行一些代码了。让我们使用预先训练的 Transformer 模型 bert-base-uncased,并获得一些词嵌入。为此,我们将使用 transformers 库。让我们首先加载模型及其tokenizer

from transformers import AutoModel, AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
model = AutoModel.from_pretrained("bert-base-uncased")

到目前为止我们还没有讨论过tokenization。到目前为止,我们假设我们将数据拆分为单词。使用transformer时,我们将文本划分为token。例如,单词“banking”可以分为两个token:“bank”和“ing”。分词器负责将数据分解为token,其分割数据的方式是特定于模型的,并且是一个确定性的学习过程,这意味着相同的单词将始终被分割为相同的token。让我们看看代码中的样子:

text = "The king and the queen are happy."
tokenizer.tokenize(text, add_special_tokens=True)
['[CLS]', 'the', 'king', 'and', 'the', 'queen', 'are', 'happy', '.', '[SEP]']

好吧,在这个例子中,每个单词都是一个token! (情况并非总是如此,我们很快就会看到)。但我们也看到了两件可能出乎意料的事情: [CLS][SEP] 。这些是添加到句子开头和结尾的特殊标记。使用这些是因为 BERT 是使用该格式进行训练的。 BERT 的训练目标之一是 预测一句,这意味着它被训练来预测两个句子是否连续。 [CLS] 标记代表整个句子, [SEP] 标记分隔句子。当我们稍后讨论句子嵌入时,这会很有趣。

现在让我们获取每个token的嵌入。

encoded_input = tokenizer(text, return_tensors="pt")
output = model(**encoded_input)
output["last_hidden_state"].shape
torch.Size([1, 10, 768])

牛X! BERT 为每个 token 提供了 768 个值的嵌入。这些token中的每一个都具有语义信息 - 它们捕获句子上下文中单词的含义。让我们看看这个上下文中“king”一词对应的嵌入是否与“queen”中的嵌入相似。

king_embedding = output["last_hidden_state"][0][2]  # 2 is the position of king
queen_embedding = output["last_hidden_state"][0][5]  # 5 is the position of queen
print(f"Shape of embedding {king_embedding.shape}")
print(
    f"Similarity between king and queen embedding {util.pytorch_cos_sim(king_embedding, queen_embedding)[0][0]}"
)
Shape of embedding torch.Size([768])
Similarity between king and queen embedding 0.7920711040496826

好吧,看起来他们在这个上下文中非常相似!现在我们来看看“happy”这个词。

happy_embedding = output.last_hidden_state[0][7]  # happy
util.pytorch_cos_sim(king_embedding, happy_embedding)
tensor([[0.5239]], grad_fn=<MmBackward0>)

这是有道理的;与happy嵌入相比,queen嵌入更类似于 king 。

现在让我们看看同一个单词如何根据上下文具有不同的值:

text = "The angry and unhappy king"
encoded_input = tokenizer(text, return_tensors="pt")
output = model(**encoded_input)
output["last_hidden_state"].shape
torch.Size([1, 7, 768])
tokenizer.tokenize(text, add_special_tokens=True)
['[CLS]', 'the', 'angry', 'and', 'unhappy', 'king', '[SEP]']
king_embedding_2 = output["last_hidden_state"][0][5]
util.pytorch_cos_sim(king_embedding, king_embedding_2)
tensor([[0.5740]], grad_fn=<MmBackward0>)

哇!尽管这两种嵌入似乎都对应于“king”嵌入,但它们在向量空间中却有很大不同。到底是怎么回事?请记住,这些是上下文嵌入。第一句话的语境相当积极,而第二句话则相当消极。因此,嵌入是不同的。

之前,我们讨论了tokenizer如何将一个单词拆分为多个token。一个有效的问题是在这种情况下我们如何获得词嵌入。让我们看一个带有长词“tokenization”的示例。

tokenizer.tokenize("tokenization")
['token', '##ization']

“tokenization”这个词被分成了两个token,但我们关心的只是 “tokenization”的嵌入!我们可以做什么?我们可以采用池化策略,获取每个 token 的嵌入,然后对它们进行平均以获得词嵌入。我们来尝试一下吧!

As before, we get started by tokenizing the test and running the token IDs through the model.
和以前一样,我们首先对测试进行tokenizing并通过模型运行 token ID。

text = "this is about tokenization"

encoded_input = tokenizer(text, return_tensors="pt")
output = model(**encoded_input)

让我们看一下句子的tokenization:

tokenizer.tokenize(text, add_special_tokens=True)
['[CLS]', 'this', 'is', 'about', 'token', '##ization', '[SEP]']

因此,我们希望通过对token 4 和 5 进行平均来池化它们的嵌入。我们首先获取token的嵌入。

word_token_indices = [4, 5]
word_embeddings = output["last_hidden_state"][0, word_token_indices]
word_embeddings.shape
torch.Size([2, 768])

现在让我们使用 torch.mean 对它们进行平均。

import torch

torch.mean(word_embeddings, dim=0).shape
torch.Size([768])

让我们将所有内容包装在一个函数中,以便稍后可以轻松使用它。

def get_word_embedding(text, word):
    # Encode the text and do a forward pass through the model to get the hidden states
    encoded_input = tokenizer(text, return_tensors="pt")
    with torch.no_grad():  # We don't need gradients for embedding extraction
        output = model(**encoded_input)

    # Find the indices for the word
    word_ids = tokenizer.encode(
        word, add_special_tokens=False
    )  # No special tokens anymore
    word_token_indices = [
        i
        for i, token_id in enumerate(encoded_input["input_ids"][0])
        if token_id in word_ids
    ]

    # Pool the embeddings for the word
    word_embeddings = output["last_hidden_state"][0, word_token_indices]
    return torch.mean(word_embeddings, dim=0)

Example 1. 示例 1:kingqueen 嵌入在双方都 angry 的上下文中的相似性。

util.pytorch_cos_sim(
    get_word_embedding("The king is angry", "king"),
    get_word_embedding("The queen is angry", "queen"),
)
tensor([[0.8564]])

Example 2.
示例 2. 在 king happy和queen angry 的上下文中,kingqueen 嵌入之间的相似性。请注意,它们与前面的示例相比不太相似。

util.pytorch_cos_sim(
    get_word_embedding("The king is happy", "king"),
    get_word_embedding("The queen is angry", "queen"),
)
tensor([[0.8273]])

Example 3. Similarity between king embeddings in two very different contexts. Even if they are the same word, the different context of the word makes the embeddings very different.
示例 3. 两个截然不同的上下文中 ,即使king是同一个单词,单词在不同上下文也会使嵌入非常不同。

# This is same as before
util.pytorch_cos_sim(
    get_word_embedding("The king and the queen are happy.", "king"),
    get_word_embedding("The angry and unhappy king", "king"),
)
tensor([[0.5740]])

*Example 4. 示例 4. 具有两种不同含义的单词之间的相似性。 “bank”这个词是有歧义的,它可以是河岸,也可以是银行。嵌入根据上下文而不同。

util.pytorch_cos_sim(
    get_word_embedding("The river bank", "bank"),
    get_word_embedding("The savings bank", "bank"),
)
tensor([[0.7587]])

我希望这能让您了解什么是词嵌入。现在我们了解了词嵌入,让我们看看句子嵌入!

Sentence Embeddings 句子嵌入

正如词嵌入是词的向量表示一样,句子嵌入是句子的向量表示。我们还可以计算段落和文档的嵌入!我们来看看吧。

我们可以采取三种方法: [CLS] 池化、最大池化和均值池化。

  • 均值池化意味着对句子的 所有 词嵌入 进行平均。
  • 最大池化是指取 词嵌入 每个维度的最大值。
  • [CLS] 池化意味着使用 [CLS] token对应的嵌入作为句子嵌入。让我们更深入地研究最后一个,这是最不直观的。

[CLS] Pooling

正如我们之前看到的,BERT 在句子开头添加了一个特殊token [CLS]cls是 class 类别 的意思。该token用于表示整个句子。例如,当有人想要微调 BERT 模型以执行文本分类时,常见的方法是在 [CLS] 嵌入之上添加linear 层。这个想法是 [CLS] token将捕获整个句子的含义。

CLS token对应的隐藏状态/嵌入可用于微调分类模型。

我们可以采取相同的方法,使用 [CLS] token的嵌入代表句子嵌入。让我们看看它在代码中是如何工作的。我们将使用与之前相同的句子。

encoded_input = tokenizer("This is an example sentence", return_tensors="pt")
model_output = model(**encoded_input)
sentence_embedding = model_output["last_hidden_state"][:, 0, :]
sentence_embedding.shape
torch.Size([1, 768])

漂亮!我们获得了模型输出的第一个嵌入,对应于[CLS] token。让我们将这段代码封装到一个函数中。

def cls_pooling(model_output):
    return model_output["last_hidden_state"][:, 0, :]


def get_sentence_embedding(text):
    encoded_input = tokenizer(text, return_tensors="pt")
    with torch.no_grad():
        model_output = model(**encoded_input)
    return cls_pooling(model_output)
embeddings = [get_sentence_embedding(sentence) for sentence in sentences]
query_embedding = get_sentence_embedding("Today is a sunny day")
for embedding, sentence in zip(embeddings, sentences):
    similarity = util.pytorch_cos_sim(query_embedding, embedding)
    print(similarity, sentence)
tensor([[0.9261]]) The weather today is beautiful
tensor([[0.8903]]) It's raining!
tensor([[0.9317]]) Dogs are awesome

嗯……这里看起来有些不对劲🤔人们会期望它能开箱即用。

事实证明,BERT 还有一个额外的技巧。如前所述,训练 BERT 时,使用 CLS token 来预测两个句子是否连续。为此,BERT 处理 [CLS] 对应的嵌入,并将其传递给linear层和 tanh 激活函数(see code here).。这个想法是linear层和 tanh 激活函数将学习 [CLS] token的更好表示。这是 BERT 模型的 pooler 组件,用于获取 model_output.pooler_output

这可能听起来令人困惑,所以让我们重复一下这里发生的事情。

  1. BERT 输出每个 token 的嵌入。
  2. 第一个嵌入对应于 [CLS] token。
  3. [CLS] token 通过linear层和 tanh 激活函数进行处理以获得 pooler_output

训练时,pooler_output用于预测两个句子是否连续(BERT的预训练任务之一)。这使得处理 [CLS] token比原始 [CLS] 嵌入更有意义。

为了表明这里没有发生任何魔法,我们可以将单词嵌入列表传递给 model.pooler 或简单地从模型输出中获取 pooler_output 。我们来尝试一下吧!

model.pooler(model_output["last_hidden_state"])[0][:10]
tensor([-0.9302, -0.4884, -0.4387,  0.8024,  0.3668, -0.3349,  0.9438,  0.3593,
        -0.3216, -1.0000], grad_fn=<SliceBackward0>)
model_output["pooler_output"][0][:10]
tensor([-0.9302, -0.4884, -0.4387,  0.8024,  0.3668, -0.3349,  0.9438,  0.3593,
        -0.3216, -1.0000], grad_fn=<SliceBackward0>)

耶!正如您所看到的,嵌入的前十个元素是相同的!现在让我们使用这种新的嵌入技术重新计算距离:

def cls_pooling(model_output):
    return model.pooler(model_output["last_hidden_state"])  # we changed this


# This stays the same
embeddings = [get_sentence_embedding(sentence) for sentence in sentences]
query_embedding = get_sentence_embedding("Today is a sunny day")
for embedding, sentence in zip(embeddings, sentences):
    similarity = util.pytorch_cos_sim(query_embedding, embedding)
    print(similarity, sentence)
tensor([[0.9673]], grad_fn=<MmBackward0>) The weather today is beautiful
tensor([[0.9029]], grad_fn=<MmBackward0>) It's raining!
tensor([[0.8930]], grad_fn=<MmBackward0>) Dogs are awesome

好多了!我们刚刚获得了最接近“Today is a sunny day”的句子。

Sentence Transformers

Using the transformers library

这会产生一些不错的结果,但在实践中,这并不比使用 Word2Vec 或 GloVe 词嵌入并对它们求平均值好多少。原因是 [CLS] token没有被训练成一个好的句子嵌入。它被训练成一个很好的句子嵌入来预测下一个句子

隆重推出🥁🥁🥁Sentence Transformers! Sentence Sentence Transformers(也称为 SBERT)有一种特殊的训练技术,专注于产生高质量的句子嵌入。正如本博文的 TL;DR 部分一样,我们使用all-MiniLM-L6-v2 模型。一开始,我们使用 sentence-transformers 库,它是 transformers 的高级包装库。让我们先从难点的搞起!流程如下:

  1. 对输入句子进行tokenize。

  2. 通过模型处理token。

  3. 计算token嵌入的平均值。

  4. 对嵌入进行归一化以确保嵌入向量具有单位长度。

就像以前一样,我们可以加载模型 和 tokenizer,对句子进行分词并将其传递给模型

tokenizer = AutoTokenizer.from_pretrained("sentence-transformers/all-MiniLM-L6-v2")
model = AutoModel.from_pretrained("sentence-transformers/all-MiniLM-L6-v2")
encoded_input = tokenizer("Today is a sunny day", return_tensors="pt")
model_output = model(**encoded_input)

到目前为止,我们所做的与之前所做的非常相似,只是我们使用了不同的模型。下一步是进行池化。虽然之前我们做了 [CLS] 池化,但sentence transformers通常使用均值或最大池化。试一下!

token_embeddings = model_output["last_hidden_state"]
token_embeddings.shape
torch.Size([1, 7, 384])

注意,使用此模型,每个嵌入都更小(384 个值而不是 768 个值)。我们现在可以计算嵌入的平均值以获得句子嵌入。

mean_embedding = torch.mean(token_embeddings, dim=1)
mean_embedding.shape
torch.Size([1, 384])

最后一步是执行标准化。归一化确保嵌入向量具有单位长度,这意味着其长度(或模)为 1。

什么是归一化?

要理解为什么我们要进行归一化,重新审视一些向量数学会很有帮助。对于具有分量 (v1, v2, …, vn) 的向量 v,其长度定义为

----这里有公式

对向量进行归一化时,我们会缩放值以使向量长度为​​1。这是通过将每个向量元素除以向量的大小来完成的。

----这里有公式

当我们想要比较向量时,这特别有用。例如,如果我们想计算两个向量之间的余弦相似度,我们通常会比较它们的方向而不是它们的大小。对向量进行归一化可确保每个向量对相似性的贡献相同。我们很快就会详细讨论嵌入比较!我们来尝试一下吧!

笔记

实际上,我们使用余弦相似度来计算嵌入之间的相似度。正如我们稍后将在博客文章中看到的,在计算余弦相似度时,嵌入的大小并不重要,但如果我们想尝试其他方法来测量距离,将它们归一化仍然是一个好主意。

import torch.nn.functional as F

normalized_embedding = F.normalize(mean_embedding)
normalized_embedding.shape
torch.Size([1, 384])

让我们将其包装在一个函数中!

def mean_pooling(model_output):
    return torch.mean(model_output["last_hidden_state"], dim=1)


def get_sentence_embedding(text):
    encoded_input = tokenizer(text, return_tensors="pt")
    with torch.no_grad():
        model_output = model(**encoded_input)
    sentence_embeddings = mean_pooling(model_output)
    return F.normalize(sentence_embeddings)


get_sentence_embedding("Today is a sunny day")[0][:5]
tensor([-0.0926,  0.5913,  0.5535,  0.4214,  0.2129])

在实践中,您可能会对批量句子进行编码,因此我们需要进行一些更改

  • 修改tokenization,以便我们应用 truncation (如果句子长于最大长度则剪切句子)和 padding (将 [PAD] 标记添加到句子末尾) 。
  • 修改池化,以便我们考虑注意力掩码。注意力掩码是一个由 0 和 1 组成的向量,指示哪些标记是真实的,哪些是填充的。我们希望在计算平均值时忽略填充标记!
def mean_pooling(model_output, attention_mask):
    token_embeddings = model_output["last_hidden_state"]
    input_mask_expanded = (
        attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float()
    )
    return torch.sum(token_embeddings, 1) / torch.clamp(
        input_mask_expanded.sum(1), min=1e-9
    )


# This now receives a list of sentences
def get_sentence_embedding(sentences):
    encoded_input = tokenizer(
        sentences, padding=True, truncation=True, return_tensors="pt"
    )
    with torch.no_grad():
        model_output = model(**encoded_input)
    sentence_embeddings = mean_pooling(model_output, encoded_input["attention_mask"])
    return F.normalize(sentence_embeddings)
query_embedding = get_sentence_embedding("Today is a sunny day")[0]
query_embedding[:5]
tensor([-0.0163,  0.1041,  0.0974,  0.0742,  0.0375])

我们得到了相同的结果,太棒了!现在让我们重复之前的搜索示例。

embeddings = [get_sentence_embedding(sentence) for sentence in sentences]
for embedding, sentence in zip(embeddings, sentences):
    similarity = util.pytorch_cos_sim(query_embedding, embedding)
    print(similarity, sentence)
tensor([[0.7344]]) The weather today is beautiful
tensor([[0.4180]]) It's raining!
tensor([[0.1060]]) Dogs are awesome

漂亮!与普通的 BERT [CLS] 池化嵌入相比,sentence transformer 嵌入更有意义,并且在不相关向量之间具有更大的差异!

何时使用每种池化策略?这取决于任务。

  • 当transformer模型已针对特定下游任务进行微调时,通常会使用 [CLS] 池化,这使得[CLS] token 非常有用。
  • 均值池化通常对于尚未在下游任务上进行微调的模型更有效。它确保句子的所有部分在嵌入中均等地表示,并且适用于应捕获所有标记的影响的长句子。
  • 最大池化对于捕获句子中最重要的特征很有用。如果特定关键字信息量很大,那么这可能非常有用,但它可能会错过更微妙的上下文。

在实践中,池化方法将与模型一起存储,您不必担心它。如果没有指定方法,均值池化通常是一个很好的默认值。

Using the sentence-transformers library

这相对容易,但是 sentence-transformers 库使我们更容易完成所有这一切!这里的代码与 TL;DR 部分中的代码相同。

from sentence_transformers import SentenceTransformer

# We load the model
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")

query_embedding = model.encode("Today is a sunny day")
embeddings = model.encode(sentences)

for embedding, sentence in zip(embeddings, sentences):
    similarity = util.pytorch_cos_sim(query_embedding, embedding)
    print(similarity, sentence)
tensor([[0.7344]]) The weather today is beautiful
tensor([[0.4180]]) It's raining!
tensor([[0.1060]]) Dogs are awesome

这可是相当的给力啊! 如果您必须在不使用机器学习的情况下实现识别重复问题的功能,那么您可能需要实现一个词法搜索系统(查看输入问题的精确匹配)、一个模糊搜索系统(查看输入问题的近似匹配)问题),或统计搜索系统(查看输入问题中单词的频率)。

通过嵌入,我们可以轻松找到 相似的问题,而无需实现任何这些系统并获得出色的结果!

下图是一个很好的示例,说明如何使用嵌入来查找可以回答用户问题的代码。

Image of code search

Embedding dimensions 嵌入尺寸

如您之前所见,我们使用的模型 all-MiniLM-L6-v2 生成 384 个值的句子嵌入。这是模型的超参数,可以更改。嵌入大小越大,嵌入可以捕获的信息越多。然而,较大的嵌入的计算和存储成本更高。

流行的开源模型的嵌入从 384 到 1024。截至撰写本文时,当前最好的模型的嵌入维度为 4096 个值,但与其他模型相比,该模型要大得多(70 亿个参数)。在闭源世界中,Cohere 拥有从 384 到 4096 维的 API,OpenAI 拥有 1536 的嵌入,等等。嵌入维度是一种权衡。如果您使用非常大的嵌入,您可能会获得更好的结果,但您也必须为托管和推理支付更多费用。如果您使用矢量数据库,您还需要支付更多的存储费用。

Sequence length 序列长度

transformer模型的局限性之一是它们具有最大序列长度。这意味着他们只能处理一定数量的令牌。例如,BERT 的最大上下文长度为 512 个token。这意味着,如果您想要使用超过 512 个token对句子进行编码,则必须找到解决此限制的方法。例如,您可以将句子拆分为包含 512 个token的多个句子,然后对嵌入进行平均。这并不理想,因为模型将无法捕获整个句子的上下文。

对于大多数用例来说这不是问题,但对于长文档来说可能是个问题。例如,如果要对 1000 个单词的文档进行编码,则必须将其拆分为多个包含 512 个token的句子。这并不理想,因为模型将无法捕获整个文档的上下文。另一种方法可以是首先生成文本摘要,然后对摘要进行编码。如果您想要对长文档进行编码,但需要一个可能太慢的良好摘要模型,这是一个很好的方法。或者,您可能知道文档的特定部分是否良好(例如摘要、介绍、结论等),并且仅在该部分对您的任务最有意义的情况下才对该部分进行编码。

应用1.查找最相似的Quora重复项

我们将使用开源 Quora 数据集,其中包含来自 Quora 的 400,000 对问题。我们不会训练模型(还没有!),而只是使用嵌入来查找给定新问题的类似问题。让我们开始吧!

我们的第一步是加载数据 - 为此,我们将使用 datasets 库。

!pip install datasets
from datasets import load_dataset

dataset = load_dataset("quora")["train"]
dataset
Dataset({
    features: ['questions', 'is_duplicate'],
    num_rows: 404290
})

要快速查看 Dataset 对象中的数据,我们可以将其转换为 Pandas DataFrame 并查看第一行。

dataset.to_pandas().head()
questions is_duplicate 0 {'id': [1, 2], 'text': ['What is the step by s... False 1 {'id': [3, 4], 'text': ['What is the story of ... False 2 {'id': [5, 6], 'text': ['How can I increase th... False 3 {'id': [7, 8], 'text': ['Why am I mentally ver... False 4 {'id': [9, 10], 'text': ['Which one dissolve i... False

好的,所以每个样本都是一个字典。我们不关心这里的 is_duplicate 列。我们的目标是找出该数据集中是否有任何问题与新问题相似。让我们处理数据集,这样我们就只有问题列表了。

corpus_questions = []
for d in dataset:
    corpus_questions.append(d["questions"]["text"][0])
    corpus_questions.append(d["questions"]["text"][1])
corpus_questions = list(set(corpus_questions))  # Remove duplicates
len(corpus_questions)
537362

下一步是嵌入所有问题。为此,我们将使用 sentence-transformers 库。我们将使用 quora-distilbert-multilingual model,该模型针对 100 种语言进行了训练,并且专门针对 Quora 风格的问题进行了训练。这是一个较大的模型,因此会稍微慢一些。它还将生成 768 个值的更大嵌入。

为了快速获得结果,而不必等待模型处理所有问题 5 分钟,我们将仅处理前 100000 个问题。在实践中,您可以在实验时处理所有问题或随机排列问题并处理其中的随机子集。

model = SentenceTransformer("quora-distilbert-multilingual")
questions_to_embed = 100000
corpus_embeddings = model.encode(
    corpus_questions[:questions_to_embed],
    show_progress_bar=True,
    convert_to_tensor=True,
)
corpus_embeddings.shape
torch.Size([100000, 768])

我们刚刚在 20 秒内获得了 100,000 个嵌入,即使这个 Sentence Transformer 模型并不小,而且我在我的 GPU 较差的计算机上运行它。与自回归且通常慢得多的生成模型不同,基于 BERT 的模型速度非常快!

现在让我们编写一个函数来搜索语料库中最相似的问题。

import time


def search(query):
    start_time = time.time()
    query_embedding = model.encode(query, convert_to_tensor=True)
    results = util.semantic_search(query_embedding, corpus_embeddings)
    end_time = time.time()

    print("Results (after {:.3f} seconds):".format(end_time - start_time))
    # We look at top 5 results
    for result in results[0][:5]:
        print(
            "{:.3f}\t{}".format(result["score"], corpus_questions[result["corpus_id"]])
        )
search("How can I learn Python online?")
Results (after 0.612 seconds):
0.982   What is the best online resource to learn Python?
0.980   Where I should learn Python?
0.980   What's the best way to learn Python?
0.980   How do I learn Python in easy way?
0.979   How do I learn Python systematically?

让我们尝试一下西班牙语吧!

search("Como puedo aprender Python online?")
Results (after 0.016 seconds):
0.980   What are the best websites to learn Python?
0.980   How can I start learning the developing of websites using Python?
0.979   How do I learn Python in easy way?
0.976   How can I learn Python faster and effectively?
0.976   How can I learn advanced Python?

看起来效果很好!请注意,尽管我们的模型可以处理其他语言的查询(例如上面示例中的西班牙语),但嵌入是针对英语问题生成的。这意味着该模型将无法找到其他语言的类似问题。

嵌入之间的距离

Cosine similarity 余弦相似度

到目前为止,我们一直在计算嵌入之间的余弦相似度。这是一个介于 0 和 1 之间的数字,表示两个嵌入的相似程度。值 1 表示嵌入相同,而 0 表示嵌入完全不同。到目前为止,我们已经将它用作黑匣子,所以让我们进一步研究一下它。

余弦相似度使我们能够比较两个向量的相似程度,忽略它们的大小。例如,如果我们有两个向量,[1,2,3]和[2,4,6],它们在方向上非常相似,但它们的大小不同。余弦相似度会接近1,表明它们非常相似。

a = torch.FloatTensor([1, 2, 3])
b = torch.FloatTensor([2, 3, 4])
util.cos_sim(a, b)
tensor([[0.9926]])

让我们绘制两个向量。正如您所看到的,它们在方向上非常相似,但大小不同。

a
tensor([1., 2., 3.])
import matplotlib.pyplot as plt
import numpy as np

V = np.array([a.tolist(), b.tolist()])
origin = np.array([[0, 0], [0, 0]])  # origin point

plt.quiver(*origin, V[:, 0], V[:, 1], color=["r", "b", "g"], scale=10)
plt.show()

让我们深入研究它的数学。余弦相似度定义为向量的点积除以其模的乘积: 在这里插入图片描述

我们已经在博客文章的开头讨论了模。我们需要计算向量分量平方和的平方根

在这里插入图片描述

我们还需要计算向量的点积。点积定义为相应向量分量的乘积之和 在这里插入图片描述

在这种情况下,A 和 B 的点积如下所示 在这里插入图片描述

最后,我们可以通过以下方式计算余弦相似度 在这里插入图片描述

这与我们上面的结果相符。

笔记 你能想到余弦相似度为 1的两个向量吗?考虑具有相同方向但不同大小的向量。

Dot product 点积

余弦相似度不考虑模,但可能存在 大小 有意义的用例。在这些情况下,点积是更好的衡量标准。这意味着,由于其大小,具有相似内容的较长或更详细的句子可能比具有相似内容的较短句子具有更高的相似性得分。

点积定义为相应向量分量的乘积之和(这就是我们之前所做的!) 在这里插入图片描述

如果你看一下余弦相似度公式,如果你假设向量被归一化(即它们的大小为 1),那么余弦相似度就相当于点积。这意味着余弦相似度是归一化点积。

让我们创建一个新向量,[4,6,8]。该向量与 [2, 3, 4] 方向相同,但长度是 [2, 3, 4] 的两倍。让我们计算 [1, 2, 3] 与 [2, 3, 4] 和 [4, 6, 8] 的点积。

c = torch.FloatTensor([4, 6, 8])

print(f"Cosine Similarity between a and b: {util.cos_sim(a, b)}")
print(f"Cosine Similarity between a and c: {util.cos_sim(a, c)}")

print(f"Dot product between a and b: {torch.dot(a, b)}")
print(f"Dot product between a and c: {torch.dot(a, c)}")
Cosine Similarity between a and b: tensor([[0.9926]])
Cosine Similarity between a and c: tensor([[0.9926]])
Dot product between a and b: 20.0
Dot product between a and c: 40.0

这是有合理的!由于b和c具有相同的角度,因此a和b以及a和c之间的余弦相似度相同。然而,a 和 c 的点积较高,因为 c 比 b 长。

V = np.array([a.tolist(), b.tolist(), c.tolist()])
origin = np.array([[0, 0, 0], [0, 0, 0]])  # origin point

plt.quiver(*origin, V[:, 0], V[:, 1], color=["r", "b", "g"], scale=20)
plt.show()

Euclidean Distance 欧氏距离

欧几里得距离是通过测量 两个向量之间的直线 来计算两个向量之间的距离。正如点积一样,欧几里得距离也考虑了大小。我不会过多地解释这两个指标,但主要思想是点积测量一个向量向另一个向量的方向延伸的程度,而欧几里得距离测量两个向量之间的直线距离。它被定义为向量分量之间的平方差之和的平方根。它的定义为

在这里插入图片描述

在实践中,您可以使用平方欧几里得(L2-Squared) 在这里插入图片描述

选择评分函数

我们刚刚了解了点积、余弦相似度和欧氏距离。什么时候用哪个?

这取决于model!某些模型将以产生归一化嵌入的方式进行训练。在这种情况下,点积、余弦相似度和欧氏距离都将产生相同的结果。

其他模型没有以产生归一化嵌入的方式进行训练 - 它们针对点积进行了调整。在这种情况下,点积将是在向量空间中查找最接近项的最佳函数。即使如此,如果模并不重要,我们也可以像前面几节中所做的那样进行标准化。您可以根据您的用例使用不同的距离函数。具有归一化嵌入的模型会更喜欢较短的句子,而具有非归一化嵌入的模型会更喜欢较长的句子。这是因为对于较长的句子,嵌入的模会更大

Distance function Values When to use Cosine similarity [-1, 1] When the magnitude is not important
当大小不重要时 Dot product [-inf, inf] When the magnitude is important
当模很重要时 Euclidean distance [0, inf] When the magnitude is important
当模很重要时

回顾一下:

  • Cosine similarity 余弦相似度关注向量之间的角度。它是归一化的点积。
  • Dot product 点积关注大小和角度。
  • Euclidean distance 欧几里德距离测量向量之间的空间距离。

还有其他距离函数,例如曼哈顿距离,但这些都是常见的函数,对我们的用例很有用!

如何扩展

到目前为止,我们只处理了几句话。在实践中,您可能需要处理数百万个嵌入,并且我们不能总是计算到所有嵌入的距离(这称为暴力搜索)。

一种方法是使用近似最近邻算法 approximate nearest neighbor algorithm。这些算法将数据划分为具有相似嵌入的桶。这使我们能够快速找到最近的嵌入,而无需计算到所有嵌入的距离。这并不精确,因为一些具有高相似性的向量可能仍然会被遗漏。您可以使用不同的库来执行此操作,例如 Spotify 的 Annoy 和 Facebook 的 Faiss。 Pinecone 和 Weaviate 等矢量数据库也使用最近邻技术,能够在几毫秒内搜索数百万个对象。

现在,让我们看一个有趣的应用程序,其中扩展问题变得更加明显。

应用2. Paraphrase Mining

到目前为止,通过语义搜索,我们一直在寻找与查询句子最相似的句子。在paraphrase mining中,目标是在非常大的语料库中找到具有相似含义的文本。让我们使用 Quora 数据集,看看是否可以找到类似的问题。

questions_to_embed = 10
short_corpus_questions = corpus_questions[:questions_to_embed]
short_corpus_questions
['',
 'What are the Nostradamus Predictions for the 2017?',
 'Is it expensive to take music lessons?',
 'what are the differences between first world and third world countries? Are there any second world countries?',
 'How much is a 1963 2 dollar bill with a red seal worth?',
 'What is the capital of Finland?',
 'Which is the best project management app for accounting companies?',
 "What is Dire Straits' best album ever?",
 'How does Weapon Silencers work?',
 'How should we study in medical school?']
model = SentenceTransformer("quora-distilbert-multilingual")
embeddings = model.encode(short_corpus_questions, convert_to_tensor=True)

# Compute distance btween all embeddings
start_time = time.time()
distances = util.pytorch_cos_sim(embeddings, embeddings)
end_time = time.time()

print("Results (after {:.3f} seconds):".format(end_time - start_time))
distances
Results (after 0.000 seconds):
tensor([[1.0000, 0.7863, 0.6348, 0.7524, 0.7128, 0.7620, 0.6928, 0.7316, 0.6973,
         0.6602],
        [0.7863, 1.0000, 0.7001, 0.8369, 0.8229, 0.8093, 0.7694, 0.8111, 0.7849,
         0.7157],
        [0.6348, 0.7001, 1.0000, 0.6682, 0.7346, 0.7228, 0.7257, 0.7434, 0.7529,
         0.7616],
        [0.7524, 0.8369, 0.6682, 1.0000, 0.7484, 0.8042, 0.6713, 0.7560, 0.7336,
         0.6901],
        [0.7128, 0.8229, 0.7346, 0.7484, 1.0000, 0.7222, 0.7419, 0.7603, 0.8080,
         0.7145],
        [0.7620, 0.8093, 0.7228, 0.8042, 0.7222, 1.0000, 0.7327, 0.7542, 0.7349,
         0.6992],
        [0.6928, 0.7694, 0.7257, 0.6713, 0.7419, 0.7327, 1.0000, 0.7820, 0.7270,
         0.7513],
        [0.7316, 0.8111, 0.7434, 0.7560, 0.7603, 0.7542, 0.7820, 1.0000, 0.7432,
         0.7151],
        [0.6973, 0.7849, 0.7529, 0.7336, 0.8080, 0.7349, 0.7270, 0.7432, 1.0000,
         0.7243],
        [0.6602, 0.7157, 0.7616, 0.6901, 0.7145, 0.6992, 0.7513, 0.7151, 0.7243,
         1.0000]], device='cuda:0')

我们刚刚计算了 10 个嵌入与 10 个嵌入的距离。速度相当快。现在让我们尝试 1000 个查询。

def compute_embeddings_slow(questions, n=10):
    embeddings = model.encode(
        questions[:n], show_progress_bar=True, convert_to_tensor=True
    )

    # Compute distance btween all embeddings
    start_time = time.time()
    distances = util.pytorch_cos_sim(embeddings, embeddings)
    end_time = time.time()

    return distances, end_time - start_time


_, s = compute_embeddings_slow(corpus_questions, 20000)
print("Results (after {:.3f} seconds):".format(s))
Results (after 0.000 seconds):

好吧,还是很快!让我们看看其他一些值

import matplotlib.pyplot as plt

n_queries = [1, 10001, 20001, 30001]  # If I keep going my computer explodes
times = []

for n in n_queries:
    _, s = compute_embeddings_slow(corpus_questions, n)
    times.append(s)
    torch.cuda.empty_cache()  # Clear GPU cache

plt.plot(n_queries, times)
plt.xlabel("Number of queries")
plt.ylabel("Time (seconds)")
Text(0, 0.5, 'Time (seconds)')

上面的算法有一个二次的运行时间,所以如果我们不断增加查询数量,它就不会很好地扩展。对于较大的集合,我们可以使用 paraphrase mining technique,该技术更加复杂和高效。

start_time = time.time()
paraphrases = util.paraphrase_mining(
    model, corpus_questions[:100000], show_progress_bar=True
)
end_time = time.time()
len(paraphrases)
250976
paraphrases[:3]
[[0.999999463558197, 18862, 24292],
 [0.9999779462814331, 10915, 61354],
 [0.9999630451202393, 60527, 86890]]

第一个值是分数,第二个值是语料库问题的索引,第三个值是语料库问题的另一个索引。分数表明两个问题的相似程度。

好的!我们只是 1. 计算 100,000 个问题的嵌入 2. 获得最相似的句子,以及 3. 对它们进行排序

这一切都在 20 秒内完成!让我们看看相似度最高的 5 场比赛

for score, i, j in paraphrases[:5]:
    print("{:.3f}\t{} and {}".format(score, corpus_questions[i], corpus_questions[j]))
1.000   How do I  increase traffic on my site? and How do I increase traffic on my site?
1.000   who is the best rapper of all time? and Who is the best rapper of all time?
1.000   How can I become an automobile engineer? and How can I become a automobile engineer?
1.000   I made a plasma vortex at my home, but why doesn't it produce a zapping sound like at time when we see sparks and does the air nearby it ionizes? and I made a plasma vortex at my home, but why doesn't it produce a zapping sound like at time when we see sparks and does the air nearby it, ionizes?
1.000   Why was Cyrus Mistry removed as the chairman of Tata Sons? and Why was Cyrus Mistry removed as the Chairman of Tata Sons?

这个方法如何运作?语料库被分为更小的块,这使我们能够管理内存和计算使用情况。分块发生的方式有两种:

  • Query Chunk Size: 查询块大小:确定有多少句子被视为潜在的释义。这是与查询句子进行比较并用 query_chunk_size 控制的句子数量(默认为 5000)。
  • Corpus Chunk Size: 语料库块大小:确定同时比较的语料库块数。这是通过 corpus_chunk_size 控制的(默认为 100000)。

例如,使用默认参数,算法一次处理 5000 个句子,将每个句子与语料库其余部分的 100000 个句子块进行比较。该算法专注于获取顶部匹配项 - 使用 top_k ,对于查询块中的每个句子,该算法仅从语料库块中选择顶部 k 个匹配项。这意味着该算法不会找到所有匹配项,但会找到最热门的匹配项。这是一个很好的权衡,因为我们通常不需要所有的比赛,而只需要顶级的比赛。

这两个参数都使过程更加高效,因为在计算上更容易处理较小的数据子集。它还有助于使用更少的内存,因为我们不必将整个语料库加载到内存中来计算相似度。为这些参数找到正确的值是速度和准确性之间的权衡。值越大,结果越准确,但算法越慢。

笔记

您可以使用 max_pairs 来限制返回的对的数量。

这是该算法的一些伪代码:

# Initialize an empty list to store the results
results = []

for query_chunk in query_chunks:
    for corpus_chunk in corpus_chunks:
        # Compute the similarity between the query chunk and the corpus chunk
        similarity = compute_similarity(query_chunk, corpus_chunk)
        # Get the top k matches in the other chunk
        top_k_matches = similarity.top_k(top_k)
        # Add the top k matches to the results
        results.add(top_k_matches)

选择和评估模型

您应该对句子嵌入以及我们可以用它们做什么有很好的理解。今天,我们使用了两种不同的模型, all-MiniLM-L6-v2quora-distilbert-multilingual 。我们如何知道该使用哪一个?我们如何知道一个模型好不好?

第一步是知道在哪里发现句子嵌入模型。如果您使用开源的,Hugging Face Hub 允许您filter for them.。社区已分享超过 4000 个模型!虽然查看 Hugging Face 上的趋势模型是一个很好的指标(例如,我可以看到 Microsoft Multilingual 5 Large 模型,一个不错的模型),但我们需要更多信息来选择模型。

MTEB为我们提供了保障。该排行榜包含针对各种任务的多个评估数据集。让我们快速看看选择模型时我们感兴趣的一些标准。

  • Sequence length. 序列长度。如前所述,您可能需要根据预期的用户输入对更长的序列进行编码。例如,如果您要对长文档进行编码,则可能需要使用具有更大序列长度的模型。另一种选择是将文档拆分为多个句子并单独对每个句子进行编码。
  • Language. 语言。排行榜主要包含英语或多语言模型,但您也可以找到其他语言的模型,例如中文、波兰语、丹麦语、瑞典语、德语等。
  • Embedding dimension. 嵌入维度。如前所述,嵌入维度越大,嵌入可以捕获的信息越多。然而,较大的嵌入的计算和存储成本更高。
  • Average metrics across tasks. 跨任务的平均指标。排行榜包含多个任务,例如聚类、重新排名和检索。您可以查看所有任务的平均性能,以了解模型的性能。
  • Task-specific metrics. 特定于任务的指标。您还可以查看模型在特定任务中的表现。例如,如果您对聚类感兴趣,您可以查看模型在聚类任务中的表现。

了解模型的目的也很重要。有些模型将是通用模型。其他任务(例如 Specter 2,)则专注于特定任务,例如科学论文。我不会过多地讨论排行榜中的所有任务,但您可以查看 MTEB paper 论文以获取更多信息。我先简单总结一下MTEB。

MTEB tasks image from the paper

MTEB 任务 图片来自论文

MTEB 提供了跨 8 个任务的 56 个数据集的基准,包含 112 种语言。可以轻松扩展将数据集和模型添加到排行榜。总的来说,它是一个简单的工具,可以为您的用例找到合适的速度与精度权衡。

今天(2024 年 1 月 7 日)的顶级模型是一个大型模型 E5-Mistral-7B-instruct,其大小为 14.22Gb,56 个数据集的平均值为 66.63。其次最好的开源模型之一是 BGE-Large-en-v1.5,它只有 1.34Gb,平均性能为 64.23。 BGE 的基本模型更小 (0.44Gb),质量为 63.55!作为比较,text-embedding-ada-002 即使提供了 1536 维的更大嵌入,其性能也为 60.99。这是 MTEB 基准测试中的第 23 位! Cohere 提供了更好的嵌入,质量为 64.47,嵌入维度为 1024。

我建议查看 Twitter thread from 2022,其中将 OpenAI 嵌入与其他嵌入进行了比较。结果非常有趣!与较小的型号相比,其成本高出许多数量级,并且质量也大大降低。

综上所述,不要过度关注单个数字。您应该始终查看任务的具体指标以及特定的资源和速度要求

查看 MTEB 中涵盖的不同任务以更好地了解潜在的句子嵌入应用程序很有趣。

  • Bitext Mining. 此任务涉及在两组句子中找到最相似的句子,每个句子都使用不同的语言。它对于机器翻译和跨语言搜索至关重要。
  • Classification. 在此应用中,使用句子嵌入来训练逻辑回归分类器以执行文本分类任务。
  • Clustering. 在这里,k-means 模型在句子嵌入上进行训练,将相似的句子分组在一起,这在无监督学习任务中很有用。
  • Pair Classification. 此任务需要预测一对句子是否相似,例如确定它们是否重复或释义,从而有助于释义检测。
  • Re-ranking. 在这种情况下,参考文本列表会根据其与查询句子的相似性进行重新排序,从而改进搜索和推荐系统。
  • Retrieval. 该应用程序涉及嵌入查询和关联文档,以查找与给定查询最相似的文档,这在搜索相关任务中至关重要。
  • Semantic Similarity. 该任务侧重于确定一对句子之间的相似度,输出连续的相似度得分,可用于释义检测和相关任务。
  • Summarization. 这涉及通过计算一组摘要与参考(人工编写)摘要之间的相似性来对一组摘要进行评分,这在摘要评估中很重要。

展示应用程序:浏览器中的实时嵌入

我们不会亲自动手,但我想向您展示一种cool app的嵌入应用程序。 Lee Butterman 构建了一个很酷的应用程序,用户可以使用嵌入在数百万篇维基百科文章中进行搜索。这里特别好的一点是,这是离线的:嵌入存储在浏览器中,并且模型也直接在浏览器中运行 - 没有任何内容被发送到服务器! 🤯

准备数据

  • 我们首先预先计算嵌入数据库。作者使用了一个小而有效的模型,all-minilm-l6-v2
  • 数据库包含 600 万页 * 384 个维度 * 每个浮点 4 字节 = 9.2 GB。对于用户下载来说,这是相当大的。
  • 作者使用了一种称为product quantization的技术来减小数据库的大小。
  • 然后将数据导出为名为 Arrow 的格式,该格式非常紧凑!

笔记

不要太担心这里的细节。我们的主要目标是了解这个项目的高层想法;因此,如果这是您第一次听到“量化”这个词,请不要害怕!

在推理时

  • Lee 使用了 transformers.js,这是一个允许使用 JavaScript 在浏览器中运行 Transformer 模型的库。这需要有量化模型。这是一个例子
const extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
const output = await extractor('This is a simple test.', { pooling: 'mean', normalize: true });
// Tensor {
//   type: 'float32',
//   data: Float32Array [0.09094982594251633, -0.014774246141314507, ...],
//   dims: [1, 384]
// }
  • transformers.jsall-MiniLM-L6-v2模型下载到浏览器,并用于计算浏览器中的嵌入。
  • 然后使用 pq.js 计算距离。

Lee’s blog post中阅读有关该项目的更多信息。这是一个很好的示例,说明如何在浏览器中使用嵌入!

生态系统状况

围绕嵌入的生态系统非常庞大。

构建在嵌入之上:

  • 有一些很酷的工具,例如 top2vecbertopic 专为构建主题嵌入而设计。
  • keybert 是一个库,允许使用 BERT 嵌入提取类似于文档的关键字和关键短语。
  • setfit 是一个库,允许对句子转换器进行有效的几次微调,以将它们用于文本分类。

Embedding databases 向量数据库

2023 年是嵌入数据库的一年。 LangChain Integrations Section显示 65 个向量存储。从 Weaviate、Pinecone 和 Chroma 到 Redis、ElasticSearch 和 Postgres。嵌入数据库专门用于加速嵌入的相似性搜索,通常使用近似搜索算法。新一波的嵌入式数据库初创公司吸引了大量资金投入其中。与此同时,现有的经典数据库公司已经将向量索引集成到他们的产品中,例如Cassandra和MongoDB。

研究

围绕嵌入的研究也相当活跃。如果您遵循 MTEB 基准,它每隔几周就会发生变化。其中的一些参与者包括微软(E5 模型)、Cohere、BAAI(BGE)、阿里巴巴(GTE)、香港大学 NLP 小组(讲师)和 Jina 等。

总结

多么美妙的旅程啊!我们只是将句子嵌入从 0 变为 1。我们了解了它们是什么、如何计算它们、如何比较它们以及如何缩放它们。我们还看到了嵌入的一些很酷的应用,例如语义搜索和释义挖掘。我希望这篇博文能让您很好地理解什么是句子嵌入以及如何使用它们。这是该系列的第一部分。还需要学习什么?

  • 向量数据库的作用
  • 如何将嵌入用于更复杂的排名系统
  • 主题建模
  • Multimodality 多模态
  • 如何训练自己的嵌入模型
  • 关于 RAG 的所有信息

每个人都会有一个时间!现在,我建议休息一下,检查一下你的知识。不要犹豫,更改代码并使用它!如果您喜欢这篇博文,请不要犹豫留下 GitHub Star 或分享它!

知识检查

  1. 是什么让 Transformer 模型在计算嵌入方面比 GloVe 或 Word2Vec 更有用?
  2. [CLS] token在 BERT 中的作用是什么?它如何帮助计算句子嵌入?
  3. pooler_output[CLS] token 嵌入有什么区别?
  4. [CLS] 池化、最大池化和均值池化之间有什么区别?
  5. Transformer 模型的序列长度限制是什么?我们如何解决它?
  6. 我们什么时候需要归一化嵌入?
  7. 哪两个向量的余弦相似度为 -1? 0呢?
  8. 解释 paraphrase_mining 函数的不同参数。
  9. 您将如何选择最适合您的用例的模型?

资源

以下是一些有用的资源:


原文:hackerllama - Sentence Embeddings. Introduction to Sentence Embeddings

更多阅读:

Sentence Embeddings. Cross-encoders and Re-ranking句子嵌入。交叉编码器和重新排名