Skip to content

annoy

annoy是由 spotify 开发的用于在高维向量中快速查找相似近邻(Approximate Nearest Neighbors, ANN)。他特别适用于大规模数据集上进行向量相似性搜索。

Annoy 原理

Annoy 的核心算法是基于随机投影(Random Projection)和树结构(Tree Structure)来实现的。Annoy 会创建多个二叉树,每个树通过随机投影对数据进行分割,这样当我们进行查询时,便可以从多个方向来进行搜索,从而提高搜索的准确性和效率。

整个构建树的流程如下:

  1. 随机选择超平面: 在构建树的过程中,从数据中随机选取一个向量方向(超平面)并计算投影,按照这个方向将数据分成两部分
  2. 递归分割: 不断递归进行这种划分,直到每个叶节点中剩下的点数达到预定阈值(例如 10 个点)
  3. 构建多棵树: 为了确保查找的准确性,Annoy 会构建多棵树,每棵树使用不同的随机投影方向。查询时会同时遍历多个树,以确保得到更精确的结果

整个搜索过程同样是基于树来进行:

  1. 递归搜索树: 从根节点开始,Annoy 通过投影方向判断查询向量落在哪一边的子空间,并递归地向下搜索,直到到达叶节点
  2. 从叶节点中选取候选集: 在叶节点,Annoy 会返回所有包含在该节点中的向量作为候选集
  3. 遍历多棵树: Annoy 会对所有树进行搜索,并对每棵树的结果进行合并。合并后,对这些候选向量计算精确距离,并返回最近邻结果

通过先构建在搜索 Annoy 就能够快速返回近似最近邻。他使用多棵树来减少随机投影时引入的误差,从而提高结果的准确性。理论上构建的树数量越多(n_trees),查询的结果准确性也越高,但是伴随的就是构建和内存占用也会增加,搜索速度也会变慢,因此通常通过实验来调整 n_trees 来平衡构建时间、内存消耗和查询精度。

Annoy 使用流程

Annoy 的基本使用流程:

  1. 创建高维向量,例如针对于文本的搜索我们通常使用 TF-IDF 来构建稀疏矩阵作来作为输入源
  2. 构建索引(AnnoyIndex(n, 'angular')),对于文本数据通常选择余弦距离(angular)来构建索引
  3. 插入数据(index.add_item(key, vector)),注意 Annoy 仅支持整数键
  4. 构建树(index.build(n_trees)),这其中的核心就是 n_trees 的确定
  5. 为了以后复用通常可以将训练数据保存到磁盘中(index.save("annoy_index.ann")),之后我们可以通过 index.load('annoy_index.ann') 来加载而不需要重复构建
  6. 查询最近邻,index.get_nns_by_vector(query_vector, k: int) 其中 k 表示最接近的 10 个向量
Python
from annoy import AnnoyIndex

# 传入特征值,vectorizer.get_feature_name_out()
# X.shape[1] 也可以
# 第二个是距离算法, angular 就是余弦,可以测试下结果
annoy_index = AnnoyIndex(X.shape[1], 'angular')

# 遍历矩阵,将每一个行数据都添加到 Annoy 索引中
for i in range(X.shape[0]):
    # 注意 TfidfVectorizer 返回的是稀疏矩阵
    # annoy 只能使用密集矩阵,因此这样是它非常耗费内存的原因
    annoy_index.add_item(i, X[i].toarray()[0])

# 创建索引树,树数量越多越精确但是也越慢
annoy_index.build(10)

# 之后就可以查找近邻了
# 查询
query = '需要查找的字符串'
# 注意需要具有同样特征根的 TfidfVectorizer 来 transform 并转换为密集矩阵
query_vector = vectorizer.transform([query]).toarray()[0]# 转换查询为 TF-IDF 向量
n_neighbors = 5
nearest_neighbors = annoy_index.get_nns_by_vector(query_vector, n_neighbors)

# 可以通过 source_data[nearest_neighbors] 来获取最近邻
print("最近邻的索引:", nearest_neighbors)


# 还可以保存索引在下次使用
annoy_index.save('annoy_index.ann')

# 加载索引
loaded_index = AnnoyIndex(n_features, 'euclidean')
loaded_index.load('annoy_index.ann')  # 记得指定相同的距离度量

# 但是需要注意 tf-idf 需要相同的特征根因此还需要保存 vectorizer 对象,这可以通过 pickle 来实现

数据分块

Annoy 主要是设计用于内存中的近似最近邻搜索,并且它只支持密集矩阵,但你可以通过将数据分块并在需要时加载特定部分来实现一种“磁盘支持”搜索的效果。以下是实现方法的步骤:

Python
import numpy as np
from annoy import AnnoyIndex
import os

# 示例数据
n_samples = 10000  # 总样本数量
n_features = 10    # 特征数量
X = np.random.rand(n_samples, n_features)  # 随机生成数据

# 定义块大小
block_size = 2000
n_blocks = n_samples // block_size + (n_samples % block_size > 0)

# 创建和保存多个 Annoy 索引
for block_id in range(n_blocks):
    annoy_index = AnnoyIndex(n_features, 'euclidean')
    start = block_id * block_size
    end = min(start + block_size, n_samples)

    # 添加数据到当前块的索引
    for i in range(start, end):
        annoy_index.add_item(i, X[i])

    # 建立索引并保存到磁盘
    annoy_index.build(10)
    annoy_index.save(f'annoy_index_block_{block_id}.ann')

# 查询
def query_annoy(query_vector):
    nearest_neighbors = []

    # 逐块加载索引并查询
    for block_id in range(n_blocks):
        annoy_index = AnnoyIndex(n_features, 'euclidean')
        annoy_index.load(f'annoy_index_block_{block_id}.ann')

        # 进行查询
        neighbors = annoy_index.get_nns_by_vector(query_vector, 5)
        nearest_neighbors.extend(neighbors)

    return nearest_neighbors

# 示例查询
query_vector = np.random.rand(n_features)
result = query_annoy(query_vector)
print("最近邻的索引:", result)