正文
但有一个关键问题:为了让 HNSW 能够快速保存到 Redis 的 RDB 中并重新加载,我对图结构进行了序列化,而不是 [元素-向量] 对,否则我需要将数据重新插入 HNSW,这速度会慢上 100 倍!
因此,我将节点之间的连接以整数形式存储,然后再将其解析为指针,这是个巧妙的技巧,效果很好。
但如果将这种存储方式与图结构的随机损坏结合起来,再加上我对 HNSW 的改造要求节点之间必须存在双向连接(我自行实现了一个具备许多实用功能的 HNSW 版本,双向连接是实现其中许多功能的必要条件),就可能出现以下情况:
-
我们加载了损坏的数据,其中显示 A 连接到 B,但 B 不再连接到 A(节点 ID 损坏)。
-
我们删除节点 B:由于双向连接被破坏,系统不会清除 A 到 B 的连接。
-
随后我们扫描图结构,当访问到 B 时会尝试访问 A:这就会导致“使用已释放内存”的错误:-D 😃 😐
因此,在加载数据后,我需要检查每条连接是否都是双向的。但在常规情况下,这需要 O(N²) 的时间复杂度——对于每个节点,我们需要扫描所有层级,在每个层级中扫描该节点的所有邻居,并通过扫描对应层级的连接来检查对方是否也指向该节点。这显然不够高效。
人类与 LLM 的较量
首先,我实现了常规方法,想看看模糊测试器是否无法再发现漏洞。结果确实有效,但加载一个包含 2000 万向量的大型向量集时,时间从 45 秒增加到了 90 秒左右。这太糟糕了。于是我打开 Gemini 2.5 PRO 对话界面,询问 LLM :“我们能怎么做?有没有更快的方法?”
Gemini 能想到的最佳方案是:对邻居连接的指针进行排序,这样就可以使用二分查找。好吧,我当然知道这个方法,但不确定在 16/32 个指针的数组中,这到底会更快还是更慢。于是我追问:“还有其他办法吗?”得到的回答是没有更好的解决方案了。
于是我提出:“你看,当我们在层级 X 中看到 A 连接 B 时,将其存储在哈希表 A:B:X 中(但我们始终对 A 和 B 进行排序,确保 A>B,这样无论方向如何,连接都能统一处理)。当再次看到该连接时,就将其清除。实际上,我们在将连接中的 ID 解析为指针时已经在扫描整个结构了,如果最后哈希表不为空,就说明存在非双向连接,这个方法如何?”
Gemini 称这是个好主意,但提到了 snprintf() 函数生成键的开销和哈希计算时间等问题。不过我指出,根本不需要 snprintf(),我们可以直接将指针 memcpy() 到固定大小的键中。Gemini 认可了这种可行性,随后我又有了新想法……