导读
刘芷溢同学,在 OSPP 2025 中,为 NebulaGraph 实现了向量数据持久化与 ANN Search(向量近似近邻检索),以赋能 AI 场景。该功能使 NebulaGraph 进一步具备知识图谱和向量检索相结合的能力,高效地用 GraphRAG 来改善传统 RAG 的痛点。
-
构建 ANN Index Adapter,将 HNSWlib 和 faiss 封装成统一的接口 -
实现 ANN Index 的 DDL,支持创建和删除 ANN Index -
利用 ANN Index 进行 ANN Search
-
ANN Index 的生命周期管理谁来负责? -
ANN Index 的数据存储在哪里? -
ANN Search 生成的计划如何使用 Ann Index 进行搜索?
⚠️这里为了简化,我们假设使用 Tag Schema 进行说明
ANN Search Interface
在 ANN Index DDL 章节中会详细介绍创建和删除索引的实现。实际上是通过 VectorIndexManager 来管理向量索引的生命周期,这个单例会维护一个内存中的向量索引映射表,Key 是 GraphSpaceID + PartID + IndexID,Value 是具体的向量索引实例。
这个 VectorIndexManager 在存储守护进程启动时初始化(加载持久化的向量索引),在进程退出前持久化所有的向量索引到磁盘。
实际上这些 ann search 库持久化底层使用的也是 C++ IOStream 序列化机制。
class AnnIndex {public:AnnIndex() = default;AnnIndex(GraphSpaceID graphID,PartitionID partitionID,IndexID indexID,const std::string &indexName,bool propFromNode,size_t dim,const std::string &rootPath,MetricType metricType,size_t minTrainDataSize = 3);virtual ~AnnIndex() = default;AnnIndex(const AnnIndex &) = delete;AnnIndex &operator=(const AnnIndex &) = delete;[[nodiscard]] virtual Status init(const BuildParams *params) = 0;// add data to index incrementally[[nodiscard]] virtual Status add(const VecData *data) = 0;// upsert data to index[[nodiscard]] virtual Status upsert(const VecData *data) = 0;// soft delete data from index, return number of deleted vectors[[nodiscard]] virtual StatusOr<size_t> remove(const IDSelector &sel) = 0;// ann search[[nodiscard]] virtual Status search(const SearchParams *params, SearchResult *res) = 0;// reconstruct vector by id[[nodiscard]] virtual StatusOr<Vector> reconstruct(VectorID id) = 0;// load index file from disk// flush index to disk[[nodiscard]] virtual Status read(const std::string &file) = 0;[[nodiscard]] virtual Status write(const std::string &dir, const std::string &file) = 0;virtual AnnIndexType indexType() const = 0;virtual std::string toString() const = 0;};
-
为了简化向量索引的使用,我们定义了一些辅助数据结构和枚举类型:MetricType -
MetricType:表示向量距离度量类型,如 L2 距离和内积。 -
AnnIndexType:表示向量索引类型,如 IVF 和 HNSW。 -
IDSelector:用于选择要删除的向量 ID 列表。 -
VecData:表示向量数据,包括向量数量、维度、数据和 ID。 -
BuildParams:表示向量索引构建参数的基类,以及其派生类 BuildParamsIVF 和 BuildParamsHNSW。 -
SearchParams:表示向量搜索参数的基类,以及其派生类 SearchParamsIVF 和 SearchParamsHNSW。 -
SearchResult:表示向量搜索结果,包括向量 ID、距离和向量数据。
enum MetricType : int8_t { L2, INNER_PRODUCT };enum AnnIndexType : int8_t { IVF, HNSW };// faiss usedstruct IDSelector {size_t cnt;VectorID* ids; // vector of IDs to select};struct VecData {size_t cnt; // number of vectorssize_t dim; // dimension of each vectorfloat* fdata; // float type vector data sourceVectorID* ids; // int64 identifier of each vector};struct OwnedVecData {std::vector<float> flat;std::vector<VectorID> ids;VecData view;};// ANN index build parametersstruct BuildParams {MetricType metricType{MetricType::L2};AnnIndexType indexType{AnnIndexType::IVF};};struct BuildParamsIVF final : public BuildParams {size_t nl{3}; // number of listssize_t ts{3}; // train size};struct BuildParamsHNSW final : public BuildParams {size_t maxDegree{16}; // the maximum degreessize_t efConstruction{8}; // expansion in construction timesize_t capacity{10000}; // capacity of the index};struct SearchParams {size_t topK{10}; // number of nearest neighbors to searchfloat* query{nullptr}; // query vector datasize_t queryDim{0}; // dimension of query vector};struct SearchParamsIVF final : public SearchParams {size_t nprobe{10}; // number of lists to probe};struct SearchParamsHNSW final : public SearchParams {size_t efSearch{16}; // expansion factor at search time};// ANN search resultstruct SearchResult {std::vector<VectorID> IDs;// distances of the result vectorsstd::vector<float> distances;// result vectorsstd::vector<float> vectors;};
ANN Search DDL
-
Tag Ann Index Creation Syntax
CREATE TAG ANNINDEX <index_name> ON <tag_name_list>::(<field_name>) [IF NOT EXISTS] ann_index_params}[COMMENT '<comment>']
-
ANN Index Parameters
-
ANNINDEX_TYPE: Index type, support IVF and HNSW -
DIM: Vector dimension -
METRIC_TYPE: Metric type, support L2 and INNER_PRODUCT -
NLIST: Number of lists, only for IVF index -
TRAINSIZE: Training size, only for IVF index -
MAXDEGREE: Maximum degree, only for HNSW index -
EFCONSTRUCTION: Expansion factor at construction time,only for HNSW index -
MAXELEMENTS: Capacity of the index, only for HNSW index
{ANNINDEX_TYPE: "IVF", DIM:128, METRIC_TYPE:"L2", NLIST:3, TRAINSIZE:3}{ANNINDEX_TYPE: "HNSW", DIM:128, METRIC_TYPE:"L2", MAXDEGREE:15, EFCONSTRUCTION:200, MAXELEMENTS:10000}
struct IndexItem {1: common.IndexID index_id,2: binary index_name,3: common.SchemaID schema_id4: binary schema_name,5: list<ColumnDef> fields,6: optional binary comment,7: optional IndexParams index_params,}
ann index params:[IVF/*ann type*/, 128 /*dim*/, L2/*metric type*/, 3/*nlist*/, 3/*train size*/][HNSW/*ann type*/, 128 /*dim*/, L2/*metric type*/, 16/*max degree*/, 200/*ef construction*/, 100000/*max elements*/]
ann index params:[IVF/*ann type*/, 128 /*dim*/, L2/*metric type*/, 3/*nlist*/, 3/*train size*/][HNSW/*ann type*/, 128 /*dim*/, L2/*metric type*/, 16/*max degree*/, 200/*ef construction*/, 100000/*max elements*/]
struct CreateTagAnnIndexReq {1: common.GraphSpaceID space_id,2: binary index_name,3: list<binary> tag_names,4: IndexFieldDef field,5: bool if_not_exists,6: optional binary comment,7: optional list<binary> ann_params,}
struct ListTagAnnIndexesResp {1: common.ErrorCode code,2: common.HostAddr leader,3: list<AnnIndexItem> items,}struct ListEdgeAnnIndexesResp {1: common.ErrorCode code,2: common.HostAddr leader,3: list<AnnIndexItem> items,}
-
在存储守护进程中,设计一个 VectorIndexManager 单例来管理 Storaged 中的所有 ANN Index. -
ANN Index 的生命周期:
-
创建 :通过 CreateTagAnnIndex 请求创建 Ann 索引。> 除非删除,否则它将始终保存在内存中。退出时需要将所有 Ann Index 持久化到磁盘,并在系统重启后从磁盘重新加载。 -
访问 :通过 GetTagAnnIndex 请求访问 Ann 索引用于加速 Ann Search。 -
删除 :通过 DropTagAnnIndex 请求删除 Ann Index。 -
更新 :通过 UpdateTagAnnIndex 请求更新 Ann Index。
class VectorIndexManager final {public:static VectorIndexManager& getInstance();Status init(meta::IndexManager* indexManager, std::string annIndexPath);Status start();Status stop();// Create & RebuildStatus createOrUpdateIndex(GraphSpaceID spaceId,PartitionID partitionId,IndexID indexId,const std::shared_ptr<meta::cpp2::AnnIndexItem>& indexItem);Status rebuildIndex(GraphSpaceID spaceId,PartitionID partitionId,IndexID indexId,const std::shared_ptr<meta::cpp2::AnnIndexItem>& indexItem);// Access & SearchStatusOr<std::shared_ptr<AnnIndex>> getIndex(GraphSpaceID spaceId,PartitionID partitionId,IndexID indexId);StatusOr<SearchResult> searchVectors(GraphSpaceID spaceId,PartitionID partitionId,IndexID indexId,const SearchParams& searchParams);// Update & DeleteStatus addVectors(GraphSpaceID spaceId,PartitionID partitionId,IndexID indexId,const VecData& vecData);Status removeIndex(GraphSpaceID spaceId, PartitionID partitionId, IndexID indexId);// Utilitystd::vector<std::shared_ptr<AnnIndex>> getIndexesByPartition(GraphSpaceID spaceId,PartitionID partitionId);bool hasIndex(GraphSpaceID spaceId, PartitionID partitionId, IndexID indexId) const;// ... private members for lifecycle management};
-
Graphd: 解析 CREATE ANNINDEX DDL 并生成生成计划 Start->CreateTagAnnIndex->SubmitJob. -
Metad: 执行 CreateTagAnnIndex 步骤。Metad 会在内部创建 AnnIndexItem 元数据条目。创建成功后,提交一个 AdminJob. -
Metad: 该 AdminJob 将 ANN Index 参数打包成 AdeminTask 发送给 Storaged。Storaged 通过其内部的 AdminTaskManager 处理这些任务以完成作业。这涉及到一个分布式时序问题:
Meta Client 缓存更新机制: 存储节点的 IndexManager 通过 MetaClient 获取索引信息,但 MetaClient 的缓存通过心跳周期进行更新:
1.时序问题: 元服务创建索引后,存储节点需要等到下一个心跳周期才能看到新索引。
2.同步问题: getTagAnnIndex 直接从缓存读取数据。如果缓存尚未更新,则会返回 IndexNotFound 错误。
-
扫描 KVStore(RocksDB)中与属性名称匹配的所有 Vector 属性数据。 -
将这些向量数据批量添加到新创建的向量索引实例中。 -
将顶点 ID/边类型和向量 ID 之间的映射关系存储到 KVStore 的 id-vid 列族中。
顶点 ID 的类型为 std::string。这里 VectorID 是通过哈希计算得到的,因此需要存储两个映射:VectorID->VertexID 和 VertexID->VectorID.
ANN Search
MATCH (v:v1)RETURN v, euclidean(vector(0.90, 0.85, 0.88), v.embedding) AS distanceORDER BY euclidean(vector(0.90, 0.85, 0.88), v.embedding)APPROXIMATE LIMIT 1OPTIONS {ANNINDEX_TYPE:'IVF', METRIC_TYPE:L2, NPROBE:2};
-
Graphd (优化):优化规则识别出 APPROXIMATE LIMIT 语法,并生成一个包含 TagAnnIndexCcan 节点的物理执行计划。 -
Storaged (索引扫描):Storaged 收到 TagAnnIndexScan 请求。它会生成一个内部计划,在底层的 ANN Index(例如 IVF)上执行搜索。 -
Storaged (返回 VIDs):索引扫描返回匹配的 VectorID。Storaged 随后将 VectorID 映射回 vid,并将 vid 返回给 Graphd. -
Graphd (获取属性):Graphd 调用 Storaged 的 GetProp 操作符,为上一步返回的 vid 批量获取其余的非向量属性。 -
Graphd (收尾):Graphd 执行最终的 Limit 和 Project 操作,整理数据后返回给客户端。
MATCH (v:tag5:tag6)RETURN vORDER BY euclidean(vector(1.0,2.0,3.0), v.vec)APPROXIMATE LIMIT 3OPTIONS {ANNINDEX_TYPE:'IVF', METRIC_TYPE:L2, NPROBE:3}
MATCH (v:tag5:tag6)RETURN v, euclidean(vector(1.0,2.0,3.0), v.vec)ORDER BY euclidean(vector(1.0,2.0,3.0), v.vec)APPROXIMATE LIMIT 1OPTIONS {ANNINDEX_TYPE:'IVF', METRIC_TYPE:L2, NPROBE:3}
MATCH (v:coach_vector:player_vector)RETURN v, euclidean(vector(0.90, 0.85, 0.88), v.embedding) AS distanceORDER BY euclidean(vector(0.90, 0.85, 0.88), v.embedding)APPROXIMATE LIMIT 1OPTIONS {ANNINDEX_TYPE:'IVF', METRIC_TYPE:L2, NPROBE:2};
-
原有限制:原始表达式强制要求使用 v.tag.prop 的形式来获取顶点属性,这样的话 v.embedding 会被识别成对 Tag 的获取直接返回 NULL 给用户。 -
修改后:对于向量属性,我们放宽了这一限制。系统现在支持 v.embedding 这种形式来获取 Vertex 的 Vector 属性。
-
初始化: 执行 doExecute,它会调用 resetIter 来准备数据。 -
获取 VectorID: resetIter 负责调用 Ann Index(例如 IVF)的搜索接口。这一步返回的结果仅仅是 VectorID(ANN 索引内部的 ID),而不是实际的 vid. -
迭代与映射: doNext 负责遍历 resetIter 获取的 VectorID 结果。 -
获取 VID: 在 doNext 内部,调用 AnnIndexVertexScan::getVidByVectorid() 函数。此函数通过获取 RocksDB 的 id-vid 列族数据,得到 VectorID 对应的实际 vid. -
返回数据: Storaged 将 vid 返回给 Graphd.
-
目的:当我们对 tag5 和 tag6 进行 ANN Search 时,一个顶点可能只匹配了 tag5(即它只有 tag5),但没有 tag6。 -
行为:collectAllVertexProps 函数能正确处理这种情况,将未匹配标签的属性设置为 EMPTY(空),确保返回给 Graphd 的数据结构始终一致。
_vid|tag5.id|tag5.vec|tag5._tag|tag6.num|tag6.vec|tag6._tag"v7"|7|vector(1.9,2.0,2.1)|2|__EMPTY__|__EMPTY__|__EMPTY__"v8"|8|vector(2.2,2.3,2.4)|2|__EMPTY__|__EMPTY__|__EMPTY__"v6"|6|vector(1.6,1.7,1.8)|2|__EMPTY__|__EMPTY__|__EMPTY__
踩过的坑
-
问题:最初我们尝试使用通用索引项 IndexItem 来表示 ANN Index 元数据,但它无法支持在多个标签上对同名属性创建索引的需求。 -
解决方案:我们设计了新的 AnnIndexItem 结构,能够包含多个 Tag 的信息,并且使用列表来存储创建 ANN Index 所需的参数。
-
问题:实现过程中并未考虑 Metad 和 Storaged 的时序问题,在测试时发现无法获取到 ANN Index -
原理:在 Metad 创建 ANN Index 后,Storaged 可能无法立即看到新创建的索引,因为 Meta Client 的缓存需要通过心跳周期更新。 -
解决方案:我们在 Storaged 中实现了重试机制。如果多次重试失败,我们会直接从元服务器请求数据以强制刷新缓存。
-
问题:最初的优化规则只覆盖了返回向量距离的情况,忽略了不返回向量距离的场景。 -
解决方案:我们扩展了优化规则,确保它能够识别两种情况,并正确生成包含 TagAnnIndexScan 节点的执行计划。
-
问题:在 Multi-Tag ANN Search 场景下,v.embedding 这种形式无法正确获取向量属性,导致返回结果总是为空。 -
原因:原始的 AttributeExpression 实现强制要求使用 v.tag.prop 的形式来获取顶点属性。 -
解决方案:我们修改了 AttributeExpression 的处理逻辑,允许对向量属性使用 v.embedding 这种形式进行访问。同时为了简化逻辑,我们设计了 collectAllVertexProps 函数,确保即使某些标签未匹配,其属性也会被设置为 EMPTY,从而保持数据结构的一致性。
总结
实在是没有时间,分布式下的功能还没有完成,希望以后有机会再来填坑。
-
统一的 ANN Index 接口: 设计了 AnnIndex 抽象接口,成功封装了 HNSWlib 和 Faiss 两种主流向量索引库,为后续扩展更多索引类型奠定了基础。 -
完整的索引生命周期管理: 通过 VectorIndexManager 单例实现了向量索引的创建、访问、更新和删除的完整生命周期管理,包括索引的持久化和重启后的自动加载。 -
Multi-Tag ANN Index 支持: 突破了传统索引只能作用于单个 Schema 的限制,实现了跨多个 Tag 对同名向量属性建立索引的能力,这是本次实现的一大亮点。 -
高效的 ANN Search 执行流程: 设计了从 Graphd 到 Storaged 的完整执行链路,包括新的优化规则、TagAnnIndexScan 执行节点,以及 VectorID 到 VertexID 的映射机制。
-
内存存储:向量索引实例(IVF、HNSW)常驻内存,通过 VectorIndexManager 管理,以支持高性能的向量搜索。 -
磁盘持久化:调用 Faiss 和 HNSWlib 的序列化接口,将索引以二进制形式写入本地文件系统。这些库底层使用 C++ IOStream 序列化机制。 -
映射关系存储:VectorID 与 VertexID/EdgeType 之间的映射关系存储在 RocksDB 的 id-vid 列族中,确保能够将向量搜索结果转换回实际的顶点或边。
-
Graphd 优化阶段:新的优化规则识别 APPROXIMATE LIMIT 语法,将 ApproximateLimit->Sort->Project->ScanVertices 转换为 Limit->TagAnnIndexScan 执行计划。 -
Storaged 执行阶段:AnnIndexScan 节点通过 VectorIndexManager 获取对应的向量索引实例,调用其 search 接口返回 VectorID 列表。 -
ID 映射转换:通过查询 RocksDB 的 id-vid 列族,将 VectorID 转换为实际的 VertexID。 -
属性补全:Graphd 使用返回的 VertexID 列表调用 GetProp 获取完整的顶点属性,通过 collectAllVertexProps 函数处理 Multi-Tag 场景。
-
索引生命周期由 Storaged 管理: 将向量索引实例维护在存储层,既能保证数据局部性,又能简化分布式环境下的一致性问题。 -
新的元数据结构 AnnIndexItem: 摆脱了通用 IndexItem 的限制,专门为 ANN Index 设计的元数据结构,能够更好地支持 Multi-Tag 场景。 -
重试机制解决时序问题: 通过在 Storaged 中引入重试机制,解决了 Meta Client 缓存更新延迟带来的时序问题。
-
充分考虑分布式系统的时序问题: 在分布式系统中,不同组件之间的状态同步是异步的。我们在 ANN Index 创建流程中遇到的 Meta Client 缓存更新问题就是一个典型案例。设计时应该充分考虑这类时序问题,并设计相应的重试或强制刷新机制。 -
接口设计要考虑可扩展性: AnnIndex 接口的设计使得我们能够轻松支持多种向量索引库。统一的接口不仅简化了上层调用逻辑,也为后续引入新的索引类型(如 DiskANN、ScaNN 等)预留了空间。 -
优化规则要覆盖所有场景: 在实现 ANN Search 优化规则时,我们最初只考虑了返回距离的场景,导致不返回距离的查询无法被优化。这提醒我们在设计优化规则时,需要全面梳理所有可能的查询模式。 -
测试先行: 在开发过程中,充分的测试帮助我们发现了许多设计上的疏漏,如 Multi-Tag 场景下的属性获取问题。完善的测试用例不仅能帮助发现 bug,还能驱动我们思考更多的边界情况。
-
索引更新策略优化: 当前的索引更新是实时的,对于大规模数据插入场景,可以考虑引入批量更新或异步更新机制。 -
更多索引类型支持: 可以引入更多类型的向量索引,如基于磁盘的 DiskANN,以支持超大规模向量数据。 -
混合查询优化: 探索向量搜索与图遍历的更深度融合,如在图遍历过程中动态执行向量过滤。 -
分布式索引: 当前索引是按分区独立管理的,未来可以考虑实现跨分区的分布式索引,提升大规模场景下的查询性能。
https://github.com/vesoft-inc/nebula/pull/6083
https://github.com/vesoft-inc/nebula/pull/6074
https://github.com/vesoft-inc/nebula/pull/6099
https://github.com/vesoft-inc/nebula/pull/6076
https://github.com/vesoft-inc/nebula/pull/6087
https://github.com/vesoft-inc/nebula/pull/6068
https://github.com/vesoft-inc/nebula/pull/6104
https://github.com/vesoft-inc/nebula/pull/6090
⚠️NebulaGraph 的向量相关功能,在 OSPP 2025 结束后,将统一收录于 nebula-contrib(收录了许多来自社区开发者贡献的生态工具)
https://github.com/nebula-contrib/nebula-vsearch/
💡本文首发于刘芷溢同学 Blog,点击「阅读原文」即可跳转。
🥳欢迎大家在评论区交流关于向量和 GraphRAG 的疑问/开发经验。
🔥日本 nMeetUp 火热报名中
🔥O2O 计划招募中,免费为你提供技术支持
📧来论坛,GraphRAG 产品反馈一键直达 NebulaGraph 产品团队。
https://discuss.nebula-graph.com.cn/t/topic/17256
✦
如果你觉得 NebulaGraph 能帮到你,或者你只是单纯支持开源精神,可以在 GitHub 上为 NebulaGraph 点个 Star!
每一个 Star 都是对我们的支持和鼓励✨
GitHub:https://github.com/vesoft-inc/nebula
官网:https://www.nebula-graph.com.cn/
论坛:https://discuss.nebula-graph.com.cn/
✦
✦
扫码添加
可爱星云
技术交流
资料分享
NebulaGraph 用户案例
✦
Why Graph Database?⬇️
风控场景:普适智能|中证数智|BlockSec|携程|Airwallex|众安保险|中国移动|Akulaku|邦盛科技|360数科|BOSS直聘|金蝶征信|快手|青藤云安全
平台建设:博睿数据|携程|众安科技|微信|OPPO|vivo|美团|百度爱番番|携程金融|普适智能|BIGO
知识图谱:普适智能|中证数智|中医药大学|企查查|腾讯音乐|中科大脑|泰康在线|苏宁|微澜|同花顺|携程酒店
营销推荐:阿里妈妈
GraphRAG:中科数睿
✦
✦

