第2章 相似匹配——万物皆可Embedding
第2章 相似匹配——万物皆可Embedding
第一章我们简单介绍了Embedding的概念,我们知道Embedding可以用来表示一个词或一句话。读者可能会有困惑,这和ChatGPT或者大模型有什么关系,为什么需要Embedding?在哪里需要Embedding?这两个问题可以简单用一句话概括:因为需要获取“相关”上下文。具体来说,NLP不少任务以及大模型的应用都需要一定的“上下文”知识,而Embedding技术就用来获取这些上下文。这一过程在NLP处理中也叫“相似匹配”——把相关内容转成Embedding表示,然后通过Embedding相似度来获取最相关内容作为上下文。
本章我们首先将进一步了解相似匹配的相关基础,尤其是如何更好地表示一段自然语言文本,以及如何衡量Embedding的相似程度。接下来我们将介绍ChatGPT相关接口使用,其他厂商提供的类似接口用法也差不多。最后,我们将介绍与Embedding相关的几个任务和应用,这里面有些可以用大模型解决,但也可以不用大模型。无论是ChatGPT还是大模型,都只是我们工具箱中的一个工具,我们将侧重任务和应用,重点介绍如何解决此类问题。也期望读者能在阅读的过程中能感受到目的和方法的区别,方法无论如何总归是为目的服务的。
2.1 相似匹配基础
2.1.1 更好的Embedding表示
1.Embedding表示回顾
首先我们简单回顾一下上一章介绍的Embedding表示。对于自然语言,因为它的输入是一段文本,在中文里就是一个一个字,或一个一个词,行业内把这个字或词叫Token。如果要使用模型,拿到一段文本的第一件事就是把它Token化,当然,可以按字、也可以按词,或按你想要的其他方式,比如每两个字(Bi-Gram)一组、每三个字(Tri-Gram)一组。我们看下面这个例子。
- 给定文本:人工智能让世界变得更美好。
- 按字Token化:人 工 智 能 让 世 界 变 得 更 美 好 。
- 按词Token化:人工智能 让 世界 变 得 更 美好 。
- 按字Bi-Gram Token化:人/工 工/智 智/能 能/让 让/世 世/界 界/变 变/得 得/更 更/美 美/好 好/。
- 按词Tri-Gram Token化:人工智能/让 让/世界 世界/变得 变得/更 更/美好 美好/。
那自然就有一个新的问题:我们应该怎么选择Token化方式?其实每种不同的方法都有自己的优点和不足,英文一般用子词表示,中文以前常见的是字或词的方式。大模型时代,中文为主的大模型基本都是以字+词的方式。如第一章所述,这种方式一方面能够更好地表示语义,另一方面对于没见过的词又可以用字的方式表示,避免了遇到不在词表中的词时导致的无法识别和处理的情况。
Token化后,第二件事就是要怎么表示这些Token,我们知道计算机只能处理数字,所以要想办法把这些Token给变成计算机能识别的数字才行。这里需要一个词表,将每个词映射成词表对应位置的序号。以上面的句子为例,假设以字为粒度,那么词表就可以用一个txt文件存储,内容如下:
人 工 智 能 让 世 界 变 得 更 美 好
一行一个字,每个字作为一个Token,此时,0=我,1=们,……,以此类推,我们假设词表大小为N。这里有一点需要注意,就是词表的顺序是无关紧要的,不过一旦确定下来训练好模型后就不能再随便调整了。这里说的调整包括调整顺序、增加词、删除词、修改词等。如果只是调整顺序或删除词,则不需要重新训练模型,但需要手动将Embedding参数也相应地调整顺序或删除对应行。如果是增改词表,则需要重新训练模型,获取增改部分的Embedding参数。接下来就是将这些序号(Token ID)表示成稠密向量(Embedding)。它的主要思想如下。
- 把特征固定在某一个维度D,比如256、300、768等等,这个不重要,总之不再是词表那么大的数字。
- 利用自然语言文本的上下文关系学习一个由D个浮点数组成的稠密表示。
接下来是Embedding的学习过程。首先,随机初始化这个数组。就像下面这样:
import numpy as np
rng = np.random.default_rng(42)
# 词表大小N=16,维度D=256
table = rng.uniform(size=(16, 256))
table.shape == (16, 256)
假设词表大小为16,维度为256,初始化后,我们就获得了一个16×256大小的二维数组,每一行浮点数就表示对应位置的Token。接下来就是通过一定算法和策略来调整(训练)这里面的数字(更新参数)。当训练结束时,最终得到的数组就是词表的Embedding表示,也就是词向量。这种表示方法在深度学习早期(2014年左右)比较流行,不过由于这个矩阵训练好后就固定不变了,这在有些时候就不合适。比如“我喜欢苹果”这句话在不同的情况下可能完全是不同的意思,因为“苹果”可以指一种水果,也可以指苹果手机。
我们知道,句子才是语义的最小单位,相比Token,我们其实更加关注和需要句子的表示。而且,如果我们能够很好地表示句子,词也可以看作是一个很短的句子,表示起来自然也不在话下。我们还期望可以根据不同上下文动态地获得句子表示。这中间经历了比较多的探索,但最终走向了模型架构上做设计——当输入任意一段文本,模型经过一定计算后就可以直接获得对应的向量表示。
2.如何更好地表示
前面的介绍我们都将模型当作黑盒,默认输入一段文本就会给出一个表示。但这中间其实也有不少细节,具体来说,就是如何给出这个表示。下面,我们介绍几种常见的方法,并探讨其中机理。
直观来看,我们可以借鉴词向量的思想,把这里的“词”换成“句”,当模型训练完后,就可以得到句子向量了。不过,稍微思考一下就会发现,其实它本质上只是粒度更大的一种Token化方式,粒度太大有的问题它更加突出。而且,这样得到的句子向量还有个问题:无法通过句子向量获取其中的词向量,而有些场景下又需要词向量。看来,此路难行。
还有一种操作起来更简单的方式,我们在第一章中也提到过:直接对词向量取平均。无论一句话或一篇文档有多少个词,找到每个词的词向量,平均就好了,得到的向量大小和词向量一样。事实上,在深度学习NLP刚开始的几年,这种方式一直是主流,也出现了不少关于如何平均的工作,比如使用加权求和,权重可以根据词性、句法结构等设定一个固定值。
2014年,也就是Google发布Word2Vec后一年,差不多是同一批人提出了一种表示文档的方法——Doc2Vec,其思想是在每句话前面增加一个额外的“段落”Token作为段落的向量表征 ,我们可以将它视为该段落的主题。训练模型可以采用和词向量类似的方式,但每次更新词向量参数时,需要额外更新这个“段落Token”向量。直观看来,就是把文档的语义都融入到这个特殊的Token向量里。不过这个方法有个很严重的问题,那就是推理时,如果遇到训练集里没有的文档,需要将这个文档的参数更新到模型里。这不仅不方便,而且效率也低。
随后,随着深度学习进一步发展,涌现出一批模型,最经典的就是TextCNN和RNN。RNN我们在第一章有过介绍,TextCNN的想法来自图像领域的卷积神经网络(convolutional neural network,CNN)。它以若干个固定大小的窗口在文本上滑动,每个窗口从头滑到尾就会得到一组浮点数特征,若干个不同大小窗口(一般取2、3、4)就会得到若干个不同的特征。将它们拼接起来就可以表示这段文本了。TextCNN的表示能力其实不错,一直以来都是作为基准模型使用,很多线上模型也用它。它的主要问题是只利用了文本的局部特征,没有考虑全局语义。RNN和它的几个变种都是时序模型,从前到后一个Token接着一个Token处理。它也有不错的表示能力,但有两个比较明显的不足:一个是比较慢,没法并行;另一个问题是文本太长时效果不好。总的来说,这一阶段词向量用的比较少了,文本的表示主要通过模型架构体现,Token化的方式以词为主。
2017年,Transformer横空出世,带来了迄今最强特征表示方式:自注意力机制。模型开始慢慢变大,从原来的十万、百万级别逐渐增加到了亿级别。文档表示方法并没有太多创新,但由于模型变大,表示效果有了明显提升。自此,NLP进入了预训练时代——基于Transformer架构训练一个大模型,做任务时都以该模型为起点,在对应数据上进行微调训练。代表性的工作是BERT和GPT,前者用了Transformer的编码器,后者用了解码器。BERT在每个文档前面添加一个[CLS]
Token用来表示整句话的语义,但与Doc2Vec不同的是,它在推理时不需要额外训练,模型会根据当前输入通过计算自动获得表示。也就是说,同样的输入,相比Doc2Vec,BERT因为其强大的表示能力,可以通过模型计算,不额外训练就能获得不错的文本表示。GPT在第一章中有相关介绍,这里不再赘述。无论是哪个预训练模型,底层其实都是对每个Token进行计算(计算时一般都会利用到其他Token信息)。所以,预训练模型一般都可以获得每个Token位置的向量表示。于是,文档表示依然可以使用那种最常见的方式——取平均。当然,由于模型架构变得复杂,取平均的方式也更加灵活多样,比如用自注意力作为权重加权平均。
3.进一步思考
ChatGPT的出现其实是语言模型的突破,并没有涉及到Embedding,但是由于模型在处理超长文本上的限制(主要是资源限制和超长距离的上下文依赖问题),Embedding成了一个重要组件。我们先不讨论大模型,依然把关注点放在Embedding上。
接下来主要是笔者本人的一些思考,期望能与读者共同探讨。如前所述,如今Embedding已经转变成了模型架构的副产物,架构变强——Token表示变强——文档表示变强。这中间第一步目前没什么问题,Token表示通过架构充分利用了各种信息,而且还可以得到不同层级的抽象。但第二步却有点单薄,要么是[CLS]
Token,要么就是变着法子的取平均。这些方法在句子上可能问题不大,因为句子一般比较短,但在段落、篇章,甚至更长文本下却不一定了。
还是以人类阅读进行类比(很多模型都是从人类获得启发,比如CNN、自注意力等)。我们在看一句话时,会重点关注其中一些关键词,整体语义可能通过这些关键词就能表达一二。看一段话时可能依然是关键词、包含关键词的关键句等。但当我们在看一篇文章时,其中的关键词和关键句可能就不那么突出了,我们可能会更加关注整体在表达什么,描述这样的表达可能并不会用到文本中的词或句。
也就是说,我们人类在处理句子和篇章的方式是不一样的。但是现在模型却把它们当成同样的东西进行处理,没有考虑这中间量变引起的质变。通俗点来说,这是几粒沙子和沙堆的区别。我们的模型设计是否可以考虑这样的不同?
最后我们简单总结一下,Embedding本质就是一组稠密向量(不用过度关注它怎么来的),用来表示一段文本(可以是字、词、句、段等)。获取到这个表示后,我们就可以进一步做一些任务。读者不妨先思考一下,当给定任意句子并获得到它的固定长度的语义表示时,我们可以干什么?
2.1.2 如何度量Embedding相似度
提起相似度,读者可能首先会想到编辑距离相似度,它可以用来衡量字面量的相似度,也就是文本本身的相似程度。但如果是语义层面,我们一般会使用余弦(cosine)相似度,它可以评估两个向量在语义空间上的分布情况,如式(2.1)所示。
其中,
我们举个例子:
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
在这个例子中,我们给定两个向量表示a
和b
,然后用式(1)计算相似度,得到它们的相似度为0.9926。
上一小节我们得到了一段文本的向量表示,这一小节我们可以计算两个向量的相似程度。换句话说,我们现在可以知道两段给定文本的相似程度,或者说给定一段文本可以从库里找到与它语义最相似的若干段文本。这个逻辑会用在很多NLP应用上,我们一般也会把这个过程叫作“语义匹配”。不过在正式介绍任务和应用之前,先来了解下ChatGPT的相关接口。
2.2 ChatGPT接口使用
这部分我们主要为读者介绍两个接口,一个是ChatGPT提供的Embedding接口,另一个是ChatGPT接口。前者可以获取给定文本的向量表示;后者可以直接完成语义匹配任务。
2.1 Embedding接口
首先是一些准备工作,主要是设置OPENAI_API_KEY
,这里建议读者通过环境变量获取,不要明文将自己的密钥写在任何代码文件里。当然,更加不要上传到开源代码仓库。
import os
import openai
# 用环境变量获取
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
# 或直接填上自己的API key,不建议正式场景下使用
OPENAI_API_KEY = "填入专属的API key"
openai.api_key = OPENAI_API_KEY
接下来,我们输入文本,指定相应模型,获取文本对应的Embedding。
text = "我喜欢你"
model = "text-embedding-ada-002"
emb_req = openai.Embedding.create(input=[text], model=model)
接口会返回输入文本的向量表示,结果如下。
emb = emb_req.data[0].embedding
len(emb) == 1536
type(emb) == list
我们看到,Embedding表示是一个列表,里面包含1536个浮点数。
OpenAI官方还提供了一个集成接口,既包括获取Embedding,也包括计算相似度,使用起来更加简单(读者也可以尝试自己写一个),如下所示。
from openai.embeddings_utils import get_embedding, cosine_similarity
text1 = "我喜欢你"
text2 = "我钟意你"
text3 = "我不喜欢你"
# 注意它默认的模型是text-similarity-davinci-001,我们也可以换成text-embedding-ada-002
emb1 = get_embedding(text1)
emb2 = get_embedding(text2)
emb3 = get_embedding(text3)
接口直接返回向量表示,结果如下。
len(emb1) == 12288
type(emb1) == list
和上面不同的是Embedding的长度变了,从1536变成了12288。这个主要是因为get_embedding
接口默认的模型和上面我们指定的模型不一样。模型不同时,Embedding的长度(维度)也可能不同。一般情况下,Embedding维度越大,表示效果越佳,但同时计算速度越慢(从调用API的角度可能感知不明显)。当然,它们的价格也可能不一样。
现在来计算一下几个文本的相似度,直观感受一下。
cosine_similarity(emb1, emb2) == 0.9246855139297101
cosine_similarity(emb1, emb3) == 0.8578009661644189
cosine_similarity(emb2, emb3) == 0.8205299527695261
前两句是一个意思,相似度高一些。第一句和第三句,第二句和第三句是相反的,所以相似度低一些。下面,我们换维度为1536的模型试一下效果,如下所示。
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")
使用方法类似,只是第二个参数我们改成了1536维的模型,结果如下。
cosine_similarity(emb1, emb2) == 0.8931105629213952
cosine_similarity(emb1, emb3) == 0.9262074073566393
cosine_similarity(emb2, emb3) == 0.845821877417193
这个结果不太令人满意。不过,我们正好可以用来探讨关于“相似度”的一个有意思的观点。为什么很多语义匹配模型都会认为“我喜欢你”和“我不喜欢你”的相似度比较高?其实,从客观角度来看,这两句话是就是相似的,它们结构一样,都是表达一种情感倾向,句式结构也相同,之所以我们觉得不相似只是因为我们只关注了一个(我们想要的)角度[4]。所以,如果想要模型的输出和我们想要的一致,就需要重新设计和训练模型。我们需要明确地告诉模型,“我喜欢你”与”我钟意你“比”我喜欢你“和”我不喜欢你“更相似。
因此,在实际使用时,我们最好能够在自己的数据集上进行测试,明确各项指标的表现。如果不满足需要,还需要考虑是否需要在自己的数据集上专门训练一个Embedding模型。同时,应综合考虑性能、价格等因素。
2.2 ChatGPT+提示词
接下来我们用万能的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")
这里,我们直接调用GPT-3.5(也就是ChatGPT)的API,返回结果如下所示。
1和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")
注意这里我们直接在原content
基础上增加格式要求,结果如下所示。
{"ab": 0.8, "ac": -1, "bc": 0.7}\n\n解释:a和b的相似度为0.8,因为两句话表达了相同的情感;a和c的相似度为-1,因为两句话表达了相反的情感;b和c的相似度为0.7,因为两句话都是表达情感,但一个是积极情感,一个是消极情感,相似度略低。
可以看到,ChatGPT输出了我们想要的格式,但后两句b
和c
的结果并不是我们想要的。不过我们看它给出的解释:“两句话都是表达情感,但一个是积极情感,一个是消极情感,相似度略低。”这点和我们上一小节讨论的关于“相似度”的观点是类似的。不过,类似ChatGPT这样的大模型接口要在自己的数据上进行训练就不那么方便了。这时候可以在提示词里先给一些类似的示例,让它知道我们想要的是语义上的相似。读者不妨自己尝试一下。
2.3 相关任务与应用
有读者可能会疑惑,既然ChatGPT已经这么强大了,为什么还要介绍Embedding这种看起来好像有点“低级”的技术呢?这个我们在本章一开头就简单提到了。这里稍微再扩充一下,其实目前来看主要有两个方面的原因。第一,有些问题使用Embedding解决(或其他非ChatGPT的方式)会更加合理。通俗来说就是“杀鸡焉用牛刀”。第二,ChatGPT性能方面不是特别高效,毕竟是一个Token一个Token吐出来的。
关于第一点,我们要额外多说几句。选择技术方案就跟找工作一样,合适最重要。只要你的问题(需求)没变,能解决的技术就是好技术。比如任务就是一个二分类,明明一个很简单的模型就能解决,就没必要非得用个很复杂的。除非ChatGPT这样的大语言模型API已经大众到一定程度——任何人都能够非常流畅、自由地使用;而且,我们就是想要简单、低门槛、快捷地实现功能。
言归正传,使用Embedding的应用大多跟语义相关,我们这里介绍与此相关的几个经典任务和相关的应用。
2.3.1 简单问答:以问题找问题
QA是问答的意思,Q表示Question,A表示Answer,QA是NLP非常基础和常用的任务。简单来说,就是当用户提出一个问题时,我们能从已有的问题库中找到一个最相似的,并把它的答案返回给用户。这里有两个关键点:第一,事先需要有一个QA库。第二,用户提问时,系统要能够在QA库中找到一个最相似的。
用ChatGPT或其他生成模型做这类任务有点麻烦,尤其是当:QA库非常庞大时;或给用户的答案是固定的、不允许自由发挥时。生成方式做起来是事倍功半。但是Embedding却天然的非常适合,因为该任务的核心就是在一堆文本中找出与给定文本最相似的文本。简单总结,QA问题其实就是相似度计算问题。
我们使用Kaggle提供的Quora数据集:all-kaggle-questions-on-qoura-dataset,数据集可以在Kaggle官网搜索下载。下载后是一个csv文件,先把它给读进来。
import pandas as pd
df = pd.read_csv("dataset/Kaggle related questions on Qoura - Questions.csv")
df.shape == (1166, 4)
数据集包括1166行,4列。
df.head()
使用df.head()
可以读取数据集的前5条,如表2-1所示。
表2-1 Quora数据集样例
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
当作答案构造QA数据对,基本的流程如下。
- 第一步:对每个问题计算Embedding。
- 第二步:存储Embedding,同时存储每个问题对应的答案。
- 第三步:从存储的地方检索最相似的问题。
第一步我们将借助OpenAI的Embedding接口,但是后两步得看实际情况了。如果问题的数量比较少,比如只有几万条甚至几千条,那我们可以把计算好的Embedding直接存储成文件,每次服务启动时直接加载到内存或缓存里就好了。使用时,挨个计算输入问题和存储的所有问题的相似度,然后给出最相似的问题的答案。演示代码如下。
from openai.embeddings_utils import get_embedding, cosine_similarity
import openai
import numpy as np
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
openai.api_key = OPENAI_API_KEY
首先依然是导入需要的工具包,并配置好OPENAI_API_KEY
。然后遍历DataFrame计算Embedding并存储,如下所示。
vec_base = []
for v in df.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]
为了方便展示,我们假设只有5条,如下所示。
sims == [
0.665769204766594,
0.8711775410642538,
0.7489853201153621,
0.7384357684745508,
0.7287129153982224
]
此时,第二个相似度最高,我们返回第二个(索引为1)文档即可。
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]
)
这里先将所有问题的Embedding构建成一个NumPy数组。
q_arr = np.expand_dims(q_emb, 0)
q_arr.shape == (1, 12288)
对给定输入的Embedding也将它变成NumPy的数组。需要注意的是,我们要给给它扩展一个维度,便于后面的计算。
from sklearn.metrics.pairwise import cosine_similarity
sims = cosine_similarity(arr, q_arr)
使用sklearn
包里的cosine_similarity
可以批量计算两个数组的相似度。这里的批量主要利用的NumPy的向量化计算,可以极大地提升效率,建议读者亲自尝试两种方案的效率差异。还是假设只有5条数据,结果如下所示。
sims == array([
[0.6657692 ],
[0.87117754],
[0.74898532],
[0.73843577],
[0.72871292]
])
不过,当问题非常多时,比如上百万甚至上亿,这种方式就不合适了。一方面是内存里可能放不下,另一方面是算起来也很慢。这时候就必须借助一些专门用来做语义检索的工具了。比较常用的工具有下面几个。
- FaceBook的faiss:高效的相似性搜索和稠密向量聚类库。
- milvus-io的milvus:可扩展的相似性搜索和面向人工智能应用的向量数据库。
- Redis:是的,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")
我们初始化了一个Redis实例,然后设置了一个key-value对,其中key就是字符串key
,value是字符串value
。现在就可以通过key获取相应的value,如下所示。
r.get("key") == b'value'
接下来的内容和刚刚放在内存的步骤差不多,但是这里我们需要先建索引,然后生成Embedding并把它存储到Redis,再进行使用(从索引中搜索)。由于我们使用了向量工具,具体步骤会略微不同。
索引的概念和数据库中的索引有点类似,需要定义一组Schema,告诉Redis每个字段是什么,有哪些属性。还是先导入需要的依赖。
from redis.commands.search.query import Query
from redis.commands.search.field import TextField, VectorField
from redis.commands.search.indexDefinition import IndexDefinition
接下来就是定义字段和Schema。
# 向量维度
VECTOR_DIM = 12288
# 索引名称
INDEX_NAME = "faq"
# 建好要存字段的索引,针对不同属性字段,使用不同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)
上面embedding
字段里的HNSW
是层级可导航小世界算法(hierarchical navigable small worlds,HNSW)。它是一种用于高效相似性搜索的算法,主要思想是将高维空间中的数据点组织成一个多层级的图结构,使得相似的数据点在图上彼此靠近。搜索时可以先通过粗略的层级找到一组候选数据点,然后逐渐细化搜索,直到找到最近似的邻居。
然后尝试创建索引,如下所示。
index = r.ft(INDEX_NAME) # ft表示full text search
try:
info = index.info()
except:
index.create_index(schema, definition=IndexDefinition(prefix=[INDEX_NAME + "-"]))
建好索引后,就可以往里面导入数据了。有时候我们可能需要删除已有的文档,可以使用下面的命令实现。
index.dropindex(delete_documents=True)
再然后就是把数据导入Redis,整体逻辑和之前类似,不同的是需要将Embedding的浮点数存为字节。
for v in df.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
}
# 重点是这句set操作
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()}
获取给定输入的Embedding和之前一样,构造参数字典就是将其转为字节。接下来是编写并构造查询,如下所示。
k = 3
base_query = f"* => [KNN {k} @embedding $query_embedding AS score]"
return_fields = ["question", "answer", "score"]
query = (
Query(base_query)
.return_fields(*return_fields)
.sort_by("score")
.paging(0, k)
.dialect(2)
)
query的语法为:{some filter query}=>[ KNN {num|$num} @vector_field $query_vec]
,包括以下几项。
{some filter query}
:字段过滤条件,可以使用多个条件。*
表示任意。[ KNN {num|$num} @vector_field $query_vec]
:K最近邻算法(K Nearest Neighbors,KNN)的主要思想是对未知点分别和已有的点算距离,挑距离最近的K个点。num
表示K。vector_field
是索引里的向量字段,这里是embedding
。query_embedding
是参数字典中表示给定输入Embedding的名称,这里是query_embedding
。AS score
:表示K最近邻算法计算结果的数据名称为score
。注意,这里的score
其实是距离,不是相似度。换句话说,score
越小,相似度越大。
paging
表示分页,参数为:offset
和num
,默认值为0和10。 dialect
表示查询语法的版本,不同版本之间会有细微差别。
此时,我们就可以通过search
API直接查询了,查询过程和结果如下。
# 查询
res = index.search(query, params_dict)
for i,doc in enumerate(res.docs):
# 注意这里相似度和分数正好相反
similarity = 1 - float(doc.score)
print(f"{doc.id}, {doc.question}, {doc.answer} (Similarity: {round(similarity, 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就是我们的仓库,每当一个新的问题过来时,我们就用这个问题去和咱们仓库里的每一个问题去匹配,然后找到最相似的那个,接下来就把该问题的答案当作新问题的答案交给用户。
这个任务的核心就是如何找到这个最相似的,涉及两个知识点:如何表示一个问题,以及如何查找到相似的问题。对于第一点,我们用API提供的Embedding表示,我们可以把它当作一个黑盒子,输入任意长度的文本,输出一个向量。查找相似问题则主是用到相似度算法,语义相似度一般用余弦距离来衡量。
当然实际中可能会更加复杂一些,比如我们可能除了使用语义匹配,还会使用字词匹配(经典的做法)。而且,一般都会找到前若干个最相似的,然后对这些结果进行排序,选出最可能的那个。不过,这个我们前面在讲“ChatGPT提示词”一节时举过例子了,现在完全可以通过ChatGPT这样的大模型API来解决,让它帮你选出最好的那个。
2.3.2 聚类任务:物以类聚也以群分
聚类的意思是把彼此相近的样本聚集在一起,本质也是在使用一种表示和相似度衡量来处理文本。比如我们有大量的未分类文本,如果事先能知道有几种类别,就可以用聚类的方法先将样本大致分一下。
这个例子我们使用Kaggle的DBPedia数据集:DBPedia Classes,数据集可以在Kaggle官网搜索下载。该数据集会对一段文本会给出三个不同层次级别的分类标签,我们用第一层的类别作为示例。和上个任务一样,依然是先读取并查看数据。
import pandas as pd
df = pd.read_csv("./dataset/DBPEDIA_val.csv")
df.shape == (36003, 4)
df.head()
数据如表2-2所示,第一列是文本,后面三列分别是三个层级的标签。
表2-2 DBPedia数据集样例
text | l1 | l2 | l3 | |
---|---|---|---|---|
0 | Li Curt is a station on the Bernina Railway li... | Place | Station | RailwayStation |
1 | Grafton State Hospital was a psychiatric hospi... | Place | Building | Hospital |
2 | The Democratic Patriotic Alliance of Kurdistan... | Agent | Organisation | PoliticalParty |
3 | Ira Rakatansky (October 3, 1919 – March 4, 201... | Agent | Person | Architect |
4 | Universitatea Reșița is a women handball club ... | Agent | SportsTeam | HandballTeam |
接下来可以查看一下类别数量,value_counts
可以统计出每个值的频次,这里我们只看第一层的标签。
df.l1.value_counts()
结果如下所示,可以看到,Agent数量最多,Device数量最少。
Agent 18647
Place 6855
Species 3210
Work 3141
Event 2854
SportsSeason 879
UnitOfWork 263
TopicalConcept 117
Device 37
Name: l1, dtype: int64
由于整个数据比较多,展示起来不太方便,我们随机采样200条。
sdf = df.sample(200)
sdf.l1.value_counts()
随机采样使用sample
接口,采样数据的分布和采样源是接近的。
Agent 102
Place 31
Work 22
Species 19
Event 12
SportsSeason 10
UnitOfWork 3
TopicalConcept 1
Name: l1, dtype: int64
为了便于观察,我们只保留3个数量差不多的类别:Place
、Work
和Species
(类型太多时,样本点会混在一块难以观察,读者不妨自己尝试一下)。
cdf = sdf[
(sdf.l1 == "Place") | (sdf.l1 == "Work") | (sdf.l1 == "Species")
]
cdf.shape == (72, 6)
我们过滤了其他标签,只保留了选定的3个,最终数据量是72条,相信观察起来会非常直观。
由于需要把文本表示成向量,所以和上小节一样,先把工具准备好。
from openai.embeddings_utils import get_embedding, cosine_similarity
import openai
import numpy as np
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
openai.api_key = OPENAI_API_KEY
我们前面提到过,这个get_embedding
API可以支持多种模型(engine
参数),它默认的是:text-similarity-davinci-001
,我们这里用另一个:text-embedding-ada-002
,它稍微快一些(维度比前面的少很多)。
cdf["embedding"] = cdf.text.apply(lambda x: get_embedding(x, engine="text-embedding-ada-002"))
接下来用主成分分析算法(principal component analysis,PCA)进行特征降维,将原来的向量从1536维降到3维,便于显示(超过3维就不好画了)。
from sklearn.decomposition import PCA
arr = np.array(cdf.embedding.to_list())
pca = PCA(n_components=3)
vis_dims = pca.fit_transform(arr)
cdf["embed_vis"] = vis_dims.tolist()
arr.shape == (72, 1536), vis_dims.shape == (72, 3)
可以看到,得到的vis_dims
只有3维,这3个维度就是最主要的特征,我们可以说这3个特征能够“大致上”代表所有的1536个特征。然后就是将所有数据可视化,也就是把三种类型的点(每个点3个维度)在三维空间中绘制出来。
%matplotlib inline
import matplotlib.pyplot as plt
import numpy as np
fig, ax = plt.subplots(subplot_kw={"projection": "3d"}, figsize=(8, 8))
cmap = plt.get_cmap("tab20")
categories = sorted(cdf.l1.unique())
# 分别绘制每个类别
for i, cat in enumerate(categories):
sub_matrix = np.array(cdf[cdf.l1 == cat]["embed_vis"].to_list())
x = sub_matrix[:, 0]
y = sub_matrix[:, 1]
z = sub_matrix[:, 2]
colors = [cmap(i/len(categories))] * len(sub_matrix)
ax.scatter(x, y, z, c=colors, label=cat)
ax.legend(bbox_to_anchor=(1.2, 1))
plt.show();
结果如图2-1所示。
图2-1 聚类示意图
可以比较明显地看出,3个不同类型的数据分别在空间的不同位置。如果我们事先不知道每个样本是哪种标签(但我们知道有3个标签),这时候还可以通过KMeans算法将数据划分为不同的族群,同族内相似度较高,不同族之间相似度较低。KMeans算法的基本思路如下。
- 随机选择K(此处为3)个点作为初始聚类中心。
- 将每个点分配到距离最近的那个中心。
- 计算每个族群的平均值,将其作为新的聚类中心。
- 重复上述步骤,直到聚类中心不再明显变化为止。
示例代码如下:
from sklearn.cluster import KMeans
kmeans = KMeans(n_clusters=3).fit(vis_dims)
然后就可以通过kmeans.labels_
得到每个数据的标签了。
聚类任务在实际中直接用作最终方案的情况不是特别多(主要是因为无监督方法精度有限),一般都会作为辅助手段对数据进行预划分,用于下一步的分析。但有一些特定场景却比较适合使用,比如异常点分析、客户分群、社交网络分析等。
2.3.3 推荐应用:一切都是Embedding
我们在很多APP或网站上都能看到推荐功能。比如购物网站,每当你登陆或者选购一件商品后,系统就会给你推荐一些相关的产品。在这一小节中,我们就来做一个类似的应用,不过我们推荐的不是商品,而是文本,比如帖子、文章、新闻等。我们以新闻为例,基本逻辑如下。
- 首先要有一个基础的文章库,可能包括标题、内容、标签等。
- 计算已有文章的Embedding并存储。
- 根据用户浏览记录,推荐和浏览记录最相似的文章。
这次我们使用Kaggle的AG News数据集。上面的逻辑看起来好像和前面的QA差不多,事实也的确如此,因为它们本质上都是相似匹配问题。只不过QA使用的是用户的问题去匹配已有QA库,而推荐是使用用户的浏览记录去匹配。实际上推荐相比QA要更复杂一些,主要包括以下几个方面。
- 刚开始用户没有记录时如何推荐(一般被业界称为冷启动问题)。
- 除了相似还有其他要考虑的因素:比如热门内容、新内容、内容多样性、随时间变化的兴趣变化等等。
- 编码问题(Embedding的输入):我们应该取标题呢,还是文章,还是简要描述或者摘要,或者考虑全部。
- 规模问题:推荐面临的量级一般会远超QA,除了横向扩展机器,是否能从流程和算法设计上提升效率。
- 用户反馈对推荐系统的影响问题:用户反感或喜欢与文章本身并没有直接关系,比如用户喜欢体育新闻但讨厌中国足球。
- 线上实时更新问题。
当然,一个完整的线上系统要考虑的因素可能更多。列出这些只是希望读者在设计一个方案时能够充分调研和考虑,同时结合实际情况进行。反过来说,可能也并不需要考虑上面的每个因素。所以我们要活学活用,实际操作时充分理解需求后再动手实施。
我们综合考虑上面的因素给读者一个比较简单的方案,但务必注意,其中每个模块的方案都不是唯一的。简要设计方案如下。
- 用户注册登录时,让其选择感兴趣的类型(如体育、音乐、时尚等),我们通过这一步将用户限定在几个类别的范围内(推荐时可以只考虑这几个类别,提升效率),同时也可以用来解决冷启动问题。
- 给用户推荐内容时,根据用户注册时选择的类别或浏览记录对应的类别确认推荐类别后,接下来应依次考虑时效性、热门程度、多样性等。
- 考虑到性能问题,可以只编码标题和摘要。
- 对大类别进一步细分,只在细分类别里进行相似度计算。
- 记录用户实时行为,如浏览、评论、收藏、点赞、转发等。
- 动态更新内容库,更新用户行为库。
现实场景中最常用的是流水线方案:召回+排序。召回就是通过各种不同属性或特征(如用户偏好、热点、行为等)先找到一批要推荐的列表。排序则是根据多样性、时效性、用户反馈、热门程度等属性对召回结果进行排序,将排在前面的优先推荐给用户。我们这里只简单展示召回。
这个例子我们使用Kaggle的AG_News数据集:AG News Classification Dataset,数据集可以在Kaggle官网搜索下载。还是老样子,先读取并查看数据。
from dataclasses import dataclass
import pandas as pd
df = pd.read_csv("./dataset/AG_News.csv")
df.shape == (120000, 3)
df.head()
数据如表2-3所示,共包含三列:类型、标题和描述。
表2-3 AG_News数据集样例
Class Index | Title | Description | |
---|---|---|---|
0 | 3 | Wall St. Bears Claw Back Into the Black (Reuters) | Reuters - Short-sellers, Wall Street's dwindli... |
1 | 3 | Carlyle Looks Toward Commercial Aerospace (Reu... | Reuters - Private investment firm Carlyle Grou... |
2 | 3 | Oil and Economy Cloud Stocks' Outlook (Reuters) | Reuters - Soaring crude prices plus worries\ab... |
3 | 3 | Iraq Halts Oil Exports from Main Southern Pipe... | Reuters - Authorities have halted oil export\f... |
4 | 3 | Oil prices soar to all-time record, posing new... | AFP - Tearaway world oil prices, toppling reco... |
用value_counts
查看类型统计。
df["Class Index"].value_counts()
一共4种类型,每个类型3万条数据。
3 30000
4 30000
2 30000
1 30000
Name: Class Index, dtype: int64
根据数据集介绍,四个类型分别是:1-World、2-Sports、3-Business、4-Sci/Tech。接下来,我们将使用上面介绍的知识来做一个简单的流水线系统。为了便于展示,依然取100条样本作为示例。
sdf = df.sample(100)
sdf["Class Index"].value_counts()
样本分布如下。
2 28
4 26
1 24
3 22
Name: Class Index, dtype: int64
首先需要维护一个用户偏好和行为记录,我们将相关的数据结构创建为dataclass
类。
from typing import List
@dataclass
class User:
user_name: str
@dataclass
class UserPrefer:
user_name: str
prefers: List[int]
@dataclass
class Item:
item_id: str
item_props: dict
@dataclass
class Action:
action_type: str
action_props: dict
@dataclass
class UserAction:
user: User
item: Item
action: Action
action_time: str
创建几条数据示例以便后面演示。
u1 = User("u1")
up1 = UserPrefer("u1", [1, 2])
i1 = Item("i1", {
"id": 1,
"catetory": "sport",
"title": "Swimming: Shibata Joins Japanese Gold Rush",
"description": "\
ATHENS (Reuters) - Ai Shibata wore down French teen-ager Laure Manaudou to win the women's 800 meters \
freestyle gold medal at the Athens Olympics Friday and provide Japan with their first female swimming \
champion in 12 years.",
"content": "content"
})
a1 = Action("浏览", {
"open_time": "2023-04-01 12:00:00",
"leave_time": "2023-04-01 14:00:00",
"type": "close",
"duration": "2hour"
})
ua1 = UserAction(u1, i1, a1, "2023-04-01 12:00:00")
接下来计算所有文本的Embedding,这一步和之前一样。
from openai.embeddings_utils import get_embedding, cosine_similarity
from sklearn.metrics.pairwise import cosine_similarity
import openai
import numpy as np
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
openai.api_key = OPENAI_API_KEY
sdf["embedding"] = sdf.apply(
lambda x: get_embedding(x.Title + x.Description, engine="text-embedding-ada-002"), axis=1)
这里简单起见,我们直接把标题Title
和描述Description
拼接。
召回模块涉及下面几种不同的召回方式。
- 根据用户行为记录召回。首先获取用户行为记录,一般在数据库中查表可获得。我们忽略数据库操作,直接固定输出。然后获取用户最感兴趣的条目,可以选近一段时间内用户浏览时间长、次数多,收藏、评论过的条目。最后根据用户感兴趣的条目进行推荐,这里和之前QA一样——根据感兴趣的条目,在同类别下找到相似度最高的条目。
- 根据用户偏好召回。这一步比较简单,我们在用户偏好的类别下随机选择一些条目,这个往往会用在冷启动上。
- 热门召回。真实场景下这个肯定有一个动态列表的,我们这里就随机选择了。
import random
class Recall:
"""
召回模块,代码比较简单,只是为了展示流程
"""
def __init__(self, df: pd.DataFrame):
self.data = df
def user_prefer_recall(self, user, n):
up = self.get_user_prefers(user)
idx = random.randrange(0, len(up.prefers))
return self.pick_by_idx(idx, n)
def hot_recall(self, n):
# 随机选择示例
df = self.data.sample(n)
return df
def user_action_recall(self, user, n):
actions = self.get_user_actions(user)
interest = self.get_most_interested_item(actions)
recoms = self.recommend_by_interest(interest, n)
return recoms
def get_most_interested_item(self, user_action):
idx = user_action.item.item_props["id"]
im = self.data.iloc[idx]
return im
def recommend_by_interest(self, interest, n):
cate_id = interest["Class Index"]
q_emb = interest["embedding"]
# 确定类别
base = self.data[self.data["Class Index"] == cate_id]
# 此处可以复用QA那一段代码,用给定embedding计算base中embedding的相似度
base_arr = np.array(
[v.embedding for v in base.itertuples()]
)
q_arr = np.expand_dims(q_emb, 0)
sims = cosine_similarity(base_arr, q_arr)
# 排除掉自己
idxes = sims.argsort(0).squeeze()[-(n+1): -1]
return base.iloc[reversed(idxes.tolist())]
def pick_by_idx(self, category, n):
df = self.data[self.data["Class Index"] == category]
return df.sample(n)
def get_user_actions(self, user):
dct = {"u1": ua1}
return dct[user.user_name]
def get_user_prefers(self, user):
dct = {"u1": up1}
return dct[user.user_name]
def run(self, user):
ur = self.user_action_recall(user, 5)
if len(ur) == 0:
ur = self.user_prefer_recall(user, 5)
hr = self.hot_recall(3)
return pd.concat([ur, hr], axis=0)
执行一下看看效果。
r = Recall(sdf) rd = r.run(u1)
我们得到8个条目,其中5个根据用户行为推荐、3个热门推荐,如表2-4所示。
表2-4 推荐结果列表
Class Index | Title | Description | |
---|---|---|---|
12120 | 2 | Olympics Wrap: Another Doping Controversy Surf... | ATHENS (Reuters) - Olympic chiefs ordered Hun... |
5905 | 2 | Saturday Night #39;s Alright for Blighty | Matthew Pinsents coxless four team, sailor Ben... |
29729 | 2 | Beijing Paralympic Games to be fabulous: IPC P... | The 13th Summer Paralympic Games in 2008 in Be... |
27215 | 2 | Dent tops Luczak to win at China Open | Taylor Dent defeated Australian qualifier Pete... |
72985 | 2 | Rusedski through in St Petersburg | Greg Rusedski eased into the second round of t... |
28344 | 3 | Delta pilots wary of retirements | Union says pilots may retire en masse to get p... |
80374 | 2 | Everett powerless in loss to Prince George | Besides the final score, there is only one sta... |
64648 | 4 | New Screening Technology Is Nigh | Machines built to find weapons hidden in cloth... |
一个简陋的推荐系统就做好了。需要再次说明的是,这只是一个大致的流程,而且只有召回。即便如此,实际场景中,上面的每个地方都需要优化。我们简单罗列一些优化点供读者参考。
- 建数据库表(上面代码中
get_
开头的方法实际上都是在查表),并处理增删改逻辑。 - 将用户、行为记录等也Embedding化。这与文本无关,但确实是真实场景中的方案。
- 对“感兴趣”模块更多的优化,考虑更多行为和反馈,召回更多不同类型条目。
- 性能和自动更新数据的考虑。
- 线上评测,A/B测试等。
可以发现,我们虽然只做了召回一步,但其中涉及到的内容已经远远不止之前QA那一点内容了,QA用到的知识在这里可能只是其中的一小部分。不过事无绝对,即便是QA任务也可能根据实际情况需要做很多优化。但总体来说,类似推荐这样比较综合的系统相对来说会更加复杂一些。
后面的排序模块需要区分不同的应用场景,可以做或不做。做也可以简单或复杂做,比如简单点就按发布时间,复杂点就综合考虑多样性、时效性、用户反馈、热门程度等多种因素。具体操作时,可以直接按相关属性排序,也可以用模型排序。限于主题和篇幅,我们就不再探讨了。
2.4 本章小结
相似匹配是整个AI算法领域非常基础和重要的任务,NLP、搜索、图像、推荐等方向都涉及到此内容,而Embedding就是其中最重要的一项技术。通俗点来说,它就是把数据表示成空间中的一个点,通过稠密向量表示复杂语义信息。Embedding后,不同类型的数据就可以彼此进行交互、融合,从而得到更好的效果。即便强大如ChatGPT这样的大模型,也依然需要Embedding技术来更好地获取上下文。随着多模态技术的不断发展,Embedding在未来可能会变得更加重要。
__EOF__
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 本地部署 DeepSeek:小白也能轻松搞定!
· 如何给本地部署的DeepSeek投喂数据,让他更懂你
· 从 Windows Forms 到微服务的经验教训
· 李飞飞的50美金比肩DeepSeek把CEO忽悠瘸了,倒霉的却是程序员
· 超详细,DeepSeek 接入PyCharm实现AI编程!(支持本地部署DeepSeek及官方Dee