HNSW算法原理(二)之删除结点

本篇文章继之前的一篇文章 HNSW算法原理(一) ,这次讲讲HNSW算法中一个关键问题:HNSW中如何删除元素。

一、HNSW中如何删除元素

一个理想的索引结构应该支持增删改查操作,由于 HNSW算法原始论文 只给出了增与查的伪代码,并没有给出删的代码,而一些项目中往往需要对已经插入到HNSW索引中的结点进行删除,比如在某些人脸识别任务中,已经1个月没有出现的人脸特征可以删除掉。下面开始分析如何在HNSW中实现删除元素操作。

首先,明确下HNSW中插入元素的过程是怎么样的。当来了一个待插入新特征,记为 x,我们先随机生成 x 所在的层次level,然后分2个步骤,1)从max_level到level+1查询离 x 最近的一个结点;2)从level到0层查询 x 最近的 maxM 个结点,然后将 x 与这些M个点建立有向连接。上述2个步骤,当需要删除元素时,就需要删除与它相连的边,第1个步骤对删除操作毫无影响,第2个步骤对删除操作的影响主要包括:1)x 在每一层有许多有向边指向 x 的邻居结点,删除 x 那么也要删除 x 所指向的边;2)x 在每一层可能是其他结点的前M个邻居结点之一,设该结点为 b,当删除 x时,应该删除由 b 出现指向 x 的有向边。

当我们需要实现删除操作时,那么必须要检查每一层中所有结点的maxM个邻居是否因为删除 x 而发生改变,如果发生改变,那么就需要重建该结点的maxM邻居信息。这个工作非常耗时,要达到此目的,我这有2个办法。一是,采用lazy模式,实际上不删除 x,而是把x加入到黑名单中,下次搜索时判断是否在黑名单中而决定是否返回x,当黑名单数超过一定阈值时,重建HNSW索引;二是采用空间换时间办法,当把 x插入时就在x的邻居信息中记住 x 是哪些结点的邻居。

我们发现在hnswlib和faiss中都没有关于删除操作的实现,可以从下面代码中关于双向链表的内存申请中看出:

//from hnswlib code
//https://github.com/nmslib/hnswlib/blob/master/hnswlib/hnswalg.h
HierarchicalNSW(SpaceInterface *s, size_t max_elements, size_t M = 16, size_t ef_construction = 200, size_t random_seed = 100) :
		link_list_locks_(max_elements), element_levels_(max_elements) {
	max_elements_ = max_elements;
    // other codes
	visited_list_pool_ = new VisitedListPool(1, max_elements);
    // other codes
    //此处给双向链表开辟空间,第i个元素的邻居结点信息在linkLists_[i];
    //第i个元素的第j>=1层邻居信息起始地址放在linkLists_[i]+(layer-1)*size_links_per_element_处
	linkLists_ = (char **) malloc(sizeof(void *) * max_elements_);
    //linkLists_是char**,计算每层每个结点的邻居信息字节长度
	size_links_per_element_ = maxM_ * sizeof(tableint) + sizeof(linklistsizeint);
    // other codes
}
//from faiss code
//https://github.com/facebookresearch/faiss/blob/master/HNSW.h
/// neighbors[offsets[i]:offsets[i+1]] is the list of neighbors of vector i
/// for all levels. this is where all storage goes.
std::vector neighbors;

下面讲讲online-hnsw实现删除操作的原理及代码解读。

在基于图的索引结构中,邻居关系是非对称关系,A是B的topk邻居,但B不一定是A的topk邻居,因此表示邻居关系需要用一条有向边来表示,当A是B的topk邻居,则有一条从B指向A的有向边,为了在检索时按照距离排序,有向边带有权重,权重为A到B的距离。在hnsw中,为了保持邻居关系,设置每个结点有M个邻居结点,结点之间建立单向连接;为了支持删除操作,那么需要建立双向连接,即当A是B的topk邻居,则B的outgoing_links中有条边指向A,且A的ingoing_links中有一条边指向B。这样,当插入B时,建立B的与其M个邻居的双向连接,同时将B添加到每个邻居的ingoing_links中;当删除B时,需要删除B的所有outgoing_links,同时在B的邻居结点的ingoing_links中删除B,此外B也有ingoing_links,需要删除B的ingoing_links,之后还得在B的ingoing_links中对每个结点C,将B从C的outgoing_links中删除。

下面这个代码是修改一个结点的邻居关系操作:

//from online-hnsw code
//https://github.com/andrusha97/online-hnsw/blob/master/include/hnsw/index.hpp
//重新设置结点node的邻居关系为new_links_set
void set_links(const key_t &node,
			   size_t layer,
			   const std::vector> &new_links_set)
{
	size_t need_links = max_links(layer);
	std::vector> new_links;
	new_links.reserve(need_links);

	if (options.insert_method == index_options_t::insert_method_t::link_nearest) {
		new_links.assign(
			new_links_set.begin(),
			new_links_set.begin() + std::min(new_links_set.size(), need_links)
		);
	} else {
		select_diverse_links(max_links(layer), new_links_set, new_links);
	}

	auto &outgoing_links = nodes.at(node).layers.at(layer).outgoing;
	//将node从原来的连接中清除
	for (const auto &link: outgoing_links) {
		nodes.at(link.first).layers.at(layer).incoming.erase(node);
	}
	//对node的所有邻居按key升序排列
	std::sort(new_links.begin(), new_links.end(), [](const auto &l, const auto &r) { return l.first < r.first; });
	//重新设置node的outgoing_links
	outgoing_links.assign_ordered_unique(new_links.begin(), new_links.end());
	//更新node邻居点的ingoing_links
	for (const auto &key: new_links) {
		nodes.at(key.first).layers.at(layer).incoming.insert(node);
	}
}

下面的代码是删除一个结点node的操作:

//把key对应的结点移除
void remove(const key_t &key) {
	auto node_it = nodes.find(key);
	if (node_it == nodes.end()) {
		return;
	}
	const auto &layers = node_it->second.layers;
	for (size_t layer = 0; layer < layers.size(); ++layer) {
		for (const auto &link: layers[layer].outgoing) {
			nodes.at(link.first).layers.at(layer).incoming.erase(key);
		}
		for (const auto &link: layers[layer].incoming) {
			nodes.at(link).layers.at(layer).outgoing.erase(key);
		}
	}
	if (options.remove_method != index_options_t::remove_method_t::no_link) {
		//other code
	}
	auto level_it = levels.find(layers.size());
	if (level_it == levels.end()) {
		throw std::runtime_error("hnsw_index::remove: the node is not present in the levels index");
	}
	level_it->second.erase(key);
	// Shrink the hash table when it becomes too sparse
	// (to reduce memory usage and ensure linear complexity for iteration).
	if (4 * level_it->second.load_factor() < level_it->second.max_load_factor()) {
		level_it->second.rehash(size_t(2 * level_it->second.size() / level_it->second.max_load_factor()));
	}
	if (level_it->second.empty()) {
		levels.erase(level_it);
	}
	nodes.erase(node_it);
	if (4 * nodes.load_factor() < nodes.max_load_factor()) {
		nodes.rehash(size_t(2 * nodes.size() / nodes.max_load_factor()));
	}
}

hnsw中删除一个结点后会出现一个问题,因为hnsw需要保持每个结点有固定数目的邻居点和它连接,如果删除了一个结点,将可能使得其他结点的连接数减少。为了使得结点删除后依然满足固定连接数的要求,需要对连接数减少的结点重新进行搜索,在重新搜索时只需要比较不在已有邻居点集中的结点即可。代码入下:

if (options.remove_method != index_options_t::remove_method_t::no_link) {
	for (size_t layer = 0; layer < layers.size(); ++layer) {
		for (const auto &inverted_link: layers[layer].incoming) {
			auto &peer_links = nodes.at(inverted_link).layers.at(layer).outgoing;
			const key_t *new_link_ptr = nullptr;

			if (options.insert_method == index_options_t::insert_method_t::link_nearest) {
				new_link_ptr = select_nearest_link(inverted_link, peer_links, layers.at(layer).outgoing);
			} else if (options.insert_method == index_options_t::insert_method_t::link_diverse) {
				new_link_ptr = select_most_diverse_link(inverted_link, peer_links, layers.at(layer).outgoing);
			} else {
				assert(false);
			}

			if (new_link_ptr) {
				auto new_link = *new_link_ptr;
				auto &new_link_node = nodes.at(new_link);
				auto d = distance(nodes.at(inverted_link).vector, new_link_node.vector);
				peer_links.emplace(new_link, d);
				new_link_node.layers.at(layer).incoming.insert(inverted_link);
				try_add_link(new_link, layer, inverted_link, d);
			}
		}
	}
}

原文链接:https://blog.csdn.net/CHIERYU/article/details/86647014

你可能感兴趣的:(索引技术,近似最近邻检索技术)