几个需要关注的点:
最近工作中使用了一下annoy,于是抽时间看了下代码,记录下。。
annoy支持三种距离度量方式,cos距离,欧式距离和曼哈顿距离。下面主要通过最简单的欧氏距离来看。
首先看下节点node的结构
n_descendants记录了该节点下子节点的个数,children[2]记录了左右子树,v和a之后会详细说,先知道v[1]代表该节点对应的向量,a代表偏移就好。
然后看下AnnoyIndex类
_n_items记录了我们一共有多少个向量需要构建索引,_n_nodes记录了一共有多少个节点,_s是node占有的空间大小,_f是向量的维度,_nodes所有节点,_roots是所有树的根节点。
annoy建树的时候当该区域内的节点数小于k的时候就不会再继续递归建树,之前疑惑怎么调整k这个参数,看完代码才发现没法调整,_K是一个定值,如果一个区域内的节点数小于_K的时候,这个节点就不再记录向量v,v的空间也用来记录节点的id。
另外还有一个比较奇怪的事情就是annoy为node开辟空间的方式。。比如我有三个item,建索引的时候id分别为3,6,10,那么annoy会开辟11个node空间,从0-10。。看下面这段代码就能明白
再接下来就是到了建树。annoy建树如下图,每次选择空间中的两个质心作为分割点,相当于kmeans过程,以使得两棵子树分割的尽量均匀以保证logn的检索复杂度。以垂直于过两点的直线的超平面来分割整个空间,然后在两个子空间内递归分割直到子空间最多只有k个点。如下图
然后看下创建分割面的过程,入参为当前空间的所有点nodes,维度f,随机函数random,分割节点n
best_iv和best_jv就是选出来的那两个点,n->v存储的就是这两个点连线对应的向量,即分隔面的法向量,计算方式就是两点对应向量相减。n->a存储的就是分割超平面对应的偏移,以三维空间举例,三维空间中的平面表示方法为Ax + By + Cz + D = 0,n->a存储的就是这个D,计算方法如下,因为平面的法向量已经确定,又因为该平面过best_iv和best_jv连线中点,将中点坐标代入,连线中心点定义为m=((best_iv[0] + best_jv[0])/2, (best_iv[1] + best_jv[1])/2, (best_iv[2] +best_jv[2])/2),则A * m[0] + B * m[1] + C * m[2] + D =0 => D= -(A * m[0] +B * m[1] + C * m[2])。
接下来看一下是如何选择两个点的,即two_means
为了保证nlogn的检索复杂度,需要使得每次分割得到的两棵子树尽量平衡,所以要找空间中的两个质心,过程很像kmeans,初始随机选取两个点,每次迭代过程中随机选择一个点计算该点属于哪个子树,并更新对应的质心坐标。
建树完成之后就是检索,对于给定的点去树中找topk近邻,最基本的想法就是从根开始,根据该点的向量信息和每个树节点的分割超平面比较决定去哪个子树遍历。如图所示
但是这样还是存在一些问题,就是最近邻不一定会和查询点在同一个叶结点上
解决方法是这样的,一是建立多棵树,二是在查询点遍历树的时候不一定只选择一条