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<dist_t> *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<storage_idx_t> 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<std::pair<key_t, scalar_t>> &new_links_set)
{
	size_t need_links = max_links(layer);
	std::vector<std::pair<key_t, scalar_t>> 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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章