语义搜索通过理解搜索查询的内容来提高搜索准确性。与传统搜索引擎不同,传统搜索引擎仅根据词法匹配查找文档,而语义搜索还可以找到同义词。

背景

语义搜索的基本思想是将语料库中的所有条目(无论是句子、段落还是文档)都嵌入到一个向量空间中。

在搜索时,查询会被嵌入到相同的向量空间中,然后从语料库中找到与查询最接近的嵌入。这些嵌入应该与查询具有高度语义重叠。

对称与非对称

对称语义搜索是指你的查询和语料库中的条目长度大致相同,并且具有相同数量的内容。一个例子是: 通过搜索类似的问题 “如何在线学习 Python?” ,你想找到一个像“如何在网络上学习 Python?”这样的条目。对于对称任务,你可能可以在语料库中翻找到查询和对应的条目。

非对称语义搜索是指你通常有一个简短的查询(例如一个问题或一些关键词),你想找到一个更长的段落来回答查询。像 “什么是 Python” 的查询,你想找到段落“Python 是一种解释型、高级且通用的编程语言。Python 的设计理念是……”。对于非对称任务,在语料库中翻找通常没有意义。

选择适合任务类型的模型非常重要。

适合对称语义搜索的模型:Pre-Trained Sentence Embedding Models

适合非对称语义搜索的模型:Pre-Trained MS MARCO Models

Python

在数据量不大的语料库中(条目数量最多大约100万),我们有能力计算出搜索词与语料库内每一个条目之间的余弦相似度。

在接下来的示例中,我们创建了一个包括几个样本句子的小型语料库,并为这个语料库以及我们的搜索词分别计算了它们的嵌入向量。

接着,我们运用 sentence_transformers.util.cos_sim() 函数来测量搜索词与语料库中所有条目之间的余弦相似性。

面对庞大的语料库,对所有评分逐一排序实在是效率太低。所以,我们采用了 torch.topk 函数来直接提取得分最高的前 k 个条目。

下面是一个简单的示例;参见 semantic_search.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
"""
This is a simple application for sentence embeddings: semantic search

We have a corpus with various sentences. Then, for a given query sentence,
we want to find the most similar sentence in this corpus.

This script outputs for various queries the top 5 most similar sentences in the corpus.
"""
from sentence_transformers import SentenceTransformer, util
import torch

embedder = SentenceTransformer('all-MiniLM-L6-v2')

# Corpus with example sentences
corpus = [
'A man is eating food.',
'A man is eating a piece of bread.',
'The girl is carrying a baby.',
'A man is riding a horse.',
'A woman is playing violin.',
'Two men pushed carts through the woods.',
'A man is riding a white horse on an enclosed ground.',
'A monkey is playing drums.',
'A cheetah is running behind its prey.'
]
corpus_embeddings = embedder.encode(corpus, convert_to_tensor=True)

# Query sentences:
queries = ['A man is eating pasta.', 'Someone in a gorilla costume is playing a set of drums.', 'A cheetah chases prey on across a field.']


# Find the closest 5 sentences of the corpus for each query sentence based on cosine similarity
top_k = min(5, len(corpus))
for query in queries:
query_embedding = embedder.encode(query, convert_to_tensor=True)

# We use cosine-similarity and torch.topk to find the highest 5 scores
cos_scores = util.cos_sim(query_embedding, corpus_embeddings)[0]
top_results = torch.topk(cos_scores, k=top_k)

print("\n\n======================\n\n")
print("Query:", query)
print("\nTop 5 most similar sentences in corpus:")

for score, idx in zip(top_results[0], top_results[1]):
print(corpus[idx], "(Score: {:.4f})".format(score))

"""
# Alternatively, we can also use util.semantic_search to perform cosine similarty + topk
hits = util.semantic_search(query_embedding, corpus_embeddings, top_k=5)
hits = hits[0] #Get the hits for the first query
for hit in hits:
print(corpus[hit['corpus_id']], "(Score: {:.4f})".format(hit['score']))
"""

速度优化

要想让 sentence_transformers.util.cos_sim() 方法运行得更快,最好的做法是将 query_embeddingscorpus_embeddings 存在同一块 GPU 设备上。这样做可以明显提升处理性能。

另外,我们还可以对语料库嵌入进行标准化处理,使每个语料库嵌入的长度都为 1。这样,我们就可以通过点积运算来计算得分了。

1
2
3
4
5
6
corpus_embeddings = corpus_embeddings.to('cuda')
corpus_embeddings = util.normalize_embeddings(corpus_embeddings)

query_embeddings = query_embeddings.to('cuda')
query_embeddings = util.normalize_embeddings(query_embeddings)
hits = util.semantic_search(query_embeddings, corpus_embeddings, score_function=util.dot_score)

ElasticSearch

从 7.3 版本开始,ElasticSearch 推出了一个新功能,即能够索引密集向量 (dense vectors),并将其用于对文档进行评分。所以,我们可以利用 ElasticSearch 对文档以及嵌入向量(embeddings)进行索引,以此在搜索时使用对应的嵌入向量寻找相关的文档信息。

ElasticSearch的一个优点是,它便于向索引中添加新的文档,而且能够和我们的向量一起存储其他数据。但缺点是它的性能较慢,这是因为它需要将搜索的嵌入内容和每一个已经存储的嵌入内容进行比较。这种操作的时间成本是线性的,对于大规模(超过 100k)的数据集来说,可能会慢得无法接受。

更多详细信息,请参见 semantic_search_quora_elasticsearch.py

近似最近邻点

如果使用精确的最近邻搜索方法(如 sentence_transformers.util.semantic_search 所采用的方式),在一个巨大的语料库中进行查找,特别是这个语料库中包含数百万个嵌入,可能会花费大量的时间。

在这种情况下,近似最近邻(Approximate Nearest Neighor,ANN)可能会很有帮助。这里,数据被划分为相似的嵌入小部分。利用索引可以有效地进行搜索,甚至在有数百万的向量时也能在毫秒内检索到最高相似性的嵌入(即最近的邻居)。

不过,结果未必都是精确的。可能有些具有高度相似性的向量被遗漏了。这就是我们称它为“近似最近邻居”的原因。

所有的人工神经网络(ANN)方法都通常需要调整一到多个参数,以达到召回率与搜索速度的权衡。如果你追求极高的搜索速度,可能会错过一些重要的搜索结果。反之,如果你期望得到高召回率,搜索的速度就可能会变慢。

近似最近邻搜索库中,AnnoyFAISShnswlib 都很热门。但是,我个人更偏向 hnswlib,因为它不仅使用起来十分简单,性能卓越,而且包含了许多在实际应用中至关重要的特色功能。

示例:

召回和重排

对于复杂的语义搜索场景,建议使用「召回和重排」流程:

当我们有一个搜索请求时,我们会首先使用一个检索系统,这个系统能够找出大约 100 个可能的结果,这些结果可能与我们的搜索请求相关。在进行检索时,我们可以选择使用词汇搜索,比如说使用 ElasticSearch 这样的工具,或者我们也可以选择使用双向编码器进行深度检索。

但是,这个检索系统可能会找到一些与搜索请求并不太相关的文档。因此,在第二步,我们会使用一个基于交叉编码器的重新排序系统,这个系统会评估所有候选结果与搜索请求的相关性。

最终,我们将得到一个排名的结果列表,这个列表可以直接呈现给用户。

召回: Bi-Encoder

在寻找候选结果集的过程中,我们可以选择使用词汇搜索(比如 ElasticSearch),或者我们也可以选择使用在这个代码库中实现的双向编码器。

词汇搜索是在你的文档库中寻找与查询词完全匹配的内容,它无法识别同义词、缩写或拼写的不同形式。而语义搜索(也被称为密集检索)则是将搜索的关键词转化为向量空间的形式,然后找出在这个向量空间中与其最接近的文档。

语义搜索弥补了词汇搜索的不足,能够识别同义词和缩写词。

重排:Cross-Encoder

检索器需要能够高效处理包含数百万条目的大型文档库。但是,有时候它可能会找出一些与查询无关的结果。

利用 Cross-Encoder 的重新排序技术,我们可以大幅提升搜索结果的质量。在这个过程中,我们会把搜索请求和可能的文档同时输入到 Transformer 网络中,然后网络会输出一个介于 0 到 1 之间的分数,这个分数代表了文档与搜索请求的匹配程度。

Cross-Encoders的优势在于它们能提供更出色的性能,这是因为它们能在处理查询和文档时运用注意力机制。

如果我们要对大量的(查询,文档)对进行评分,那将会非常耗时。所以,我们采用的策略是使用检索器先生成一组可能的候选者,比如 100 个,然后再通过 Cross-Encoder 对这些候选者进行重新排序。

完整示例

相似问题检索

semantic_search_quora_pytorch.py [Colab Version] 是一个基于Quora重复问题数据集的应用案例。通过它,用户可以输入任何问题,然后代码会运用 sentence_transformers.util.semantic_search 方法从数据集中找出与输入问题最相近的问题。模型是 distilbert-multilingual-nli-stsb-quora-ranking,它的主要任务是去识别类似的问题,并且它支持超过50种语言。所以,无论用户用这50多种语言中的何种来提问,都可以得到有效的答案。这是一个对称的搜索任务,因为搜索查询的长度和内容与语料库中的问题相同。

相似出版物检索

semantic_search_publications.py [Colab Version] 这个示例演示了如何找到与某篇科学论文相似的其他论文。我们的语料库由在 EMNLP 2016 - 2018 会议上发表的所有论文组成。在搜索过程中,我们会输入最近发表的论文的标题和摘要,然后在我们的语料库中寻找相关的论文。我们使用的是 SPECTER 模型。这个搜索任务是对称的,因为我们的语料库中的论文和我们搜索的内容都是由标题和摘要组成。

问答检索

semantic_search_wikipedia_qa.py [Colab Version]:这个例子展示了一个在 Natural Questions dataset 数据集上进行训练的模型。这个数据集包含了大约十万条真实的 Google 搜索请求,以及从维基百科获取并附带注解的段落,这些段落提供了问题的答案。这是一个非对称搜索任务的典型例子。在这个例子中,我们使用了体积较小的 Simple English Wikipedia 作为语料库,这样它就可以轻松地加载到内存中。

retrieve_rerank_simple_wikipedia.py [Colab Version ]:这个脚本采用了 召回和重排 的策略,是一个非对称搜索任务的典型例子。我们把所有维基百科的文章切分成各个段落,并用双向编码器进行编码处理。当有新的查询或问题输入时,我们也用同样的双向编码器进行编码,然后找出与之余弦相似度最高的段落。然后,我们用一个交叉编码器对找到的候选段落进行重新排序,最终将得分最高的5个段落展示给用户。我们使用的模型是在 MS Marco Passage Reranking datase 数据集上进行训练的,这个数据集包含了大约 500k 来自 Bing 搜索的真实查询。

Reference

Semantic Search