CDN Vald是雅虎公司开源的一款分布式向量检索库,专门用于处理超大规模高维向量的近实时搜索任务。它并没有重新发明底层的最近邻算法,而是基于日本雅虎此前开源的NGT(Neighborhood Graph and Tree)索引结构,在外层包裹了完整的云原生微服务体系。这样的设计让Vald既保留了NGT在十亿级别向量下毫秒级响应的能力,又解决了单机内存上限与故障恢复的问题。在推荐召回、以图搜图、语义文档匹配等业务中,工程师常常需要在几毫秒内从数亿向量里找出最相似的条目,同时系统还在持续写入新向量,Vald正是为这种读写混合的高压场景而生。

Vald的底层索引与核心算法原理
要理解Vald为什么快,必须先看它依赖的NGT算法。NGT结合了邻域图(Graph)与树状结构(Tree),在构建阶段会为每个向量建立多层近邻连接,形成一张可以跳跃遍历的图。查询时从入口点出发,沿着边不断走向更近的节点,配合树结构做粗排,能在很少的访问次数内收敛到真实最近邻。与暴力检索相比,NGT将复杂度从线性降到近似对数级;与纯HNSW图相比,NGT在召回率和内存占用上做了折中,更适合超大规模数据常驻内存的场景。
Vald并没有把NGT直接暴露给业务,而是将其封装成独立的索引代理(agent)。每个agent进程使用mmap把索引文件映射到虚拟内存,操作系统负责将热数据留在物理内存,冷数据换出。这样单节点可以管理远超物理内存的向量量,且重启时无需重新构建索引,直接从磁盘映射恢复。下面的代码片段展示了如何在Go客户端中向Vald集群插入一个向量,并指定索引名与向量值:
package main
import (
"context"
"log"
"github.com/vdaas/vald-client-go/v1/vald"
"google.golang.org/grpc"
)
func main() {
// 连接到Vald网关地址
conn, err := grpc.Dial("vald-gateway:8081", grpc.WithInsecure())
if err != nil {
log.Fatal(err)
}
defer conn.Close()
client := vald.NewValdClient(conn)
// 构造一个128维的向量
vec := make([]float32, 128)
for i := range vec {
vec[i] = float32(i) * 0.01
}
// 插入向量,指定唯一ID
res, err := client.Insert(context.Background(), &vald.InsertRequest{
Vector: &vald.ObjectVector{
Id: "item_1001",
Vector: vec,
},
})
if err != nil {
log.Fatal(err)
}
log.Println("insert result:", res.GetAck())
}
从代码可以看出,Vald对外提供的是标准的gRPC接口,业务侧不需要关心向量落在哪个分片。控制平面会依据一致性哈希将ID映射到特定agent,写入操作在agent本地完成NGT的增量更新。由于NGT支持动态插入,新向量在毫秒级就能被后续搜索命中,这正是近实时特性的来源。不过动态插入过多也会导致图结构膨胀,因此Vald会周期性在后台做索引压缩与重建,这个过程对线上查询影响极小。
分布式架构与高可用设计
Vald的整体架构分为控制平面与数据平面。数据平面由多个agent组成,每个agent管理一部分分片,彼此之间通过Raft-like的心跳汇报存活状态。控制平面中的discoverer组件负责监听Kubernetes或自建调度器的节点变化,将最新的agent列表推送给网关节点。网关(gateway)作为统一入口,接收外部插入、搜索、删除请求,并根据哈希环把请求转发到对应agent。这种分层让扩容变得简单:当向量总量上涨,只需增加agent Pod,discoverer感知后重新分配分片,网关自动更新路由表。
为了保证节点宕机不丢数据,Vald引入了边车(sidecar)备份机制。每个agent旁边跑一个backup容器,实时把索引增量同步到对象存储或持久卷。一旦主agent崩溃,调度器拉起新实例,sidecar从最新备份恢复索引,再加入到哈希环中。相比传统主从复制,这种方案节省了一倍的内存副本,因为备份数据以压缩格式落盘而非常驻内存。下面的表格对比了Vald与常见自建Faiss集群在关键指标上的差异:
| 维度 | Vald | 自建Faiss集群 |
|---|---|---|
| 水平扩容 | 自动分片,网关无感 | 需手动重分片,停写窗口 |
| 故障恢复 | sidecar备份秒级拉起 | 主从全量同步耗时久 |
| 近实时写入 | NGT增量更新毫秒可见 | 需批量重建索引 |
| 运维复杂度 | 原生K8s集成 | 脚本维护成本高 |
在实际生产里,这种架构让雅虎自身支撑了跨机房亿级向量的商品图像检索。当流量突发时,HPA基于agent的CPU与待写入队列长度自动扩缩容,控制平面在十秒内完成拓扑收敛。需要注意的是,Vald默认牺牲强一致性来换取可用性与性能,即搜索可能短暂读不到刚写入一秒内的向量,这对绝大多数推荐场景是可接受的,但金融风控等强一致需求要额外加一层校验。
性能调优与落地实践建议
把Vald跑起来只是第一步,要让它在亿级数据下保持低延迟,还需针对业务特征调参。首先是NGT的构建参数,包括边缘候选数(edge size)与搜索入口数,数值越大召回越高但内存与查询耗时也涨。经验上,128维向量边缘数设20到40之间,搜索时取候选10到20个,能在99%召回下控制单次查询在2毫秒内。其次是网关的并发模型,Vald网关默认用Go的goroutine池处理转发,如果客户端使用长连接批量搜索,应调大网关的max-concurrent-streams避免队头阻塞。
另一个常见误区是认为向量维度越高越好。实际上在Vald中,超过512维的向量会显著放大内存与图遍历成本,建议业务侧先用PCA或蒸馏模型把向量压到128或256维。下面示例展示如何通过Vald的搜索接口携带过滤条件,在返回结果中只取距离最小的十个,并利用客户端超时防止慢节点拖垮整体:
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
searchRes, err := client.Search(ctx, &vald.SearchRequest{
Vector: queryVec,
Config: &vald.SearchConfig{
Num: 10,
Radius: -1,
Epsilon: 0.01,
},
})
if err != nil {
log.Printf("search warn: %v", err)
return
}
for _, r := range searchRes.GetResults() {
log.Printf("id=%s distance=%f", r.GetId(), r.GetDistance())
}
落地时建议先把Vald部署在独立命名空间,通过Service Mesh做流量镜像,用真实查询回放验证延迟分布。当确认P99低于业务阈值后,再切主流量。如果私有云没有Kubernetes,Vald也提供了裸机发现的插件,只需在discoverer配置里填上静态节点表即可。经过合理调优,一套三节点agent加单网关的Vald集群,便能用普通64G内存机器承载两亿128维向量的实时检索,这对中小厂来说性价比极高。