HNSW——Hierarchical NSW (分层的NSW算法),是近似k近邻搜索中的新方法,也是对NSW方法的改进,它由多层的邻近图组成,因此称为分层的NSW方法。
NSW的搜索机制可以通过一个例子解释清楚。
首先在这张图中有六个地点,每一个点代表一个机场,而点的大小表示了这个机场的规模。现在我们要从Toksook bay出发,目的地是Ibaza,那么首先,我们在Toksook bay附近找到了距离Ibaza最近的机场Bethel,之后,我们在Bethel附近找距离Ibaza最近的机场,最终我们找到了Anchorage,然后接着在Anchorage附近找距离Ibaza最近的机场,…,最后,我们找到了Ibaza。NSW的搜索机制就是这样的,在base node(Toksook bay)的近邻中找到与query(Ibaza)最近的点,然后把这个点更新为新的base node,再重复以上过程,直到找到query。
在上面的过程中,搜索分为两个阶段,缩小和放大阶段。我们从节点度小(机场规模小)的节点开始搜索,然后逐渐找到节点度大的节点,这是缩小阶段(粗略搜索),它能够让我们一步走很多,加快了搜索速度。找节点度大的节点的好处是它的连接数量多,能够有更多的选择,在这里,它也被称为网络中的枢纽节点。当枢纽节点与我们的query距离较近时,我们又开始搜索周围节点度小的节点,这是放大阶段(精细搜索)。缩小和放大的阶段可以理解成使用高德地图时的缩小和放大。
这样的方法高效但是也存在这问题。计算机不像人类可以选择大规模的“机场”,我们只传递给它距离信息,因此在它选择“机场“的时候,只会考虑距离因素,而不考虑”机场“的规模。假设我们从Toksook bay出发的时候,有一个距离更近的小规模机场A,那我们会去这个A机场,然后假如A机场旁边又有一个小机场B且距离最近,那我们会去这个B机场。就这样我们陷入了局部搜索而很难达到目的地。
解决这个问题的一个方法是从节点度大的节点开始搜索,实验证明这个方法能在低维数据上有较高的准确率和性能。但是,NSW方法在搜索上的时间复杂度是多重对数复杂度,而且它在高维的数据上表现不行。它具有多重对数复杂度的原因是:NSW中计算距离的次数,大致与贪婪搜索的跳数和贪婪路径上的节点度的乘积成比例。其中平均跳数是对数复杂度的,而贪婪路径上的节点度也是对数复杂度,原因有两个:
因此,HNSW方法就提出固定每个元素的连接数,这样就获得了对数复杂度。
HNSW的改进方法主要有:
这个启发式方法考虑了候选者之间的距离,对聚集起来的数据很有用。原理也用一个例子说清楚。
在这张图中,我们的启发式方法要在灰色的候选集中选择query的m个近邻。此时,我们已经选了query的三个近邻,在图中已经与query相连。现在,对于curObj这个点,我们是否会选择它呢?可以注意到,curObj与已经相连的三个点之间的距离 >= curObj与query的距离,因此,我们会选择curObj作为query的近邻。启发式方法只有在以下情况建立与query的连接:候选元素与任何已连接的候选元素之间的距离 >= 该候选元素与query的距离。
这里的hnsw算法来自hnswlib。
图1. HNSW算法的建图流程,其中红色节点代表待插节点,绿色表示已插入节点。(1)输入建立索引所需的参数,设M=2;(2)获取用于建图的所有图片,并依次传入算法中建立索引;(3)对输入的图片提取特征;(4)将特征当作节点,并获取节点的层次l;(5)插入节点,对于第一个插入的节点,不做任何操作。(6)① 在L层找到距离待插节点最近的节点ep,并作为下一层的输入;②该层及以下为待插元素的插入层,从ep开始查找距离待插元素最近的ef个节点,从ef节点中选出M个与待插节点连接,并将这M个节点作为下一层的输入;③从M个节点开始搜索,找到距离与待插节点最近的ef个节点,并选出M个与待插元素连接;④ 同③。(7)①在L层找到距离待插节点最近的节点ep,并作为下一层的输入;②在l=2层找到距离待插节点最近的节点ep,并作为下一层的输入;③该层及以下为待插元素的插入层,从ep开始查找距离待插元素最近的ef个节点,从ef节点中选出M个与待插节点连接,并将这M个节点作为下一层的输入;④同③。
图2. 检索流程,其中红色的节点表示搜索节点q(可以是非图中的节点),绿色节点表示图中已建立的节点,黄色的节点表示搜索结果。(a)输入索引路径和图片;(b)根据索引路径加载索引,并对图片提取特征;(c)获取需要搜索的近邻数K,并将图片特征作为节点输入搜索算法;(d)在L层找到距离q最近的一个节点ep,并作为下一层的输入;(e)在l=2层中,从ep开始,在ep的邻居中找到距离q最近的一个邻居,作为新的ep,并作为下一层的输入;(f)在l=1层中,从ep开始,在ep的邻居中找到距离q最近的一个邻居,作为新的ep,并作为下一层的输入;(g)在最底层中,从ep开始,搜索距离q最近的K个节点;(h)输出节点q和K个近邻。
这是论文中的alg.2,寻找一层中距离q最近的ef个邻居,通过动态邻居W返回,ep是enterpoint。
// 这里是论文中的alg.2---找出layer中距离data point(也就是论文中的q)最近的前ef个元素,返回动态列表top_candidates
std::priority_queue<std::pair<dist_t, tableint>, std::vector<std::pair<dist_t, tableint>>, CompareByFirst>
searchBaseLayer(tableint enterpoint_id, void *data_point, int layer) {
// visitedList包括数组和数,visited_array 是数组,tag是数
VisitedList *vl = visited_list_pool_->getFreeVisitedList();
// visited_array存储已访问的元素
vl_type *visited_array = vl->mass;
vl_type visited_array_tag = vl->curV;
// top_candidates存储每一层距离datapoint最近的ef个邻居,对应于论文中的动态列表W
std::priority_queue<std::pair<dist_t, tableint>, std::vector<std::pair<dist_t, tableint>>, CompareByFirst> top_candidates;
// candidateSet存储候选元素,对应于动态列表中的C
std::priority_queue<std::pair<dist_t, tableint>, std::vector<std::pair<dist_t, tableint>>, CompareByFirst> candidateSet;
// 计算data_point(query) 到enterpoint的距离,结果保存在dist中。
dist_t dist = fstdistfunc_(data_point, getDataByInternalId(enterpoint_id), dist_func_param_);
// enterpoint加入到最近邻列表
top_candidates.emplace(dist, enterpoint_id);
// enterpoint加入到候选列表
candidateSet.emplace(-dist, enterpoint_id);
// enterpoint加入已访问列表
visited_array[enterpoint_id] = visited_array_tag;
// lowerBound存储当前到datapoint的最近距离
dist_t lowerBound = dist;
//当候选列表不为空时 while |C|>0
while (!candidateSet.empty()) {
// 从C中取出距离q最近的元素
std::pair<dist_t, tableint> curr_el_pair = candidateSet.top();
// 如果C中最近元素与q的距离 > W中与q最远元素的距离,说明W中的每一个元素都已评估过,退出循环
if ((-curr_el_pair.first) > lowerBound) {
break;
}
// 弹出候选列表队头
candidateSet.pop();
//获取当前元素的label
tableint curNodeNum = curr_el_pair.second;
std::unique_lock <std::mutex> lock(link_list_locks_[curNodeNum]);
// 获取当前元素的邻居
int *data;// = (int *)(linkList0_ + curNodeNum * size_links_per_element0_);
// 如果在第0层
if (layer == 0):
// 计算当前元素邻居的内存
data = (int *) (data_level0_memory_ + curNodeNum * size_data_per_element_ + offsetLevel0_);
else
// 计算当前元素邻居的内存
data = (int *) (linkLists_[curNodeNum] + (layer - 1) * size_links_per_element_);
// 把邻居的内存指向的数赋给size
int size = *data;
// datal表示当前元素第一个邻居的label
tableint *datal = (tableint *) (data + 1);
// 对于layer层中的当前元素的每一个邻居candidate
for (int j = 0; j < size; j++) {
tableint candidate_id = *(datal + j);
// 如果candidate已经访问过(对应论文中,如果e属于v,无操作,循环次数加1)
if (visited_array[candidate_id] == visited_array_tag) continue;
// 没有访问过,将已访问列表中并入candidate
visited_array[candidate_id] = visited_array_tag;
// 根据candidate的id号获取这个candidate元素,也就是currObj1
char *currObj1 = (getDataByInternalId(candidate_id));
//计算currObj1到data point之间的距离,对应于论文中的distance(e,q)
dist_t dist1 = fstdistfunc_(data_point, currObj1, dist_func_param_);
// 取出top_candidates中距离data point最远的元素并比较大小
// 对应于论文中,如果distance(e,q)
if (top_candidates.top().first > dist1 || top_candidates.size() < ef_construction_) {
// 将currObj1的id——candidate_id加到候选列表中
candidateSet.emplace(-dist1, candidate_id);
// 将currObj1的id——candidate_id加到动态列表中
top_candidates.emplace(dist1, candidate_id);
// 如果动态列表的长度大于ef,那么减掉“最弱的”元素,对应于论文中|W| > ef
if (top_candidates.size() > ef_construction_) {
// 取出W中距离q最远的元素
top_candidates.pop();
}
// 更新distance(f,q)
lowerBound = top_candidates.top().first;
}
}
}
visited_list_pool_->releaseVisitedList(vl);
// 返回动态列表,也就是返回layer层中距离q最近的ef个邻居
return top_candidates;
}
// 论文中的alg.4--启发式方法选择邻居,从top_candidates中选择距离q最近的M个元素
void getNeighborsByHeuristic2(
std::priority_queue<std::pair<dist_t, tableint>, std::vector<std::pair<dist_t, tableint>>, CompareByFirst> &top_candidates,
const size_t M) {
// 如果top_candidates里元素个数小于M,那还选个啥,直接return
if (top_candidates.size() < M) {
return;
}
// queue_closest是working queue for the candidates,论文中是W ,存放候选者
std::priority_queue<std::pair<dist_t, tableint>> queue_closest;
// return_list存放最终的M个结果,论文中是R,初始为空集
std::vector<std::pair<dist_t, tableint>> return_list;
// 将queue_closest初始化为top_candidates,论文中为W<--C
while (top_candidates.size() > 0) {
queue_closest.emplace(-top_candidates.top().first, top_candidates.top().second);
top_candidates.pop();
}
// 当queue_closest内的元素个数大于0
while (queue_closest.size()) {
// 如果return_list内元素个数已经大于M,那么启发式查找过程结束
if (return_list.size() >= M)
break;
// curent_pair是queue_closest(W)的元素
std::pair<dist_t, tableint> curent_pair = queue_closest.top();
// dist_to_query是curent_pair与query的距离
dist_t dist_to_query = -curent_pair.first;
// queue_cloest元素减一
queue_closest.pop();
bool good = true;
// 对于return_list(R)中的每一个元素
for (std::pair<dist_t, tableint> second_pair : return_list) {
// curdist是 curent_pair 与 return_list(R)中的每个元素的距离
dist_t curdist =
fstdistfunc_(getDataByInternalId(second_pair.second),
getDataByInternalId(curent_pair.second),
dist_func_param_);;
// 如果curent_pair 与已经与q连接元素的距离 < curent_pair与query的距离
if (curdist < dist_to_query) {
// curent_pair将不会作为q的邻居返回
good = false;
break;
}
}
// 如果curent_pair 与已经与q连接元素的距离 >= curent_pair与query的距离,见论文中Fig.2
// 那么将curent_pair并入return_list(也就是论文里的R)
if (good) {
return_list.push_back(curent_pair);
}
}
//knn搜索 ---论文中的alg.5
std::priority_queue<std::pair<dist_t, labeltype >> searchKnn(const void *query_data, size_t k) const {
// currObj和curdist分别记录距离data point最近的点和距离
tableint currObj = enterpoint_node_;
dist_t curdist = fstdistfunc_(query_data, getDataByInternalId(enterpoint_node_), dist_func_param_);
// 在层L...1之间
for (int level = maxlevel_; level > 0; level--) {
while (changed) {
// 首先没有变化,表示在同一层中搜索
changed = false;
int *data;
// 获得currObj的连接数,也就是邻居
data = (int *) (linkLists_[currObj] + (level - 1) * size_links_per_element_);
int size = *data;
tableint *datal = (tableint *) (data + 1);
// 对于currObj的每一个邻居,计算它与data point的距离,并及时更新currObj和currdist
for (int i = 0; i < size; i++) {
// 获取邻居的id
tableint cand = datal[i];
if (cand < 0 || cand > max_elements_)
throw std::runtime_error("cand error");
// 根据id获取邻居并计算其到query的距离
dist_t d = fstdistfunc_(query_data, getDataByInternalId(cand), dist_func_param_);
// 如果这个邻居与query的距离比curdist还要小,更新curdist为这个邻居,changed改为true
if (d < curdist) {
curdist = d;
currObj = cand;
changed = true;
}
}
}
}
// 目前已获得第一层与query最近的元素currObj
// 在第零层获取currObj邻居中距离query最近的max(k,ef)个近邻,也就是动态列表top_candidates-----论文中是W
std::priority_queue<std::pair<dist_t, tableint>, std::vector<std::pair<dist_t, tableint>>, CompareByFirst> top_candidates = searchBaseLayerST(
currObj, query_data, std::max(ef_,k));
std::priority_queue<std::pair<dist_t, labeltype >> results;
// top_candidates修剪为k个
while (top_candidates.size() > k) {
top_candidates.pop();
}
// 结果存到result中
while (top_candidates.size() > 0) {
std::pair<dist_t, tableint> rez = top_candidates.top();
results.push(std::pair<dist_t, labeltype>(rez.first, getExternalLabel(rez.second)));
top_candidates.pop();
}
return results;
};
代码贴不动了 还有的代码太长,需要的同学可以私信O(∩_∩)O。
在HNSW结构中,层与层之间并没有直接的实际连接,比如在搜索算法中,从高层到低层是通过循环完成的,在每一层中重新获得元素的邻居以实现“更新”层的操作。
节点结构类似于结构体,通过pair定义,节点中包括两个属性,一是该节点与q的距离,另一个是每个节点独有的id,根据id号可以实现点与点之间的连接。例如A点有一个关于邻居的表data,其中存储了A点邻居的id,如果A点要与B点连接,只要将B点的id加到这个表中。
在插入过程中,决定一个节点的层数是通过 l = − l n ( u n i f ( 0 , 1 ) ) ∗ m L l=-ln(unif(0,1))*m_{L} l=−ln(unif(0,1))∗mL,决定的,其中是0到1之间的随机数,mL是归一化因子,看起来节点层数的决定具有随机性,但其实, m L = 1 / l n ( M ) m_{L}=1/ln(M) mL=1/ln(M),M是节点的连接数。M增大, m L m_{L} mL单调递减,而 − l n ( u n i f ( 0 , 1 ) ) -ln(unif(0,1)) −ln(unif(0,1))也是单调递减的,因此随着M的增大,节点的l也会增大,也就保证了根据尺度半径分层。 这里有点问题。
以第一层为例,代码中定义了一个字符串数组data_level0_memory,用来保存第0层的全部内容。假设结构中一共有1000个元素,maxM=16,maxM0=32,data_size=128。那么有:
(以下部分可以对照源码理解)
size_links_level0_ = 32*4 + 4 = 132
size_data_per_element_ = size_links_level0_ + 128 + 4 = 264
(以上式子中,加4是为了获取真实的物理地址,见博客)
(以下两个变量为偏移值,待会儿解释)
offsetData_ = size_links_level0_ = 132
label_offset_ = 128 + size_links_level0_ = 260
data_level0_memory_ = (char *) malloc(max_elements_ * size_data_per_element_)
也就是说data_level0_memory_是一个1000×264的字符串数组。
这里data_level0_memory_(1000*264)的结构是这样的:
可以把一个264的大小看成是一个单位,这样的单位一共有1000个,每一个单位的结构是这样的:
这样的结构有一个好处,可以使用一个元素的id获取该元素data。例如在代码中有这样一个函数
// 根据internal_id获取元素id
inline labeltype *getExternalLabeLp(tableint internal_id) const {
return (labeltype *) (data_level0_memory_ + internal_id * size_data_per_element_ + label_offset_);
}
假如我们传入的internal_id=2,那么data_level0_memory_ + 2*264 +260就指向了第二个元素中data_size后的位置,根据这个位置,可以得到元素的id(也就是label)。
// 根据internal_id获取Data
inline char *getDataByInternalId(tableint internal_id) const {
return (data_level0_memory_ + internal_id * size_data_per_element_ + offsetData_);
}
同理,假如我们传入的internal_id=2,那么data_level0_memory_+2*264+132就指向了size_links_level0之后的位置,也是data的第一个位置,这样就可以获得data了。
这样看来,offsetData和label_offset_的作用就很明显了,他们分别数据data和元素id的偏移量,为了能够找到一个元素中处于不同位置的data和id。
获取邻居也是一样的道理,邻居都存储在size_links_level0这块内存中,其中一共有32个links,获取的规则和之前一样,例如:
// 获取curNodeNum的邻居id
data = (int *) (data_level0_memory_ + curNodeNum * size_data_per_element_ + offsetLevel0_);
tableint *datal = (tableint *) (data + 1);
此时的datal数组中就存储了元素curNodeNum 的所有邻居id。
基于HNSW近邻算法的人脸检索Web应用