最近几年向量数据库突然火了起来,各大厂都在推自己的向量数据库产品。作为一个长期从事数据库内核开发的工程师,我决定从最基础的HNSW算法入手,用C++实现一个轻量级的向量数据库内核。这个项目最难的部分在于如何在高维空间中快速构建索引,同时支持高效的多线程检索。
向量数据库与传统数据库最大的区别在于,它处理的是高维向量数据而非结构化数据。比如一张图片经过神经网络处理后,会变成一个512维甚至更高维的向量。我们要做的就是快速找到与查询向量最相似的几个向量。HNSW(Hierarchical Navigable Small World)算法是目前效果最好的近似最近邻搜索算法之一,它通过构建多层图结构来加速搜索过程。
HNSW的核心思想是构建一个分层的图结构。最底层(第0层)包含所有数据点,越往上层节点数越少。上层图可以看作是下层图的"高速公路",帮助搜索快速接近目标区域。
构建过程是这样的:
cpp复制// 节点插入伪代码
template <typename T>
void HNSW<T>::insertNode(const std::vector<float>& vec, T data) {
int level = randomLevel(); // 随机确定节点层数
std::vector<Node*> neighbors;
// 从顶层开始搜索
for(int l = maxLevel; l > level; l--) {
neighbors = searchLayer(vec, entryPoint, 1, l);
entryPoint = neighbors[0];
}
// 逐层插入
for(int l = min(level, maxLevel); l >= 0; l--) {
neighbors = searchLayer(vec, entryPoint, efConstruction, l);
// 建立双向连接
addConnections(newNode, neighbors, l);
}
}
标准的HNSW使用简单的最近邻策略建立连接,但在实际应用中我们发现这会导致两个问题:
我们的改进策略:
score = α * (1 - distance/max_distance) + (1-α) * (1 - connection_count/Mmax)cpp复制// 优化后的连接建立
void addConnections(Node* newNode, vector<Node*> candidates, int level) {
vector<pair<Node*, float>> scored;
for(auto& node : candidates) {
float dist = distance(newNode->vec, node->vec);
float conn_score = 1 - (node->connCount[level] / (float)Mmax);
float total_score = alpha * (1 - dist/max_dist) + (1-alpha) * conn_score;
scored.emplace_back(node, total_score);
}
sort(scored.begin(), scored.end(), [](auto& a, auto& b) {
return a.second > b.second;
});
// 选择top M建立连接
for(int i=0; i<min(M, scored.size()); i++) {
newNode->addConnection(scored[i].first, level);
}
}
HNSW的搜索过程天然适合并行化,因为:
我们设计了三级并行策略:
cpp复制// 并行搜索实现
vector<Node*> parallelSearch(const vector<float>& query, int k, int ef) {
vector<Node*> results;
#pragma omp parallel for
for(int l = maxLevel; l >= 0; l--) {
auto localResults = searchLayerParallel(query, ef, l);
#pragma omp critical
{
results.insert(results.end(), localResults.begin(), localResults.end());
}
}
// 最后在最底层做精细搜索
return refineResults(query, results, k);
}
多线程环境下最大的挑战是避免锁竞争。我们测试了三种方案:
最终采用的混合方案:
cpp复制class Node {
vector<atomic<Node*>> neighbors; // 邻居列表
vector<mutex> connMutex; // 连接修改锁
};
void addConnection(Node* from, Node* to, int level) {
// 读操作不需要锁
if(from->neighbors[level].load() == to) return;
// 写操作使用分片锁
int shard = from->id % LOCK_SHARDS;
lock_guard<mutex> lock(connectionLocks[shard]);
from->neighbors[level].store(to);
}
高维向量计算对内存访问非常敏感。我们做了以下优化:
cpp复制// 内存优化后的数据结构
class HNSW {
vector<vector<float>> vectors; // 所有向量连续存储
vector<vector<atomic<uint32_t>>> neighbors; // 邻居索引
vector<uint8_t> levels; // 节点层级
};
距离计算是性能瓶颈,我们使用AVX2指令集加速欧式距离计算:
cpp复制float euclideanDistance(const float* a, const float* b, int dim) {
__m256 sum = _mm256_setzero_ps();
for(int i=0; i<dim; i+=8) {
__m256 va = _mm256_load_ps(a + i);
__m256 vb = _mm256_load_ps(b + i);
__m256 diff = _mm256_sub_ps(va, vb);
sum = _mm256_add_ps(sum, _mm256_mul_ps(diff, diff));
}
float result[8];
_mm256_store_ps(result, sum);
return result[0] + result[1] + result[2] + result[3]
+ result[4] + result[5] + result[6] + result[7];
}
我们在SIFT1M数据集(100万条128维向量)上进行了测试:
| 线程数 | 构建时间(s) | 搜索QPS | 召回率@10 |
|---|---|---|---|
| 1 | 342 | 1,200 | 0.98 |
| 4 | 98 | 3,800 | 0.97 |
| 8 | 56 | 6,500 | 0.96 |
| 16 | 42 | 9,200 | 0.95 |
关键发现:
可视化工具:开发了一个简单的2D投影可视化工具,用于调试图结构
python复制# 使用UMAP降维后绘制图结构
import umap
reducer = umap.UMAP()
embedding = reducer.fit_transform(vectors)
nx.draw(graph, pos=embedding, node_size=5)
一致性检查:定期验证图结构的连通性和无环性
cpp复制bool checkGraphConsistency() {
for(auto& node : nodes) {
for(int l=0; l<=node.level; l++) {
for(auto& neighbor : node.neighbors[l]) {
if(!neighbor->hasConnection(node, l)) {
return false; // 双向连接不一致
}
}
}
}
return true;
}
搜索性能突然下降:
多线程随机崩溃:
召回率不稳定:
持久化存储优化:
混合精度支持:
分布式扩展:
提示:在实际部署时,建议先在小数据集上验证参数效果。HNSW对efConstruction和M参数非常敏感,需要根据数据分布调整。