欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

标题:最新ChatGPT GPT-4 相似匹配Embedding技术详解(附ipynb与python源码及视频讲解)——开源DataWhale发布入门ChatGPT技术新手从0到1必备使用指南手册(一)

最编程 2024-02-24 20:06:07
...

目录

  • 前言
  • 最新ChatGPT GPT-4 相似匹配Embedding技术详解
    • 1. 何为Embedding
    • 2. 相关API
      • 2.1 LMAS Embedding API
      • 2.2 ChatGPT Style
    • 3. Embedding应用
      • 3.1 QA
      • 3.2 聚类
      • 3.3 推荐
    • 相关文献
  • 参考资料
  • 其它资料下载

在这里插入图片描述

前言

如果您想提高ChatGPT中文本处理的效率和精度,那么Embedding技术就是您必须掌握的最重要利器。

在本文中,我们不仅将详细介绍Embedding的基本概念,还将通过实际代码演示如何使用相关API,其中包括LMAS Embedding API和ChatGPT API等。同时,还将深入剖析Embedding在QA应用、聚类应用和推荐应用方面的具体应用场景。

无论您是初学者还是资深人士,这篇文章都将为您提供强有力的支持,帮助您更好地掌握Embedding技术的核心要点,使您的文本处理工作得到质的飞跃。

最新ChatGPT GPT-4 相似匹配Embedding技术详解

1. 何为Embedding

  对于自然语言,因为它的输入是一段文本,在中文里就是一个一个字,或一个一个词,行业内把这个字或词叫Token。如果要使用模型,拿到一段文本的第一件事就是把它Token化,当然,可以按字、也可以按词,或按你想要的其他方式,比如每两个字一组(Bi-Gram)。举个例子:

  • 给定文本:我们相信AI可以让世界变得更美好。
  • 按字Token化:我/们/相/信/A/I/可/以/让/世/界/变/得/更/美/好/。
  • 按词Token化:我们/相信/AI/可以/让/世界/变得/更/美好/。
  • 按Bi-Gram Token化:我们/们相/相信/信A/AI/I可/可以/以让/让世/世界/界变/变得/得更/更美/美好/好。

  那自然就有一个新的问题:我们应该怎么选择Token化方式?其实每种不同的方法都有自己的优点和不足,在大模型之前,按词的方式比较常见。但在有了大模型之后,基本都是按字来了,不用再纠结这个点了。

  Token化后,第二件事就是要怎么表示这些Token,我们知道计算机只能处理数字,所以要想办法把这些Token给变成计算机「认识」的数字才行。读者不妨思考一下如果要你来做这件事会怎么做。

  其实很简单很直观,把所有字作为一个字典,序号就代表它自己。我们还是以上面的句子为例,假设词表就包含上面那些字,那么词表就可以用一个txt文件存储,内容如下:

我
们
相
信
A
I
可
以  
让  
世  
界  
变  
得  
更  
美  
好  
。 

  一行一个字,每个字作为一个Token,此时,0=我,1=们,……,以此类推。拿中文来说,这个词表可能只要几千行,即使包含各种特殊符号、生僻字,也就2万个多点,我们假设词表大小为N。

  接下来我们考虑如何用这些数字来表示一段文本。最简单的方法就是用它的ID直接串起来,这样也不是不行,但这种表示方法的特征是一维的,也就是说只能表示一个特征。这种方法不太符合实际情况,效果也不理想。所以,研究人员就想到另一种表示方法:One-Hot编码。其实,将文本变为数字表示的过程本质上就是一种编码过程。One-Hot的意思是,对每一个字都有N(词表大小)个特征,除了该字的ID位置值为1,其余都为0。我们依然用上面的例子来说明,把整个词表表示为下面的形式:

我 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
们 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0
相 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0
信 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0
……下面省略

  此时,对每一个Token(字),它的表示就变成了一个一维向量,比如「我」:[1,0,...0],这个向量的长度就是词表的大小N,它被称为「我」这个字的One-Hot表示。

  对于一段文本,我们一般会将每个Token的表示结合起来,结合方式可以采用求和或平均。这样,对于任意长度的任意文本,我们都能将其表示为固定大小的向量,非常方便进行各种矩阵或张量(三维以上的数组)计算,这对深度学习至关重要。

  举个例子,比如有这么一句话:让世界更美好。现在我们使用刚刚的方法将其表示为一个向量,采用平均的方式。

  首先,列出每个字的向量:

让 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0
世 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0
界 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0
更 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0
美 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0
好 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0
。 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1

  然后针对每一列取平均,结果为:0 0 0 0 0 0 0 1/7 1/7 1/7 0 0 1/7 1/7 1/7 1/7。不难发现,对任两句话,只要其中包含的字不完全一样,最终得到的向量表示也不会完全一样。注①

  当然,在实际使用时,往往不会这么简单的用1/0来表示,因为每个字在句子中的作用是不一样的,所以一般会给不同的Token赋予不同的权重。最常见的方法是使用在句子中出现的频率,那些高频的(但不是「的」「更」这样的虚词)被认为是重要的。更多可以参考【相关文献1】。

  这种方法不错,在深度学习之前很长一段时间里都是这样的,不过它有两个很大的问题:

  1. 数据维度太高:太高的维度会导致向量在空间中聚集在一个非常狭窄的角落,模型难以训练。
  2. 数据稀疏,向量之间缺乏语义上的交互(语义鸿沟):比如「我爱吃苹果」和「我爱用苹果」,前者是水果,后者是手机,怎么判断出来的呢?根据上下文。但由于现在这种表示方式,导致上下文之间是孤立的,所以模型学不到这个知识点。还有类似「我喜欢你」和「你喜欢我」这样会得到同样的表示,但其实是不同的意思。注①

更多可以参考【相关文献 1】。

  终于轮到我们的主角Embedding登场了,它的主要思想是这样的:

  • 把特征固定在某一个维度D,比如256、300、768等等,这个不重要,总之不再是词表那么大的数字。这就避免了维度过高的问题。
  • 利用自然语言文本的上下文关系学习一个稠密表示。也就是说,每个Token的表示不再是预先算好的了,而是在过程中学习到的,元素也不再是很多个0,而是每个位置都有一个小数,这D个小数构成了一个Token表示。至于D个特征到底是什么,不知道,也不重要。我们只需要知道这D个小数就表示这个Token。

  还是继续以前面的例子来说明,这时候词表的表示变成下面这样了:

我 0.xxx0, 0.yyy0, 0.zzz0, … D个小数
们 0.xxx1, 0.yyy1, 0.zzz1, … D个小数
相 0.xxx2, 0.yyy2, 0.zzz2, … D个小数
信 0.xxx3, 0.yyy3, 0.zzz3, … D个小数
……下面省略

  这些小数怎么来的?简单,随机来的。就像下面这样:

import numpy as np
rng = np.random.default_rng(42)
# 词表大小N=16,维度D=256
table = rng.uniform(size=(16, 256))
table.shape
(16, 256)
table
array([[0.77395605, 0.43887844, 0.85859792, ..., 0.24783956, 0.23666236,
        0.74601428],
       [0.81656876, 0.10527808, 0.06655886, ..., 0.11585672, 0.07205915,
        0.84199321],
       [0.05556792, 0.28061144, 0.33413004, ..., 0.00925978, 0.18832197,
        0.03128351],
       ...,
       [0.50647331, 0.22303613, 0.94414565, ..., 0.79202324, 0.40169878,
        0.72247782],
       [0.9151384 , 0.80071297, 0.39044651, ..., 0.03994193, 0.79502741,
        0.28297954],
       [0.68255979, 0.64272531, 0.65262805, ..., 0.18645529, 0.21927175,
        0.32320729]])

  在模型训练过程中,会根据不同的上下文不断地更新这个参数,最后模型训练完后得到的这个矩阵就是Token的表示。我们完全可以把它当成一个黑盒子,输入一个X,根据标签Y不断更新参数,最终就得到一组参数,这些参数的名字就叫「模型」。

  这种表示方法在深度学习早期(2013-2015年左右)比较流行,不过由于这个矩阵训练好后就固定不变了,这在有些时候就不合适。比如「你好坏」这句话在不同的情况下可能完全是不同的意思。

  我们知道,句子才是语义的最小单位,因此相比Token,我们其实更加关注和需要句子的表示,我们期望可以根据不同上下文动态地获得句子表示。这中间当然经历了比较多的探索,一直到如今的大模型时代,对模型输入任意一句话,它都能给我们返回一个非常不错的表示,而且依然是固定长度的向量。

  如果对这方面感兴趣,可以进一步阅读【相关文献2】和【相关文献3】。

  我们总结一下,Embedding本质就是一组稠密向量,用来表示一段文本(可以是字、词、句、段等),获取到这个表示后,我们就可以进一步做一些任务。大家不妨先思考一下,当给定任意句子并获得到它的固定长度的语义表示时,我们可以干什么?我们在下一节将先介绍一下OpenAI提供的接口,以及一些后面任务可能用到的概念。

2. 相关API

2.1 LMAS Embedding API

import os
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
import openai
# OPENAI_API_KEY = "填入专属的API key"
openai.api_key = OPENAI_API_KEY
text = "我喜欢你"
model = "text-embedding-ada-002"
emb_req = openai.Embedding.create(input=[text], model=model)
emb = emb_req.data[0].embedding
len(emb), type(emb)
(1536, list)

  与Embedding息息相关的一个概念是「相似度」,准确来说是「语义相似度」。在自然语言处理领域,我们一般使用cosine相似度作为语义相似度的度量,评估两个向量在语义空间上的分布情况。

  具体来说就是下面这个式子:

cosine ( v , w ) = v ⋅ w ∣ v ∣ ∣ w ∣ = ∑ i = 1 N v i w i ∑ i = 1 N v i 2 ∑ i = 1 N w i 2 \text{cosine}(v,w) = \frac {v·w}{|v||w|} = \frac {\displaystyle \sum_{i=1}^N v_iw_i} {\displaystyle \sqrt{\sum_{i=1}^N v_i^2} \sqrt{\sum_{i=1}^N w_i^2}} cosine(v,w)=v∣∣wvw=i=1Nvi2 i=1Nwi2 i=1Nviwi

  我们举个例子:

import numpy as np
a = [0.1, 0.2, 0.3]
b = [0.2, 0.3, 0.4]
cosine_ab = (0.1*0.2+0.2*0.3+0.3*0.4)/(np.sqrt(0.1**2+0.2**2+0.3**2) * np.sqrt(0.2**2+0.3**2+0.4**2))
cosine_ab
0.9925833339709301

  OpenAI官方提供了一个集成接口,使用起来更加简单(但其实你也可以自己写一个):

from openai.embeddings_utils import get_embedding, cosine_similarity
# 注意它默认的模型是text-similarity-davinci-001,我们也可以换成text-embedding-ada-002
text1 = "我喜欢你"
text2 = "我钟意你"
text3 = "我不喜欢你"
emb1 = get_embedding(text1)
emb2 = get_embedding(text2)
emb3 = get_embedding(text3)
len(emb1), type(emb1)
(12288, list)
cosine_similarity(emb1, emb2)
0.9246855139297101
cosine_similarity(emb1, emb3)
0.8578009661644189
cosine_similarity(emb2, emb3)
0.8205299527695261
text1 = "我喜欢你"
text2 = "我钟意你"
text3 = "我不喜欢你"
emb1 = get_embedding(text1, "text-embedding-ada-002")
emb2 = get_embedding(text2, "text-embedding-ada-002")
emb3 = get_embedding(text3, "text-embedding-ada-002")
cosine_similarity(emb1, emb2)
0.8931105629213952
cosine_similarity(emb1, emb3)
0.9262074073566393
cosine_similarity(emb2, emb3)
0.845821877417193

  text-embedding-ada-002模型在这个例子上表现不太令人满意。更多模型可以在这里查看:New and improved embedding model

2.2 ChatGPT Style

  接下来我们用万能的ChatGPT尝试一下,注意它不会给你返回Embedding,它是尝试直接告诉你答案!

content = "请告诉我下面三句话的相似程度:\n1. 我喜欢你。\n2. 我钟意你。\n3.我不喜欢你。\n"
response = openai.ChatCompletion.create(
    model="gpt-3.5-turbo", 
    messages=[{"role": "user", "content": content}]
)

response.get("choices")[0].get("message").get("content")
'\n\n1和2相似,都表达了对某人的好感或喜欢之情。而3则与前两句截然相反,表示对某人的反感或不喜欢。'

牛逼,不过这个格式不太好,我们调整一下:

content += '第一句话用a表示,第二句话用b表示,第三句话用c表示,请以json格式输出两两相似度,类似下面这样:\n{"ab": a和b的相似度}'
response = openai.ChatCompletion.create(
    model="gpt-3.5-turbo", 
    messages=[{"role": "user", "content": content}]
)

response.get("choices")[0].get("message").get("content")
'\n\n{"ab": 0.8, "ac": -1, "bc": 0.7}\n\n解释:a和b的相似度为0.8,因为两句话表达了相同的情感;a和c的相似度为-1,因为两句话表达了相反的情感;b和c的相似度为0.7,因为两句话都是表达情感,但一个是积极情感,一个是消极情感,相似度略低。'

牛逼++!

3. Embedding应用

  有读者可能会疑惑,既然有了这么牛逼Plus的ChatGPT了,为什么还要介绍Embedding这种看起来好像是「低级货」的技术呢?这里主要有两个原因:

  1. 有些问题使用Embedding解决(或其他非ChatGPT的方式)会更加合理。通俗来说就是「杀鸡焉用牛刀」。
  2. ChatGPT性能方面不是特别友好,毕竟是一个Token一个Token吐出来的。

  关于第一点,我们要额外多说几句。选择技术方案就跟选对象一样,合适最重要。只要你的问题(需求)没变,能解决的技术就是好技术。比如你的任务就是一个二分类,明明一个很简单的模型就能解决,就没必要非得上个很复杂的。除非ChatGPT这样的LLM已经大众到一定阶段(任何人都能够非常流畅、*地使用),我们从统一性角度考虑。

  言归正传,使用Embedding的应用大多跟语义相关,我们这里介绍与此相关的几个经典任务以及衍生的应用。

3.1 QA

  QA是问答的意思,Q表示Question,A表示Answer,QA是NLP非常基础和常用的任务。简单来说,就是当用户提出一个问题时,我们能从已有的问题库中找到一个最相似的,并把它的答案返回给用户。这里有两个关键点:

  1. 事先需要有一个QA库。
  2. 用户提问时,系统要能够在QA库中找到一个最相似的。

  ChatGPT(或生成方式)做这类任务相对有点麻烦,尤其是当:

  • QA库非常庞大时
  • 给用户的答案是固定的,不允许*发挥时

  生成方式做起来是事倍功半。但是Embedding确实天然的非常适合,因为该任务的核心就是在一堆文本中找出给定文本最相似的。简单来说,其实就是个相似度计算问题。

  我们使用Kaggle提供的Quora数据集:FAQ Kaggle dataset! | Data Science and Machine Learning,先把它给读进来。

import pandas as pd
df = pd.read_csv("dataset/Kaggle related questions on Qoura - Questions.csv")
df.shape
(1166, 4)
df.head()
Questions Followers Answered Link
0 How do I start participating in Kaggle competi... 1200 1 /How-do-I-start-participating-in-Kaggle-compet...
1 Is Kaggle dead? 181 1 /Is-Kaggle-dead
2 How should a beginner get started on Kaggle? 388 1 /How-should-a-beginner-get-started-on-Kaggle
3 What are some alternatives to Kaggle? 201 1 /What-are-some-alternatives-to-Kaggle
4 What Kaggle competitions should a beginner sta... 273 1 /What-Kaggle-competitions-should-a-beginner-st...

  这里,我们就把Link当做答案构造数据对。基本的流程如下:

  • 对每个Question计算Embedding
  • 存储Embedding,同时存储每个Question对应的答案
  • 从存储的地方检索最相似的Question

  第一步我们将借助OpenAI的Embedding接口,但是后两步得看实际情况了。如果Question的数量比较少,比如只有几万条甚至几千条,那我们可以把计算好的Embedding直接存储成文件,每次服务启动时直接加载到内存或缓存里就好了。使用时,挨个计算输入问题和存储的所有问题的相似度,然后给出最相似的问题的答案。

  为了快速演示,我们只取前5个句子为例:

from openai.embeddings_utils import get_embedding, cosine_similarity
import openai
import numpy as np
OPENAI_API_KEY = "填入专属的API key"
openai.api_key = OPENAI_API_KEY
vec_base = []
for v in df.head().itertuples():
    emb = get_embedding(v.Questions)
    im = {
        "question": v.Questions,
        "embedding": emb,
        "answer": v.Link
    }
    vec_base.append(im)

  然后给定输入,比如:“is kaggle alive?”,我们先获取它的Embedding,然后逐个遍历vec_base计算相似度,并取最高的作为响应。

query = "is kaggle alive?"
q_emb = get_embedding(query)
sims = [cosine_similarity(q_emb, v["embedding"]) for v in vec_base]
sims
[0.665769204766594,
 0.8711775410642538,
 0.7489853201153621,
 0.7384357684745508,
 0.7287129153982224]

  我们返回第二个即可:

vec_base[1]["question"], vec_base[1]["answer"]
('Is Kaggle dead?', '/Is-Kaggle-dead')

  当然,在实际中,我们不建议使用循环,大家可以使用NumPy进行批量计算。

arr = np.array(
    [v["embedding"] for v in vec_base]
)
arr.shape
(5, 12288)
q_arr = np.expand_dims(q_emb, 0)
q_arr.shape
(1, 12288)
from sklearn.metrics.pairwise import cosine_similarity
cosine_similarity(arr, q_arr)
array([[0.6657692 ],
       [0.87117754],
       [0.74898532],
       [0.73843577],
       [0.72871292]])

  不过,当Question非常多,比如上百万甚至上亿时,这种方式就不合适了。一个是内存里可能放不下,另一个是算起来也很慢。这时候就必须借助一些专门用来做语义检索的工具了。

  比较常用的工具有:

  • facebookresearch/faiss: A library for efficient similarity search and clustering of dense vectors.
  • milvus-io/milvus: Vector database for scalable similarity search and AI applications.
  • Vector similarity | Redis

此处,我们以Redis为例,其他工具用法类似。

  首先,我们需要一个redis,建议使用docker直接运行:

docker run -p 6379:6379 -it redis/redis-stack:latest

执行后,docker会自动从hub把镜像拉到本地,默认是6379端口。

  然后安装redis-py,也就是Redis的Python客户端:

pip install redis

这样我们就可以用Python和Redis进行交互了。

  先来个最简单的例子:

import redis
r = redis.Redis()
r.set("key", "value")
True
r.get("key")
b'value'

  如果大家使用过ElasticSearch,接下来的内容会非常容易理解。总的来说,和刚刚的步骤差不多,但是这里我们需要先建索引,然后生成Embedding并把它存储到Redis,再进行使用(从索引中搜索)。不过由于我们使用了工具,具体步骤会略微不同。

  索引的概念和数据库中的索引有点相似,就是要定义一组Schema,告诉Redis你的字段是什么,有哪些属性。

VECTOR_DIM = 12288
INDEX_NAME = "faq"
from redis.commands.search.query import Query
from redis.commands.search.field import TextField, VectorField
# 建好要存字段的索引,针对不同属性字段,使用不同Field
question = TextField(name="question")
answer = TextField(name="answer")
embedding = VectorField(
    name="embedding", 
    algorithm="HNSW", 
    attributes={
        "TYPE": "FLOAT32",
        "DIM": VECTOR_DIM,
        "DISTANCE_METRIC": "COSINE"
    }
)
schema = (question, embedding, answer)
index = r.ft(INDEX_NAME)
try:
    info = index.info()
except:
    index.create_index(schema)

Hierarchical Navigable Small Worlds

# 如果需要删除已有文档的话,可以使用下面的命令
index.dropindex(delete_documents=True)
b'OK'

  接下来就是把数据存到Redis。

for v in df.head().itertuples():
    emb = get_embedding(v.Questions)
    # 注意,redis要存储bytes或string
    emb = np.array(emb, dtype=np.float32).tobytes()
    im = {
        "question": v.Questions,
        "embedding": emb,
        "answer": v.Link
    }
    # 重点是这句
    r.hset(name=f"{INDEX_NAME}-{v.Index}", mapping=im)

  然后我们就可以进行搜索查询了,这一步构造查询输入稍微有一点麻烦。

# 构造查询输入
query = "kaggle alive?"
embed_query = get_embedding(query)
params_dict = {"query_embedding": np.array(embed_query).astype(dtype=np.float32).tobytes()}
k = 3
# {some filter query}=>[ KNN {num|$num} @vector_field $query_vec]
base_query = f"* => [KNN {k} @embedding $query_embedding AS similarity]"
return_fields = ["question", "answer", "similarity"]
query = (
    Query(base_query)
     .return_fields(*return_fields)
     .sort_by("similarity")
     .paging(0, k)
     .dialect(2)
)

KNN(K最近邻算法),简单来说就是对未知点,分别和已有的点算距离,挑距离最近的K个点。

# 查询
res = index.search(query, params_dict)
for i,doc in enumerate(res.docs):
    score = 1 - float(doc.similarity)
    print(f"{doc.id}, {doc.question}, {doc.answer} (Score: {round(score ,3) })")
faq-1, Is Kaggle dead?, /Is-Kaggle-dead (Score: 0.831)
faq-2, How should a beginner get started on Kaggle?, /How-should-a-beginner-get-started-on-Kaggle (Score: 0.735)
faq-3, What are some alternatives to Kaggle?, /What-are-some-alternatives-to-Kaggle (Score: 0.73)

  上面,我们通过几种不同的方法为大家介绍了如何使用Embedding进行QA任务。简单回顾一下,要做QA任务首先咱们得有一个QA库,这些QA就是我们的仓库,每当一个新的问题过来时,我们就用这个问题去和咱们仓库里的每一个Q去匹配,然后找到最相似的那个,接下来就把该问题的Answer当做新问题的Answer交给用户。

  这个任务的核心就是如何找到这个最相似的,涉及两个知识点:如何表示一个Question,以及如何查找到相似的Question。对于第一点,我们用API提供的Embedding表示,我们可以把它当做一个黑盒子,输入任意长度的文本,输出一个向量。查找相似问题则主要是用到相似度算法,语义相似度一般用cos