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

news2024/12/24 9:17:54
  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是一种可以在自己电脑快速学习上手和验证效果的工具。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2044613.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Efficient DETR:别再随机初始化了,旷视提出单解码层的高效DETR | CVPR 2021

Efficient DETR结合密集检测和稀疏集合检测的优点,利用密集先验来初始化对象容器,弥补单层解码器结构与 6 层解码器结构的差距。在MS COCO上进行的实验表明,仅 3 个编码器层和 1 个解码器层即可实现与最先进的目标检测方法竞争的性能&#xf…

指针函数与函数指针的区别

1、指针函数 1-1、定义 指针函数,顾名思义,是一个函数,但其返回类型是指针。这意味着当这个函数被调用时,它会返回一个地址值,这个地址值指向某个类型的数据。 1-2、特点 函数性质:首先,它是…

【2024】20个高级 Java 面试问题及答案

1:解释Java序列化中transient关键字的意义。 在 Java 中,“ transient ”关键字用于指示变量在对象序列化期间不应被序列化。当变量被标记为“transient”时,意味着该变量应被序列化机制忽略。 这在处理不应持久的敏感或临时数据时特别有用…

基础 - 前端知识体系详解

一、前端三要素 HTML(结构): 超文本标记语言(Hyper Text Markup Language),决定网页的结构和内容。CSS(表现): 层叠样式表(Cascading Style Sheets&#xff0…

基于飞腾平台的Hbase的安装配置

【写在前面】 飞腾开发者平台是基于飞腾自身强大的技术基础和开放能力,聚合行业内优秀资源而打造的。该平台覆盖了操作系统、算法、数据库、安全、平台工具、虚拟化、存储、网络、固件等多个前沿技术领域,包含了应用使能套件、软件仓库、软件支持、软件适…

人脸操作:从检测到识别的全景指南

人脸操作:从检测到识别的全景指南 在现代计算机视觉技术中,人脸操作是一个非常重要的领域。人脸操作不仅包括检测图像中的人脸,还涉及到人脸识别、表情分析、面部特征提取等任务。这些技术在各种应用中发挥着关键作用,从社交媒体…

Windows Server 2016 Standard 将程序加入开机自启动

分3步 1 打开“启动”文件夹:在Windows的搜索栏中输入“shell:startup”,点击搜索结果中的 “启动” 文件夹即可打开。 2 在启动文件夹中,右键点击空白区域,选择“新建”->“快捷方式”。 3 将 “程序的快捷方式” 添加到启动…

IP转地理位置:3个好用免费开源库代码及数据库对比体验详解

最近在做一个IP定位显示国家省市功能,在全网找了一圈,也每个安装体验过,测试他的数据库精准度。 本人是用PHP的第三方库,整理以下使用过的ip定位转地理位置库。 ip定位转地理位置库 1.itbdw/ip-database: **gihub地…

Qt 系统相关 - 网络与音视频

目录 一、Qt 网络 1. UDP Socket 1.1 核心 API 概览 1.2 回显服务器 1.3 回显客户端 2. TCP Socket 2.1 核心 API 概览 2.2 回显服务器 2.3 回显客户端 3. HTTP Client 3.1 核心 API 3.2 代码示例 二、Qt 音视频 1. Qt 音频 1.1 核心API概览 1.2 示例 2. Qt 视…

Java面试八股之请简述消息队列的发布订阅模式

请简述消息队列的发布订阅模式 发布订阅(Publish-Subscribe,简称 Pub/Sub)模型是一种消息传递模式,它在组件之间提供了高度的解耦和灵活性。这种模式广泛应用于分布式系统、事件驱动架构以及消息队列系统中。下面是发布订阅模型的…

什么是核心交换机?这样回答太到位了

号主:老杨丨11年资深网络工程师,更多网工提升干货,请关注公众号:网络工程师俱乐部 你们好,我的网工朋友。 无论是企业内部通信还是对外服务,稳定高效的数据传输都是成功的关键。 在这个背景下&#xff0c…

A-CSPO课程概念澄清和实操:假定(Assumptions)、实验(Experiments)、假设(Hypotheses)

“确保你把这当作一个实验。” “我们的工作假设是客户想要这个。” 这些场景熟悉吗?你的团队(或整个组织)可能会经常混淆假定(Assumptions)、实验(Experiments)和假设(Hypotheses)等术语,这会造成混乱。 让我们澄清一下每一个…

JAVA社会校招人力资源招聘系统小程序源码

解锁职场新篇章,揭秘“社会校招人力资源招聘系统”的奥秘 一、引言:为何社会校招需要数字化升级? 在这个信息爆炸的时代,企业面临着前所未有的招聘挑战:如何从海量简历中精准筛选出合适的人才?如何高效管…

SQLAlchemy 中使用 GroupBy 和 Sum 导致重复计数的问题及解决方法

在 SQLAlchemy 中使用 GroupBy 和 Sum 时,有时会遇到重复计数或意外的查询结果。这通常是因为在聚合查询中没有正确地指定聚合函数或 GroupBy 条件,导致结果集没有按预期方式分组。 1、问题背景 在使用 SQLAlchemy 进行数据查询时,用户在尝试…

入门 - vue整个过程的生命周期详解

生命周期概念 Vue的生命周期就是vue实例从创建到销毁的全过程,也就是new Vue()开始就是vue生命周期的开始。Vue 实例有⼀个完整的⽣命周期,也就是从开始创建、初始化数据、编译模版、挂载Dom->渲染、更新->渲染、卸载 等⼀系列过程,称…

无人机灯光含义的详解!!!

一、LED指示灯和状态指示灯 LED指示灯:通常位于飞行器的头部机臂上,用于显示无人机的当前状态。 状态指示灯:位于尾部机臂上,提供更多关于无人机状态的信息。 红绿黄灯交替闪烁 表示无人机正在进行系统自检。稍等片刻&#xf…

Mybatis获取主键自增的方法

原本的方法 使用原本的JDBC去获取主键自增的方法如下图所示: 在这段代码中,通过连接对象 conn 的 prepareStatement 方法创建了一个PreparedStatement对象,并将SQL语句和 RETURN_GENERATED_KEYS 常量作为参数传递给该方法。这意味着执行SQL…

使用 Python 创建 Windows 程序列表生成器:从安装程序到配置文件

在当今的数字时代,我们的计算机上安装了数不胜数的程序。但是,您是否曾想过如何快速获取所有已安装程序的列表,并将其转化为可用的配置文件?今天,我们将探讨如何使用 Python 创建一个强大的工具,它不仅可以…

StarRocks Lakehouse 快速入门——Apache Paimon

StarRocks Lakehouse 快速入门指南为您提供了湖仓技术概览,旨在帮助您迅速掌握其核心特性、独特优势和应用场景。本指南将指导您如何高效地利用 StarRocks 构建解决方案。文章末尾,我们集合了来自阿里云、饿了么、喜马拉雅和同程旅行等行业领导者在 Star…

【私有云场景案例分享①】高效的集群管理能力

一、前言 设备的管理对企业至关重要,会影响生产效率、成本控制和竞争力。然而,企业在设备管理上面临设备数量多、设备分布广、维护成本高等挑战。DeviceKeeper设备管理网站作为解决方案,可以通过远程设备监控、远程设备维护和包体共享等功能…