新手请教一个问题,如何从百万条句子中,找出相似度较高的句子?

关注者
110
被浏览
50,902

15 个回答

ACL2020 的 best-paper 里,有一篇《Don't Stop Pretraining:Adapt Language Models to Domains and Task》,文章的 5.2 小节讲到领域数据增强时,有这样一段原文及注释:

Given these constraints, we employ VAMPIRE, a lightweight bag-of-words language model. We pretrain VAMPIRE on a large deduplicated3 sample of the domain (1M sentences) to obtain embeddings of the text from both the task and domain sample. We then select k candidates of each task sentence from the domain sample, in embeddings space.
... ...
We use a flat search index with cosine similarity between embeddings with the FAISS library.

作者的应用场景,是为领域数据集中的每个句子,从一百万候选数据中,抽取最相关的 k 条,补充到训练集中。这里面涉及到两个核心环节:

  • 如何计算句子的向量表示;
  • 如何计算句子间的相似度;

这本不是困难的 NLP 问题。不过,当数据达到百万量级,关键点变成了“embed possibly millions of sentences in a reasonable time”。也就是说,对“准”的需求,不得不让位于“快”。

从“性能优先”的角度出发,作者选择了两个开源工具包来实现上述两个步骤。

首先是 VAMPIRE,论文来自 ACL2019,Variational Pretraining for Semi-supervised Text Classification,翻译过来是“半监督文本分类的变分预训练”。这一步完成的是句子向量化。我扒了一下论文,VAMPIRE 有一个变分自编码器的外壳,但本质上市一个话题模型。它在预训练过程中,舍弃了句子的序列信息,着重建模词频以及词的共现。比起 BERT 等自注意力结构的预训练模型,这样做的好处显而易见,就是速度快。VAMPIRE 是开源的,但它是基于 AllenNLP 做的接口,很遗憾我对这个框架不熟悉,折腾了好久也没有成功地导出句子向量。┓( ´∀` )┏ 不过,既然它本身是 BOW 模型的一种变体,直接用 vanilla 的 BOW,应该也问题不大。

然后是更早被提出的 FAISSBillion-scale similarity search with GPUs,这是一个可以在 GPU 上跑的向量相似度计算工具。它以 10% 的准确度牺牲,换来了 10x 的计算速度。FAISS 同样已开源,并且非常用户友好,安装只需要一行命令:

conda install faiss-gpu cudatoolkit=10.0 -c pytorch # For CUDA10

内部 C++ 实现,外面 Python 做接口。官方的 demo 中,用的是最基础的 L2 距离做相似度度量,我改了一下变成余弦相似度:

import faiss
import numpy as np
d = 128                                                # dimension
xb = np.random.random((1000, d)).astype('float32')     # 候选句子向量.
xq = np.random.random((10, d)).astype('float32')       # 搜索句子向量.
# 注意必须是 float32,不能是 float64. 因为 C++ 里用的是 float 不是 double.

faiss.normalize_L2(xb)    # 向量归一化.
faiss.normalize_L2(xq)    # 为余弦相似度做准备.

index = faiss.IndexFlat(d, faiss.METRIC_INNER_PRODUCT) # 内积.
index.add(xb)                  # add vectors to the index
k = 10                         # top-k.
D, I = gpu_index.search(xq, k) # done. D是距离,I是下标.

以上是 CPU 版本,如果放到 GPU 上,有两行额外代码:

res = faiss.StandardGpuResources()
cpu_index = faiss.IndexFlat(d, faiss.METRIC_INNER_PRODUCT)
gpu_index = faiss.index_cpu_to_gpu(res, gpu_id, cpu_index)

分召回排序两阶段进行。

第一阶段召回,用embedding做召回或用bm25做召回都行,也可以都用上做多路召回。如果用embedding做召回,计算句子的向量表示时可以直接将句子中每次词的向量进行平均池化。其他复杂的方法如bert做相似性检索的效果并不会比简单平均明显好。计算句子向量表示之前可将预训练的词向量矩阵用pca做降维,降到100维左右。在略微损失效果的情况下可以明显降低召回阶段的时间和显存占用。得到句子的向量表示后使用faiss库做相似性检索,为每个句子找出与它相似的top 100个句子id。这里句子表示不做归一化,且按L2范数进行检索效果好一些。

用embedding召回得到的句对往往语义相似,但在词级别上差别较大,容易丢失细节。

这时候再用TER或者LogTER做排序,过滤掉词法细节上差别过大的句对。TER和LogTER的计算均可以批量计算,使用GPU加速计算。

百万条数据中找相似的句对,召回排序两阶段均可在10分钟内完成计算。亲测有效。