LSH算法:高效相似性搜索的原理与Python实现II

image.png

 局部敏感哈希(LSH)是一种高效的近似相似性搜索技术,广泛应用于需要处理大规模数据集的场景。在当今数据驱动的世界中,高效的相似性搜索算法对于维持业务运营至关重要,它们是许多顶尖公司技术堆栈的核心。

相似性搜索面临的主要挑战在于处理庞大的数据规模。许多企业每天都要处理从百万到数十亿不等的数据点。例如,面对一亿个数据点,逐个进行比较显然是不切实际的。

更进一步,企业的需求远不止单次搜索。以谷歌为例,它每分钟处理的搜索请求超过380万次。这种高频率的搜索需求,再加上数据点的规模,构成了一个巨大的技术挑战。

此外,还没有涉及到数据的维度问题或相似性函数的复杂性。在这些因素的共同作用下,对于大型数据集进行全面的搜索变得不可行。

那么,如何在如此难以想象的大规模数据集上进行有效搜索呢?答案就是近似搜索。通过近似搜索,不必对每一对数据点进行详尽的比较。相反,采用LSH技术,它通过近似方法筛选出潜在的匹配项,从而大幅减少了需要进行详尽比较的数据点数量。

通过这种方式,LSH不仅提高了搜索效率,还保持了搜索结果的准确性,使其成为大规模数据集相似性搜索的理想解决方案。

局部敏感哈希(LSH)

局部敏感哈希(LSH)是一种广泛使用的近似最近邻搜索(ANNS)方法。它依赖于一种特殊的哈希函数,这种函数设计用来将相似的项目映射到同一个哈希桶中。面对大规模数据集,LSH通过哈希函数将项目分配到不同的桶,从而简化搜索过程。

LSH算法的一个关键特点是它与常规哈希函数不同。传统哈希函数致力于最小化哈希冲突,而LSH算法则有意增加哈希冲突的概率,目的是将相似的项目聚集在一起。

image.png

哈希函数对比:上图展示了两种哈希函数的效果。顶部(蓝色)的哈希函数致力于最小化哈希冲突,而底部(品红色)的哈希函数则是LSH使用的,它旨在最大化相似项之间的哈希冲突。

在LSH中,相似的向量倾向于产生相同的哈希值,并因此被分到同一个桶里。相对地,不相似的向量则希望不会被分到同一个桶中。

使用LSH进行搜索

LSH搜索过程包括以下三个步骤:

  1. 索引向量:首先,将所有向量通过LSH哈希函数处理,并将它们索引到对应的哈希桶中。
  2. 哈希查询向量:当引入一个查询向量时,使用相同的LSH哈希函数对其进行处理。
  3. 桶比较:然后,通过比较汉明距离来识别查询向量与哪些哈希桶中的向量最近。

这些步骤构成了LSH方法论的基础,将在后续的文章中对这些概念进行更深入的探讨和详细说明。

近似效果

在深入研究LSH技术之前,重要的是要认识到,通过将向量映射到低分辨率的哈希向量中,实际上是在进行一种近似处理。这种方法意味着搜索过程可能不会详尽无遗地比较每个向量,因此预期搜索的精确度会有所降低。

image.png

将可能非常大的密集向量压缩成高度压缩的二进制向量,以实现更快的搜索速度。虽然这种压缩牺牲了一定的搜索质量,但它显著提高了搜索效率。

方法选择

LSH有多种实现方式,每种方法使用不同的哈希构建技术和距离或相似度度量。在这里不深入细节,因为不同的版本适用于不同的应用场景。

最受欢迎的两种LSH实现方法是:

  • 文档分片、MinHashing和带状LSH:这是一种较为传统的LSH方法,适用于特定类型的数据集和查询。
  • 随机超平面与点积和汉明距离:这种方法使用随机超平面来构建哈希函数,并通过点积和汉明距离来衡量向量间的相似性。

本文将专注于介绍随机超平面方法,它不仅更常用,而且在多个流行库中得到了实现,例如Faiss。这种方法因其高效性和易于实现的特点,在工业界和学术界都受到了广泛的关注。

随机超平面(Random Hyperplanes)

随机超平面方法,尽管听起来简单,实际上是一种高效的技术,用于在高维空间中进行近似最近邻搜索。这种方法可能难以理解,但通过以下示例,将深入探讨其工作原理。

这里使用Sift1M数据集进行示例。假设我们有一个查询向量xq,目标是从数组xb中识别出前k个最近邻。

image.png

返回查询向量xq的三个最近邻

创建超平面

在随机超平面方法中,通过构建超平面来分割数据点。每个超平面由一个法向量定义,数据点根据与法向量的点积结果被分配为0或1。

image.png

位于超平面正侧的数据点分配值1,为负侧的数据点分配值0

确定数据点位于超平面哪一侧的关键在于超平面的法向量。点积的结果告诉数据点位于超平面的哪一侧。如果两个向量方向相同,点积结果为正。如果它们方向不同,结果为负。

image.png

当超平面法向量与另一个向量产生 +ve 点积时,可以将该向量视为位于超平面前面。对于产生 -ve 点积的向量来说,情况正好相反

在两个向量完全垂直(位于超平面边缘)的极少数情况下,点积为0——将这种情况归入负方向向量。

单个二进制值并不能告诉太多关于向量相似性的信息,但当添加更多超平面时,编码的信息量会迅速增加。

image.png

添加更多超平面来增加二进制向量中存储的位置信息量。

通过使用这些超平面将向量投影到低维空间中,产生新的哈希向量。

在上图中,使用了两个超平面,实际上可能需要更多——这个特性通过nbits参数定义。如果使用四个超平面,通过将nbits设置为4。

在Python中创建超平面的法向量。

 

python

复制代码

nbits = 4 # 超平面的数量 d = 2 # 向量的维度 import numpy as np # 创建一组随机法向量 plane_norms = np.random.rand(nbits, d) - .5 plane_norms

 

lua

复制代码

array([[-0.26623211, 0.34055181], [ 0.3388499 , -0.33368453], [ 0.34768572, -0.37184437], [-0.11170635, -0.0242341 ]])

通过 np.random.rand 创建了一组 0 → 1 范围内的随机值。然后添加-.5使数组值以原点 (0, 0) 为中心。可视化这些向量:

image.png

定义超平面位置的法向量,均以原点 (0, 0) 为中心

哈希向量

给定几个向量,可以使用这些法向量来计算它们的哈希值。

 

python

复制代码

a = np.asarray([1, 2]) b = np.asarray([2, 1]) c = np.asarray([3, 1]) # 计算点积 a_dot = np.dot(a, plane_norms.T) b_dot = np.dot(b, plane_norms.T) c_dot = np.dot(c, plane_norms.T) a_dot # array([ 0.41487151, -0.32851916, -0.39600301, -0.16017455]) a_dot = a_dot > 0 b_dot = b_dot > 0 c_dot = c_dot > 0 a_dot # array([ True, False, False, False]) # 将点积结果转换为二进制值 a_dot = a_dot.astype(int) b_dot = b_dot.astype(int) c_dot = c_dot.astype(int) a_dot # array([1, 0, 0, 0]) b_dot # array([0, 1, 1, 0]) c_dot # array([0, 1, 1, 0])

再次可视化,得到了三个向量 a、b 和 c,以及四个超平面(垂直于它们各自的法向量)。分别取 +ve 和 -ve 点积值得出:

image.png

0表示矢量位于平面后面(-ve 点积),1表示矢量位于平面前面(+ve 点积),组合起来创建二进制向量

LSH使用哈希向量来创建桶,每个桶包含具有相同哈希值的向量。

 

python

复制代码

vectors = [a_dot, b_dot, c_dot] buckets = {} i = 0 for i in range(len(vectors)): hash_str = ''.join(vectors[i].astype(str)) if hash_str not in buckets.keys(): buckets[hash_str] = [] buckets[hash_str].append(i) print(buckets) # {'1000': [0], '0110': [1, 2]}

现在搜索过程中,使用查询向量0111的哈希值来快速定位到相关的桶,然后通过比较汉明距离来找到最近的匹配。

使用这个向量,与LSH索引中的每个桶进行比较——在这个例子中只有两个值——1000和0110。使用汉明距离找到最近的匹配,这实际上是0110。

image.png

汉明距离,第一个两个向量之间有四个不匹配,汉明距离为4,接下来的两个只包含一个不匹配,汉明距离为1

使用LSH进行近似搜索意味着可能会牺牲一些搜索质量,但这是换取速度的代价。通过分组到桶中,显著减少了搜索所需的计算量。

平衡质量与速度

在相似性搜索中,一个关键的挑战是在搜索质量和速度之间找到合适的平衡点。

质量与速度的平衡

以我们的小规模示例为起点,注意到随机投影可能导致一些向量难以区分,例如,三个向量中的两个被映射到了相同的哈希值。现在,设想将这种情况放大到一个包含一百万个向量的大型数据集。

当引入查询向量xq并计算其哈希值(例如0111)时,发现它与两个桶(10000110)的汉明距离非常短。这种方法的速度非常快,因为它只需要两次距离计算就完成了搜索,但准确性却大大降低,因为可能返回了大约70万个哈希值为0110的样本。

桶的数量

在现实中,如果使用nbits值为4,将得到16个可能的桶:

 

python

复制代码

nbits = 4 # 计算nbits值的二进制组合数 1 << nbits # 16 # 打印给定nbits值的所有可能桶 for i in range(1 << nbits): # 获取整数的二进制表示,并格式化为nbits位 b = bin(i)[2:] b = '0' * (nbits - len(b)) + b print(b, end=' | ')

 

yaml

复制代码

0000 | 0001 | 0010 | 0011 | 0100 | 0101 | 0110 | 0111 | 1000 | 1001 | 1010 | 1011 | 1100 | 1101 | 1110 | 1111 |

即使有16个桶,一百万个向量被分成的桶数量仍然很少,导致每个桶内的样本非常不精确。

提高分辨率

为了提高搜索质量,可以通过增加超平面的数量来增加分辨率。更多的超平面意味着更高的分辨率二进制向量,从而产生更精确的向量表示。 通过nbits值来控制这种分辨率。一个更高的nbits值通过增加哈希向量的分辨率来提高搜索质量,但同时也可能增加搜索的计算成本。

image.png

增加nbits参数会增加用于构建二进制向量表示的超平面的数量

 

python

复制代码

for nbits in [2, 4, 8, 16]: print(f"nbits: {nbits}, buckets: {1 << nbits}")

 

yaml

复制代码

nbits: 2, buckets: 4 nbits: 4, buckets: 16 nbits: 8, buckets: 256 nbits: 16, buckets: 65536

通过调整nbits值,可以在搜索质量和速度之间进行权衡。在实际应用中,选择合适的nbits值是实现高效相似性搜索的关键。

Faiss中的LSH

回顾Faiss

Faiss(Facebook AI Similarity Search)是一个开源框架,专门用于高效实现相似性搜索。它提供了多种索引类型,包括IndexLSH,这是之前讨论过的局部敏感哈希(LSH)的高效实现。

初始化LSH索引

在Faiss中初始化LSH索引并添加数据集的示例代码如下:

 

python

复制代码

import faiss d = wb.shape[1] # 向量维度 nbits = 4 # 控制哈希向量的分辨率 # 初始化LSH索引 index = faiss.IndexLSH(d, nbits) # 添加数据集 index.add(wb)

执行搜索

一旦索引准备好,可以使用index.search(xq, k)方法进行搜索,其中xq是查询向量,k是希望返回的最近匹配数量。

 

python

复制代码

xq0 = xq[0].reshape(1, d) # 查询向量 # 搜索k个最近邻 D, I = index.search(xq0, k=10) # 返回最近邻的索引和距离 print("Indexes:", I) # 索引位置 print("Distances:", D) # 距离

测量性能

使用返回的索引,可以从数据集中检索原始向量,并计算它们与查询向量之间的余弦相似度。

 

python

复制代码

# 检索原始向量 retrieved_vectors = wb[I[0]] # 计算余弦相似度 cosine_sim = cosine_similarity(retrieved_vectors, xq0) print("Cosine Similarity:", cosine_sim)

 

ini

复制代码

array([[0.7344476 ], [0.6316513 ], [0.6995599 ], [0.20448919], [0.3054034 ], [0.25432232], [0.30497947], [0.341374 ], [0.6914262 ], [0.26704744]], dtype=float32)

从那些原始向量中,可以看到LSH索引是否返回了相关结果。通过测量查询向量xq0与前k个匹配之间的余弦相似性来进行这一操作。这个索引中有向量应该返回大约0.8的相似度分数,但返回的向量相似度分数仅为0.2,反映出性能低下。

诊断性能问题

如果返回的相似度分数较低,需要诊断性能问题。nbits值决定了索引中潜在桶的数量。如果nbits设置得太低,可能导致大量向量被分配到少数几个桶中,从而降低搜索质量。 如果尝试将1M个向量塞进只有16个哈希桶中,每个桶很可能包含10-100K+个向量。 所以,当哈希搜索查询时,它完美地匹配了这16个桶中的一个——但是索引无法区分被塞进那个单个桶中的大量向量——它们都有相同的哈希向量。可以通过检查距离D来确认这一点:

 

python

复制代码

print(D) # array([[0., 0., 0., 0., 0., 0., 0., 0., 0., 0.]], dtype=float32)

返回每个项目的完美距离分数为零,汉明距离只有在完美匹配时才能为零——这意味着所有的这些哈希向量必须是相同的。 如果所有的这些向量都返回完美匹配,它们必须都有相同的哈希值。因此,上述生成的索引无法区分它们——就LSH索引而言,它们都共享相同的位置。 如果增加k直到返回一个非零的距离值,应该能够推断出有多少个向量被分桶了这个相同的哈希码。

 

python

复制代码

k = 100 xq0 = xq[0].reshape(1, d) while True: D, I = index.search(xq0, k=k) if D.any() != 0: print(k) break k += 100 print(D) print(D[:, 172039:172041])

 

lua

复制代码

172100 array([[0., 0., 0., ..., 1., 1., 1.]], dtype=float32) array([[0., 1.]], dtype=float32)

一个包含172,039个向量的单个桶,这意味着是在从这172K个向量中随机选择前k个值。显然,需要减少桶的大小。 对于1M个样本,哪个nbits值有足够的桶以实现向量的更稀疏分布,通过计算平均值进行估计:

 

python

复制代码

for nbits in [2, 4, 8, 16, 24, 32]: buckets = 1 << nbits print(f"nbits == {nbits}") print(f"{wb.shape[0]} / {buckets} = {wb.shape[0]/buckets}")

 

ini

复制代码

nbits == 2 1000000 / 4 = 250000.0 nbits == 4 1000000 / 16 = 62500.0 nbits == 8 1000000 / 256 = 3906.25 nbits == 16 1000000 / 65536 = 15.2587890625 nbits == 24 1000000 / 16777216 = 0.059604644775390625 nbits == 32 1000000 / 4294967296 = 0.00023283064365386963

使用nbits值为16时,仍然在每个桶中得到大约15.25个向量——这看起来比实际情况要好。必须考虑到,有些桶会比其他桶大得多,因为不同的区域会包含更多的向量。 实际上,nbits值为24和32可能是实现真正有效桶大小的转折点。

 

python

复制代码

xq0 = xq[0].reshape(1, d) k = 100 for nbits in [2, 4, 8, 16, 24, 32]: index = faiss.IndexLSH(d, nbits) index.add(wb) D, I = index.search(xq0, k=k) cos = cosine_similarity(wb[I[0]], xq0) print(np.mean(cos))

 

复制代码

0.5424448 0.560827 0.6372647 0.6676912 0.7162514 0.7048228

看起来估计是正确的——前100个向量的整体相似度在每个nbits值增加之前突然上升,在𝑛𝑏𝑖𝑡𝑠==24nbits==24点之前稳定下来。

image.png

随着nbits值增加向量分辨率,结果将变得更加精确——可以看到更大的nbits值导致结果中余弦相似度更高。

提取二进制向量

Faiss允许提取向量的二进制表示,这有助于直接分析桶中的向量分布。

 

python

复制代码

# 提取二进制代码 arr = faiss.vector_to_array(index.codes) arr # array([ 5, 12, 5, ..., 15, 13, 12], dtype=uint8) arr.shape # (1000000,) # 将整数转换为二进制向量格式 (((arr[:, None] & (1 << np.arange(nbits)))) > 0).astype(int)

 

css

复制代码

array([[1, 0, 1, 0], [0, 0, 1, 1], [1, 0, 1, 0], ..., [1, 1, 1, 1], [1, 0, 1, 1], [0, 0, 1, 1]])

通过这个,可以可视化这些16个桶中向量的分布——显示使用最多的桶和一些空的桶。

image.png

当nbits == 4时,不同桶中向量的分布

通过调整nbits值,可以在搜索质量和速度之间找到平衡。在Faiss中使用LSH时,理解不同参数如何影响性能对于优化搜索结果至关重要。

使用LSH

局部敏感哈希(LSH)提供了一种快速的索引机制,尽管它可能不如平面(Flat)索引准确。在Sift1M数据集上,通过逐渐增加数据集的大小,发现在nbits值设定为768时,可以实现最佳的召回率——尽管这需要牺牲一些搜索时间来获得更高的召回率。

image.png

召回率与索引向量数量的关系:召回率是衡量搜索结果与使用IndexFlatL2进行详尽搜索的匹配程度的指标。

值得注意的是,即使使用nbits值为768,LSH的搜索速度也只是略快于平面Flat索引。

image.png

搜索时间的比较:展示了不同索引大小和nbits值下,IndexFlatL2的搜索时间相对于其自身在不同条件下的变化。

在实际应用中,一个更现实的召回率目标是接近40%,同时保持合理的速度提升。数据集的大小和维度对LSH的性能有显著影响。随着维度的增加,为了保持准确性,可能需要使用更高的nbits值,但这仍有可能实现更快的搜索速度。关键在于为每个用例和数据集找到正确的平衡点。向量相似性搜索是一个多样化的领域,Flat索引和LSH只是众多选择中的两种。选择正确的索引策略需要结合实验和专业知识。

在相似性搜索中,始终需要在不同的索引选项和参数设置之间寻找最佳解决方案,这是一种平衡的行为。

总结

选择正确的相似性搜索算法取决于多种因素,包括数据集的大小和维度、搜索性能的要求,以及准确性的容忍度。LSH是众多工具中的一个,它在某些情况下表现出色,但也可能需要与其他技术相结合以达到最佳效果。除了LSH,还有许多其他算法适合于高效的相似性搜索,例如:

  • HNSW(Hierarchical Navigable Small World):提供在大规模数据集上进行近似最近邻搜索的能力。
  • IVF(Inverted File Index):通过聚类和索引减少搜索范围,提高了搜索速度。
  • PQ(Product Quantization):一种量化技术,通过将向量空间划分为较小的子空间来加速搜索。

鼓励读者根据本文提供的信息,进行实验和探索,以找到最适合自己特定需求的搜索算法。

  • 26
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
LSH(局部敏感哈希)是一种用于高维数据的近似最近邻搜索的技术。以下是Python实现LSH算法的步骤: 1. 安装必要的库:numpy和bitarray。可以使用以下命令进行安装: ```shell pip install numpy bitarray ``` 2. 导入库: ```python import numpy as np from bitarray import bitarray ``` 3. 定义哈希函数: ```python def hash_vector(v, planes): return "".join(['1' if np.dot(v, plane) > 0 else '0' for plane in planes]) ``` 4. 定义LSH类: ```python class LSH: def __init__(self, num_planes, input_dim): self.num_planes = num_planes self.input_dim = input_dim self.planes = np.random.normal(size=(num_planes, input_dim)) self.hash_tables = {} for i in range(num_planes): self.hash_tables[i] = {} def add(self, v, label): hash_val = hash_vector(v, self.planes) for i in range(self.num_planes): if hash_val[i] not in self.hash_tables[i]: self.hash_tables[i][hash_val[i]] = bitarray(self.input_dim) self.hash_tables[i][hash_val[i]].setall(0) self.hash_tables[i][hash_val[i]][label] = 1 def query(self, v): hash_val = hash_vector(v, self.planes) candidates = None for i in range(self.num_planes): if hash_val[i] not in self.hash_tables[i]: return None if candidates is None: candidates = self.hash_tables[i][hash_val[i]] else: candidates = candidates & self.hash_tables[i][hash_val[i]] return candidates ``` 5. 创建LSH对象并添加数据: ```python lsh = LSH(num_planes=10, input_dim=100) for i in range(1000): v = np.random.normal(size=100) lsh.add(v, i) ``` 6. 查询最近邻: ```python query_v = np.random.normal(size=100) candidates = lsh.query(query_v) if candidates is not None: distances = np.linalg.norm(query_v - candidates, axis=1) nearest_neighbor = np.argmin(distances) ```

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值