浅谈 Semantic Search
举个简单的例子。假设有以下的 Query和 Responses:
Quer y:
- Where is the world cup?
Responses:
-
The world cup is in Qatar.
-
The sky is blue.
-
The bear lives in the woods.
-
An apple is a fruit.
我们可以观察 Responses中每个结果中的词与 Query 中的词,然后找出在 Query和 Response 都出现的词的个数:
-
The world cup is in Qatar. (4 words in common)
-
The sky is blue. (2 words in common)
-
The bear lives in the woods. (2 words in common)
- An apple is a fruit. (1 word in common)
非常幸运的是,排在第一个的就是我们最想要的回答。
然而,哪有那么多幸运啊。现实情况是,用户输入的 Query总有你想不到的。比如这个:“Where in the world is my cup of coffee? ”(我的咖啡在哪里?)这种场合下,排在第一个的回答就与用户问的问题风马牛不相及了。
当然,我们可以采取一些办法来改善搜索结果。比如,可以通过删除诸如 “the”、“and”、“is” 等停止词,使用类似 TF-IDF的方法来区分相关词和非相关词等。
但,这还远远不够。为了让搜索结果更精准,还需要对 Query进行一系列的处理,比如:纠错、同义词处理、意图识别、多国语言处理等。另外,如果系统除了搜索文本,还需要支持搜索语音、图片、视频,那又该怎么呢?当我们谈及这些问题时,其实已经进入语义搜索讨论的范畴了。
好了,进入正题。
语义搜索的工作原理是怎样的呢?简而言之,可以归纳如下:
-
使用 Embedding 技术,将文档转换为向量;
-
将向量存储在向量数据库中(如Pinecone,Milvus, Weaviate, Chroma 等);
-
使用 Embedding 技术,将 Query 也转成向量;
-
使用向量检索算法(如 IVD,Annoy,LSH,HNSW 等),查找与 Query 向量相似的文档向量;
- 返回 Top N 个结果。
如何使用 Embedding
就像万物都可以做 NFT一样,万物都可以做 embedding(中文:“嵌入”)。比如:一个词,一个 Query,一篇文章,一段语音,一张图片,你在电商网站上的历史购物清单,等等。我们可以通过 OpenAI, Huggingface 等第三方提供的接口获取文本的 embedding。
那么,什么是 embedding呢?
Embedding是一种将对象(指上面说的 词、 Query、文章、语音、图片等)进行语义赋值的方法。赋值后得到一个特征向量:如 (0.221, -0.012, 0.048, 0.321, 0.561, -0.159)。例子中的向量是一个 1X6 维向量。实际应用中,特征向量的维度可以基于场景做选择,通常维度越大,向量能表达的信息越多,当然计算量也就越大。
特征向量中的每个维度对应不同的特征,有些可能是有物理意义的特征(如年龄、性别等),但更多的是人不能辨别的抽象特征。
有了特征向量,接下来我们就可以计算不同对象之间的相似性了。两个语义相近的词,其特征向量间的距离也是很靠近的。
下面,我们来举个例子。
为了强化视觉效果,我们通过降维算法(如 t-SNE,UMAP),把高维的向量压缩到 2 维空间,可以用 X、Y坐标表示。例如,假设句子 “ The world cup is in Qatar?”的向量为 ( 4, 2) , 也就是图中 X坐标是4, Y坐标是 2 的那个足球。
把所有句子对应的向量在坐标轴上标注好后,我们就肉眼可见:由 “奖杯” 表示的 Query最接近由 “足球” 表示的句子:“ The world cup is in Qatar”,而其他的句子都离 “奖杯” 比较远。距离的远近反映了相似的程度,距离越近,语义越相似。这样,“ The world cup is in Qatar.” 这个回答就会排在第一。
再来看一个复杂一点的例子。
先准备 4 个问句和对应的 4 个回答:
Queries:
Q1: Where does the bear live?Q2: Where is the world cup?
Q3: What color is the sky?
Q4: What is an apple?Responses:
R1: The bear lives in the woods.R2: The world cup is in Qatar.
R3: The sky is blue.
R4: An apple is a fruit.
这里,我使用了 Sentence-Transformers 来获取上面 8 个句子的 embedding,然后再使用 UMAP降维算法将向量降到 2 维,这样就可以在 X,Y坐标上标注它们了。
将各向量在坐标轴上标注后,如下图所示:fromsentence_transformersimportSentenceTransformer
importumap
importmatplotlib.pyplotasplt
model = SentenceTransformer('all-mpnet-base-v2')
#Our sentences we like to encode
sentences = ['Where does the bear live?',
'Where is the world cup?',
'What color is the sky?',
'What is an apple?',
'The bear lives in the woods.',
'The world cup is in Qatar',
'The sky is blue.',
'An apple is a fruit.'
]
#Sentences are encoded by calling model.encode()
embeddings = model.encode(sentences)
# Compress the embeddings to 2 dimensions
reducer = umap.UMAP(init='spectral', random_state =42, n_neighbors =49, min_dist =0.0, n_components =2)
umap_embeds = reducer.fit_transform(embeddings)
plt.figure(figsize=(10,8))
plt.scatter(umap_embeds[:,0], umap_embeds[:,1], c='green')
fori, txtinenumerate(sentences):
plt.annotate(txt, (umap_embeds[i,0], umap_embeds[i,1]))
plt.grid(color='gray', linestyle='--', linewidth=0.5)
plt.show()
结合上图,我们可以发现每个 Query最接近其对应的 Response。这意味着,如果我们使用语义搜索来查询,并且返回结果只取排名最高的 1 个,那么查询上面 4 个 Query,每一个我们都能得到正确的 Response。
怎么找到相似的文档
在上面的例子中,其实我们使用了欧几里得距离,也就是两个点之间的直线距离,来度量向量之间的相似度,距离越近就越相似。
这种距离度量简单直接,而且在低维空间(如2维,3维)也表现得很好,但是当向量维度高了之后,比如 4096 维的向量,这时再用欧几里得距离来度量相似度就不合适了。因为在高维情况下,每个向量之间的欧几里得距离都很靠近,也就无法起到度量相似性的作用了。
那么,怎么度量高维向量的相似度呢?通常有下面两种方法:
-
Dot product similarity 点积相似度:计算高维向量之间的距离,距离越小就越相似;
- Cosine similarity 余弦相似度:计算两个向量之间的夹角,夹角越小,向量之间越相似。
这两种度量方式,一定程度上可以转换,这里我们选择余弦相似度。余弦相似度的返回值在 0 到 1 之间,一段文本与其自身的相似度总是 1,而两段风马牛不相及的文本相似度是0。
接下来,我们用余弦相似度代替欧几里得距离,再来计算一下上述数据集中 8 个句子两两之间的相似度。计算结果如下图所示:
观察上图,可以得出如下结论:
-
颜色越黑表示越不相似,颜色越浅越相似;
-
对角线上全是 1:因为每个句子和它自己的相似度是1;
-
每个 Query 与其相应的 Response 之间的相似度在 0.7 左右;
- 任何其他句子之间的相似度得分都很低。
到目前为止,我们是怎么找到与一个 Query最相似的 Response的呢?
没错,目前其实就是用 “强大的” 暴力搜索!也就是说,为了找到一个已知点最近的 “邻居”,需要计算该点与数据集中所有其他点之间的距离!
比如,为了找到与 “ Where is the world cup?” 最近的点(标记为红色),我们必须计算除自己外的所有 7 个点的距离,如下图所示:
在数据集比较小的时候,比如一个总共只有几百条或几十条问答对的 QA系统,暴力搜索其实也可以工作得很好,我们没必要过早对算法进行优化。
但是,当数据集变得越来越大的时候,暴力搜索就力不从心了,如果一个用户 Query过来,系统要响应 10 多秒,甚至分钟级别,那用户早就跑了。所以,我们需要改进检索模型,让查找 “邻居” 的算法更加高效一些。
我们可以用一种近似的算法,来缩小候选集合,而不是再傻傻地去和所有的数据做比较。常用的近似最近邻检索算法,主要有:
-
IVD: 先对所有的文档进行聚类,得到聚类后,再分别用一个中心向量表示每个聚类。查询的时候,先找到离 Query 最近的中心向量,再计算 Query 和这个中心向量代表的聚类中所有点的距离;
- LSH:局部敏感 Hash。核心思想是将潜在相似的文档放在同一个哈希桶。查询的时候,先计算用户 Query 落在哪一个哈希桶,然后再计算 Query 和桶中的各个“邻居”之间的距离;
- Annoy:通过构建多个独立的二叉树,每棵二叉树包含部分的数据点。查询的时候,通过遍历多个二叉树来寻找最近的 “邻居”;
- HNSW: 这是一种快速且高效的近似最近邻搜索算法。它通过将数据点组织成多层结构,然后在每一层上构建图形连接,进而实现快速地搜索与目标点距离最近的数据点。通俗地说,HNSW 算法就像一个分层的城市导航系统,可以帮助你寻找到离目标位置附近的点。
有 Embedding 就够了吗?
说到现在,似乎一直都在说基于 embedding的语义搜索是多么棒,多么有效。
但,它就真的这么完美吗?答案当然是: No!
我们来扩展一下最初的数据集,针对 “ Where is the world cup?” 这个问题添加一些回答。然后如同之前一样,获取 embedding并进行向量压缩,最后标注在坐标轴上。如下:
Query:
- “Where is the world cup?”
Responses:
- The world cup is in Qatar.
- The world cup is in the moon.
-
The previous world cup was in Russia.
Notes:眼尖的你或许发现了,下图中句子的坐标和之前的不一样。这是因为 UMAP使用邻域图和基于梯度的优化方法来降维,当你向数据集中添加一个新的样本时,整个数据结构可能会发生变化。这意味着,在将新句子添加到数据集中并对其进行降维时,之前句子的降维表示可能会发生变化。
如上图所示, Query:“W here is the world cup ?” 的3个回答都比较靠近它。但是,距离最近的却并不是我们最想要的 “ The world cup is in Qatar.”,而是 “ The world cup is in the moon.”。虽然从 embedding的相似性上来判断,这个回答看起来和 Query最相似,但这完全是错误的。再看回答:“ The previous world cup was in Russia.”,虽然从语义上来说,这个回答是一个正确的陈述,但它也不是当前问题的最佳答案。
从这个例子可以看出,如果仅依靠 embedding 之间的相似性,诸如此类的错误或不相关的回答,是完全有可能会成为语义搜索模型中的 “最佳答案” 的。
那么,怎么解决这个问题,从而让模型返回用户最想要的结果,或者至少接近这个目标?
实践中方法有多种,列举一二:
1. 使用注意力机制:构造( query_i, response_i) 之间的上下文向量 context_vector_i,再计算 context_vector_i与 response_i之间的相似性。相似度越高的回答,最终的排名更高;
2. 使用强化学习:设计奖励函数,将相关的答案排名更高,不相关的答案排名更低。
比如:
- 如果相关的答案: “The world cup is in Qatar” 排名第一,那么可以给予正的奖励,比如 +1。这样可以鼓励模型学习将相关的答案排名更高;
- 如果不相关的答案:“The world cup is in the moon” 排名第一,可以给予负的奖励,比如 -1。这样可以鼓励模型学习将不相关的答案排名更低;
- 如果相关的答案排名第二,可以给予较小的正奖励,比如 +0.5。这仍然可以鼓励模型将相关的答案排名更高,但效果不如排名第一时那么理想;
- 如果不相关的答案排名较高;比如前三名,可以给予较小的负奖励,比如 -0.5。这仍然可以鼓励模型将不相关的答案排名更低,但效果不如排名第一时那么理想。
- 除以上规则之外,不给予任何奖励或惩罚。
本文中,我们讨论了基于 embedding的语义搜索,以及常见的距离度量和向量搜索算法。
-
Embedding 是强大的表示方法,无论是在向量之间的距离方面还是在向量算术方面;
-
文中使用的 3 个主要距离度量是欧几里得距离、向量点积以及余弦相似度;
-
近似近邻搜索算法可以有效提升查找相似文档的效率。目前最常用的是 HNSW,但也要根据实际情况选择,不过早做优化。
最后,如果你想玩一下语义搜索,除了可以使用文中提及的各种向量数据库外,也可以使用 Elasticsearch8.0 以上版本。 ES8.0 以上版本支持了 dense_vector 以及 knn_search。这样的好处就是,你可以根据业务场景觉定只使用关键词搜索,亦或是只使用语义搜索,还是两者结合使用。