基于二叉树的近似最近邻搜索-Annoy

  1. 推荐系统的召回阶段,会实时计算用户的表征向量(user/query),然后去物料库去寻找与用户最匹配的N个物料返回给用户;
  2. 在搜索系统,也同样存在这样的需求,用户的搜素(query)转化为向量,然后去文档库中搜索最相似的N个文档进行返回;
  3. 再比如如今火热的LLM(大语言模型)中的一个分支-RAG(检索增强生成),也是将用户输入(query)转化为语义向量,然后去向量数据库中召回最相关的N个文档作为上下文补充;

再比如人脸识别、相似图片搜索等等,诸如此类的场景,最朴素(暴力)的做法是遍历所有备选数据,与query逐一进行比较,计算向量的相关性,得到最相关的Top-N个备选项,时间复杂度为O(n)。但在工业场景中,备选数据集往往是非常庞大,这种做法明显不符合要求。

因此,便有了今天的主题:Approximate Nearest Neighbors Search(近似最近邻搜索)它可以在牺牲一定精度的前提下,大大提升这个搜索过程的速度,这也是工业采用的普遍做法。

近似最近邻搜索目前是有多种实现算法的,这篇文章介绍其中一种基于二叉树的算法:Annoy(Approximate Nearest Neighbors Oh Yeah)

原理解析

annoy的目标是能够以 O(log n) 的时间复杂度来实现最近邻的搜索,使用了二叉树的数据结构

下面,便于演示和理解,以二维向量的数据集为例进行分析,正式开始整个原理的解析。

二叉树构建

首先,从数据集中随机选择两个点,然后用这两个点的等距超平面(在二维中便是垂直平分线/中垂线),去将数据集切分为两部分。如下图,灰色的线连接的两个点,黑色的粗线对应中垂线,将整个数据集切分为绿色和蓝色两部分。

接着,分别对两个子空间进行同样的操作,如下图,这样就可以得到四个空间。

以二叉树的结构来表示,所有数据点只存储在叶子节点,比如下图,橙色的叶子节点存储了60个点:

下面便是递归地持续进行同样的操作,直到每个节点/空间最多只有K个点

比如下图,便是K=10的最终划分结果:

K=10

其对应的二叉树结构如下:

二叉树-K=10

这样,我们就得到了一棵将原来的数据点划分成多个空间的二叉树。并且,巧妙的是那些在同一个空间的所有点之间,会比在其他空间的点更有可能是更相近的换句话说,两个相近的点是不太可能会被分开的。

搜索过程

那么,完整地构建了这棵二叉树之后,对于任意一个点,或者来一个新的数据点,如何找到与其最相近的N个点便成了关键。

  • 首先,从二叉树的根节点开始遍历。每一个中间节点(分支节点)都对应一个上述的超平面
  • 根据这个超平面,判断是往左子节点还是右子节点继续往下遍历
  • 直到遍历到叶子节点,便结束搜索过程,叶子节点存储的所有点都是在同一个子空间的,更大可能是与之相近的点。

空间遍历

二叉树遍历

比如上图[空间遍历],红色的点X最终划分到了浅蓝色的子空间,便是最终搜索得到的7个最近邻点。对应二叉树的遍历路径则对应上图[二叉树遍历]。

这个过程的时间复杂度是O(log n),因为它的搜索遍历是二叉树的深度。

存在问题

进一步分析,上面的搜索过程是可能存在以下两个问题的:

  1. 如果我们想要超过7个的最近邻点呢
  2. 一些更加相近的点是存在于叶子节点之外的,即可能不在同一个子空间

(上述也提到了两个相近的点是不太可能会被分开的,但这仅仅只是可能性低,符合大部分的数据点)

针对这两个问题,annoy采用了两个技巧来应对。

技巧1

在遍历二叉树的过程中,两个方向/左右子树如果"足够相近",那么会同时沿着两个方向进行遍历,而不是只往一个方向进行遍历。

如下图,最终可以进入到多个子空间:

对应的二叉树遍历路径:

那么,这个“足够相近”需要有一个量化的东西,这里可以定义一个距离阈值(与超平面的距离),如果小于这个阈值,那么就同时沿着“错误”的另外一边进行遍历。比如上一个小节没有用到这个阈值判断,也即等同于阈值=0,总是只往着“正确”的一边进行遍历,得到了7个最近邻点。而当阈值=0.5时,便对应了上图的遍历路径,能够得到26个最近邻点(7+4+1+3+3+8)

而真正的技巧在于如何使用这个阈值:

使用一个优先队列放入按照与超平面的距离进行排序的“错误”节点,这样便可以从距离最近->距离较远的“错误”节点去遍历,直到超过指定阈值。

技巧2

类似于机器学习中的随机森林,annoy使用的第2个技巧便是同样的思路:构建多棵二叉树,由于每次划分子空间时是随机选取的点,因此这些二叉树是不同的。

创建一个优先队列,对构建的所有二叉树使用上述的方法进行搜索遍历,那么每一棵树都能得到一个叶子节点,这些叶子节点存储了与query point更高可能性相近的点,即质量很好的预估最邻近点

比如下面两张图,不同的二叉树可以搜索遍历得到不同的叶子节点/子空间:

二叉树-1

二叉树-2

那么,这样我们就得到一个叶子节点的集合,在整个数据点空间可以表示为如下图:

到了最后一步,可以将这个集合缩小一下。可以注意到,到目前为止,甚至完全没有出现过与每一个点的距离计算,那么这一步便是query point与集合里面的所有点一一进行距离的计算,然后进行排序,得到距离最近的Top N个点。

不过,从下图可以看出,仍然存在小部分的点会被排除在外,这是无法百分百避免,因为这是一个“近似”最近邻搜索,有小小的精度损失是可以接受的,但这可以带来极大的性能提升,比如我们仅仅需要与1%的点进行距离计算,这便可以带来100倍的性能提升,与暴力搜索相比。

算法总结

预处理阶段:

  1. 构建一堆二叉树,对于每一棵树,都是递归的进行随机空间划分

搜索阶段:

  1. 将每一棵树的根节点加入优先队列
  2. 使用这个队列,对所有二叉树进行搜索遍历,直到我们得到了search_k个备选的最近邻点;
  3. 移除重复的点
  4. 与所有备选的点进行一一距离计算
  5. 根据距离进行排序
  6. 返回距离最近的Top N个点

到这里,整个annoy的算法原理便全部介绍完毕了。里面有两个关键的参数:构建的二叉树的数量-n_trees和搜索的最近邻点的备选数量search_k,用于平衡搜索性能和搜索精度的,更大的n_treessearch_k可以得到更高的精度,但同时会带来更多的预处理和搜索耗时。

annoy实践

安装

pip install fastannoy

github: fastannoy

api介绍

  • AnnoyIndex(f, metric): 返回一个可读写的新索引, 并存储f维的向量。度量可以是"angular", “euclidean”, “manhattan”, “hamming”, or “dot”。
  • a.add_item(i, v): 将项i(任何非负整数)与向量v相加。请注意, 它将为max(i)+1项分配内存。
  • a.build(n_trees, n_jobs=-1): 构建n棵树的森林。更多的树在查询时具有更高的精度。调用build后, 无法添加更多项目。njobs指定用于构建树的线程数。n_jobs=-1使用所有可用的CPU内核。
  • a.save(fn, preful=False): 将索引保存到磁盘并加载(请参阅下一个函数)。保存后, 无法添加更多项目。
  • a.load(fn, preful=False): 从磁盘加载(mmaps)索引。如果preful设置为True, 它将把整个文件预读入内存(使用mmap和MAP_POPULATE)。默认值为False。
  • a.unload(): 卸载。
  • a.get_nns_by_item(i, n, search_k=-1, include_ranges=False): 返回n个最接近的项。在查询过程中, 它将检查最多search_k个节点, 如果没有提供, 则默认为n_trees*n。search_k为您提供了更好的准确性和速度之间的运行时权衡。如果你将include_ranges设置为True, 它将返回一个包含两个列表的2元素元组:第二个列表包含所有相应的距离。
  • a.get_nns_by_vector(items, n, search_k=-1, include_densives=False): 相同, 但按向量v查询。
  • get_batch_nns_by_items(vectors, n, search_k=-1, include_densives=False)get_nns_by_item 的批量查询版本。
  • a.get_nns_by_vector(v, n, search_k=-1, include_densives=False)get_nns_by_vector 的批量查询版本。
  • a.get_item_vector(i): 返回之前添加的项i的向量。
  • a.get_distance(i, j): 返回项目i和j之间的距离。注意:这用于返回距离的平方, 但自2016年8月起已更改。
  • a.get_n_items(): 返回索引中的项目数。
  • a.get_n_trees(): 返回索引中的树数。
  • a.on_disk_build(fn): 准备在指定文件而不是RAM中构建索引(在添加项之前执行, 构建后无需保存)
  • a.set_sed(seed): 将使用给定的种子初始化随机数生成器。仅用于构建树, 即只需在添加项目之前通过此项。调用a.build(n_trees)或a.load(fn)后将无效。

代码示例

from fastannoy import AnnoyIndex
import random

f = 40  # Length of item vector that will be indexed

t = AnnoyIndex(f, 'angular')
for i in range(1000):
    v = [random.gauss(0, 1) for _ in range(f)]
    t.add_item(i, v)

t.build(10) # 10 trees
t.save('test.ann')

# ...

u = AnnoyIndex(f, 'angular')
u.load('test.ann') # super fast, will just mmap the file
print(u.get_nns_by_item(0, 100)) # will find the 100 nearest neighbors
"""
[0, 17, 389, 90, 363, 482, ...]
"""

print(u.get_nns_by_vector([random.gauss(0, 1) for _ in range(f)], 100)) # will find the 100 nearest neighbors by vector
"""
[378, 664, 296, 409, 14, 618]
"""

批量查询

# will find the 100 nearest neighbors

print(u.get_batch_nns_by_items([0, 1, 2], 100))
"""
[[0, 146, 858, 64, 833, 350, 70, ...], 
[1, 205, 48, 396, 382, 149, 305, 125, ...], 
[2, 898, 503, 618, 23, 959, 244, 10, 445, ...]]
"""

print(u.get_batch_nns_by_vectors([
    [random.gauss(0, 1) for _ in range(f)]
    for _ in range(3)
], 100))
"""
[[862, 604, 495, 638, 3, 246, 778, 486, ...], 
[260, 722, 215, 709, 49, 248, 539, 126, 8, ...], 
[288, 764, 965, 320, 631, 505, 350, 821, 540, ...]]
"""

mmap

最后在提一点,annoy除了高效的搜索性能之外,还实现了一种内存映射技术 mmap,这可以在多个程序(特别是对于python的多进程)场景下,节省大量内存。

总结

  • annoy是一种基于二叉树的近似最近邻搜索算法,可以在牺牲小部分精度的前提下,大大提升搜索性能。在推荐系统、搜索系统以及LLM-RAG的相似文档召回等领域都存在着广泛的应用
  • 并且annoy是一种可以在自己电脑快速学习上手和验证效果的工具。
  • 9
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值